Spring Security:自定义认证服务器Introspection响应及优化问题
背景说明
我基于Spring Security实现安全登录功能,选择**不透明令牌(Opaque Token)**方案而非JWT,核心原因是避免在令牌中暴露敏感安全信息,同时需要支持令牌吊销能力。目前已实现完整流程:
- 客户端向认证服务器发送授权码,认证服务器返回短版访问令牌
- 客户端携带该令牌调用资源服务器接口
- 资源服务器将令牌转发至认证服务器校验
- 认证服务器通过内存缓存验证令牌有效性后,返回令牌状态(
active=true/false)、用户名、过期时间等基础信息
疑问与需求
- 希望认证服务器在验证令牌时,返回额外敏感信息(如用户权限、账户状态等未放入令牌的内容),能否配置Introspection端点,在验证成功后通过
UserDetailsService调用数据库获取这些属性并嵌入响应,供资源服务器生成认证对象? - 为减少认证服务器与资源服务器间的Introspection调用次数(默认每次请求都需调用),能否在资源服务器实现本地缓存,优先使用缓存中的认证对象?缓存有效期不超过令牌过期时间,但令牌吊销时如何绕过缓存?
- 是否无需在认证服务器获取额外信息并通过Introspection返回,而是让资源服务器通过独立的
UserDetailsService自行获取信息并生成认证对象?
认证服务器初始配置代码
@Bean /* registeredClientRepository (defines: authorization grants, OIDC scopes, etc.) */ fun registeredClientRepository(): InMemoryRegisteredClientRepository { // front-end client val oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("oidc-client") .clientSecret(passwordEncoder.encode("secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("https://www.aaa.com/authorized") .postLogoutRedirectUri("https://www.aaa.com/authorized") .tokenSettings( TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.REFERENCE) .accessTokenTimeToLive(Duration.ofHours(12)) .build()) .scope("OPAQUE") .scopes { scopes -> scopes.addAll(listOf("read", "write")) } .scope(OidcScopes.OPENID) // requests an ID token, which is necessary for any OpenID Connect request. .scope(OidcScopes.PROFILE) // requests access to the user's profile information (e.g., name, date of birth) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build() // back-end client val resourceServer = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("resource_server") .clientSecret(passwordEncoder.encode("resource_server_secret")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .build() return InMemoryRegisteredClientRepository(oidcClient, resourceServer) }
更新1:自定义Introspection端点报错
尝试自定义Introspection响应时,出现以下错误:
{ "error": "Internal Server Error", "message": "Introspection endpoint responded with HTTP status code 302" }
相关自定义代码:
@Bean @Order(1) @Throws(Exception::class) /* security filter chain for protocol endpoints */ fun authorizationServerSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { // apply default http security settings to oauth 2.0 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) // enable OpenID Connect 1.0 + customise OAuth2TokenIntrospection response val authorizationServerConfigurer = http.getConfigurer(OAuth2AuthorizationServerConfigurer::class.java) authorizationServerConfigurer.oidc(withDefaults()) authorizationServerConfigurer.tokenIntrospectionEndpoint { configurer -> configurer.introspectionResponseHandler(CustomIntrospectionResponseHandler()) } // redirect to the login page when not authenticated http .exceptionHandling { it.defaultAuthenticationEntryPointFor( LoginUrlAuthenticationEntryPoint("/login"), MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) } return http.build() } class CustomIntrospectionResponseHandler : AuthenticationSuccessHandler { private val objectMapper = ObjectMapper() override fun onAuthenticationSuccess(request: HttpServletRequest, response: HttpServletResponse, authentication: Authentication) { val introspectionAuthentication = authentication as OAuth2TokenIntrospectionAuthenticationToken val responseMap = mutableMapOf<String, Any?>() responseMap["active"] = introspectionAuthentication.tokenClaims.isActive responseMap["scope"] = introspectionAuthentication.tokenClaims.scopes responseMap["client_id"] = introspectionAuthentication.tokenClaims.clientId responseMap["username"] = introspectionAuthentication.tokenClaims.username responseMap["token_type"] = introspectionAuthentication.tokenClaims.tokenType responseMap["exp"] = introspectionAuthentication.tokenClaims.expiresAt responseMap["iat"] = introspectionAuthentication.tokenClaims.issuedAt responseMap["nbf"] = introspectionAuthentication.tokenClaims.notBefore responseMap["sub"] = introspectionAuthentication.tokenClaims.subject responseMap["aud"] = introspectionAuthentication.tokenClaims.audience responseMap["iss"] = introspectionAuthentication.tokenClaims.issuer responseMap["jti"] = introspectionAuthentication.tokenClaims.id // add custom claim responseMap["custom_claim"] = "custom_value" // ensure the response has the correct content type and status response.contentType = "application/json;charset=UTF-8" response.status = HttpServletResponse.SC_OK // write the modified response response.writer.write(objectMapper.writeValueAsString(responseMap)) } }
更新2:解决方案
无需编辑Introspection响应,在令牌存入OAuth2MemoryService前直接修改令牌,添加自定义Claims即可:
@Configuration internal class OpaqueTokenConfig { @Bean // for adding custom claims to opaque tokens (OAuth2AccessToken) fun accessTokenCustomizer(): OAuth2TokenCustomizer<OAuth2TokenClaimsContext> { return OAuth2TokenCustomizer { context -> val userDetails = context.getPrincipal<Authentication>().principal as UserDetails if (!userDetails.username.isNullOrEmpty()) { context.claims .claim( "authorities", userDetails.authorities.map { it.authority }.toSet() ) .claim( "username", userDetails.username ) } else { throw IllegalStateException("Bad UserDetails, username is empty") } } } }
问题解答
1. 关于Introspection端点返回额外信息
可以实现,但你找到的OAuth2TokenCustomizer方案是更简洁可靠的方式:在令牌生成阶段就将用户权限、账户状态等信息注入Claims,Introspection端点会自动返回这些内容。如果需要实时从数据库拉取最新信息(而非令牌生成时的快照),可以自定义OAuth2TokenIntrospectionAuthenticationProvider,在令牌校验逻辑中调用UserDetailsService获取最新数据并添加到响应Claims中。
你之前自定义响应处理器报302错误,是因为Introspection端点默认受客户端认证保护,自定义的AuthenticationSuccessHandler没有正确处理非HTML请求的认证逻辑,触发了登录跳转。用OAuth2TokenCustomizer可以避免这个问题。
2. 关于资源服务器本地缓存与令牌吊销
完全可以在资源服务器实现本地缓存:
- 自定义带缓存的
OpaqueTokenIntrospector,使用Caffeine、Redis等缓存框架,将缓存有效期设置为令牌的剩余过期时间(从Introspection响应的exp字段计算)。 - 令牌吊销时的缓存绕过方案:
- 短缓存TTL:设置较短的缓存有效期(比如5分钟),平衡性能与一致性
- 主动失效:认证服务器维护吊销列表,当令牌被吊销时,通过分布式事件(如Redis Pub/Sub)通知所有资源服务器清除对应缓存条目
- 校验兜底:资源服务器在缓存命中时,额外调用认证服务器的吊销查询接口(需自行实现)验证令牌状态
3. 关于资源服务器独立获取用户信息
这种方案可行,但存在明显缺陷:
- 数据一致性问题:认证服务器的用户信息更新后,资源服务器无法及时感知,可能使用过期数据
- 失去集中式管理优势:权限规则分散在多个服务,难以统一维护
- 仍需令牌校验:资源服务器必须先调用认证服务器的Introspection端点验证令牌有效性,再通过令牌中的
sub或username调用本地UserDetailsService获取信息,无法省去Introspection调用
如果业务对数据一致性要求不高,且需要降低认证服务器压力,可以考虑这种方案,但建议优先选择集中式的Claims注入方式。
内容的提问来源于stack exchange,提问作者Sachin

