You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 11:12:09