Spring Authorization Server多租户OAuth2授权码流自定义认证方案咨询
业务需求
构建多租户Web服务,需实现以下OAuth2授权码流逻辑:
- 第三方客户端(如Team Tailor)向Auth2服务发起授权码请求
- Auth2服务通过Client ID识别租户,重定向用户到租户端完成认证(SSO/账号密码登录)
- 租户认证成功后返回JWT,Auth2服务解析该JWT的Claims(如
username、用户档案ID) - 将Claims传递给Spring Authorization Server,由其完成后续流程并为第三方客户端签发最终JWT(禁止手动生成令牌)
核心问题与解决方案
1. 拦截授权码请求并提取Client ID,重定向至租户登录页
通过自定义过滤器拦截/oauth2/authorize端点的未认证请求,提取Client ID并触发租户重定向:
自定义租户授权请求过滤器
public class TenantAuthorizationRequestFilter extends OncePerRequestFilter { private final ClientRegistrationRepository clientRepo; private final TenantService tenantService; // 自定义服务,根据Client ID获取租户登录URL private final RedisTemplate<String, String> redisTemplate; public TenantAuthorizationRequestFilter(ClientRegistrationRepository clientRepo, TenantService tenantService, RedisTemplate<String, String> redisTemplate) { this.clientRepo = clientRepo; this.tenantService = tenantService; this.redisTemplate = redisTemplate; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String uri = request.getRequestURI(); // 仅处理未认证的授权码请求 if ("/oauth2/authorize".equals(uri) && request.getUserPrincipal() == null) { String clientId = request.getParameter("client_id"); String state = request.getParameter("state"); String redirectUri = request.getParameter("redirect_uri"); if (clientId != null && state != null) { // 校验客户端合法性 ClientRegistration client = clientRepo.findByRegistrationId(clientId); if (client != null) { // 保存授权请求上下文,用于回调时恢复 String contextKey = "auth_ctx:" + state; redisTemplate.opsForValue().set(contextKey, new ObjectMapper().writeValueAsString(Map.of("clientId", clientId, "redirectUri", redirectUri)), 30, TimeUnit.MINUTES); // 重定向到租户登录页,携带state String tenantLoginUrl = tenantService.getTenantLoginUrl(clientId) + "?state=" + state; response.sendRedirect(tenantLoginUrl); return; } } } chain.doFilter(request, response); } }
注册过滤器到Security链
将过滤器放在OAuth2AuthorizationRequestRedirectFilter之前,确保优先拦截请求:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, TenantAuthorizationRequestFilter tenantFilter) throws Exception { http .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .oauth2AuthorizationServer(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint .authorizationRequestRepository(cookieAuthorizationRequestRepository()) ) ) .addFilterBefore(tenantFilter, OAuth2AuthorizationRequestRedirectFilter.class); return http.build(); } // 可选:用Cookie保存授权请求(默认实现) private OAuth2AuthorizationRequestRepository<OAuth2AuthorizationRequest> cookieAuthorizationRequestRepository() { return new CookieOAuth2AuthorizationRequestRepository(); } }
2. 避免租户回调的无限循环
核心是使用专属回调端点处理租户返回的JWT,而非直接回到授权端点:
租户回调控制器
@RestController @RequestMapping("/tenant/callback") public class TenantCallbackController { private final JwtDecoder jwtDecoder; private final RedisTemplate<String, String> redisTemplate; private final ObjectMapper objectMapper; public TenantCallbackController(JwtDecoder jwtDecoder, RedisTemplate<String, String> redisTemplate, ObjectMapper objectMapper) { this.jwtDecoder = jwtDecoder; this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; } @GetMapping public String handleCallback(@RequestParam String state, @RequestParam String tenantJwt) throws Exception { // 1. 恢复授权请求上下文 String contextStr = redisTemplate.opsForValue().get("auth_ctx:" + state); if (contextStr == null) throw new IllegalArgumentException("Invalid/expired state"); Map<String, String> authCtx = objectMapper.readValue(contextStr, Map.class); String clientId = authCtx.get("clientId"); String redirectUri = authCtx.get("redirectUri"); // 2. 验证并解析租户JWT Jwt decodedJwt = jwtDecoder.decode(tenantJwt); Map<String, Object> userClaims = decodedJwt.getClaims(); // 3. 构建认证对象,存入SecurityContext Collection<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); Authentication auth = new UsernamePasswordAuthenticationToken( decodedJwt.getSubject(), null, authorities ); auth.setDetails(userClaims); // 将租户Claims存入认证详情 SecurityContextHolder.getContext().setAuthentication(auth); // 4. 重定向回授权端点,继续流程(此时用户已认证,不会触发租户重定向) return String.format("redirect:/oauth2/authorize?client_id=%s&redirect_uri=%s&state=%s", clientId, redirectUri, state); } }
关键逻辑:回调完成后用户已处于认证状态,再次进入/oauth2/authorize时,过滤器会跳过重定向,避免循环。
3. 让Spring Authorization Server自动生成令牌
自定义OAuth2UserService,从认证对象中提取租户JWT的Claims,供授权服务器使用:
自定义OAuth2用户服务
@Service public class TenantClaimsOAuth2UserService implements OAuth2UserService<OAuth2AuthorizationRequest, OAuth2User> { @Override public OAuth2User loadUser(OAuth2AuthorizationRequest authorizationRequest) throws OAuth2AuthenticationException { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 从认证详情中获取租户JWT的Claims Map<String, Object> tenantClaims = (Map<String, Object>) auth.getDetails(); // 返回OAuth2User对象,授权服务器会用这些Claims生成最终令牌 return new DefaultOAuth2User( auth.getAuthorities(), tenantClaims, "username" // 指定用户名对应的Claim键 ); } }
配置授权服务器使用自定义用户服务
@Configuration public class AuthorizationServerConfig { @Bean public OAuth2AuthorizationServerConfigurer authorizationServerConfigurer(TenantClaimsOAuth2UserService userService) { return new OAuth2AuthorizationServerConfigurer() .authorizationEndpoint(endpoint -> endpoint .userService(userService) // 绑定自定义用户服务 ); } }
这样Spring Authorization Server会自动使用租户JWT的Claims生成最终的访问令牌/ID令牌,无需手动处理令牌生成逻辑。
完整流程梳理
- 第三方客户端发起
/oauth2/authorize请求,携带client_id、state、redirect_uri - 租户过滤器拦截未认证请求,保存授权上下文,重定向到租户登录页
- 用户在租户端完成认证,租户携带
state和tenantJwt回调到/tenant/callback - 回调端点验证JWT,构建认证对象存入SecurityContext,重定向回授权端点
- 授权端点检测用户已认证,调用自定义用户服务获取租户Claims
- Spring Authorization Server生成包含租户Claims的授权码,返回给第三方客户端
- 第三方客户端用授权码换取最终令牌,流程完成
内容的提问来源于stack exchange,提问作者Dupinder Singh
相关产品推荐
相关产品推荐

