diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 7d4327910b..9ff7ccef5d 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -16,6 +16,24 @@ package org.springframework.security.config.web.server; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.core.Ordered; @@ -65,6 +83,7 @@ import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionReactiveAuthenticationManager; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; @@ -143,23 +162,6 @@ import org.springframework.web.cors.reactive.DefaultCorsProcessor; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; -import reactor.util.context.Context; - -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.security.interfaces.RSAPublicKey; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Function; import static org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint.DelegateEntry; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.match; @@ -994,8 +996,11 @@ public class ServerHttpSecurity { private ServerAuthenticationEntryPoint entryPoint = new BearerTokenServerAuthenticationEntryPoint(); private ServerAccessDeniedHandler accessDeniedHandler = new BearerTokenServerAccessDeniedHandler(); private ServerAuthenticationConverter bearerTokenConverter = new ServerBearerTokenAuthenticationConverter(); + private BearerTokenServerWebExchangeMatcher bearerTokenServerWebExchangeMatcher = + new BearerTokenServerWebExchangeMatcher(); private JwtSpec jwt; + private OpaqueTokenSpec opaqueToken; /** * Configures the {@link ServerAccessDeniedHandler} to use for requests authenticating with @@ -1047,10 +1052,94 @@ public class ServerHttpSecurity { return this.jwt; } + public OpaqueTokenSpec opaqueToken() { + if (this.opaqueToken == null) { + this.opaqueToken = new OpaqueTokenSpec(); + } + return this.opaqueToken; + } + protected void configure(ServerHttpSecurity http) { + this.bearerTokenServerWebExchangeMatcher + .setBearerTokenConverter(this.bearerTokenConverter); + + registerDefaultAccessDeniedHandler(http); + registerDefaultAuthenticationEntryPoint(http); + registerDefaultCsrfOverride(http); + + if (this.jwt != null && this.opaqueToken != null) { + throw new IllegalStateException("Spring Security only supports JWTs or Opaque Tokens, not both at the " + + "same time"); + } + + if (this.jwt == null && this.opaqueToken == null) { + throw new IllegalStateException("Jwt and Opaque Token are the only supported formats for bearer tokens " + + "in Spring Security and neither was found. Make sure to configure JWT " + + "via http.oauth2ResourceServer().jwt() or Opaque Tokens via " + + "http.oauth2ResourceServer().opaqueToken()."); + } + if (this.jwt != null) { this.jwt.configure(http); } + + if (this.opaqueToken != null) { + this.opaqueToken.configure(http); + } + } + + private void registerDefaultAccessDeniedHandler(ServerHttpSecurity http) { + if ( http.exceptionHandling != null ) { + http.defaultAccessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + this.bearerTokenServerWebExchangeMatcher, + OAuth2ResourceServerSpec.this.accessDeniedHandler + ) + ); + } + } + + private void registerDefaultAuthenticationEntryPoint(ServerHttpSecurity http) { + if (http.exceptionHandling != null) { + http.defaultEntryPoints.add( + new DelegateEntry( + this.bearerTokenServerWebExchangeMatcher, + OAuth2ResourceServerSpec.this.entryPoint + ) + ); + } + } + + private void registerDefaultCsrfOverride(ServerHttpSecurity http) { + if ( http.csrf != null && !http.csrf.specifiedRequireCsrfProtectionMatcher ) { + http + .csrf() + .requireCsrfProtectionMatcher( + new AndServerWebExchangeMatcher( + CsrfWebFilter.DEFAULT_CSRF_MATCHER, + new NegatedServerWebExchangeMatcher( + this.bearerTokenServerWebExchangeMatcher))); + } + } + + private class BearerTokenServerWebExchangeMatcher implements ServerWebExchangeMatcher { + ServerAuthenticationConverter bearerTokenConverter; + + @Override + public Mono matches(ServerWebExchange exchange) { + return this.bearerTokenConverter.convert(exchange) + .flatMap(this::nullAuthentication) + .onErrorResume(e -> notMatch()); + } + + public void setBearerTokenConverter(ServerAuthenticationConverter bearerTokenConverter) { + Assert.notNull(bearerTokenConverter, "bearerTokenConverter cannot be null"); + this.bearerTokenConverter = bearerTokenConverter; + } + + private Mono nullAuthentication(Authentication authentication) { + return authentication == null ? notMatch() : match(); + } } /** @@ -1062,9 +1151,6 @@ public class ServerHttpSecurity { private Converter> jwtAuthenticationConverter = new ReactiveJwtAuthenticationConverterAdapter(new JwtAuthenticationConverter()); - private BearerTokenServerWebExchangeMatcher bearerTokenServerWebExchangeMatcher = - new BearerTokenServerWebExchangeMatcher(); - /** * Configures the {@link ReactiveAuthenticationManager} to use * @param authenticationManager the authentication manager to use @@ -1128,17 +1214,10 @@ public class ServerHttpSecurity { } protected void configure(ServerHttpSecurity http) { - this.bearerTokenServerWebExchangeMatcher.setBearerTokenConverter(bearerTokenConverter); - - registerDefaultAccessDeniedHandler(http); - registerDefaultAuthenticationEntryPoint(http); - registerDefaultCsrfOverride(http); - ReactiveAuthenticationManager authenticationManager = getAuthenticationManager(); AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(authenticationManager); oauth2.setServerAuthenticationConverter(bearerTokenConverter); oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint)); - http .addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION); } @@ -1170,59 +1249,63 @@ public class ServerHttpSecurity { return authenticationManager; } + } - private void registerDefaultAccessDeniedHandler(ServerHttpSecurity http) { - if ( http.exceptionHandling != null ) { - http.defaultAccessDeniedHandlers.add( - new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( - this.bearerTokenServerWebExchangeMatcher, - OAuth2ResourceServerSpec.this.accessDeniedHandler - ) - ); - } + /** + * Configures Opaque Token Resource Server support + * + * @author Josh Cummings + * @since 5.2 + */ + public class OpaqueTokenSpec { + private String introspectionUri; + private String introspectionClientId; + private String introspectionClientSecret; + + /** + * Configures the URI of the Introspection endpoint + * @param introspectionUri The URI of the Introspection endpoint + * @return the {@code OpaqueTokenSpec} for additional configuration + */ + public OpaqueTokenSpec introspectionUri(String introspectionUri) { + Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); + this.introspectionUri = introspectionUri; + return this; } - private void registerDefaultAuthenticationEntryPoint(ServerHttpSecurity http) { - if (http.exceptionHandling != null) { - http.defaultEntryPoints.add( - new DelegateEntry( - this.bearerTokenServerWebExchangeMatcher, - OAuth2ResourceServerSpec.this.entryPoint - ) - ); - } + /** + * Configures the credentials for Introspection endpoint + * @param clientId The clientId part of the credentials + * @param clientSecret The clientSecret part of the credentials + * @return the {@code OpaqueTokenSpec} for additional configuration + */ + public OpaqueTokenSpec introspectionClientCredentials(String clientId, String clientSecret) { + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.introspectionClientId = clientId; + this.introspectionClientSecret = clientSecret; + return this; } - private void registerDefaultCsrfOverride(ServerHttpSecurity http) { - if ( http.csrf != null && !http.csrf.specifiedRequireCsrfProtectionMatcher ) { - http - .csrf() - .requireCsrfProtectionMatcher( - new AndServerWebExchangeMatcher( - CsrfWebFilter.DEFAULT_CSRF_MATCHER, - new NegatedServerWebExchangeMatcher( - this.bearerTokenServerWebExchangeMatcher))); - } + /** + * Allows method chaining to continue configuring the {@link ServerHttpSecurity} + * @return the {@link ServerHttpSecurity} to continue configuring + */ + public OAuth2ResourceServerSpec and() { + return OAuth2ResourceServerSpec.this; } - private class BearerTokenServerWebExchangeMatcher implements ServerWebExchangeMatcher { - ServerAuthenticationConverter bearerTokenConverter; + protected ReactiveAuthenticationManager getAuthenticationManager() { + return new OAuth2IntrospectionReactiveAuthenticationManager( + this.introspectionUri, this.introspectionClientId, this.introspectionClientSecret); + } - @Override - public Mono matches(ServerWebExchange exchange) { - return this.bearerTokenConverter.convert(exchange) - .flatMap(this::nullAuthentication) - .onErrorResume(e -> notMatch()); - } - - public void setBearerTokenConverter(ServerAuthenticationConverter bearerTokenConverter) { - Assert.notNull(bearerTokenConverter, "bearerTokenConverter cannot be null"); - this.bearerTokenConverter = bearerTokenConverter; - } - - private Mono nullAuthentication(Authentication authentication) { - return authentication == null ? notMatch() : match(); - } + protected void configure(ServerHttpSecurity http) { + ReactiveAuthenticationManager authenticationManager = getAuthenticationManager(); + AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(authenticationManager); + oauth2.setServerAuthenticationConverter(bearerTokenConverter); + oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint)); + http.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java index 2409b1b889..7d2195b820 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 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. @@ -13,16 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.config.web.server; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.hamcrest.core.StringStartsWith.startsWith; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +package org.springframework.security.config.web.server; import java.io.IOException; import java.math.BigInteger; @@ -32,18 +24,22 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.RSAPublicKeySpec; import java.time.Instant; +import java.util.Base64; import java.util.Collection; import java.util.Collections; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; - import javax.annotation.PreDestroy; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.apache.http.HttpHeaders; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; - import reactor.core.publisher.Mono; import org.springframework.beans.factory.NoSuchBeanDefinitionException; @@ -53,6 +49,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; @@ -81,8 +78,14 @@ import org.springframework.web.context.support.GenericWebApplicationContext; import org.springframework.web.reactive.DispatcherHandler; import org.springframework.web.reactive.config.EnableWebFlux; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.hamcrest.core.StringStartsWith.startsWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Tests for {@link org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2ResourceServerSpec} @@ -113,6 +116,22 @@ public class OAuth2ResourceServerSpecTests { Collections.singletonMap("alg", JwsAlgorithms.RS256), Collections.singletonMap("sub", "user")); + private String clientId = "client"; + private String clientSecret = "secret"; + private String active = "{\n" + + " \"active\": true,\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + + @Rule public final SpringTestRule spring = new SpringTestRule(); @@ -332,6 +351,18 @@ public class OAuth2ResourceServerSpecTests { .isInstanceOf(NoSuchBeanDefinitionException.class); } + @Test + public void introspectWhenValidThenReturnsOk() { + this.spring.register(IntrospectionConfig.class, RootController.class).autowire(); + this.spring.getContext().getBean(MockWebServer.class) + .setDispatcher(requiresAuth(clientId, clientSecret, active)); + + this.client.get() + .headers(headers -> headers.setBearerAuth(this.messageReadToken)) + .exchange() + .expectStatus().isOk(); + } + @EnableWebFlux @EnableWebFluxSecurity static class PublicKeyConfig { @@ -525,6 +556,37 @@ public class OAuth2ResourceServerSpecTests { } } + @EnableWebFlux + @EnableWebFluxSecurity + static class IntrospectionConfig { + private MockWebServer mockWebServer = new MockWebServer(); + + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + String introspectionUri = mockWebServer().url("/introspect").toString(); + + // @formatter:off + http + .oauth2ResourceServer() + .opaqueToken() + .introspectionUri(introspectionUri) + .introspectionClientCredentials("client", "secret"); + // @formatter:on + + return http.build(); + } + + @Bean + MockWebServer mockWebServer() { + return this.mockWebServer; + } + + @PreDestroy + void shutdown() throws IOException { + this.mockWebServer.shutdown(); + } + } + @RestController static class RootController { @GetMapping @@ -538,6 +600,33 @@ public class OAuth2ResourceServerSpecTests { } } + private static Dispatcher requiresAuth(String username, String password, String response) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String authorization = request.getHeader(org.springframework.http.HttpHeaders.AUTHORIZATION); + return Optional.ofNullable(authorization) + .filter(a -> isAuthorized(authorization, username, password)) + .map(a -> ok(response)) + .orElse(unauthorized()); + } + }; + } + + private static boolean isAuthorized(String authorization, String username, String password) { + String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); + return username.equals(values[0]) && password.equals(values[1]); + } + + private static MockResponse ok(String response) { + return new MockResponse().setBody(response) + .setHeader(org.springframework.http.HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } + + private static MockResponse unauthorized() { + return new MockResponse().setResponseCode(401); + } + private static RSAPublicKey publicKey() throws NoSuchAlgorithmException, InvalidKeySpecException { String modulus = "26323220897278656456354815752829448539647589990395639665273015355787577386000316054335559633864476469390247312823732994485311378484154955583861993455004584140858982659817218753831620205191028763754231454775026027780771426040997832758235764611119743390612035457533732596799927628476322029280486807310749948064176545712270582940917249337311592011920620009965129181413510845780806191965771671528886508636605814099711121026468495328702234901200169245493126030184941412539949521815665744267183140084667383643755535107759061065656273783542590997725982989978433493861515415520051342321336460543070448417126615154138673620797"; String exponent = "65537"; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java new file mode 100644 index 0000000000..078c752b5c --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java @@ -0,0 +1,270 @@ +/* + * Copyright 2002-2019 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.authentication; + +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.nimbusds.oauth2.sdk.TokenIntrospectionResponse; +import com.nimbusds.oauth2.sdk.TokenIntrospectionSuccessResponse; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.id.Audience; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUED_AT; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; + +/** + * An {@link ReactiveAuthenticationManager} implementation for opaque + * Bearer Tokens, + * using an + * OAuth 2.0 Introspection Endpoint + * to check the token's validity and reveal its attributes. + *

+ * This {@link ReactiveAuthenticationManager} is responsible for introspecting and verifying an opaque access token, + * returning its attributes set as part of the {@see Authentication} statement. + *

+ * Scopes are translated into {@link GrantedAuthority}s according to the following algorithm: + *

    + *
  1. + * If there is a "scope" attribute, then convert to a {@link Collection} of {@link String}s. + *
  2. + * Take the resulting {@link Collection} and prepend the "SCOPE_" keyword to each element, adding as {@link GrantedAuthority}s. + *
+ * + * @author Josh Cummings + * @since 5.2 + * @see ReactiveAuthenticationManager + */ +public class OAuth2IntrospectionReactiveAuthenticationManager implements ReactiveAuthenticationManager { + private URI introspectionUri; + private WebClient webClient; + + /** + * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} with the provided parameters + * + * @param introspectionUri The introspection endpoint uri + * @param clientId The client id authorized to introspect + * @param clientSecret The client secret for the authorized client + */ + public OAuth2IntrospectionReactiveAuthenticationManager(String introspectionUri, + String clientId, String clientSecret) { + + Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + + this.introspectionUri = URI.create(introspectionUri); + this.webClient = WebClient.builder() + .defaultHeader(HttpHeaders.AUTHORIZATION, basicHeaderValue(clientId, clientSecret)) + .build(); + } + + /** + * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} with the provided parameters + * + * @param introspectionUri The introspection endpoint uri + * @param webClient The client for performing the introspection request + */ + public OAuth2IntrospectionReactiveAuthenticationManager(String introspectionUri, + WebClient webClient) { + + Assert.hasText(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(webClient, "webClient cannot be null"); + + this.introspectionUri = URI.create(introspectionUri); + this.webClient = webClient; + } + + private static String basicHeaderValue(String clientId, String clientSecret) { + String headerValue = clientId + ":"; + if (StringUtils.hasText(clientSecret)) { + headerValue += clientSecret; + } + return "Basic " + Base64.getEncoder().encodeToString(headerValue.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public Mono authenticate(Authentication authentication) { + return Mono.justOrEmpty(authentication) + .filter(BearerTokenAuthenticationToken.class::isInstance) + .cast(BearerTokenAuthenticationToken.class) + .map(BearerTokenAuthenticationToken::getToken) + .flatMap(this::authenticate) + .cast(Authentication.class); + } + + private Mono authenticate(String token) { + return introspect(token) + .map(response -> { + Map claims = convertClaimsSet(response); + Instant iat = (Instant) claims.get(ISSUED_AT); + Instant exp = (Instant) claims.get(EXPIRES_AT); + + // construct token + OAuth2AccessToken accessToken = + new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, iat, exp); + Collection authorities = extractAuthorities(claims); + return new OAuth2IntrospectionAuthenticationToken(accessToken, claims, authorities); + }); + } + + private Mono introspect(String token) { + return Mono.just(token) + .flatMap(this::makeRequest) + .flatMap(this::adaptToNimbusResponse) + .map(this::parseNimbusResponse) + .map(this::castToNimbusSuccess) + .doOnNext(response -> validate(token, response)) + .onErrorMap(e -> !(e instanceof OAuth2AuthenticationException), this::onError); + } + + private Mono makeRequest(String token) { + return this.webClient.post() + .uri(this.introspectionUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_UTF8_VALUE) + .body(BodyInserters.fromFormData("token", token)) + .exchange(); + } + + private Mono adaptToNimbusResponse(ClientResponse responseEntity) { + HTTPResponse response = new HTTPResponse(responseEntity.rawStatusCode()); + response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.headers().contentType().get().toString()); + if (response.getStatusCode() != HTTPResponse.SC_OK) { + throw new OAuth2AuthenticationException( + invalidToken("Introspection endpoint responded with " + response.getStatusCode())); + } + return responseEntity.bodyToMono(String.class) + .doOnNext(response::setContent) + .map(body -> response); + } + + private TokenIntrospectionResponse parseNimbusResponse(HTTPResponse response) { + try { + return TokenIntrospectionResponse.parse(response); + } catch (Exception ex) { + throw new OAuth2AuthenticationException( + invalidToken(ex.getMessage()), ex); + } + } + + private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) { + if (!introspectionResponse.indicatesSuccess()) { + throw new OAuth2AuthenticationException(invalidToken("Token introspection failed")); + } + return (TokenIntrospectionSuccessResponse) introspectionResponse; + } + + private void validate(String token, TokenIntrospectionSuccessResponse response) { + // relying solely on the authorization server to validate this token (not checking 'exp', for example) + if (!response.isActive()) { + throw new OAuth2AuthenticationException(invalidToken("Provided token [" + token + "] isn't active")); + } + } + + private Map convertClaimsSet(TokenIntrospectionSuccessResponse response) { + Map claims = response.toJSONObject(); + if (response.getAudience() != null) { + List audience = response.getAudience().stream() + .map(Audience::getValue).collect(Collectors.toList()); + claims.put(AUDIENCE, Collections.unmodifiableList(audience)); + } + if (response.getClientID() != null) { + claims.put(CLIENT_ID, response.getClientID().getValue()); + } + if (response.getExpirationTime() != null) { + Instant exp = response.getExpirationTime().toInstant(); + claims.put(EXPIRES_AT, exp); + } + if (response.getIssueTime() != null) { + Instant iat = response.getIssueTime().toInstant(); + claims.put(ISSUED_AT, iat); + } + if (response.getIssuer() != null) { + claims.put(ISSUER, issuer(response.getIssuer().getValue())); + } + if (response.getNotBeforeTime() != null) { + claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant()); + } + if (response.getScope() != null) { + claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList())); + } + + return claims; + } + + private Collection extractAuthorities(Map claims) { + Collection scopes = (Collection) claims.get(SCOPE); + return Optional.ofNullable(scopes).orElse(Collections.emptyList()) + .stream() + .map(authority -> new SimpleGrantedAuthority("SCOPE_" + authority)) + .collect(Collectors.toList()); + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } catch (Exception ex) { + throw new OAuth2AuthenticationException( + invalidToken("Invalid " + ISSUER + " value: " + uri), ex); + } + } + + private static BearerTokenError invalidToken(String message) { + return new BearerTokenError("invalid_token", + HttpStatus.UNAUTHORIZED, message, + "https://tools.ietf.org/html/rfc7662#section-2.2"); + } + + + private OAuth2AuthenticationException onError(Throwable e) { + OAuth2Error invalidToken = invalidToken(e.getMessage()); + return new OAuth2AuthenticationException(invalidToken, e.getMessage()); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java new file mode 100644 index 0000000000..4217a714e7 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java @@ -0,0 +1,310 @@ +/* + * Copyright 2002-2019 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.resource.authentication; + +import java.io.IOException; +import java.net.URL; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import net.minidev.json.JSONObject; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.USERNAME; + +/** + * Tests for {@link OAuth2IntrospectionReactiveAuthenticationManager} + */ +public class OAuth2IntrospectionReactiveAuthenticationManagerTests { + private static final String INTROSPECTION_URL = "https://server.example.com"; + private static final String CLIENT_ID = "client"; + private static final String CLIENT_SECRET = "secret"; + + private static final String ACTIVE_RESPONSE = "{\n" + + " \"active\": true,\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + + private static final String INACTIVE_RESPONSE = "{\n" + + " \"active\": false\n" + + " }"; + + private static final String INVALID_RESPONSE = "{\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + + private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + + " \"active\" : \"true\",\n" + + " \"iss\" : \"badissuer\"\n" + + " }"; + + @Test + public void authenticateWhenActiveTokenThenOk() throws Exception { + try ( MockWebServer server = new MockWebServer() ) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + + String introspectUri = server.url("/introspect").toString(); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, CLIENT_SECRET); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")).block(); + + assertThat(result.getPrincipal()).isInstanceOf(Map.class); + + Map attributes = (Map) result.getPrincipal(); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(ISSUER, new URL("https://server.example.com/")) + .containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + + assertThat(result.getAuthorities()).extracting("authority") + .containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); + } + } + + @Test + public void authenticateWhenBadClientCredentialsThenAuthenticationException() throws IOException { + try ( MockWebServer server = new MockWebServer() ) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + + String introspectUri = server.url("/introspect").toString(); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, "wrong"); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + } + + @Test + public void authenticateWhenInactiveTokenThenInvalidToken() { + WebClient webClient = mockResponse(INACTIVE_RESPONSE); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void authenticateWhenActiveTokenThenParsesValuesInResponse() { + Map introspectedValues = new HashMap<>(); + introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); + introspectedValues.put(AUDIENCE, Arrays.asList("aud")); + introspectedValues.put(NOT_BEFORE, 29348723984L); + + WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString()); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")).block(); + + assertThat(result.getPrincipal()).isInstanceOf(Map.class); + + Map attributes = (Map) result.getPrincipal(); + assertThat(attributes) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(AUDIENCE, Arrays.asList("aud")) + .containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(SCOPE); + + assertThat(result.getAuthorities()).isEmpty(); + } + + @Test + public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + + @Test + public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { + WebClient webClient = mockResponse("malformed"); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + WebClient webClient = mockResponse(INVALID_RESPONSE); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); + OAuth2IntrospectionReactiveAuthenticationManager provider = + new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager("", CLIENT_ID, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, "", CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, CLIENT_ID, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private WebClient mockResponse(String response) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + when(webClient.post()).thenReturn(spec); + ClientResponse clientResponse = mock(ClientResponse.class); + when(clientResponse.rawStatusCode()).thenReturn(200); + when(clientResponse.statusCode()).thenReturn(HttpStatus.OK); + when(clientResponse.bodyToMono(String.class)).thenReturn(Mono.just(response)); + ClientResponse.Headers headers = mock(ClientResponse.Headers.class); + when(headers.contentType()).thenReturn(Optional.of(MediaType.APPLICATION_JSON_UTF8)); + when(clientResponse.headers()).thenReturn(headers); + when(spec.exchange()).thenReturn(Mono.just(clientResponse)); + return webClient; + } + + private WebClient mockResponse(Throwable t) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + when(webClient.post()).thenReturn(spec); + when(spec.exchange()).thenThrow(t); + return webClient; + } + + private static Dispatcher requiresAuth(String username, String password, String response) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + return Optional.ofNullable(authorization) + .filter(a -> isAuthorized(authorization, username, password)) + .map(a -> ok(response)) + .orElse(unauthorized()); + } + }; + } + + private static boolean isAuthorized(String authorization, String username, String password) { + String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); + return username.equals(values[0]) && password.equals(values[1]); + } + + private static MockResponse ok(String response) { + return new MockResponse().setBody(response) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } + + private static MockResponse unauthorized() { + return new MockResponse().setResponseCode(401); + } +}