这是一个非常好的问题,我真的想知道如何在新的授权服务器中以正确的方式实现它。在Spring资源服务器中,有一个关于多租户的部分。我已经成功地完成了它。
就新的Spring授权服务器多租户而言,我也为密码和客户端凭证授予类型完成了它。
但请注意,虽然它正在工作,但它有多完美我不知道,因为我只是为了学习而这样做。这只是一个示例。当我为授权码授予类型完成它时,我也会将其发布在我的github上。
我假设主数据库和租户数据库配置已完成。我无法在此处提供整个代码,因为它是大量的代码。我只会提供相关片段。但这里只是样本。
@Configuration
@Import({MasterDatabaseConfiguration.class, TenantDatabaseConfiguration.class})
public class DatabaseConfiguration {
}
我使用了单独的数据库。我在AuthorizationServerConfiguration中使用了以下类似的内容。
@Import({OAuth2RegisteredClientConfiguration.class})
public class AuthorizationServerConfiguration {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
....
http.addFilterBefore(new TenantFilter(), OAuth2AuthorizationRequestRedirectFilter.class);
SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();
addCustomOAuth2ResourceOwnerPasswordAuthenticationProvider(http);
return securityFilterChain;
}
}
这是我的TenantFilter代码
public class TenantFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LogManager.getLogger();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUrl = request.getRequestURL().toString();
if (!requestUrl.endsWith("/oauth2/jwks")) {
String tenantDatabaseName = request.getParameter("tenantDatabaseName");
if(StringUtils.hasText(tenantDatabaseName)) {
LOGGER.info("tenantDatabaseName request parameter is found");
TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
} else {
LOGGER.info("No tenantDatabaseName request parameter is found");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{'error': 'No tenant request parameter supplied'}");
response.getWriter().flush();
return;
}
}
filterChain.doFilter(request, response);
}
public static String getFullURL(HttpServletRequest request) {
StringBuilder requestURL = new StringBuilder(request.getRequestURL().toString());
String queryString = request.getQueryString();
if (queryString == null) {
return requestURL.toString();
} else {
return requestURL.append('?').append(queryString).toString();
}
}
}
这里是TenantDBContextHolder类
public class TenantDBContextHolder {
private static final ThreadLocal<String> TENANT_DB_CONTEXT_HOLDER = new ThreadLocal<>();
public static void setCurrentDb(String dbType) {
TENANT_DB_CONTEXT_HOLDER.set(dbType);
}
public static String getCurrentDb() {
return TENANT_DB_CONTEXT_HOLDER.get();
}
public static void clear() {
TENANT_DB_CONTEXT_HOLDER.remove();
}
}
现在,由于主数据库和租户数据库已经有了配置。在这些配置中,我们还检查TenantDBContextHolder类是否包含值。因为当请求令牌时,我们会检查请求并将其设置在TenantDBContextHolder中。因此,基于这个线程本地变量,正确的数据库被连接并向正确的数据库发放令牌。然后,在令牌自定义器中,您可以使用以下内容:
public class UsernamePasswordAuthenticationTokenJwtCustomizerHandler extends AbstractJwtCustomizerHandler {
....
@Override
protected void customizeJwt(JwtEncodingContext jwtEncodingContext) {
....
String tenantDatabaseName = TenantDBContextHolder.getCurrentDb();
if (StringUtils.hasText(tenantDatabaseName)) {
URL issuerURL = jwtClaimSetBuilder.build().getIssuer();
String issuer = issuerURL + "/" + tenantDatabaseName;
jwtClaimSetBuilder.claim(JwtClaimNames.ISS, issuer);
}
jwtClaimSetBuilder.claims(claims ->
userAttributes.entrySet().stream()
.forEach(entry -> claims.put(entry.getKey(), entry.getValue()))
);
}
}
现在我假设资源服务器也已经配置了多租户功能。这里是链接Spring Security Resource Server Multitenancy。基本上,您需要像以下这样为多租户配置两个bean:
public class OAuth2ResourceServerConfiguration {
....
@Bean
public JWTProcessor<SecurityContext> jwtProcessor(JWTClaimsSetAwareJWSKeySelector<SecurityContext> keySelector) {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(keySelector);
return jwtProcessor;
}
@Bean
public JwtDecoder jwtDecoder(JWTProcessor<SecurityContext> jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), jwtValidator);
decoder.setJwtValidator(validator);
return decoder;
}
}
现在有两个Spring类,你可以从token中获取租户标识符。
@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
private final TenantDataSourceRepository tenantDataSourceRepository;
private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();
....
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
String issuerURL = toTenant(token);
JwtIssuerValidator jwtIssuerValidator = validators.computeIfAbsent(issuerURL, this::fromTenant);
OAuth2TokenValidatorResult oauth2TokenValidatorResult = jwtIssuerValidator.validate(token);
String tenantDatabaseName = JwtService.getTenantDatabaseName(token);
TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
return oauth2TokenValidatorResult;
}
private String toTenant(Jwt jwt) {
return jwt.getIssuer().toString();
}
private JwtIssuerValidator fromTenant(String tenant) {
String issuerURL = tenant;
String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenantDatabaseName);
if (tenantDataSource == null) {
throw new IllegalArgumentException("unknown tenant");
}
JwtIssuerValidator jwtIssuerValidator = new JwtIssuerValidator(issuerURL);
return jwtIssuerValidator;
}
}
同样地
@Component
public class TenantJWSKeySelector implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
....
@Override
public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext) throws KeySourceException {
String tenant = toTenantDatabaseName(jwtClaimsSet);
JWSKeySelector<SecurityContext> jwtKeySelector = selectors.computeIfAbsent(tenant, this::fromTenant);
List<? extends Key> jwsKeys = jwtKeySelector.selectJWSKeys(jwsHeader, securityContext);
return jwsKeys;
}
private String toTenantDatabaseName(JWTClaimsSet claimSet) {
String issuerURL = (String) claimSet.getClaim("iss");
String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
return tenantDatabaseName;
}
private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenant);
if (tenantDataSource == null) {
throw new IllegalArgumentException("unknown tenant");
}
JWSKeySelector<SecurityContext> jwtKeySelector = fromUri(jwkSetUri);
return jwtKeySelector;
}
private JWSKeySelector<SecurityContext> fromUri(String uri) {
try {
return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri));
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
}
}
现在让我们来谈谈授权码授权类型流程。在这种情况下,我也获得了租户标识符。但是当它将我重定向到登录页面时,我失去了租户标识符,因为我认为它会从授权码请求创建一个新的登录页面请求。无论如何,我不确定,因为我必须查看授权代码流程的代码以确定实际操作。所以当它将我重定向到登录页面时,我的租户标识符就丢失了。
但是,在密码授权类型和客户端凭据授权类型的情况下,没有重定向,因此我可以在后续阶段中获取租户标识符,并成功将其放入我的令牌声明中。
然后,在资源服务器上,我获取发行者网址。从发行者网址获取租户标识符。验证它。并连接到资源服务器上的租户数据库。
我如何测试它。我使用了Spring客户端。您可以自定义授权码流程的请求。密码和客户端凭据以包括自定义参数。
谢谢。
------------------ 解决多租户授权码登录问题 ------------------
我也解决了这个问题。实际上,我在我的安全配置中所做的是使用以下配置。
public class SecurityConfiguration {
.....
@Bean(name = "authenticationManager")
public AuthenticationManager authenticationManager(AuthenticationManagerBuilder builder) throws Exception {
AuthenticationManager authenticationManager = builder.getObject();
return authenticationManager;
}
@Bean
@DependsOn(value = {"authenticationManager"})
public TenantUsernamePasswordAuthenticationFilter tenantAuthenticationFilter(AuthenticationManagerBuilder builder) throws Exception {
TenantUsernamePasswordAuthenticationFilter filter = new TenantUsernamePasswordAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager(builder));
filter.setAuthenticationDetailsSource(new TenantWebAuthenticationDetailsSource());
return filter;
}
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
FederatedIdentityConfigurer federatedIdentityConfigurer = new FederatedIdentityConfigurer().oauth2UserHandler(new UserRepositoryOAuth2UserHandler());
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
http.addFilterBefore(tenantAuthenticationFilter(authenticationManagerBuilder), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests(authorizeRequests -> authorizeRequests.requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
.antMatchers("/resources/**", "/static/**", "/webjars/**").permitAll()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
)
......
.apply(federatedIdentityConfigurer);
return http.build();
}
实际上,授权码的问题在于你首先被重定向到登录页面。成功登录后,您会看到同意页面。但是当您来到同意页面时,您会失去租户参数。
原因是Spring内部类OAuth2AuthorizationEndpointFilter拦截了Authorization Code的请求。它检查用户是否已经通过身份验证。如果用户尚未通过身份验证,则显示登录页面。成功登录后,它会检查是否需要同意。如果需要,则使用仅三个参数的重定向URI。这是Spring的内部代码。
private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response,
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication,
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthentication) throws IOException {
....
if (hasConsentUri()) {
String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request))
.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
.queryParam(OAuth2ParameterNames.CLIENT_ID, clientId)
.queryParam(OAuth2ParameterNames.STATE, state)
.toUriString();
this.redirectStrategy.sendRedirect(request, response, redirectUri);
} else {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Displaying generated consent screen");
}
DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state);
}
}
请看上面的方法是私有的,我找不到任何可以自定义它的方法。也许有,但我没有找到。无论如何,现在您的同意控制器已被调用。但没有租户标识符。你拿不到它。并且在同意之后,没有办法连接到基于标识符的租户数据库。
所以第一步是在登录页面添加租户标识符。然后在登录后,您应该有这个租户标识符,以便可以在同意页面上设置它。之后当您提交同意表单时,这个参数将会存在。
顺便说一下,我之前做过这件事,可能我错过了什么,但这就是我做的。
现在怎样在登录页面获取您的参数。我使用以下方法解决。首先,我创建了一个常量,因为我需要多次访问该名称。
public interface Constant {
String TENANT_DATABASE_NAME = "tenantDatabaseName";
}
创建以下类:
public class RedirectModel {
@NotBlank
private String tenantDatabaseName;
public void setTenantDatabaseName(String tenantDatabaseName) {
this.tenantDatabaseName = tenantDatabaseName;
}
public String getTenantDatabaseName() {
return tenantDatabaseName;
}
}
然后在我的登录控制器中,我使用以下代码获取它:
@Controller
public class LoginController {
@GetMapping("/login")
public String login(@Valid @ModelAttribute RedirectModel redirectModel, Model model, BindingResult result) {
if (!result.hasErrors()) {
String tenantDatabaseName = redirectModel.getTenantDatabaseName();
String currentDb = TenantDBContextHolder.getCurrentDb();
LOGGER.info("Current database is {}", currentDb);
LOGGER.info("Putting {} as tenant database name in model. So it can be set as a hidden form element ", tenantDatabaseName);
model.addAttribute(Constant.TENANT_DATABASE_NAME, tenantDatabaseName);
}
return "login";
}
}
这是我在登录页面通过请求获得租户标识符的第一步。
现在,我在我的安全配置中使用了TenantUsernamePasswordAuthenticationFilter。以下是过滤器:
public class TenantUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final Logger LOGGER = LogManager.getLogger();
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
String tenantDatabaseName = obtainTenantDatabaseName(request);
LOGGER.info("tenantDatabaseName is {}", tenantDatabaseName);
LOGGER.info("Setting {} as tenant database name in thread local context.", tenantDatabaseName);
TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
return super.attemptAuthentication(request, response);
}
private String obtainTenantDatabaseName(HttpServletRequest request) {
return request.getParameter(Constant.TENANT_DATABASE_NAME);
}
}
在配置中,我正在将TenantWebAuthenticationDetailsSource设置在此过滤器上,如下所示:
public class TenantWebAuthenticationDetailsSource extends WebAuthenticationDetailsSource {
@Override
public TenantWebAuthenicationDetails buildDetails(HttpServletRequest context) {
return new TenantWebAuthenicationDetails(context);
}
}
这里是类
public class TenantWebAuthenicationDetails extends WebAuthenticationDetails {
private static final long serialVersionUID = 1L;
private String tenantDatabaseName;
public TenantWebAuthenicationDetails(HttpServletRequest request) {
super(request);
this.tenantDatabaseName = request.getParameter(Constant.TENANT_DATABASE_NAME);
}
public TenantWebAuthenicationDetails(String remoteAddress, String sessionId, String tenantDatabaseName) {
super(remoteAddress, sessionId);
this.tenantDatabaseName = tenantDatabaseName;
}
public String getTenantDatabaseName() {
return tenantDatabaseName;
}
}
现在,在Spring验证用户后,我可以在详细信息中找到租户名称。然后在同意控制器中使用:
@Controller
public class AuthorizationConsentController {
....
@GetMapping(value = "/oauth2/consent")
public String consent(Authentication authentication, Principal principal, Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state) {
......
String registeredClientName = registeredClient.getClientName();
Object webAuthenticationDetails = authentication.getDetails();
if (webAuthenticationDetails instanceof TenantWebAuthenicationDetails) {
TenantWebAuthenicationDetails tenantAuthenticationDetails = (TenantWebAuthenicationDetails)webAuthenticationDetails;
String tenantDatabaseName = tenantAuthenticationDetails.getTenantDatabaseName();
model.addAttribute(Constant.TENANT_DATABASE_NAME, tenantDatabaseName);
}
model.addAttribute("clientId", clientId);
.....
return "consent-customized";
}
}
现在我的同意页面上有我的租户标识符。提交后,它将出现在请求参数中。
还有另一个我使用的类:
public class TenantLoginUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {
public TenantLoginUrlAuthenticationEntryPoint(String loginFormUrl) {
super(loginFormUrl);
}
@Override
protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) {
String tenantDatabaseNameParamValue = request.getParameter(Constant.TENANT_DATABASE_NAME);
String redirect = super.determineUrlToUseForThisRequest(request, response, exception);
String url = UriComponentsBuilder.fromPath(redirect).queryParam(Constant.TENANT_DATABASE_NAME, tenantDatabaseNameParamValue).toUriString();
return url;
}
}
无论如何,这是我的解决办法。虽然在我所有的项目中都没有这样的要求,但我想使用这个新服务器来完成它,所以我就用这种方式解决了它。
总之,代码很多。我使用Spring oauth2客户端对其进行了测试,它可以正常工作。希望我能创建一些项目并将其上传到我的Github上。再次运行它后,我会在这里更详细地解释流程,特别是在提交同意后如何设置Thread Local变量。
之后,一切都很直接了当。
希望这能有所帮助。
谢谢。