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

Spring Security:自定义认证服务器Introspection响应及优化问题

Spring Security不透明令牌(Opaque Token)实践与问题解答

背景说明

我基于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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 23:02:05