Spring Boot 2 OIDC(OAuth2)客户端/资源服务器未在 WebClient 中传播访问令牌

Posted

技术标签:

【中文标题】Spring Boot 2 OIDC(OAuth2)客户端/资源服务器未在 WebClient 中传播访问令牌【英文标题】:Spring Boot 2 OIDC (OAuth2) client / resource server not propagating the access token in the WebClient 【发布时间】:2020-04-16 03:03:42 【问题描述】:

Sample project available on Github

我已经成功地将两个 Spring Boot 2 application2 配置为针对 Keycloak 的客户端/资源服务器,并且它们之间的 SSO 很好。

此外,我正在测试彼此经过身份验证的 REST 调用,将访问令牌作为 Authorization: Bearer ACCESS_TOKEN 标头传播。

在启动 Keycloak 和我访问 http://localhost:8181/resource-server1 或 http://localhost:8282/resource-server-2 的应用程序后,并在 Keycloak 登录页面中进行身份验证。 HomeController 使用 WebClient 调用另一个资源服务器的 HelloRestController /rest/hello 端点。

@Controller
class HomeController(private val webClient: WebClient) 

    @GetMapping
    fun home(httpSession: HttpSession,
             @RegisteredOAuth2AuthorizedClient authorizedClient: OAuth2AuthorizedClient,
             @AuthenticationPrincipal oauth2User: OAuth2User): String 
        val authentication = SecurityContextHolder.getContext().authentication
        println(authentication)

        val pair = webClient.get().uri("http://localhost:8282/resource-server-2/rest/hello").retrieve()
                .bodyToMono(Pair::class.java)
                .block()

        return "home"
    


此调用返回 302,因为请求未经过身份验证(它没有传播访问令牌):

2019-12-25 14:09:03.737 DEBUG 8322 --- [nio-8181-exec-5] o.s.s.w.a.ExceptionTranslationFilter     : Access is denied (user is anonymous); redirecting to authentication entry point

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:84) ~[spring-security-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:233) ~[spring-security-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]

OAuth2配置:

@Configuration
class OAuth2Config : WebSecurityConfigurerAdapter() 

    @Bean
    fun webClient(): WebClient 
        return WebClient.builder()
                .filter(ServletBearerExchangeFilterFunction())
                .build()
    

    @Bean
    fun clientRegistrationRepository(): ClientRegistrationRepository 
        return InMemoryClientRegistrationRepository(keycloakClientRegistration())
    

    private fun keycloakClientRegistration(): ClientRegistration 
        val clientRegistration = ClientRegistration
                .withRegistrationId("resource-server-1")
                .clientId("resource-server-1")
                .clientSecret("c00670cc-8546-4d5f-946e-2a0e998b9d7f")
                .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUriTemplate("baseUrl/login/oauth2/code/registrationId")
                .scope("openid", "profile", "email", "address", "phone")
                .authorizationUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/auth")
                .tokenUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/token")
                .userInfoUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .jwkSetUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/certs")
                .clientName("Keycloak")
                .providerConfigurationMetadata(mapOf("end_session_endpoint" to "http://localhost:8080/auth/realms/insight/protocol/openid-connect/logout"))
                .build()
        return clientRegistration
    

    override fun configure(http: HttpSecurity) 
        http.authorizeRequests  authorizeRequests ->
            authorizeRequests
                    .anyRequest().authenticated()
        .oauth2Login(withDefaults())
                .logout  logout ->
                    logout.logoutSuccessHandler(oidcLogoutSuccessHandler())
                
    

    private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler? 
        val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository())
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri(URI.create("http://localhost:8181/resource-server-1"))
        return oidcLogoutSuccessHandler
    

如您所见,我在WebClient 中设置了ServletBearerExchangeFilterFunction。这是我看到的调试:

SubscriberContext 没有设置任何东西,因为authentication.getCredentials() instanceof AbstractOAuth2Token 是假的。实际上它只是一个字符串:

public class OAuth2AuthenticationToken extends AbstractAuthenticationToken 

    ... 

    @Override
    public Object getCredentials() 
        // Credentials are never exposed (by the Provider) for an OAuth2 User
        return "";
    

这里有什么问题?如何自动化令牌的传播?

【问题讨论】:

【参考方案1】:

对于纯 OAuth2/OIDC 登录应用程序似乎没有开箱即用的解决方案,我为此创建了一个 Github issue。

与此同时,我创建了一个特定的ServletBearerExchangeFilterFunction,它从OAuth2AuthorizedClientRepository 检索访问令牌。

这是我的自定义解决方案:

    @Autowired
    lateinit var oAuth2AuthorizedClientRepository: OAuth2AuthorizedClientRepository

    @Bean
    fun webClient(): WebClient 
        val servletBearerExchangeFilterFunction = ServletBearerExchangeFilterFunction("resource-server-1", oAuth2AuthorizedClientRepository)
        return WebClient.builder()
                .filter(servletBearerExchangeFilterFunction)
                .build()
    

    ...

    private fun keycloakClientRegistration(): ClientRegistration 
        return ClientRegistration
                .withRegistrationId("resource-server-1")
                ...
const val SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY = "org.springframework.security.SECURITY_CONTEXT_ATTRIBUTES"

class ServletBearerExchangeFilterFunction(private val clientRegistrationId: String,
                                          private val oAuth2AuthorizedClientRepository: OAuth2AuthorizedClientRepository?) : ExchangeFilterFunction 

    /**
     * @inheritDoc
     */
    override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> 
        return oauth2Token()
                .map  token: AbstractOAuth2Token -> bearer(request, token) 
                .defaultIfEmpty(request)
                .flatMap  request: ClientRequest -> next.exchange(request) 
    

    private fun oauth2Token(): Mono<AbstractOAuth2Token> 
        return Mono.subscriberContext()
                .flatMap  ctx: Context -> currentAuthentication(ctx) 
                .map  authentication ->
                    val authorizedClient = oAuth2AuthorizedClientRepository?.loadAuthorizedClient<OAuth2AuthorizedClient>(clientRegistrationId, authentication, null)
                    if (authorizedClient != null) 
                        authorizedClient.accessToken
                     else 
                        Unit
                    
                
                .filter  it != null 
                .cast(AbstractOAuth2Token::class.java)
    

    private fun currentAuthentication(ctx: Context): Mono<Authentication> 
        return Mono.justOrEmpty(getAttribute(ctx, Authentication::class.java))
    

    private fun <T> getAttribute(ctx: Context, clazz: Class<T>): T?  // NOTE: SecurityReactorContextConfiguration.SecurityReactorContextSubscriber adds this key
        if (!ctx.hasKey(SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY)) 
            return null
        
        val attributes: Map<Class<T>, T> = ctx[SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY]
        return attributes[clazz]
    

    private fun bearer(request: ClientRequest, token: AbstractOAuth2Token): ClientRequest 
        return ClientRequest.from(request)
                .headers  headers: HttpHeaders -> headers.setBearerAuth(token.tokenValue) 
                .build()
    


【讨论】:

以上是关于Spring Boot 2 OIDC(OAuth2)客户端/资源服务器未在 WebClient 中传播访问令牌的主要内容,如果未能解决你的问题,请参考以下文章

如何在带有Github的Spring Boot上使用OIDC

如何知道我的 Spring Boot 应用程序是不是正在使用 OIDC?

升级后的 Spring Boot Oauth2 自动配置周期

springboot

针对 Azure OIDC OAuth2 流的 Spring Security

如何在春季使用 OIDC 从授权服务器获取所有用户?