Spring Boot 2 OIDC (OAuth2) 客户端/资源服务器未在 WebClient 中传播访问令牌
Spring Boot 2 OIDC (OAuth2) client / resource server not propagating the access token in the WebClient
Sample project available on Github
我已经成功地将两个 Spring Boot 2 application2 配置为针对 Keycloak 的 client/resource 服务器,并且它们之间的 SSO 很好。
此外,我正在测试相互之间经过身份验证的 REST 调用,将访问令牌传播为 Authorization: Bearer ACCESS_TOKEN
header。
启动 Keycloak 和应用程序后,我访问 http://localhost:8181/resource-server1 or 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"
}
}
此调用 returns 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 "";
}
这里有什么问题?如何自动传播令牌?
对于纯 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()
}
}
Sample project available on Github
我已经成功地将两个 Spring Boot 2 application2 配置为针对 Keycloak 的 client/resource 服务器,并且它们之间的 SSO 很好。
此外,我正在测试相互之间经过身份验证的 REST 调用,将访问令牌传播为 Authorization: Bearer ACCESS_TOKEN
header。
启动 Keycloak 和应用程序后,我访问 http://localhost:8181/resource-server1 or 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"
}
}
此调用 returns 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 "";
}
这里有什么问题?如何自动传播令牌?
对于纯 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()
}
}