From b02b2edd54084e27d91fc37468b197053a4aa6a1 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 11 Oct 2018 15:20:02 -0600 Subject: [PATCH 1/3] Opaque Token Support Fixes: gh-5200 --- .../OAuth2ResourceServerConfigurer.java | 81 ++++- .../OAuth2ResourceServerConfigurerTests.java | 24 +- ...ing-security-oauth2-resource-server.gradle | 1 + ...bstractOAuth2TokenAuthenticationToken.java | 19 +- ...h2IntrospectionAuthenticationProvider.java | 283 ++++++++++++++++ ...Auth2IntrospectionAuthenticationToken.java | 91 +++++ .../OAuth2IntrospectionClaimNames.java | 86 +++++ ...rospectionAuthenticationProviderTests.java | 311 ++++++++++++++++++ ...IntrospectionAuthenticationTokenTests.java | 120 +++++++ 9 files changed, 996 insertions(+), 20 deletions(-) create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index eb13966cd5e..ea468369f1e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.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. @@ -22,6 +22,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; @@ -31,6 +32,7 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; @@ -86,6 +88,10 @@ * * * + *

+ * When using {@link #opaque()}, supply an introspection endpoint and its authentication configuration + *

+ * *

Security Filters

* * The following {@code Filter}s are populated when {@link #jwt()} is configured: @@ -123,7 +129,9 @@ public final class OAuth2ResourceServerConfigurer jwtAuthenticationConverter = - this.jwtConfigurer.getJwtAuthenticationConverter(); + if (this.jwtConfigurer != null) { + JwtDecoder decoder = this.jwtConfigurer.getJwtDecoder(); + Converter jwtAuthenticationConverter = + this.jwtConfigurer.getJwtAuthenticationConverter(); + + JwtAuthenticationProvider provider = + new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); + provider = postProcess(provider); - JwtAuthenticationProvider provider = - new JwtAuthenticationProvider(decoder); - provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); - provider = postProcess(provider); + http.authenticationProvider(provider); + } - http.authenticationProvider(provider); + if (this.opaqueTokenConfigurer != null) { + http.authenticationProvider(this.opaqueTokenConfigurer.getProvider()); + } } public class JwtConfigurer { @@ -248,6 +274,31 @@ JwtDecoder getJwtDecoder() { } } + public class OpaqueTokenConfigurer { + private String introspectionUri; + private String introspectionClientId; + private String introspectionClientSecret; + + public OpaqueTokenConfigurer introspectionUri(String introspectionUri) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + this.introspectionUri = introspectionUri; + return this; + } + + public OpaqueTokenConfigurer introspectionClientCredentials(String clientId, String clientSecret) { + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.introspectionClientId = clientId; + this.introspectionClientSecret = clientSecret; + return this; + } + + AuthenticationProvider getProvider() { + return new OAuth2IntrospectionAuthenticationProvider(this.introspectionUri, + this.introspectionClientId, this.introspectionClientSecret); + } + } + private void registerDefaultAccessDeniedHandler(H http) { ExceptionHandlingConfigurer exceptionHandling = http .getConfigurer(ExceptionHandlingConfigurer.class); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index 2abf49d5590..2eb6c0aad36 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.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. @@ -1109,7 +1109,7 @@ public void configuredWhenMissingJwtAuthenticationProviderThenWiringException() assertThatCode(() -> this.spring.register(JwtlessConfig.class).autowire()) .isInstanceOf(BeanCreationException.class) - .hasMessageContaining("no Jwt configuration was found"); + .hasMessageContaining("neither was found"); } @Test @@ -1120,6 +1120,13 @@ public void configureWhenMissingJwkSetUriThenWiringException() { .hasMessageContaining("No qualifying bean of type"); } + @Test + public void configureWhenUsingBothJwtAndOpaqueThenWiringException() { + assertThatCode(() -> this.spring.register(OpaqueAndJwtConfig.class).autowire()) + .isInstanceOf(BeanCreationException.class) + .hasMessageContaining("Spring Security only supports JWTs or Opaque Tokens"); + } + // -- support @EnableWebSecurity @@ -1623,6 +1630,19 @@ JwtDecoder decoder() throws Exception { } } + @EnableWebSecurity + static class OpaqueAndJwtConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .oauth2ResourceServer() + .jwt() + .and() + .opaqueToken(); + } + } + @Configuration static class JwtDecoderConfig { @Bean diff --git a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle index 7fac327e252..10159c9a8df 100644 --- a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle +++ b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle @@ -7,6 +7,7 @@ dependencies { compile springCoreDependency optional project(':spring-security-oauth2-jose') + optional 'com.nimbusds:oauth2-oidc-sdk' optional 'io.projectreactor:reactor-core' optional 'org.springframework:spring-webflux' diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java index 896ac031d86..6a4a231d85b 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java @@ -45,6 +45,8 @@ public abstract class AbstractOAuth2TokenAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + private Object principal; + private Object credentials; private T token; /** @@ -64,9 +66,20 @@ protected AbstractOAuth2TokenAuthenticationToken( T token, Collection authorities) { - super(authorities); + this(token, token, token, authorities); + } + protected AbstractOAuth2TokenAuthenticationToken( + T token, + Object principal, + Object credentials, + Collection authorities) { + + super(authorities); Assert.notNull(token, "token cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + this.principal = principal; + this.credentials = credentials; this.token = token; } @@ -75,7 +88,7 @@ protected AbstractOAuth2TokenAuthenticationToken( */ @Override public Object getPrincipal() { - return this.getToken(); + return this.principal; } /** @@ -83,7 +96,7 @@ public Object getPrincipal() { */ @Override public Object getCredentials() { - return this.getToken(); + return this.credentials; } /** diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java new file mode 100644 index 00000000000..5e080a3296c --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProvider.java @@ -0,0 +1,283 @@ +/* + * 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.time.Instant; +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 org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.support.BasicAuthenticationInterceptor; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +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.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +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 AuthenticationProvider} implementation for opaque + * Bearer Tokens, + * using an + * OAuth 2.0 Introspection Endpoint + * to check the token's validity and reveal its attributes. + *

+ * This {@link AuthenticationProvider} 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 AuthenticationProvider + */ +public final class OAuth2IntrospectionAuthenticationProvider implements AuthenticationProvider { + private URI introspectionUri; + private RestOperations restOperations; + + /** + * Creates a {@code OAuth2IntrospectionAuthenticationProvider} 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 OAuth2IntrospectionAuthenticationProvider(String introspectionUri, String clientId, String clientSecret) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + + this.introspectionUri = URI.create(introspectionUri); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); + this.restOperations = restTemplate; + } + + /** + * Creates a {@code OAuth2IntrospectionAuthenticationProvider} with the provided parameters + * + * @param introspectionUri The introspection endpoint uri + * @param restOperations The client for performing the introspection request + */ + public OAuth2IntrospectionAuthenticationProvider(String introspectionUri, RestOperations restOperations) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(restOperations, "restOperations cannot be null"); + + this.introspectionUri = URI.create(introspectionUri); + this.restOperations = restOperations; + } + + /** + * Introspect and validate the opaque + * Bearer Token. + * + * @param authentication the authentication request object. + * + * @return A successful authentication + * @throws AuthenticationException if authentication failed for some reason + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof BearerTokenAuthenticationToken)) { + return null; + } + + // introspect + BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication; + TokenIntrospectionSuccessResponse response = introspect(bearer.getToken()); + Map claims = convertClaimsSet(response); + Instant iat = (Instant) claims.get(ISSUED_AT); + Instant exp = (Instant) claims.get(EXPIRES_AT); + + // construct token + OAuth2AccessToken token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + bearer.getToken(), iat, exp); + Collection authorities = extractAuthorities(claims); + AbstractAuthenticationToken result = + new OAuth2IntrospectionAuthenticationToken(token, claims, authorities); + result.setDetails(bearer.getDetails()); + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication); + } + + private TokenIntrospectionSuccessResponse introspect(String token) { + return Optional.of(token) + .map(this::buildRequest) + .map(this::makeRequest) + .map(this::adaptToNimbusResponse) + .map(this::parseNimbusResponse) + .map(this::castToNimbusSuccess) + // relying solely on the authorization server to validate this token (not checking 'exp', for example) + .filter(TokenIntrospectionSuccessResponse::isActive) + .orElseThrow(() -> new OAuth2AuthenticationException( + invalidToken("Provided token [" + token + "] isn't active"))); + } + + private RequestEntity> buildRequest(String token) { + HttpHeaders headers = requestHeaders(); + MultiValueMap body = requestBody(token); + return new RequestEntity<>(body, headers, HttpMethod.POST, this.introspectionUri); + } + + private HttpHeaders requestHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); + return headers; + } + + private MultiValueMap requestBody(String token) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("token", token); + return body; + } + + private ResponseEntity makeRequest(RequestEntity requestEntity) { + try { + return this.restOperations.exchange(requestEntity, String.class); + } catch (Exception ex) { + throw new OAuth2AuthenticationException( + invalidToken(ex.getMessage()), ex); + } + } + + private HTTPResponse adaptToNimbusResponse(ResponseEntity responseEntity) { + HTTPResponse response = new HTTPResponse(responseEntity.getStatusCodeValue()); + response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.getHeaders().getContentType().toString()); + response.setContent(responseEntity.getBody()); + + if (response.getStatusCode() != HTTPResponse.SC_OK) { + throw new OAuth2AuthenticationException( + invalidToken("Introspection endpoint responded with " + response.getStatusCode())); + } + return 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 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"); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java new file mode 100644 index 00000000000..eb2d5d44bce --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationToken.java @@ -0,0 +1,91 @@ +/* + * 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.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.util.Assert; + +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT; + +/** + * An {@link org.springframework.security.core.Authentication} token that represents a successful authentication as + * obtained through an opaque token + * introspection + * process. + * + * @author Josh Cummings + * @since 5.2 + */ +public class OAuth2IntrospectionAuthenticationToken + extends AbstractOAuth2TokenAuthenticationToken { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private Map attributes; + private String name; + + /** + * Constructs a {@link OAuth2IntrospectionAuthenticationToken} with the provided arguments + * + * @param token The verified token + * @param authorities The authorities associated with the given token + */ + public OAuth2IntrospectionAuthenticationToken(OAuth2AccessToken token, + Map attributes, Collection authorities) { + + this(token, attributes, authorities, null); + } + + /** + * Constructs a {@link OAuth2IntrospectionAuthenticationToken} with the provided arguments + * + * @param token The verified token + * @param authorities The authorities associated with the given token + * @param name The name associated with this token + */ + public OAuth2IntrospectionAuthenticationToken(OAuth2AccessToken token, + Map attributes, Collection authorities, String name) { + + super(token, attributes, token, authorities); + Assert.notEmpty(attributes, "attributes cannot be empty"); + this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + this.name = name == null ? (String) attributes.get(SUBJECT) : name; + setAuthenticated(true); + } + + /** + * {@inheritDoc} + */ + @Override + public Map getTokenAttributes() { + return this.attributes; + } + + /** + * {@inheritDoc} + */ + @Override + public String getName() { + return this.name; + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java new file mode 100644 index 00000000000..d6eb91c0b6e --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionClaimNames.java @@ -0,0 +1,86 @@ +/* + * 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; + +/** + * The names of the "Introspection Claims" defined by an + * Introspection Response. + * + * @author Josh Cummings + * @since 5.2 + */ +interface OAuth2IntrospectionClaimNames { + + /** + * {@code active} - Indicator whether or not the token is currently active + */ + String ACTIVE = "active"; + + /** + * {@code scope} - The scopes for the token + */ + String SCOPE = "scope"; + + /** + * {@code client_id} - The Client identifier for the token + */ + String CLIENT_ID = "client_id"; + + /** + * {@code username} - A human-readable identifier for the resource owner that authorized the token + */ + String USERNAME = "username"; + + /** + * {@code token_type} - The type of the token, for example {@code bearer}. + */ + String TOKEN_TYPE = "token_type"; + + /** + * {@code exp} - A timestamp indicating when the token expires + */ + String EXPIRES_AT = "exp"; + + /** + * {@code iat} - A timestamp indicating when the token was issued + */ + String ISSUED_AT = "iat"; + + /** + * {@code nbf} - A timestamp indicating when the token is not to be used before + */ + String NOT_BEFORE = "nbf"; + + /** + * {@code sub} - Usually a machine-readable identifier of the resource owner who authorized the token + */ + String SUBJECT = "sub"; + + /** + * {@code aud} - The intended audience for the token + */ + String AUDIENCE = "aud"; + + /** + * {@code iss} - The issuer of the token + */ + String ISSUER = "iss"; + + /** + * {@code jti} - The identifier for the token + */ + String JTI = "jti"; +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java new file mode 100644 index 00000000000..98cbfcb1b45 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationProviderTests.java @@ -0,0 +1,311 @@ +/* + * 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 org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +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.client.RestOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +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 OAuth2IntrospectionAuthenticationProvider} + * + * @author Josh Cummings + * @since 5.2 + */ +public class OAuth2IntrospectionAuthenticationProviderTests { + 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" + + " }"; + + private static final ResponseEntity ACTIVE = response(ACTIVE_RESPONSE); + private static final ResponseEntity INACTIVE = response(INACTIVE_RESPONSE); + private static final ResponseEntity INVALID = response(INVALID_RESPONSE); + private static final ResponseEntity MALFORMED_ISSUER = response(MALFORMED_ISSUER_RESPONSE); + + @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(); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(introspectUri, CLIENT_ID, CLIENT_SECRET); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")); + + 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(); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(introspectUri, CLIENT_ID, "wrong"); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .isInstanceOf(OAuth2AuthenticationException.class); + } + } + + @Test + public void authenticateWhenInactiveTokenThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(INACTIVE); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .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); + + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(response(new JSONObject(introspectedValues).toJSONString())); + + Authentication result = + provider.authenticate(new BearerTokenAuthenticationToken("token")); + + 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() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenThrow(new IllegalStateException("server was unresponsive")); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + + @Test + public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(response("malformed")); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(INVALID); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OAuth2IntrospectionAuthenticationProvider provider = + new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, restOperations); + when(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .thenReturn(MALFORMED_ISSUER); + + assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting("error.errorCode") + .containsExactly("invalid_token"); + } + + @Test + public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(null, CLIENT_ID, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientIdIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, null, CLIENT_SECRET)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, CLIENT_ID, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationProvider(INTROSPECTION_URL, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static ResponseEntity response(String content) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new ResponseEntity<>(content, headers, HttpStatus.OK); + } + + 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); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java new file mode 100644 index 00000000000..0f565120062 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionAuthenticationTokenTests.java @@ -0,0 +1,120 @@ +/* + * 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.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.OAuth2AccessToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID; +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 OAuth2IntrospectionAuthenticationToken} + * + * @author Josh Cummings + */ +public class OAuth2IntrospectionAuthenticationTokenTests { + private final OAuth2AccessToken token = + new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", Instant.now(), Instant.now().plusSeconds(3600)); + private final Map attributes = new HashMap<>(); + private final String name = "sub"; + + @Before + public void setUp() { + this.attributes.put(SUBJECT, this.name); + this.attributes.put(CLIENT_ID, "client_id"); + this.attributes.put(USERNAME, "username"); + } + + @Test + public void getNameWhenConfiguredInConstructorThenReturnsName() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, + AuthorityUtils.createAuthorityList("USER"), this.name); + assertThat(authenticated.getName()).isEqualTo(this.name); + } + + @Test + public void getNameWhenHasNoSubjectThenReturnsNull() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, Collections.singletonMap("claim", "value"), + Collections.emptyList()); + assertThat(authenticated.getName()).isNull(); + } + + @Test + public void getNameWhenTokenHasUsernameThenReturnsUsernameAttribute() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, Collections.emptyList()); + assertThat(authenticated.getName()).isEqualTo(this.attributes.get(SUBJECT)); + } + + @Test + public void constructorWhenTokenIsNullThenThrowsException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token cannot be null"); + } + + @Test + public void constructorWhenAttributesAreNullOrEmptyThenThrowsException() { + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(this.token, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("principal cannot be null"); + + assertThatCode(() -> new OAuth2IntrospectionAuthenticationToken(this.token, Collections.emptyMap(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attributes cannot be empty"); + } + + @Test + public void constructorWhenPassingAllAttributesThenTokenIsAuthenticated() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, Collections.singletonMap("claim", "value"), + Collections.emptyList(), "harris"); + assertThat(authenticated.isAuthenticated()).isTrue(); + } + + @Test + public void getTokenAttributesWhenHasTokenThenReturnsThem() { + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, Collections.emptyList()); + assertThat(authenticated.getTokenAttributes()).isEqualTo(this.attributes); + } + + @Test + public void getAuthoritiesWhenHasAuthoritiesThenReturnsThem() { + List authorities = AuthorityUtils.createAuthorityList("USER"); + OAuth2IntrospectionAuthenticationToken authenticated = + new OAuth2IntrospectionAuthenticationToken(this.token, this.attributes, authorities); + assertThat(authenticated.getAuthorities()).isEqualTo(authorities); + } +} From fee65dc719ad6622ae16e26f03853af889564189 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 1 Nov 2018 17:30:10 -0600 Subject: [PATCH 2/3] Introspect endpoint Authorization Server support Issue: gh-5200 --- ... => AuthorizationServerConfiguration.java} | 80 +++++++++++++++++-- .../src/main/resources/application.yml | 2 + 2 files changed, 74 insertions(+), 8 deletions(-) rename samples/boot/oauth2authorizationserver/src/main/java/sample/{JwkSetConfiguration.java => AuthorizationServerConfiguration.java} (77%) diff --git a/samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java b/samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.java similarity index 77% rename from samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java rename to samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.java index bca7df06079..593793b7cad 100644 --- a/samples/boot/oauth2authorizationserver/src/main/java/sample/JwkSetConfiguration.java +++ b/samples/boot/oauth2authorizationserver/src/main/java/sample/AuthorizationServerConfiguration.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. @@ -21,12 +21,15 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.RSAPrivateKeySpec; import java.security.spec.RSAPublicKeySpec; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.stream.Collectors; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -37,18 +40,23 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; +import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.endpoint.FrameworkEndpoint; import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter; import org.springframework.security.oauth2.provider.token.DefaultUserAuthenticationConverter; import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; /** @@ -66,17 +74,20 @@ */ @EnableAuthorizationServer @Configuration -public class JwkSetConfiguration extends AuthorizationServerConfigurerAdapter { +public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { AuthenticationManager authenticationManager; KeyPair keyPair; + boolean jwtEnabled; - public JwkSetConfiguration( + public AuthorizationServerConfiguration( AuthenticationConfiguration authenticationConfiguration, - KeyPair keyPair) throws Exception { + KeyPair keyPair, + @Value("${security.oauth2.authorizationserver.jwt.enabled:true}") boolean jwtEnabled) throws Exception { this.authenticationManager = authenticationConfiguration.getAuthenticationManager(); this.keyPair = keyPair; + this.jwtEnabled = jwtEnabled; } @Override @@ -94,23 +105,37 @@ public void configure(ClientDetailsServiceConfigurer clients) .authorizedGrantTypes("password") .secret("{noop}secret") .scopes("message:write") + .accessTokenValiditySeconds(600_000_000) + .and() + .withClient("noscopes") + .authorizedGrantTypes("password") + .secret("{noop}secret") + .scopes("none") .accessTokenValiditySeconds(600_000_000); // @formatter:on } @Override - public void configure(AuthorizationServerEndpointsConfigurer endpoints) { + public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // @formatter:off endpoints .authenticationManager(this.authenticationManager) - .accessTokenConverter(accessTokenConverter()) .tokenStore(tokenStore()); + + if (this.jwtEnabled) { + endpoints + .accessTokenConverter(accessTokenConverter()); + } // @formatter:on } @Bean public TokenStore tokenStore() { - return new JwtTokenStore(accessTokenConverter()); + if (this.jwtEnabled) { + return new JwtTokenStore(accessTokenConverter()); + } else { + return new InMemoryTokenStore(); + } } @Bean @@ -137,7 +162,11 @@ protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .mvcMatchers("/.well-known/jwks.json").permitAll() - .anyRequest().authenticated(); + .anyRequest().authenticated() + .and() + .httpBasic() + .and() + .csrf().ignoringRequestMatchers(request -> "/introspect".equals(request.getRequestURI())); } @Bean @@ -152,6 +181,41 @@ public UserDetailsService userDetailsService() { } } +/** + * Legacy Authorization Server (spring-security-oauth2) does not support any + * Token Introspection endpoint. + * + * This class adds ad-hoc support in order to better support the other samples in the repo. + */ +@FrameworkEndpoint +class IntrospectEndpoint { + TokenStore tokenStore; + + public IntrospectEndpoint(TokenStore tokenStore) { + this.tokenStore = tokenStore; + } + + @PostMapping("/introspect") + @ResponseBody + public Map introspect(@RequestParam("token") String token) { + OAuth2AccessToken accessToken = this.tokenStore.readAccessToken(token); + Map attributes = new HashMap<>(); + if (accessToken == null || accessToken.isExpired()) { + attributes.put("active", false); + return attributes; + } + + OAuth2Authentication authentication = this.tokenStore.readAuthentication(token); + + attributes.put("active", true); + attributes.put("exp", accessToken.getExpiration().getTime()); + attributes.put("scope", accessToken.getScope().stream().collect(Collectors.joining(" "))); + attributes.put("sub", authentication.getName()); + + return attributes; + } +} + /** * Legacy Authorization Server (spring-security-oauth2) does not support any * JWK Set endpoint. diff --git a/samples/boot/oauth2authorizationserver/src/main/resources/application.yml b/samples/boot/oauth2authorizationserver/src/main/resources/application.yml index e5ba667fd3d..b0b10a294f5 100644 --- a/samples/boot/oauth2authorizationserver/src/main/resources/application.yml +++ b/samples/boot/oauth2authorizationserver/src/main/resources/application.yml @@ -1 +1,3 @@ server.port: 8081 + +# security.oauth2.authorizationserver.jwt.enabled: false From ae1f287214f7e42e315edba17c2724e751b3e74a Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 1 Nov 2018 17:30:22 -0600 Subject: [PATCH 3/3] Resource Server Opaque Token Sample Issue: gh-5200 --- .../oauth2resourceserver-opaque/README.adoc | 114 +++++++++++ ...es-boot-oauth2resourceserver-opaque.gradle | 14 ++ ...OAuth2ResourceServerApplicationITests.java | 101 ++++++++++ ...MockWebServerEnvironmentPostProcessor.java | 41 ++++ .../boot/env/MockWebServerPropertySource.java | 181 ++++++++++++++++++ .../boot/env/package-info.java | 23 +++ .../OAuth2ResourceServerApplication.java | 30 +++ .../OAuth2ResourceServerController.java | 37 ++++ ...h2ResourceServerSecurityConfiguration.java | 47 +++++ .../main/resources/META-INF/spring.factories | 1 + .../src/main/resources/application.yml | 8 + 11 files changed, 597 insertions(+) create mode 100644 samples/boot/oauth2resourceserver-opaque/README.adoc create mode 100644 samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle create mode 100644 samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories create mode 100644 samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml diff --git a/samples/boot/oauth2resourceserver-opaque/README.adoc b/samples/boot/oauth2resourceserver-opaque/README.adoc new file mode 100644 index 00000000000..fc6add9a1f3 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/README.adoc @@ -0,0 +1,114 @@ += OAuth 2.0 Resource Server Sample + +This sample demonstrates integrating Resource Server with a mock Authorization Server, though it can be modified to integrate +with your favorite Authorization Server. + +With it, you can run the integration tests or run the application as a stand-alone service to explore how you can +secure your own service with OAuth 2.0 Opaque Bearer Tokens using Spring Security. + +== 1. Running the tests + +To run the tests, do: + +```bash +./gradlew integrationTest +``` + +Or import the project into your IDE and run `OAuth2ResourceServerApplicationTests` from there. + +=== What is it doing? + +By default, the tests are pointing at a mock Authorization Server instance. + +The tests are configured with a set of hard-coded tokens originally obtained from the mock Authorization Server, +and each makes a query to the Resource Server with their corresponding token. + +The Resource Server subsquently verifies with the Authorization Server and authorizes the request, returning the phrase + +```bash +Hello, subject! +``` + +where "subject" is the value of the `sub` field in the JWT returned by the Authorization Server. + +== 2. Running the app + +To run as a stand-alone application, do: + +```bash +./gradlew bootRun +``` + +Or import the project into your IDE and run `OAuth2ResourceServerApplication` from there. + +Once it is up, you can use the following token: + +```bash +export TOKEN=00ed5855-1869-47a0-b0c9-0f3ce520aee7 +``` + +And then make this request: + +```bash +curl -H "Authorization: Bearer $TOKEN" localhost:8080 +``` + +Which will respond with the phrase: + +```bash +Hello, subject! +``` + +where `subject` is the value of the `sub` field in the JWT returned by the Authorization Server. + +Or this: + +```bash +export TOKEN=b43d1500-c405-4dc9-b9c9-6cfd966c34c9 + +curl -H "Authorization: Bearer $TOKEN" localhost:8080/message +``` + +Will respond with: + +```bash +secret message +``` + +== 2. Testing against other Authorization Servers + +_In order to use this sample, your Authorization Server must support Opaque Tokens and the Introspection Endpoint. + +To change the sample to point at your Authorization Server, simply find this property in the `application.yml`: + +```yaml +spring: + security: + oauth2: + resourceserver: + opaque: + introspection-uri: ${mockwebserver.url}/introspect + introspection-client-id: client + introspection-client-secret: secret +``` + +And change the property to your Authorization Server's Introspection endpoint, including its client id and secret: + +```yaml +spring: + security: + oauth2: + resourceserver: + opaque: + introspection-uri: ${mockwebserver.url}/introspect +``` + +And then you can run the app the same as before: + +```bash +./gradlew bootRun +``` + +Make sure to obtain valid tokens from your Authorization Server in order to play with the sample Resource Server. +To use the `/` endpoint, any valid token from your Authorization Server will do. +To use the `/message` endpoint, the token should have the `message:read` scope. diff --git a/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle new file mode 100644 index 00000000000..9074842b18a --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/spring-security-samples-boot-oauth2resourceserver-opaque.gradle @@ -0,0 +1,14 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-security-config') + compile project(':spring-security-oauth2-jose') + compile project(':spring-security-oauth2-resource-server') + + compile 'org.springframework.boot:spring-boot-starter-web' + compile 'com.nimbusds:oauth2-oidc-sdk' + compile 'com.squareup.okhttp3:mockwebserver' + + testCompile project(':spring-security-test') + testCompile 'org.springframework.boot:spring-boot-starter-test' +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java b/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java new file mode 100644 index 00000000000..02bfcda689a --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java @@ -0,0 +1,101 @@ +/* + * 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 sample; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for {@link OAuth2ResourceServerApplication} + * + * @author Josh Cummings + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class OAuth2ResourceServerApplicationITests { + + String noScopesToken = "00ed5855-1869-47a0-b0c9-0f3ce520aee7"; + String messageReadToken = "b43d1500-c405-4dc9-b9c9-6cfd966c34c9"; + + @Autowired + MockMvc mvc; + + @Test + public void performWhenValidBearerTokenThenAllows() + throws Exception { + + this.mvc.perform(get("/").with(bearerToken(this.noScopesToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Hello, subject!"))); + } + + // -- tests with scopes + + @Test + public void performWhenValidBearerTokenThenScopedRequestsAlsoWork() + throws Exception { + + this.mvc.perform(get("/message").with(bearerToken(this.messageReadToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("secret message"))); + } + + @Test + public void performWhenInsufficientlyScopedBearerTokenThenDeniesScopedMethodAccess() + throws Exception { + + this.mvc.perform(get("/message").with(bearerToken(this.noScopesToken))) + .andExpect(status().isForbidden()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, + containsString("Bearer error=\"insufficient_scope\""))); + } + + private static class BearerTokenRequestPostProcessor implements RequestPostProcessor { + private String token; + + public BearerTokenRequestPostProcessor(String token) { + this.token = token; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + request.addHeader("Authorization", "Bearer " + this.token); + return request; + } + } + + private static BearerTokenRequestPostProcessor bearerToken(String token) { + return new BearerTokenRequestPostProcessor(token); + } +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java new file mode 100644 index 00000000000..0900dd9740b --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java @@ -0,0 +1,41 @@ +/* + * 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.boot.env; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * @author Rob Winch + */ +public class MockWebServerEnvironmentPostProcessor + implements EnvironmentPostProcessor, DisposableBean { + + private final MockWebServerPropertySource propertySource = new MockWebServerPropertySource(); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, + SpringApplication application) { + environment.getPropertySources().addFirst(this.propertySource); + } + + @Override + public void destroy() throws Exception { + this.propertySource.destroy(); + } +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java new file mode 100644 index 00000000000..03a6e51785b --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java @@ -0,0 +1,181 @@ +/* + * 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.boot.env; + +import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.core.env.PropertySource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * @author Rob Winch + */ +public class MockWebServerPropertySource extends PropertySource implements + DisposableBean { + + private static final MockResponse NO_SCOPES_RESPONSE = response( + "{\n" + + " \"active\": true,\n" + + " \"sub\": \"subject\"\n" + + " }", + 200 + ); + + private static final MockResponse MESSASGE_READ_SCOPE_RESPONSE = response( + "{\n" + + " \"active\": true,\n" + + " \"scope\" : \"message:read\"," + + " \"sub\": \"subject\"\n" + + " }", + 200 + ); + + private static final MockResponse INACTIVE_RESPONSE = response( + "{\n" + + " \"active\": false,\n" + + " }", + 200 + ); + + private static final MockResponse BAD_REQUEST_RESPONSE = response( + "{ \"message\" : \"This mock authorization server requires a username and password of " + + "client/secret and a POST body of token=${token}\" }", + 400 + ); + + private static final MockResponse NOT_FOUND_RESPONSE = response( + "{ \"message\" : \"This mock authorization server responds to just one request: POST /introspect.\" }", + 404 + ); + + /** + * Name of the random {@link PropertySource}. + */ + public static final String MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME = "mockwebserver"; + + private static final String NAME = "mockwebserver.url"; + + private static final Log logger = LogFactory.getLog(MockWebServerPropertySource.class); + + private boolean started; + + public MockWebServerPropertySource() { + super(MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME, new MockWebServer()); + } + + @Override + public Object getProperty(String name) { + if (!name.equals(NAME)) { + return null; + } + if (logger.isTraceEnabled()) { + logger.trace("Looking up the url for '" + name + "'"); + } + String url = getUrl(); + return url; + } + + @Override + public void destroy() throws Exception { + getSource().shutdown(); + } + + /** + * Get's the URL (e.g. "http://localhost:123456") + * @return + */ + private String getUrl() { + MockWebServer mockWebServer = getSource(); + if (!this.started) { + initializeMockWebServer(mockWebServer); + } + String url = mockWebServer.url("").url().toExternalForm(); + return url.substring(0, url.length() - 1); + } + + private void initializeMockWebServer(MockWebServer mockWebServer) { + Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return doDispatch(request); + } + }; + + mockWebServer.setDispatcher(dispatcher); + try { + mockWebServer.start(); + this.started = true; + } catch (IOException e) { + throw new RuntimeException("Could not start " + mockWebServer, e); + } + } + + private MockResponse doDispatch(RecordedRequest request) { + if ("/introspect".equals(request.getPath())) { + return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)) + .filter(authorization -> isAuthorized(authorization, "client", "secret")) + .map(authorization -> parseBody(request.getBody())) + .map(parameters -> parameters.get("token")) + .map(token -> { + if ("00ed5855-1869-47a0-b0c9-0f3ce520aee7".equals(token)) { + return NO_SCOPES_RESPONSE; + } else if ("b43d1500-c405-4dc9-b9c9-6cfd966c34c9".equals(token)) { + return MESSASGE_READ_SCOPE_RESPONSE; + } else { + return INACTIVE_RESPONSE; + } + }) + .orElse(BAD_REQUEST_RESPONSE); + } + + return NOT_FOUND_RESPONSE; + } + + private 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 Map parseBody(Buffer body) { + return Stream.of(body.readUtf8().split("&")) + .map(parameter -> parameter.split("=")) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } + + private static MockResponse response(String body, int status) { + return new MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setResponseCode(status) + .setBody(body); + } + +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java new file mode 100644 index 00000000000..67d99c793de --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/org/springframework/boot/env/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * This provides integration of a {@link okhttp3.mockwebserver.MockWebServer} and the + * {@link org.springframework.core.env.Environment} + * + * @author Rob Winch + */ +package org.springframework.boot.env; diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java new file mode 100644 index 00000000000..465cd0af888 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerApplication.java @@ -0,0 +1,30 @@ +/* + * 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 sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Josh Cummings + */ +@SpringBootApplication +public class OAuth2ResourceServerApplication { + + public static void main(String[] args) { + SpringApplication.run(OAuth2ResourceServerApplication.class, args); + } +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java new file mode 100644 index 00000000000..32be749f287 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java @@ -0,0 +1,37 @@ +/* + * 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 sample; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Josh Cummings + */ +@RestController +public class OAuth2ResourceServerController { + + @GetMapping("/") + public String index(@AuthenticationPrincipal(expression="['sub']") String subject) { + return String.format("Hello, %s!", subject); + } + + @GetMapping("/message") + public String message() { + return "secret message"; + } +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java new file mode 100644 index 00000000000..23ed4db21d2 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java @@ -0,0 +1,47 @@ +/* + * 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 sample; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * @author Josh Cummings + */ +@EnableWebSecurity +public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}") String introspectionUri; + @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}") String clientId; + @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-secret}") String clientSecret; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .mvcMatchers("/message/**").hasAuthority("SCOPE_message:read") + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .opaqueToken() + .introspectionUri(this.introspectionUri) + .introspectionClientCredentials(this.clientId, this.clientSecret); + // @formatter:on + } +} diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories b/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..37b447c9702 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=org.springframework.boot.env.MockWebServerEnvironmentPostProcessor diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml b/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml new file mode 100644 index 00000000000..a7dcfead944 --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + security: + oauth2: + resourceserver: + opaque: + introspection-uri: ${mockwebserver.url}/introspect + introspection-client-id: client + introspection-client-secret: secret