diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index b061ce2f..c2fe9406 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -2032,7 +2032,46 @@ consumer can be a pure Client (like an SSO application) or a Resource Server. Spring Cloud Gateway can forward OAuth2 access tokens downstream to the services -it is proxying. To add this functionality to the gateway, you need to add the `TokenRelayGatewayFilterFactory` like this: +it is proxying using the `TokenRelay` `GatewayFilter`. + +The `TokenRelay` `GatewayFilter` takes one optional parameter, `clientRegistrationId`. +The following example configures a `TokenRelay` `GatewayFilter`: + +.App.java +[source,java] +---- + +@Bean +public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("resource", r -> r.path("/resource") + .filters(f -> f.tokenRelay("myregistrationid")) + .uri("http://localhost:9000")) + .build(); +} +---- + +or this + +.application.yaml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: resource + uri: http://localhost:9000 + predicates: + - Path=/resource + filters: + - TokenRelay=myregistrationid +---- + +The example above specifies a `clientRegistrationId`, which can be used to obtain and forward an OAuth2 access token for any available `ClientRegistration`. + +Spring Cloud Gateway can also forward the OAuth2 access token of the currently authenticated user `oauth2Login()` is used to authenticate the user. +To add this functionality to the gateway, you can omit the `clientRegistrationId` parameter like this: .App.java [source,java] @@ -2073,10 +2112,10 @@ To enable this for Spring Cloud Gateway add the following dependencies - `org.springframework.boot:spring-boot-starter-oauth2-client` -How does it work? The -{githubmaster}/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java[filter] -extracts an access token from the currently authenticated user, -and puts it in a request header for the downstream requests. +How does it work? The {github-code}/src/main/java/org/springframework/cloud/gateway/security/TokenRelayGatewayFilterFactory.java[filter] +extracts an OAuth2 access token from the currently authenticated user for the provided `clientRegistrationId`. +If no `clientRegistrationId` is provided, the currently authenticated user's own access token (obtained during login) is used. +In either case, the extracted access token is placed in a request header for the downstream requests. For a full working sample see https://github.com/spring-cloud-samples/sample-gateway-oauth2login[this project]. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactory.java index dba205cb..19ae95d6 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,14 @@ package org.springframework.cloud.gateway.filter.factory; +import java.util.Collections; +import java.util.List; + import reactor.core.publisher.Mono; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; @@ -29,37 +33,51 @@ import org.springframework.web.server.ServerWebExchange; /** * @author Joe Grandja + * @author Steve Riesenberg */ -public class TokenRelayGatewayFilterFactory extends AbstractGatewayFilterFactory { +public class TokenRelayGatewayFilterFactory + extends AbstractGatewayFilterFactory { private final ObjectProvider clientManagerProvider; public TokenRelayGatewayFilterFactory(ObjectProvider clientManagerProvider) { - super(Object.class); + super(NameConfig.class); this.clientManagerProvider = clientManagerProvider; } + @Override + public List shortcutFieldOrder() { + return Collections.singletonList(NAME_KEY); + } + public GatewayFilter apply() { - return apply((Object) null); + return apply((NameConfig) null); } @Override - public GatewayFilter apply(Object config) { + public GatewayFilter apply(NameConfig config) { + String defaultClientRegistrationId = (config == null) ? null : config.getName(); return (exchange, chain) -> exchange.getPrincipal() // .log("token-relay-filter") - .filter(principal -> principal instanceof OAuth2AuthenticationToken) - .cast(OAuth2AuthenticationToken.class) - .flatMap(authentication -> authorizedClient(exchange, authentication)) - .map(OAuth2AuthorizedClient::getAccessToken).map(token -> withBearerAuth(exchange, token)) + .filter(principal -> principal instanceof Authentication).cast(Authentication.class) + .flatMap(principal -> authorizationRequest(defaultClientRegistrationId, principal)) + .flatMap(this::authorizedClient).map(OAuth2AuthorizedClient::getAccessToken) + .map(token -> withBearerAuth(exchange, token)) // TODO: adjustable behavior if empty .defaultIfEmpty(exchange).flatMap(chain::filter); } - private Mono authorizedClient(ServerWebExchange exchange, - OAuth2AuthenticationToken oauth2Authentication) { - String clientRegistrationId = oauth2Authentication.getAuthorizedClientRegistrationId(); - OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId) - .principal(oauth2Authentication).build(); + private Mono authorizationRequest(String defaultClientRegistrationId, + Authentication principal) { + String clientRegistrationId = defaultClientRegistrationId; + if (clientRegistrationId == null && principal instanceof OAuth2AuthenticationToken) { + clientRegistrationId = ((OAuth2AuthenticationToken) principal).getAuthorizedClientRegistrationId(); + } + return Mono.justOrEmpty(clientRegistrationId).map(OAuth2AuthorizeRequest::withClientRegistrationId) + .map(builder -> builder.principal(principal).build()); + } + + private Mono authorizedClient(OAuth2AuthorizeRequest request) { ReactiveOAuth2AuthorizedClientManager clientManager = clientManagerProvider.getIfAvailable(); if (clientManager == null) { return Mono.error(new IllegalStateException( diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index d9c2a6af..65fadea9 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -796,12 +796,13 @@ public class GatewayFilterSpec extends UriSpec { } /** - * A filter that enables token relay. + * A filter that enables token relay by extracting the access token from the currently + * authenticated user and puts it in a request header for downstream requests. * @return a {@link GatewayFilterSpec} that can be used to apply additional filters */ public GatewayFilterSpec tokenRelay() { try { - return filter(getBean(TokenRelayGatewayFilterFactory.class).apply(o -> { + return filter(getBean(TokenRelayGatewayFilterFactory.class).apply(c -> { })); } catch (NoSuchBeanDefinitionException e) { @@ -810,6 +811,23 @@ public class GatewayFilterSpec extends UriSpec { } } + /** + * A filter that enables token relay by extracting the access token of a specified + * {@code ClientRegistration} and puts it in a request header for downstream requests. + * @param clientRegistrationId the client registration id to use for building the + * authorization request + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec tokenRelay(String clientRegistrationId) { + try { + return filter(getBean(TokenRelayGatewayFilterFactory.class).apply(c -> c.setName(clientRegistrationId))); + } + catch (NoSuchBeanDefinitionException e) { + throw new IllegalStateException("No TokenRelayGatewayFilterFactory bean was found. Did you include the " + + "org.springframework.boot:spring-boot-starter-oauth2-client dependency?"); + } + } + /** * Adds hystrix execution exception headers to fallback request. Depends on @{code * org.springframework.cloud::spring-cloud-starter-netflix-hystrix} being on the diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactoryTests.java index 01d48bdc..0514c52c 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/TokenRelayGatewayFilterFactoryTests.java @@ -22,15 +22,18 @@ import java.util.Collections; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory.NameConfig; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; @@ -46,6 +49,7 @@ import org.springframework.web.server.ServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -64,7 +68,7 @@ public class TokenRelayGatewayFilterFactoryTests { private GatewayFilterChain filterChain; - private GatewayFilter filter; + private ObjectProvider objectProvider; public TokenRelayGatewayFilterFactoryTests() { } @@ -78,9 +82,8 @@ public class TokenRelayGatewayFilterFactoryTests { when(filterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty()); authorizedClientManager = mock(ReactiveOAuth2AuthorizedClientManager.class); - ObjectProvider objectProvider = mock(ObjectProvider.class); + objectProvider = mock(ObjectProvider.class); when(objectProvider.getIfAvailable()).thenReturn(authorizedClientManager); - filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(); } @AfterEach @@ -89,6 +92,7 @@ public class TokenRelayGatewayFilterFactoryTests { @Test public void emptyPrincipal() { + GatewayFilter filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(); filter.filter(mockExchange, filterChain).block(TIMEOUT); assertThat(request.getHeaders()).doesNotContainKeys(HttpHeaders.AUTHORIZATION); } @@ -112,10 +116,58 @@ public class TokenRelayGatewayFilterFactoryTests { SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, Mono.just(securityContext)); + GatewayFilter filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(); filter.filter(exchange, filterChain).block(TIMEOUT); assertThat(request.getHeaders()).containsEntry(HttpHeaders.AUTHORIZATION, Collections.singletonList("Bearer mytoken")); + + ArgumentCaptor authorizeRequestCaptor = ArgumentCaptor + .forClass(OAuth2AuthorizeRequest.class); + verify(authorizedClientManager).authorize(authorizeRequestCaptor.capture()); + + OAuth2AuthorizeRequest authorizeRequest = authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()) + .isEqualTo(authenticationToken.getAuthorizedClientRegistrationId()); + assertThat(authorizeRequest.getClientRegistrationId()).isNotEqualTo(clientRegistration.getRegistrationId()); + } + + @Test + public void whenClientRegistrationIdConfiguredAuthorizationHeaderAdded() { + OAuth2AccessToken accessToken = mock(OAuth2AccessToken.class); + when(accessToken.getTokenValue()).thenReturn("mytoken"); + + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("myregistrationid") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).clientId("myclientid") + .tokenUri("mytokenuri").build(); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(clientRegistration, "steve", accessToken); + + when(authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .thenReturn(Mono.just(authorizedClient)); + + OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(mock(OAuth2User.class), + Collections.emptyList(), "myId"); + SecurityContextImpl securityContext = new SecurityContextImpl(authenticationToken); + SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, + Mono.just(securityContext)); + + NameConfig config = new NameConfig(); + config.setName(clientRegistration.getRegistrationId()); + + GatewayFilter filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(config); + filter.filter(exchange, filterChain).block(TIMEOUT); + + assertThat(request.getHeaders()).containsEntry(HttpHeaders.AUTHORIZATION, + Collections.singletonList("Bearer mytoken")); + + ArgumentCaptor authorizeRequestCaptor = ArgumentCaptor + .forClass(OAuth2AuthorizeRequest.class); + verify(authorizedClientManager).authorize(authorizeRequestCaptor.capture()); + + OAuth2AuthorizeRequest authorizeRequest = authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getClientRegistrationId()) + .isNotEqualTo(authenticationToken.getAuthorizedClientRegistrationId()); } @Test @@ -124,9 +176,45 @@ public class TokenRelayGatewayFilterFactoryTests { SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, Mono.just(securityContext)); + GatewayFilter filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(); filter.filter(exchange, filterChain).block(TIMEOUT); assertThat(request.getHeaders()).doesNotContainKeys(HttpHeaders.AUTHORIZATION); } + @Test + public void whenPrincipalIsNotOAuth2AuthenticationTokenAndClientRegistrationIdConfiguredAuthorizationHeaderAdded() { + OAuth2AccessToken accessToken = mock(OAuth2AccessToken.class); + when(accessToken.getTokenValue()).thenReturn("mytoken"); + + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("myregistrationid") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).clientId("myclientid") + .tokenUri("mytokenuri").build(); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(clientRegistration, "steve", accessToken); + + when(authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .thenReturn(Mono.just(authorizedClient)); + + Authentication authenticationToken = new TestingAuthenticationToken("my", null); + SecurityContextImpl securityContext = new SecurityContextImpl(authenticationToken); + SecurityContextServerWebExchange exchange = new SecurityContextServerWebExchange(mockExchange, + Mono.just(securityContext)); + + NameConfig config = new NameConfig(); + config.setName(clientRegistration.getRegistrationId()); + + GatewayFilter filter = new TokenRelayGatewayFilterFactory(objectProvider).apply(config); + filter.filter(exchange, filterChain).block(TIMEOUT); + + assertThat(request.getHeaders()).containsEntry(HttpHeaders.AUTHORIZATION, + Collections.singletonList("Bearer mytoken")); + + ArgumentCaptor authorizeRequestCaptor = ArgumentCaptor + .forClass(OAuth2AuthorizeRequest.class); + verify(authorizedClientManager).authorize(authorizeRequestCaptor.capture()); + + OAuth2AuthorizeRequest authorizeRequest = authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(clientRegistration.getRegistrationId()); + } + }