Spring-Security多租户OAuth2安全:混合JWT场景实现难题求助
Spring Security多租户OAuth2混合授权实现方案
核心思路
针对两类租户的差异,通过自定义认证逻辑区分Issuer,分支处理权限提取:自有Keycloak租户直接解析令牌内的realm-roles;外部IDP租户调用其user-info/introspection端点获取权限后完成授权。
1. 自定义JwtAuthenticationProvider
扩展Spring Security的JwtAuthenticationProvider,根据令牌的iss(签发者)字段判断租户类型,分别处理权限提取逻辑:
@Component public class TenantJwtAuthenticationProvider extends JwtAuthenticationProvider { private final WebClient webClient; private final String keycloakIssuer; private final String externalIdpUserInfoUri; private final String externalClientId; private final String externalClientSecret; public TenantJwtAuthenticationProvider(JwtDecoder jwtDecoder, WebClient.Builder webClientBuilder, @Value("${keycloak.issuer}") String keycloakIssuer, @Value("${external.idp.user-info-uri}") String externalIdpUserInfoUri, @Value("${external.idp.client-id}") String externalClientId, @Value("${external.idp.client-secret}") String externalClientSecret) { super(jwtDecoder); this.webClient = webClientBuilder.build(); this.keycloakIssuer = keycloakIssuer; this.externalIdpUserInfoUri = externalIdpUserInfoUri; this.externalClientId = externalClientId; this.externalClientSecret = externalClientSecret; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication; Jwt jwt = jwtToken.getToken(); String issuer = jwt.getIssuer(); Collection<GrantedAuthority> authorities; if (keycloakIssuer.equals(issuer)) { // 自有Keycloak租户:提取realm-roles转换为权限 Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); List<String> roles = (List<String>) realmAccess.get("roles"); authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } else { // 外部IDP租户:调用user-info端点获取权限 authorities = fetchExternalUserAuthorities(jwt.getTokenValue()); } return new JwtAuthenticationToken(jwt, authorities, jwtToken.getName()); } private Collection<GrantedAuthority> fetchExternalUserAuthorities(String token) { // 调用外部IDP的user-info端点,携带令牌及客户端凭证(按IDP要求) UserInfoResponse userInfo = webClient.get() .uri(externalIdpUserInfoUri) .headers(headers -> { headers.setBearerAuth(token); String clientAuth = Base64.getEncoder().encodeToString((externalClientId + ":" + externalClientSecret).getBytes()); headers.set("Authorization", "Basic " + clientAuth); }) .retrieve() .bodyToMono(UserInfoResponse.class) .block(); // 从返回结果中提取权限,转换为Spring Security权限对象 return userInfo.getPermissions().stream() .map(perm -> new SimpleGrantedAuthority("PERM_" + perm.toUpperCase())) .collect(Collectors.toList()); } // 自定义类接收外部IDP的用户信息响应 private static class UserInfoResponse { private List<String> permissions; public List<String> getPermissions() { return permissions; } public void setPermissions(List<String> permissions) { this.permissions = permissions; } } }
2. 配置HttpSecurity启用自定义认证
在SecurityFilterChain中替换默认认证逻辑,确保自定义Provider生效:
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, TenantJwtAuthenticationProvider tenantAuthProvider) throws Exception { http .authorizeHttpRequests(auth -> auth // 自有租户端点:按角色授权 .requestMatchers("/internal/**").hasRole("ADMIN") // 外部租户端点:按自定义权限授权 .requestMatchers("/external/**").hasAuthority("PERM_EXTERNAL_ACCESS") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .authenticationManagerResolver(context -> new ProviderManager(tenantAuthProvider) ) ) ); return http.build(); }
3. 外部IDP租户的优化处理
- 权限缓存:避免重复调用外部端点,用Spring Cache缓存权限信息:
@Cacheable(value = "externalUserAuthorities", key = "#token") private Collection<GrantedAuthority> fetchExternalUserAuthorities(String token) { // 原调用逻辑 } - Introspection替代User-Info:若外部IDP要求用Introspection端点,只需修改
fetchExternalUserAuthorities方法,改为POST请求调用Introspection接口,从返回结果中提取权限。 - 异常处理:添加捕获逻辑,当调用外部IDP失败时,抛出
AuthenticationServiceException拒绝请求。
4. 统一授权注解使用
无论租户类型,最终权限都转换为GrantedAuthority,可通过@PreAuthorize统一控制:
@RestController @RequestMapping("/external") public class ExternalController { @PreAuthorize("hasAuthority('PERM_EXTERNAL_ACCESS')") @GetMapping("/data") public String getExternalData() { return "External tenant data"; } } @RestController @RequestMapping("/internal") public class InternalController { @PreAuthorize("hasRole('ADMIN')") @GetMapping("/admin") public String getAdminData() { return "Internal admin data"; } }
内容的提问来源于stack exchange,提问作者naptoon
相关产品推荐
相关产品推荐

