基于当前登录用户自定义租户解析器
学习如何创建自定义租户解析器并使用 Grails 多租户功能根据当前登录用户或 JWT 切换租户。
作者:Sergio del Amo
Grails 版本 4.0.1
1 Grails 培训
Grails 培训 - 通过创建和积极维护 Grails 框架的人员制定和交付!。
2 开始
GORM 多租户支持包括 内置租户解析器。在本指南中,您将创建自己的租户解析器,根据 Grails rest-api 应用程序中登录的用户切换租户,该应用程序使用 Spring Security REST 插件。
2.1 你需要什么
为完成此指南,您需要以下内容
-
一些时间
-
一个合适的文本编辑器或 IDE
-
安装 JDK 1.8 或更高版本且已正确配置
JAVA_HOME
2.2 如何完成指南
如要开始,请执行以下操作
-
下载并解压源代码
或
Grails 指南代码库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用,其中有些其他代码可以给你一些入门优势。 -
complete
已完成示例。它是逐步完成指南介绍的步骤和将这些修改应用于initial
文件夹的结果。
要完成指南,请转到initial
文件夹
-
cd
进入grails-guides/grails-custom-security-tenant-resolver/initial
然后按后续部分的指导操作。
如果您cd 进入grails-guides/grails-custom-security-tenant-resolver/complete ,即可直接进入已完成示例 |
3 编写应用程序
本指南使用多租户 DISCRIMINATOR 模式。要了解详情,请阅读单数据库多租户鉴别列指南。 |
3.1 创建域类
package demo
import grails.gorm.MultiTenant
class Plan implements MultiTenant<Plan> { (1)
String title
String username
static mapping = {
tenantId name: 'username' (2)
}
}
1 | 实现MultiTenant特性,将此域类视为多租户。 |
2 | 将租户标识符设置为名为username 的列 |
创建一个接口PlanService
,它是GORM 数据服务。
package demo
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service
@Service(Plan) (1)
@CurrentTenant (2)
interface PlanDataService {
List<Plan> findAll()
Plan save(String title)
void deleteByTitle(String title)
}
1 | 使用grails.gorm.services.Service 注释对其进行注释,其中包含服务所应用到的域类。 |
2 | 解析此类的上下文中的当前租户 |
3.2 控制器
创建一个简单控制器,该控制器与之前定义的PlanService
服务配合使用。
package demo
import groovy.transform.CompileStatic
@CompileStatic
class PlanController {
PlanDataService planDataService
def index() {
[planList: planDataService.findAll()]
}
}
借助JSON 视图,我们将Plan
实例呈现为 JSON
import demo.Plan
model {
Iterable<Plan> planList
}
json tmpl.plan('plan', planList)
import demo.Plan
model {
Plan plan
}
json {
title plan.title
}
3.3 连接安全
创建安全域类。您可以使用 Spring Security Core s2-quickstart
创建这些类。
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {
private static final long serialVersionUID = 1
String username
String password
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
Set<Role> getAuthorities() {
(UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
}
static constraints = {
password nullable: false, blank: false, password: true
username nullable: false, blank: false, unique: true
}
static mapping = {
password column: '`password`'
}
}
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable {
private static final long serialVersionUID = 1
String authority
static constraints = {
authority nullable: false, blank: false, unique: true
}
static mapping = {
cache true
}
}
创建一个类UserPasswordEncoderListener
,用于处理用户的密码编码。
它使用@Listener
注释来同步监听来自 GORM 的事件。
package demo
import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
@CompileStatic
class UserPasswordEncoderListener {
@Autowired
SpringSecurityService springSecurityService
@Listener(User)
void onPreInsertEvent(PreInsertEvent event) {
encodePasswordForEvent(event)
}
@Listener(User)
void onPreUpdateEvent(PreUpdateEvent event) {
encodePasswordForEvent(event)
}
private void encodePasswordForEvent(AbstractPersistenceEvent event) {
if (event.entityObject instanceof User) {
User u = event.entityObject as User
if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
event.getEntityAccess().setProperty('password', encodePassword(u.password))
}
}
}
private String encodePassword(String password) {
springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
}
}
将先前的监听程序定义为 Bean
import demo.UserPasswordEncoderListener
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener)
}
在application.yml
中配置 Spring Security Core
grails
grails:
plugin:
springsecurity:
rest:
token:
storage:
jwt:
secret: qrD6h8K6S9503Q06Y6Rfk21TErImPYqa # change this for a new one
securityConfigType: InterceptUrlMap
userLookup:
userDomainClassName: demo.User
authorityJoinClassName: demo.UserRole
authority:
className: demo.Role
filterChain:
chainMap:
- # Stateless chain
pattern: /**
filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
interceptUrlMap:
-
pattern: /
access:
- ROLE_VILLAIN
-
pattern: /error
access:
- permitAll
-
pattern: /api/login
access:
- ROLE_ANONYMOUS
-
pattern: /api/validate
access:
- ROLE_ANONYMOUS
-
pattern: /oauth/access_token
access:
- ROLE_ANONYMOUS
-
pattern: /plan
access:
- ROLE_VILLAIN
创建多个GORM 数据服务,以使用涉及我们应用程序安全的域类。
package demo
import grails.gorm.services.Service
@Service(Role)
interface RoleDataService {
void delete(Serializable id)
void deleteByAuthority(String authority)
Role findByAuthority(String authority)
Role saveByAuthority(String authority)
}
package demo
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@Service(User)
interface UserDataService {
User save(String username, String password)
void delete(Serializable id)
User findByUsername(String username)
}
package demo
import grails.gorm.services.Query
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@Service(UserRole)
@CompileStatic
interface UserRoleDataService {
@Query("""select $user.username from ${UserRole userRole}
inner join ${User user = userRole.user}
inner join ${Role role = userRole.role}
where $role.authority = $authority""")
List<String> findAllUsernameByAuthority(String authority)
UserRole save(User user, Role role)
void delete(User user, Role role)
void deleteByUser(User user)
}
创建一个服务来处理具有特定角色的用户创建。
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
class UserService {
RoleDataService roleDataService
UserRoleDataService userRoleDataService
UserDataService userDataService
@Transactional
User save(String username, String password, String authority) {
Role role = roleDataService.findByAuthority(authority)
if ( !role ) {
role = roleDataService.saveByAuthority(authority)
}
User user = userDataService.save(username, password)
userRoleDataService.save(user, role)
user
}
}
3.4 自定义租户解析程序
下表包含所有随 GORM 提供且开箱即用的TenantResolver
实现。已将包名从org.grails.datastore.mapping
缩写为o.g.d.m
以求简洁
名称 | 描述 |
---|---|
|
针对固定租户 ID 解析 |
|
从名为 `gorm.tenantId` 的系统属性中解析租户 ID |
|
通过 DNS 从子域名解析租户 ID |
|
默认情况下,从名为 `gorm.tenantId` 的 HTTP cookie 中解析当前租户 |
|
默认情况下,通过使用属性 `gorm.tenantId` 从 HTTP 会话中解析当前租户 |
不过,你可以轻松创建自定义租户解析器。为此,创建一个实现 `org.grails.datastore.mapping.multitenancy.TenantResolver` 的类。
以下部分将向你展示如何创建两个自定义租户解析器。
3.5 通过 JWT 的租户解析器
Grails Spring Security REST 插件 允许你使用存在于 `Authorization` 标头中的 JWT 令牌对你的应用程序进行身份验证。
示例:对 `path/plan` 的请求中包含 `Authorization` 标头中的 JWT 令牌。
## Request Duplicate
curl "http://localhost:8080/plan" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....." \
-H "Accept: application/json"
我们可以创建自定义租户解析器,它会提取 JWT 令牌、重新注入它并提取用户名。
package demo
import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import javax.servlet.http.HttpServletRequest
@CompileStatic
class CurrentUserByJwtTenantResolver implements AllTenantsResolver { (1)
public static final String HEADER_NAME = 'Authorization'
public static final String HEADER_VALUE_PREFFIX = 'Bearer '
String headerName = HEADER_NAME
String headerValuePreffix = HEADER_VALUE_PREFFIX
@Autowired
TokenStorageService tokenStorageService
@Override
Serializable resolveTenantIdentifier() throws TenantNotFoundException {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes()
if(requestAttributes instanceof ServletWebRequest) {
HttpServletRequest httpServletRequest = ((ServletWebRequest) requestAttributes).getRequest()
String token = httpServletRequest.getHeader(headerName.toLowerCase())
if ( !token ) {
throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
}
if (token.startsWith(headerValuePreffix)) {
token = token.substring(headerValuePreffix.length())
}
UserDetails userDetails = tokenStorageService.loadUserByToken(token)
String username = userDetails?.username
if ( username ) {
return username
}
throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
}
throw new TenantNotFoundException("Tenant could not be resolved outside a web request")
}
@Override
Iterable<Serializable> resolveTenantIds() {
User.withTransaction(readOnly: true) {
new DetachedCriteria(User)
.distinct('username')
.list() as Iterable<Serializable>
}
}
}
1 | 当使用基于鉴别器的多租户时,你可能需要在你希望在任何时间迭代所有可用租户时,在你的 TenantResolver 中实现 AllTenantsResolver 接口。`AllTenantsResolver 扩展了 `TenantResolver`。若要创建你自己的租户解析器,请实现 `org.grails.datastore.mapping.multitenancy.TenantResolver`。 |
将自定义租户解析器定义为 bean
import demo.CurrentUserByJwtTenantResolver
beans = {
currentUserByJwtTenantResolver(CurrentUserByJwtTenantResolver)
}
创建集成测试,以便测试自定义租户解析器。
package demo
import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import spock.lang.Specification
import spock.lang.IgnoreIf
@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class CurrentUserByJwtTenantResolverSpec extends Specification {
@Autowired
CurrentUserByJwtTenantResolver currentUserTenantResolver
TokenGenerator tokenGenerator
void "Test HttpHeader resolver throws an exception outside a web request"() {
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved outside a web request"
}
void "Test not tenant id found"() {
setup:
def request = new MockHttpServletRequest("GET", "/foo")
RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved from HTTP Header: ${CurrentUserByJwtTenantResolver.HEADER_NAME}"
cleanup:
RequestContextHolder.setRequestAttributes(null)
}
void "Test HttpHeader value is the tenant id when a request is present"() {
setup:
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo")
def userDetails = Stub(UserDetails) {
getUsername() >> 'vector'
isAccountNonExpired() >> true
isAccountNonLocked() >> true
isCredentialsNonExpired() >> true
isEnabled() >> true
}
AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails, 3600)
String jwt = accessToken.accessToken
request.addHeader('Authorization', "Bearer $jwt")
RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))
when:
def tenantId = currentUserTenantResolver.resolveTenantIdentifier()
then:
tenantId == "vector"
cleanup:
RequestContextHolder.setRequestAttributes(null)
}
}
接下来,我们将配置我们的应用以使用此自定义租户解析器。
通过修改 `application.yml` 配置多租户。
grails
gorm:
multiTenancy:
mode: DISCRIMINATOR (1)
tenantResolverClass: demo.CurrentUserByJwtTenantResolver (2)
reactor:
# Whether to translate GORM events into Reactor events
# Disabled by default for performance reasons
events: false
1 | 将多租户模式定义为 `DISCRIMINATOR` |
2 | 设置使用自定义类的租户解析器类,稍后你会在本指南中编写此类。 |
3.6 按用户划分的租户解析器
或者,你可能想要使用 Spring Security REST 保护的无状态端点和 Spring Security Core 保护的有状态端点的组合。你将有兴趣按当前登录的用户创建自定义解析器。
package demo
import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.SpringSecurityService
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.TenantResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.security.core.userdetails.UserDetails
@CompileStatic
class CurrentUserTenantResolver implements AllTenantsResolver { (1)
@Autowired
@Lazy
SpringSecurityService springSecurityService
@Override
Serializable resolveTenantIdentifier() throws TenantNotFoundException {
String username = loggedUsername()
if ( username ) {
return username
}
throw new TenantNotFoundException("Tenant could not be resolved from Spring Security Principal")
}
String loggedUsername() {
if ( springSecurityService.principal instanceof String ) {
return springSecurityService.principal
}
if (springSecurityService.principal instanceof UserDetails) {
return ((UserDetails) springSecurityService.principal).username
}
null
}
@Override
Iterable<Serializable> resolveTenantIds() {
User.withTransaction(readOnly: true) {
new DetachedCriteria(User)
.distinct('username')
.list() as Iterable<Serializable>
}
}
}
1 | 当使用基于鉴别器的多租户时,你可能需要在你希望在任何时间迭代所有可用租户时,在你的 TenantResolver 中实现 AllTenantsResolver 接口。`AllTenantsResolver 扩展了 `TenantResolver`。若要创建你自己的租户解析器,请实现 `org.grails.datastore.mapping.multitenancy.TenantResolver`。 |
将自定义租户解析器定义为 bean
import demo.CurrentUserTenantResolver
beans = {
currentUserTenantResolver(CurrentUserTenantResolver)
}
创建集成测试,以便测试自定义租户解析器。
package demo
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.annotation.Rollback
import spock.lang.Specification
@Integration
class CurrentUserTenantResolverSpec extends Specification {
UserDataService userDataService
RoleDataService roleDataService
UserRoleDataService userRoleDataService
@Autowired
CurrentUserTenantResolver currentUserTenantResolver
void "Test Current User throws a TenantNotFoundException if not logged in"() {
when:
currentUserTenantResolver.resolveTenantIdentifier()
then:
def e = thrown(TenantNotFoundException)
e.message == "Tenant could not be resolved from Spring Security Principal"
}
void "Test current logged in user is resolved "() {
given:
Role role = roleDataService.saveByAuthority('ROLE_USER')
User user = userDataService.save('admin', 'admin')
userRoleDataService.save(user, role)
when:
loginAs('admin', 'ROLE_USER')
Serializable username = currentUserTenantResolver.resolveTenantIdentifier()
then:
username == user.username
when: "verify AllTenantsResolver::resolveTenantIds"
Iterable<Serializable> tenantIds
demo.User.withNewSession {
tenantIds = currentUserTenantResolver.resolveTenantIds()
}
then:
tenantIds.toList().size() == 1
tenantIds.toList().get(0) == 'admin'
cleanup:
userRoleDataService.delete(user, role)
roleDataService.delete(role)
userDataService.delete(user.id)
}
void loginAs(String username, String authority) {
User user = userDataService.findByUsername(username)
if ( user ) {
// have to be authenticated as an admin to create ACLs
List<GrantedAuthority> authorityList = AuthorityUtils.createAuthorityList(authority)
SecurityContextHolder.context.authentication = new UsernamePasswordAuthenticationToken(user.username,
user.password,
authorityList)
}
}
}
接下来,我们将配置我们的应用以使用此自定义租户解析器。
通过修改 `application.yml` 配置多租户。
grails
gorm:
multiTenancy:
mode: DISCRIMINATOR (1)
tenantResolverClass: demo.CurrentUserTenantResolver (2)
1 | 将多租户模式定义为 `DISCRIMINATOR` |
2 | 设置使用自定义类的租户解析器类,稍后你会在本指南中编写此类。 |
3.7 功能测试
创建一个功能测试,验证我们能否使用切换用户的 API。
package demo
import grails.gorm.multitenancy.Tenants
import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.IgnoreIf
@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class PlanControllerSpec extends Specification {
PlanDataService planDataService
UserDataService userDataService
UserRoleDataService userRoleDataService
UserService userService
RoleDataService roleDataService
@Shared
HttpClient client
@OnceBefore
void init() {
String baseUrl = "http://localhost:$serverPort"
this.client = HttpClient.create(baseUrl.toURL())
}
String accessToken(String u, String p) {
HttpRequest request = HttpRequest.POST("/api/login", [username: u, password: p])
HttpResponse<Map> resp = client.toBlocking().exchange(request, Map)
if ( resp.status == HttpStatus.OK) {
return resp.body().access_token
}
null
}
def "Plans for current logged user are retrieved"() {
given:
User vector = userService.save('vector', 'secret', 'ROLE_VILLAIN')
User gru = userService.save('gru', 'secret', 'ROLE_VILLAIN')
Tenants.withId("gru") { (1)
planDataService.save('Steal the Moon')
}
Tenants.withId("vector") {
planDataService.save('Steal a Pyramid')
}
when: 'login with the gru'
String gruAccessToken = accessToken('gru', 'secret')
then:
gruAccessToken
when:
HttpRequest request = HttpRequest.GET("/plan").bearerAuth(gruAccessToken)
HttpResponse<String> resp = client.toBlocking().exchange(request, String)
then:
resp.status == HttpStatus.OK
resp.body() == '[{"title":"Steal the Moon"}]'
when: 'login with the vector'
String vectorAccessToken = accessToken('vector', 'secret')
then:
vectorAccessToken
when:
request = HttpRequest.GET("/plan").bearerAuth(vectorAccessToken)
resp = client.toBlocking().exchange(request, String)
then:
resp.status == HttpStatus.OK
resp.body() == '[{"title":"Steal a Pyramid"}]'
cleanup:
Tenants.withId("gru") { (1)
planDataService.deleteByTitle('Steal the Moon')
}
Tenants.withId("vector") {
planDataService.deleteByTitle('Steal the Pyramid')
}
userRoleDataService.deleteByUser(gru)
userDataService.delete(gru.id)
userRoleDataService.deleteByUser(vector)
userDataService.delete(vector.id)
roleDataService.deleteByAuthority('ROLE_VILLAIN')
}
}
1 | 你可以使用 withId 方法使用特定的租户 ID |
4 运行测试
若要运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html