当前登录用户的自定义租户解析器
了解如何创建自定义租户解析器,并使用 Grails 多租户功能根据当前登录用户或 JWT 切换租户。
作者:Sergio del Amo
Grails 版本 3.3.8
1 Grails 培训
Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和提供!。
2 开始使用
GORM 多租户支持包括 多个内置租户解析器。在本指南中,您将创建自己的租户解析器,以根据使用 Spring Security REST 插件 的 Grails rest-api 应用程序中的登录用户切换租户。
2.1 您需要了解的内容
为了完成本指南,您需要具备以下条件:
-
有些时间
-
不错的文本编辑器或 IDE
-
已安装 JDK 1.7 或更高版本,并正确配置了
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 编写应用程序
本指南使用多租户鉴别符模式。要了解更多信息,请阅读单一数据库多租户鉴别符列指南。 |
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 Data Service。
package demo
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@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:
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 Data Services来处理与我们应用程序安全相关联的域类。
package demo
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@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 插件 允许你通过 JWT 令牌对你的应用程序进行身份验证,该令牌存在于 “授权” 头中。
示例:对 `/plan` 的请求在 “授权” 头中包含一个 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.TenantResolver
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() {
new DetachedCriteria(User)
.distinct('username')
.list()
}
}
1 | 在使用基于鉴别器的多租户时,如果想要在任何时候迭代所有可用的租户,则可能需要在你的 `TenantResolver` 实现中实现 AllTenantsResolver 接口。`AllTenantsResolver 扩展 `TenantResolver`。要创建你自己的租户解析器实现 `org.grails.datastore.mapping.multitenancy.TenantResolver`。 |
将自定义租户解析器定义为一个 bean
import demo.CurrentUserByJwtTenantResolver
beans = {
currentUserByJwtTenantResolver(CurrentUserByJwtTenantResolver)
}
创建一个集成测试来测试自定义租户解析器。
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.mock.web.MockHttpServletRequest
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
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")
String jwt = '''\
eyJhbGciOiJIUzI1NiJ9.eyJwcmluY2lwYWwiOiJINHNJQUFBQUFBQUFBSlZTdjA4VVFSUitleHhDZ2xF\
d2tjUUNHN0V6ZXdtV1Z3RkJBcGtjeHZNc01OSE03VDZXZ2RtWmRXYjJ1R3ZJVlZKUVFGUVNFbHBMXC9oT\
nRcL0FPTUZMVFV0THhaT1BhMElVNjFlZlB0OSt2TjZRV01XZ012RThPRnRHRW04MFNvMEdaR3FNUmlsQn\
ZoZW1GdTBjVG9Dc1J5QVd6UkJLNVBVSUdBUVVYRURoNnhMZDdoTmNsVlVsdHJiMkhrNmwwRGM5b2tONHd\
iaHFlNG84MTJlTXNkYVlOXC9DWlRVd2ZjS2pLM0RGSThpblN2WDBHcXBtd21EOFRwTWxqT21vMjBcL2Vo\
elJEU29udUxURDBERlV2QzB4WmpEQmM3ZXBTVldnZGZEdzJtenVoS3cxMGRVWmpHZmNXbkwzVDVLbTg5Yj\
l2YmVwS01FbjJJVnFOd3ZvVUhmUFBUVDBQT0dpbHBKU0M2M3NiRXVsT2hZYndvc1RmM1wvbXk2K0RrMzZy\
QWtDZHZMajduM0wrWkFINlB6NWNQaTJLRGlJSDAwUFdTMWk5bTVHYnFaTDVyVUd2XC9QdjQ5ZGVqaTczM0\
k2VHNFYVwvK2Z4K3o4emZOOVJaMW1uSERuUjdhRWRIdVZQMDNrU1wvY1RUN1lRaTlzaWpTVFNDOUtPWXh2\
SlVwaWlsczFXZzc2ZG5EXC96UnBiK3ZodWhiSDVsWVlmM090UWRtMUkrRUdSMnk4c1pKcld0WDkrK1BQZ\
zJSOGlXWVhSRHBjNVV1MlRKYWlScDIwMG4wK1BaaWErbmUwWElRWVArZ3JPZUdRVkZBTUFBQT09Iiwic3\
ViIjoidmVjdG9yIiwicm9sZXMiOlsiUk9MRV9WSUxMQUlOIl0sImlhdCI6MTUwNDc4MTEyNX0.cf5rGNrNolchQ3QyMsPB544fwzYGiihBkRF8KU6soxc'''
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() {
new DetachedCriteria(User)
.distinct('username')
.list()
}
}
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)
reactor:
# Whether to translate GORM events into Reactor events
# Disabled by default for performance reasons
events: false
1 | 将多租户模式定义为 `DISCRIMINATOR` |
2 | 使用一个自定义类(你将在本指南的后面编写)设置租户解析器类。 |
3.7 功能测试
创建一个功能测试,验证我们可以使用切换用户的 API。
package demo
import grails.gorm.multitenancy.Tenants
import grails.plugins.rest.client.RestBuilder
import grails.testing.mixin.integration.Integration
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
RestBuilder rest = new RestBuilder()
String accessToken(String u, String p) {
def resp = rest.post("http://localhost:${serverPort}/api/login") {
accept('application/json')
contentType('application/json')
json {
username = u
password = p
}
}
if ( resp.status == 200 ) {
return resp.json.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:
def resp = rest.get("http://localhost:${serverPort}/plan") {
accept('application/json')
header('Authorization', "Bearer ${gruAccessToken}")
}
then:
resp.status == 200
resp.json.toString() == '[{"title":"Steal the Moon"}]'
when: 'login with the vector'
String vectorAccessToken = accessToken('vector', 'secret')
then:
vectorAccessToken
when:
resp = rest.get("http://localhost:${serverPort}/plan") {
accept('application/json')
header('Authorization', "Bearer ${vectorAccessToken}")
}
then:
resp.status == 200
resp.json.toString() == '[{"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