diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index ecce84d7a9..4891d67cc4 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -13,6 +13,7 @@ dependencies { optional project(':spring-security-messaging') optional project(':spring-security-oauth2-client') optional project(':spring-security-oauth2-jose') + optional project(':spring-security-oauth2-resource-server') optional project(':spring-security-openid') optional project(':spring-security-web') optional 'io.projectreactor:reactor-core' diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/OAuth2Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/OAuth2Configurer.java index 57d42deb2c..772e812297 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/OAuth2Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/OAuth2Configurer.java @@ -21,6 +21,7 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; /** * An {@link AbstractHttpConfigurer} that provides support for the @@ -40,6 +41,8 @@ public final class OAuth2Configurer> private OAuth2ClientConfigurer clientConfigurer; + private OAuth2ResourceServerConfigurer resourceServerConfigurer; + /** * Returns the {@link OAuth2ClientConfigurer} for configuring OAuth 2.0 Client support. * @@ -52,11 +55,27 @@ public final class OAuth2Configurer> return this.clientConfigurer; } + /** + * Returns the {@link OAuth2ResourceServerConfigurer} for configuring OAuth 2.0 Resource Server support. + * + * @return the {@link OAuth2ResourceServerConfigurer} + */ + public OAuth2ResourceServerConfigurer resourceServer() { + if (this.resourceServerConfigurer == null) { + this.initResourceServerConfigurer(); + } + return this.resourceServerConfigurer; + } + @Override public void init(B builder) throws Exception { if (this.clientConfigurer != null) { this.clientConfigurer.init(builder); } + + if (this.resourceServerConfigurer != null) { + this.resourceServerConfigurer.init(builder); + } } @Override @@ -64,6 +83,10 @@ public final class OAuth2Configurer> if (this.clientConfigurer != null) { this.clientConfigurer.configure(builder); } + + if (this.resourceServerConfigurer != null) { + this.resourceServerConfigurer.configure(builder); + } } private void initClientConfigurer() { @@ -71,4 +94,10 @@ public final class OAuth2Configurer> this.clientConfigurer.setBuilder(this.getBuilder()); this.clientConfigurer.addObjectPostProcessor(this.objectPostProcessor); } + + private void initResourceServerConfigurer() { + this.resourceServerConfigurer = new OAuth2ResourceServerConfigurer<>(); + this.resourceServerConfigurer.setBuilder(this.getBuilder()); + this.resourceServerConfigurer.addObjectPostProcessor(this.objectPostProcessor); + } } 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 new file mode 100644 index 0000000000..faba20fa3e --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -0,0 +1,225 @@ +/* + * Copyright 2002-2018 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.config.annotation.web.configurers.oauth2.server.resource; + +import javax.servlet.http.HttpServletRequest; + +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; +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * + * An {@link AbstractHttpConfigurer} for OAuth 2.0 Resource Server Support. + * + * By default, this wires a {@link BearerTokenAuthenticationFilter}, which can be used to parse the request + * for bearer tokens and make an authentication attempt. + * + *

+ * The following configuration options are available: + * + *

    + *
  • {@link #jwt()} - enables Jwt-encoded bearer token support
  • + *
+ * + *

+ * When using {@link #jwt()}, a Jwk Set Uri must be supplied via {@link JwtConfigurer#jwkSetUri} + * + *

Security Filters

+ * + * The following {@code Filter}s are populated when {@link #jwt()} is configured: + * + *
    + *
  • {@link BearerTokenAuthenticationFilter}
  • + *
+ * + *

Shared Objects Created

+ * + * The following shared objects are populated: + * + *
    + *
  • {@link SessionCreationPolicy} (optional)
  • + *
+ * + *

Shared Objects Used

+ * + * The following shared objects are used: + * + *
    + *
  • {@link AuthenticationManager}
  • + *
+ * + * If {@link #jwt()} isn't supplied, then the {@link BearerTokenAuthenticationFilter} is still added, but without + * any OAuth 2.0 {@link AuthenticationProvider}s. This is useful if needing to switch out Spring Security's Jwt support + * for a custom one. + * + * @author Josh Cummings + * @since 5.1 + * @see BearerTokenAuthenticationFilter + * @see JwtAuthenticationProvider + * @see NimbusJwtDecoderJwkSupport + * @see AbstractHttpConfigurer + */ +public final class OAuth2ResourceServerConfigurer> extends + AbstractHttpConfigurer, H> { + + private BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver(); + private BearerTokenRequestMatcher requestMatcher = new BearerTokenRequestMatcher(); + + private BearerTokenAuthenticationEntryPoint authenticationEntryPoint + = new BearerTokenAuthenticationEntryPoint(); + + private BearerTokenAccessDeniedHandler accessDeniedHandler + = new BearerTokenAccessDeniedHandler(); + + private JwtConfigurer jwtConfigurer = new JwtConfigurer(); + + public JwtConfigurer jwt() { + return this.jwtConfigurer; + } + + @Override + public void setBuilder(H http) { + super.setBuilder(http); + initSessionCreationPolicy(http); + } + + @Override + public void init(H http) throws Exception { + registerDefaultDeniedHandler(http); + registerDefaultEntryPoint(http); + registerDefaultCsrfOverride(http); + } + + @Override + public void configure(H http) throws Exception { + BearerTokenResolver bearerTokenResolver = getBearerTokenResolver(); + this.requestMatcher.setBearerTokenResolver(bearerTokenResolver); + + AuthenticationManager manager = http.getSharedObject(AuthenticationManager.class); + + BearerTokenAuthenticationFilter filter = + new BearerTokenAuthenticationFilter(manager); + filter.setBearerTokenResolver(bearerTokenResolver); + filter = postProcess(filter); + + http.addFilterBefore(filter, BasicAuthenticationFilter.class); + + JwtDecoder decoder = this.jwtConfigurer.getJwtDecoder(); + + if (decoder != null) { + JwtAuthenticationProvider provider = + new JwtAuthenticationProvider(decoder); + provider = postProcess(provider); + + http.authenticationProvider(provider); + } else { + throw new IllegalStateException("Jwt is the only supported format for bearer tokens " + + "in Spring Security and no instance of JwtDecoder could be found. Make sure to specify " + + "a jwk set uri by doing http.oauth2().resourceServer().jwt().jwkSetUri(uri)"); + } + } + + public class JwtConfigurer { + private JwtDecoder decoder; + + private JwtConfigurer() {} + + public OAuth2ResourceServerConfigurer jwkSetUri(String uri) { + this.decoder = new NimbusJwtDecoderJwkSupport(uri); + return OAuth2ResourceServerConfigurer.this; + } + + private JwtDecoder getJwtDecoder() { + return this.decoder; + } + } + + private void initSessionCreationPolicy(H http) { + if (http.getSharedObject(SessionCreationPolicy.class) == null) { + http.setSharedObject(SessionCreationPolicy.class, SessionCreationPolicy.STATELESS); + } + } + + private void registerDefaultDeniedHandler(H http) { + ExceptionHandlingConfigurer exceptionHandling = http + .getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptionHandling == null) { + return; + } + + exceptionHandling.defaultAccessDeniedHandlerFor( + this.accessDeniedHandler, + this.requestMatcher); + } + + private void registerDefaultEntryPoint(H http) { + ExceptionHandlingConfigurer exceptionHandling = http + .getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptionHandling == null) { + return; + } + + exceptionHandling.defaultAuthenticationEntryPointFor( + this.authenticationEntryPoint, + this.requestMatcher); + } + + private void registerDefaultCsrfOverride(H http) { + CsrfConfigurer csrf = http + .getConfigurer(CsrfConfigurer.class); + if (csrf == null) { + return; + } + + csrf.ignoringRequestMatchers(this.requestMatcher); + } + + private BearerTokenResolver getBearerTokenResolver() { + return this.bearerTokenResolver; + } + + private static final class BearerTokenRequestMatcher implements RequestMatcher { + private BearerTokenResolver bearerTokenResolver + = new DefaultBearerTokenResolver(); + + @Override + public boolean matches(HttpServletRequest request) { + return this.bearerTokenResolver.resolve(request) != null; + } + + public void setBearerTokenResolver(BearerTokenResolver tokenResolver) { + Assert.notNull(tokenResolver, "resolver cannot be null"); + this.bearerTokenResolver = tokenResolver; + } + } +} 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 new file mode 100644 index 0000000000..4f58b09896 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java @@ -0,0 +1,828 @@ +/* + * Copyright 2002-2018 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.config.annotation.web.configurers.oauth2.server.resource; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.stream.Collectors; +import javax.annotation.PreDestroy; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Rule; +import org.junit.Test; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +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; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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; +import static org.springframework.web.bind.annotation.RequestMethod.GET; +import static org.springframework.web.bind.annotation.RequestMethod.POST; + +/** + * Tests for {@link OAuth2ResourceServerConfigurer} + * + * @author Josh Cummings + */ +public class OAuth2ResourceServerConfigurerTests { + + @Autowired + MockMvc mvc; + + @Autowired(required = false) + MockWebServer authz; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Test + public void getWhenUsingDefaultsWithValidBearerTokenThenAcceptsRequest() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("ok")); + } + + @Test + public void getWhenUsingDefaultsWithExpiredBearerTokenThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("Expired"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Expired JWT")); + } + + @Test + public void getWhenUsingDefaultsWithBadJwkEndpointThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.enqueue(new MockResponse().setBody("malformed")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Malformed Jwk set")); + } + + @Test + public void getWhenUsingDefaultsWithUnavailableJwkEndpointThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.shutdown(); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: " + + "Couldn't retrieve remote JWK set: Connection refused (Connection refused)")); + } + + @Test + public void getWhenUsingDefaultsWithMalformedBearerTokenThenInvalidToken() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + this.mvc.perform(get("/").with(bearerToken("an\"invalid\"token"))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("Bearer token is malformed")); + } + + @Test + public void getWhenUsingDefaultsWithMalformedPayloadThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("MalformedPayload"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: " + + "Malformed payload")); + } + + @Test + public void getWhenUsingDefaultsWithUnsignedBearerTokenThenInvalidToken() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + String token = this.token("Unsigned"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("Unsupported algorithm of none")); + } + + @Test + public void getWhenUsingDefaultsWithBearerTokenBeforeNotBeforeThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("TooEarly"); + + this.mvc.perform(get("/").with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: " + + "JWT before use time")); + } + + @Test + public void getWhenUsingDefaultsWithBearerTokenInTwoPlacesThenInvalidRequest() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + this.mvc.perform(get("/") + .with(bearerToken("token")) + .with(bearerToken("token").asParam())) + .andExpect(status().isBadRequest()) + .andExpect(invalidRequestHeader("Found multiple bearer tokens in the request")); + } + + @Test + public void getWhenUsingDefaultsWithBearerTokenInTwoParametersThenInvalidRequest() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("access_token", "token1"); + params.add("access_token", "token2"); + + this.mvc.perform(get("/") + .params(params)) + .andExpect(status().isBadRequest()) + .andExpect(invalidRequestHeader("Found multiple bearer tokens in the request")); + } + + @Test + public void postWhenUsingDefaultsWithBearerTokenAsFormParameterThenIgnoresToken() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + this.mvc.perform(post("/") // engage csrf + .with(bearerToken("token").asParam())) + .andExpect(status().isForbidden()) + .andExpect(header().doesNotExist(HttpHeaders.WWW_AUTHENTICATE)); + } + + @Test + public void postWhenCsrfDisabledWithBearerTokenAsFormParameterThenIgnoresToken() + throws Exception { + + this.spring.register(CsrfDisabledConfig.class).autowire(); + + this.mvc.perform(post("/") + .with(bearerToken("token").asParam())) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer")); + } + + @Test + public void getWhenUsingDefaultsWithNoBearerTokenThenUnauthorized() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + this.mvc.perform(get("/")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer")); + } + + @Test + public void getWhenUsingDefaultsWithSufficientlyScopedBearerTokenThenAcceptsRequest() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageReadScope"); + + this.mvc.perform(get("/requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("SCOPE_message:read")); + } + + @Test + public void getWhenUsingDefaultsWithInsufficientScopeThenInsufficientScopeError() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isForbidden()) + .andExpect(insufficientScopeHeader("")); + } + + @Test + public void getWhenUsingDefaultsWithInsufficientScpThenInsufficientScopeError() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageWriteScp"); + + this.mvc.perform(get("/requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isForbidden()) + .andExpect(insufficientScopeHeader("message:write")); + } + + @Test + public void getWhenUsingDefaultsAndAuthorizationServerHasNoMatchingKeyThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.enqueue(this.jwks("Empty")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/") + .with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: " + + "Signed JWT rejected: Another algorithm expected, or no matching key(s) found")); + } + + @Test + public void getWhenUsingDefaultsAndAuthorizationServerHasMultipleMatchingKeysThenOk() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("TwoKeys")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/authenticated") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("test-subject")); + } + + @Test + public void getWhenUsingDefaultsAndKeyMatchesByKidThenOk() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("TwoKeys")); + String token = this.token("Kid"); + + this.mvc.perform(get("/authenticated") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("test-subject")); + } + + // -- Method Security + + @Test + public void getWhenUsingMethodSecurityWithValidBearerTokenThenAcceptsRequest() + throws Exception { + + this.spring.register(WebServerConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageReadScope"); + + this.mvc.perform(get("/ms-requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("SCOPE_message:read")); + } + + @Test + public void getWhenUsingMethodSecurityWithValidBearerTokenHavingScpAttributeThenAcceptsRequest() + throws Exception { + + this.spring.register(WebServerConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageReadScp"); + + this.mvc.perform(get("/ms-requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("SCOPE_message:read")); + } + + @Test + public void getWhenUsingMethodSecurityWithInsufficientScopeThenInsufficientScopeError() + throws Exception { + + this.spring.register(WebServerConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/ms-requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isForbidden()) + .andExpect(insufficientScopeHeader("")); + + } + + @Test + public void getWhenUsingMethodSecurityWithInsufficientScpThenInsufficientScopeError() + throws Exception { + + this.spring.register(WebServerConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageWriteScp"); + + this.mvc.perform(get("/ms-requires-read-scope") + .with(bearerToken(token))) + .andExpect(status().isForbidden()) + .andExpect(insufficientScopeHeader("message:write")); + } + + @Test + public void getWhenUsingMethodSecurityWithDenyAllThenInsufficientScopeError() + throws Exception { + + this.spring.register(WebServerConfig.class, MethodSecurityConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidMessageReadScope"); + + this.mvc.perform(get("/ms-deny") + .with(bearerToken(token))) + .andExpect(status().isForbidden()) + .andExpect(insufficientScopeHeader("message:read")); + } + + // -- Resource Server should not engage csrf + + @Test + public void postWhenUsingDefaultsWithValidBearerTokenAndNoCsrfTokenThenOk() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(post("/authenticated") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("test-subject")); + } + + @Test + public void postWhenUsingDefaultsWithNoBearerTokenThenCsrfDenies() + throws Exception { + + this.spring.register(DefaultConfig.class).autowire(); + + this.mvc.perform(post("/authenticated")) + .andExpect(status().isForbidden()) + .andExpect(header().doesNotExist(HttpHeaders.WWW_AUTHENTICATE)); + } + + @Test + public void postWhenUsingDefaultsWithExpiredBearerTokenAndNoCsrfThenInvalidToken() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("Expired"); + + this.mvc.perform(post("/authenticated") + .with(bearerToken(token))) + .andExpect(status().isUnauthorized()) + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Expired JWT")); + } + + // -- Resource Server should not create sessions + + @Test + public void requestWhenDefaultConfiguredThenSessionIsNotCreated() + throws Exception { + + this.spring.register(WebServerConfig.class, DefaultConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + MvcResult result = this.mvc.perform(get("/") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(result.getRequest().getSession(false)).isNull(); + } + + @Test + public void requestWhenUsingDefaultsAndNoBearerTokenThenSessionIsNotCreated() + throws Exception { + + this.spring.register(DefaultConfig.class, BasicController.class).autowire(); + + MvcResult result = this.mvc.perform(get("/")) + .andExpect(status().isUnauthorized()) + .andReturn(); + + assertThat(result.getRequest().getSession(false)).isNull(); + } + + @Test + public void requestWhenSessionManagementConfiguredThenUserConfigurationOverrides() + throws Exception { + + this.spring.register(WebServerConfig.class, AlwaysSessionCreationConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + MvcResult result = this.mvc.perform(get("/") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(result.getRequest().getSession(false)).isNotNull(); + } + + // -- In combination with other authentication providers + + @Test + public void getWhenAlsoUsingHttpBasicThenCorrectProviderEngages() + throws Exception { + + this.spring.register(WebServerConfig.class, BasicAndResourceServerConfig.class, BasicController.class).autowire(); + this.authz.enqueue(this.jwks("Default")); + String token = this.token("ValidNoScopes"); + + this.mvc.perform(get("/authenticated") + .with(bearerToken(token))) + .andExpect(status().isOk()) + .andExpect(content().string("test-subject")); + + this.mvc.perform(get("/authenticated") + .with(httpBasic("basic-user", "basic-password"))) + .andExpect(status().isOk()) + .andExpect(content().string("basic-user")); + } + + // -- Incorrect Configuration + + @Test + public void configuredWhenMissingJwtAuthenticationProviderThenWiringException() { + + assertThatCode(() -> this.spring.register(JwtlessConfig.class).autowire()) + .isInstanceOf(BeanCreationException.class) + .hasMessageContaining("no instance of JwtDecoder"); + } + + @Test + public void configureWhenMissingJwkSetUriThenWiringException() { + + assertThatCode(() -> this.spring.register(JwtHalfConfiguredConfig.class).autowire()) + .isInstanceOf(BeanCreationException.class) + .hasMessageContaining("no instance of JwtDecoder"); + } + + // -- support + + @EnableWebSecurity + static class DefaultConfig extends WebSecurityConfigurerAdapter { + @Value("${mock.jwk-set-uri:https://example.org}") String uri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .antMatchers("/requires-read-scope").access("hasAuthority('SCOPE_message:read')") + .anyRequest().authenticated() + .and() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.uri); + // @formatter:on + } + } + + @EnableWebSecurity + static class CsrfDisabledConfig extends WebSecurityConfigurerAdapter { + @Value("${mock.jwk-set-uri:https://example.org}") String uri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .antMatchers("/requires-read-scope").access("hasAuthority('SCOPE_message:read')") + .anyRequest().authenticated() + .and() + .csrf().disable() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.uri); + // @formatter:on + } + } + + @EnableWebSecurity + @EnableGlobalMethodSecurity(prePostEnabled = true) + static class MethodSecurityConfig extends WebSecurityConfigurerAdapter { + @Value("${mock.jwk-set-uri:https://example.org}") String uri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.uri); + // @formatter:on + } + } + + @EnableWebSecurity + static class JwtlessConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2() + .resourceServer(); + // @formatter:on + } + } + + @EnableWebSecurity + static class BasicAndResourceServerConfig extends WebSecurityConfigurerAdapter { + @Value("${mock.jwk-set-uri:https://example.org}") String uri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .httpBasic() + .and() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.uri); + // @formatter:on + } + + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + org.springframework.security.core.userdetails.User.withDefaultPasswordEncoder() + .username("basic-user") + .password("basic-password") + .roles("USER") + .build()); + } + } + + @EnableWebSecurity + static class JwtHalfConfiguredConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().authenticated() + .and() + .oauth2() + .resourceServer() + .jwt(); // missing key configuration, e.g. jwkSetUri + // @formatter:on + } + } + + @EnableWebSecurity + static class AlwaysSessionCreationConfig extends WebSecurityConfigurerAdapter { + @Value("${mock.jwk-set-uri}") String uri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) + .and() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.uri); + // @formatter:on + } + } + + @RestController + static class BasicController { + @GetMapping("/") + public String get() { + return "ok"; + } + + @PostMapping("/post") + public String post() { + return "post"; + } + + @RequestMapping(value = "/authenticated", method = { GET, POST }) + public String authenticated(@AuthenticationPrincipal Authentication authentication) { + return authentication.getName(); + } + + @GetMapping("/requires-read-scope") + public String requiresReadScope(@AuthenticationPrincipal JwtAuthenticationToken token) { + return token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .filter(auth -> auth.endsWith("message:read")) + .findFirst().orElse(null); + } + + @GetMapping("/ms-requires-read-scope") + @PreAuthorize("hasAuthority('SCOPE_message:read')") + public String msRequiresReadScope(@AuthenticationPrincipal JwtAuthenticationToken token) { + return requiresReadScope(token); + } + + @GetMapping("/ms-deny") + @PreAuthorize("denyAll") + public String deny() { + return "hmm, that's odd"; + } + } + + @Configuration + static class WebServerConfig implements BeanPostProcessor { + private final MockWebServer server = new MockWebServer(); + + @PreDestroy + public void shutdown() throws IOException { + this.server.shutdown(); + } + + @Bean + public MockWebServer authz() { + return this.server; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof WebSecurityConfigurerAdapter) { + Field f = ReflectionUtils.findField(bean.getClass(), field -> + field.getAnnotation(Value.class) != null); + if (f != null) { + ReflectionUtils.setField(f, bean, this.server.url("/.well-known/jwks.json").toString()); + } + } + return null; + } + } + + private static class BearerTokenRequestPostProcessor implements RequestPostProcessor { + private boolean asRequestParameter; + + private String token; + + public BearerTokenRequestPostProcessor(String token) { + this.token = token; + } + + public BearerTokenRequestPostProcessor asParam() { + this.asRequestParameter = true; + return this; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + if (this.asRequestParameter) { + request.setParameter("access_token", this.token); + } else { + request.addHeader("Authorization", "Bearer " + this.token); + } + + return request; + } + } + + private static BearerTokenRequestPostProcessor bearerToken(String token) { + return new BearerTokenRequestPostProcessor(token); + } + + private static ResultMatcher invalidRequestHeader(String message) { + return header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer " + + "error=\"invalid_request\", " + + "error_description=\"" + message + "\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""); + } + + private static ResultMatcher invalidTokenHeader(String message) { + return header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer " + + "error=\"invalid_token\", " + + "error_description=\"" + message + "\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""); + } + + private static ResultMatcher insufficientScopeHeader(String scope) { + return header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer " + + "error=\"insufficient_scope\"" + + ", error_description=\"The token provided has insufficient scope [" + scope + "] for this request\"" + + ", error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"" + + (StringUtils.hasText(scope) ? ", scope=\"" + scope + "\"" : "")); + } + + private String token(String name) throws IOException { + return resource(name + ".token"); + } + + private MockResponse jwks(String name) throws IOException { + String response = resource(name + ".jwks"); + return new MockResponse() + .setResponseCode(200) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(response); + } + + private String resource(String suffix) throws IOException { + String name = this.getClass().getSimpleName() + "-" + suffix; + ClassPathResource resource = new ClassPathResource(name, this.getClass()); + try ( BufferedReader reader = new BufferedReader(new FileReader(resource.getFile())) ) { + return reader.lines().collect(Collectors.joining()); + } + } +} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Default.jwks b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Default.jwks new file mode 100644 index 0000000000..ce5e6fbf2b --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Default.jwks @@ -0,0 +1 @@ +{"keys":[{"p":"49neceJFs8R6n7WamRGy45F5Tv0YM-R2ODK3eSBUSLOSH2tAqjEVKOkLE5fiNA3ygqq15NcKRadB2pTVf-Yb5ZIBuKzko8bzYIkIqYhSh_FAdEEr0vHF5fq_yWSvc6swsOJGqvBEtuqtJY027u-G2gAQasCQdhyejer68zsTn8M","kty":"RSA","q":"tWR-ysspjZ73B6p2vVRVyHwP3KQWL5KEQcdgcmMOE_P_cPs98vZJfLhxobXVmvzuEWBpRSiqiuyKlQnpstKt94Cy77iO8m8ISfF3C9VyLWXi9HUGAJb99irWABFl3sNDff5K2ODQ8CmuXLYM25OwN3ikbrhEJozlXg_NJFSGD4E","d":"FkZHYZlw5KSoqQ1i2RA2kCUygSUOf1OqMt3uomtXuUmqKBm_bY7PCOhmwbvbn4xZYEeHuTR8Xix-0KpHe3NKyWrtRjkq1T_un49_1LLVUhJ0dL-9_x0xRquVjhl_XrsRXaGMEHs8G9pLTvXQ1uST585gxIfmCe0sxPZLvwoic-bXf64UZ9BGRV3lFexWJQqCZp2S21HfoU7wiz6kfLRNi-K4xiVNB1gswm_8o5lRuY7zB9bRARQ3TS2G4eW7p5sxT3CgsGiQD3_wPugU8iDplqAjgJ5ofNJXZezoj0t6JMB_qOpbrmAM1EnomIPebSLW7Ky9SugEd6KMdL5lW6AuAQ","e":"AQAB","use":"sig","kid":"one","qi":"wdkFu_tV2V1l_PWUUimG516Zvhqk2SWDw1F7uNDD-Lvrv_WNRIJVzuffZ8WYiPy8VvYQPJUrT2EXL8P0ocqwlaSTuXctrORcbjwgxDQDLsiZE0C23HYzgi0cofbScsJdhcBg7d07LAf7cdJWG0YVl1FkMCsxUlZ2wTwHfKWf-v4","dp":"uwnPxqC-IxG4r33-SIT02kZC1IqC4aY7PWq0nePiDEQMQWpjjNH50rlq9EyLzbtdRdIouo-jyQXB01K15-XXJJ60dwrGLYNVqfsTd0eGqD1scYJGHUWG9IDgCsxyEnuG3s0AwbW2UolWVSsU2xMZGb9PurIUZECeD1XDZwMp2s0","dq":"hra786AunB8TF35h8PpROzPoE9VJJMuLrc6Esm8eZXMwopf0yhxfN2FEAvUoTpLJu93-UH6DKenCgi16gnQ0_zt1qNNIVoRfg4rw_rjmsxCYHTVL3-RDeC8X_7TsEySxW0EgFTHh-nr6I6CQrAJjPM88T35KHtdFATZ7BCBB8AE","n":"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw"}]} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Empty.jwks b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Empty.jwks new file mode 100644 index 0000000000..9d15e791b4 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Empty.jwks @@ -0,0 +1 @@ +{"keys":[]} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Expired.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Expired.token new file mode 100644 index 0000000000..8010d04893 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Expired.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjE1MzAyMzE3MTB9.c8vXYFwe1cBuglaZbmZFXJOmLsu_IQf-OsOiiOGhEJYOzu6h6v_qEzf2xxbu5TSvwAERmDITUSK41UIIvgU75WebtgilNnTR83B_gPM-7_FI2FLzlgVH7WayzvbYTQqepE_ZUMLFkGkK4r-dRiOyB9_cfl6jq_b5hE_biH1qrgPQrjlEhU8YxeK2EE05wsARLzyjoIYifkStjPC6rC-MLFIVk5JoITNzkTh7zYYSWtKWEgwd8S_vluVtJaPk-yKPb4tXcFRzCFl_qd7aCF8_LHyhw-4wvhWRIi8DmQmRU_a1RxR0mi-UCp0jMwmBZxxkSdqJ4l_EHI1yVqpgnbMLDw diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Kid.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Kid.token new file mode 100644 index 0000000000..4f53fdea73 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Kid.token @@ -0,0 +1 @@ +eyJraWQiOiJvbmUiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJleHAiOjQ2ODM4ODM1NzJ9.UhukjNEowC5lLCccvdjCUJad5J9FGNModegMZGe9qKIbXxmfseTttZUNn3_K_6aNCfimtmRktCRbw3fUTcje2TFJOJ6SmomLcQyjq7S41Wq6oBSA2fdqOOU4vNvrk8_pSExsSyN9bfWiJ51I8Agzbq5eUDNo_HEpaJZimrIe9f2_njU1GxvAWsq_h4UhHEgPPb3kY9kN9hVYX_oShhh7JxbLJBnfsKBOKGEWOsE65GlmDgQV4om6RGjJaz6jFHKJTCpH08ADA3j2dqT0LNy4PrUmbnjPjWVtSQJkGcgUkcQW6qz0K86ZfJZZng_iB2VadRm5qO-99ySKmlxa5A-_Iw diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-MalformedPayload.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-MalformedPayload.token new file mode 100644 index 0000000000..a6c9be5a21 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-MalformedPayload.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJuYmYiOnt9LCJleHAiOjQ2ODM4OTEyMTl9.kpdv6ZXyYszZUzA4mJpviCBPzPftk6tIbIn5OoMuM09MKZCUCAFD8Y1tDmjzbWdkR_5CYiFMvSLq6DzAlugtGRAShc93dmDlyZmhcct2G477FxWaRKbtmFDjzuCjGyn7xHWpS7Wz6-Ngb-JyGI2m7FxXCgCpiYYBl-4-ONTuAT0fArJi_voA8K6YLnnjEjEprI3wsQRoS3Twa_fVdGkpMNlOGsQOqmlfjDrXpyfiANOe_ZztHxbDtJEZ9zfELxx9fzkZgTL1fD2Sj6HueDU-tMt-6IaGpBCLsg7d85RK001-U9u3Ph9awQC4QZK-8-F9OUUCY5RNcRJ57KEh9PjUfA diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TooEarly.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TooEarly.token new file mode 100644 index 0000000000..780cf10836 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TooEarly.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJuYmYiOjQ2ODM4OTI2NTUsImV4cCI6NDY4Mzg5MjY1NX0.MIaECJrmYjAByKNJoWHlP5ewg2xiW7GIxL8Vepp3ZIKf_jjM2OSMQlAWGmfD3Kf3bfesvSI7glw5qg_ZIv4FdIPaTvnmLRjWQkpk-QiLTJr_HM2wWeNbUJ1zciGWQlWAvabtQuyeGt1dsfQq53QLVNpvuioYdVg-gz_76uwDTxCKQU_99ksQhMMJsYJVDA_-uWGTzBANszcZykqwWFMaoXF4lkVPK4U68n18ISBB761wFusUCtyGWzwevX7wBAEJxcRy6ZVk3h7GyxZBsbRAd5fPn3dPMxNvL_CEp5jUYSAH-arAdDkvAph5Vk1yXof7FFRcffJpAy76HC66hR2JQA diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TwoKeys.jwks b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TwoKeys.jwks new file mode 100644 index 0000000000..16d3a00859 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-TwoKeys.jwks @@ -0,0 +1 @@ +{"keys":[{"p":"49neceJFs8R6n7WamRGy45F5Tv0YM-R2ODK3eSBUSLOSH2tAqjEVKOkLE5fiNA3ygqq15NcKRadB2pTVf-Yb5ZIBuKzko8bzYIkIqYhSh_FAdEEr0vHF5fq_yWSvc6swsOJGqvBEtuqtJY027u-G2gAQasCQdhyejer68zsTn8M","kty":"RSA","q":"tWR-ysspjZ73B6p2vVRVyHwP3KQWL5KEQcdgcmMOE_P_cPs98vZJfLhxobXVmvzuEWBpRSiqiuyKlQnpstKt94Cy77iO8m8ISfF3C9VyLWXi9HUGAJb99irWABFl3sNDff5K2ODQ8CmuXLYM25OwN3ikbrhEJozlXg_NJFSGD4E","d":"FkZHYZlw5KSoqQ1i2RA2kCUygSUOf1OqMt3uomtXuUmqKBm_bY7PCOhmwbvbn4xZYEeHuTR8Xix-0KpHe3NKyWrtRjkq1T_un49_1LLVUhJ0dL-9_x0xRquVjhl_XrsRXaGMEHs8G9pLTvXQ1uST585gxIfmCe0sxPZLvwoic-bXf64UZ9BGRV3lFexWJQqCZp2S21HfoU7wiz6kfLRNi-K4xiVNB1gswm_8o5lRuY7zB9bRARQ3TS2G4eW7p5sxT3CgsGiQD3_wPugU8iDplqAjgJ5ofNJXZezoj0t6JMB_qOpbrmAM1EnomIPebSLW7Ky9SugEd6KMdL5lW6AuAQ","e":"AQAB","use":"sig","kid":"one","qi":"wdkFu_tV2V1l_PWUUimG516Zvhqk2SWDw1F7uNDD-Lvrv_WNRIJVzuffZ8WYiPy8VvYQPJUrT2EXL8P0ocqwlaSTuXctrORcbjwgxDQDLsiZE0C23HYzgi0cofbScsJdhcBg7d07LAf7cdJWG0YVl1FkMCsxUlZ2wTwHfKWf-v4","dp":"uwnPxqC-IxG4r33-SIT02kZC1IqC4aY7PWq0nePiDEQMQWpjjNH50rlq9EyLzbtdRdIouo-jyQXB01K15-XXJJ60dwrGLYNVqfsTd0eGqD1scYJGHUWG9IDgCsxyEnuG3s0AwbW2UolWVSsU2xMZGb9PurIUZECeD1XDZwMp2s0","dq":"hra786AunB8TF35h8PpROzPoE9VJJMuLrc6Esm8eZXMwopf0yhxfN2FEAvUoTpLJu93-UH6DKenCgi16gnQ0_zt1qNNIVoRfg4rw_rjmsxCYHTVL3-RDeC8X_7TsEySxW0EgFTHh-nr6I6CQrAJjPM88T35KHtdFATZ7BCBB8AE","n":"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw"},{"p":"_CI5g5In9T4ZgakV1i62UU6yjorEr5t2URHfRYqxN7S4aKsQOzggcPoqa78xRj8PAPuf3P0ArPEAHdS6bFK7RLrFXdvyEmSNTJa1gcLCf2Zmep8bsrhrCvh6seZNvfrSMV0ULmk0B75Fs8mqE7nwcIbPtBYkinlSIw-sKRv62DM","kty":"RSA","q":"pqfexT3HBAagH-iydGsWbjG6CcYyvSQZdFtUu4LIOBCYVA0dvkN9s7uU1eoevHN_ksf-hfrF5AQH0a5P0dIJ2pp1bFa9uo9DJ7khU9sIBk9_o8nST2QLHwPQmGTW8vVlcSF7Vffvzm2fV3cQ3dfI5lvtkqfX_Z3WkF8UjFjADe8","d":"FzB5xChO8e89JisxSueY5j1RUBmatIAs_8Z3LUHOw16GlAhBhbSNl-7bXkbcUWLq9M1zTLCD91SSZXBohf9j1ebqWnbjMqQmdkxlQcVRoKcnMJ5YBabCTMBXghQnJetUMh6x6hXRnR1CSBNRdZPf-K2bnxL3xRNRSfY_7bjpb_q5pyUsK66ugSKwuEOUDNf1ttOZi4PBTsxWMDyXi_7fNFjl-B831uWNDVwdY4j68PVwGPT87zjZYjZRTZXB4ILUP11ztw4s3s_bU1Lj0PeZJsA5rmjU1iBzqCNdzgYxNlfV7M62VCkE1Wtd6M97jtysiT-5wQUMxNugoOTc9thc1Q","e":"AQAB","use":"sig","kid":"two","qi":"bnGriiVGVea9vSaN_48YYTEoKYM1kF7TrCRKERkMWdi4EHF7pZNWBv8arxaLUzElllvtGlVTNwkZlG0gOhXBoLYbcfqVikDklkBxtsuZEBKgvX7zFlDIBlNjh98lcZqDqz7Rqwr-tavxTCq2LNNlK6x-dYL61Agw_LOilYqbSfA","dp":"MmT4z-ZnnCn0WSkdlziw8iFjqP_tfhf5lwyWbsTg1PyHG0yNqvh1637k-bI2PA8ghZbFhhr_hpGI7210cXA7w-n8xtzOToTQhS1eS_hMfcBO3VVt6NPZeVDe3S3l_gHi_0DWZsxaPO336o51MwooF6WqYBlI5nCHTUC1rWXNRmc","dq":"dd_ybywc4boV87vQzQsZWGOPpG4tYR5xap1WtzHvj8gdFgYY7YQrGr8orIzlpIFE0Hroibcv1PEM3sAd8NhQ4--v8isAEz5VT3lgG0Gm0V_VdfG_8StfulYmakOYzUvIrlXyOIIfebCLrX-nzGFd1aFbzgktelLzejXmAMadQL0","n":"pCOHBsaoxlt9-qVE_INhrbkmxm7WqwEeqUBBIgHvm_JzXbmJ4iQzVF5tzAbRayxUmPbZ4E80R5HlIC2CQ7yyweTbIIWIw_TcQzXR4u3twEN1awP4s1n-00Eeurr-s9c_txZQQiDkyrCMYc9vlmsneFfubyoTvg9h_rckd8w34AyE8-wxgBRqUbm1x4ozcVmUJHkaPbQfbhIighl7osoQ4t_wXjAhTN_c9XttVjXlRwqVYPFNYUcC9GoaXWJRHjydHNFeBboOZY3E8ND6DbJ4nVtxydpUQSjTC-N-wQmhKmtYadd2hh2yywvtXpL5Q98XSphrrIHK-GWY0j8kimpunQ"}]} diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Unsigned.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Unsigned.token new file mode 100644 index 0000000000..f0b557652d --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-Unsigned.token @@ -0,0 +1 @@ +eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJuYmYiOjE1MzAzMDA4MzgsImV4cCI6MjE0NjAwMzE5OSwiaWF0IjoxNTMwMzAwODM4LCJ0eXAiOiJKV1QifQ. diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScope.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScope.token new file mode 100644 index 0000000000..0020772ffe --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScope.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJzY29wZSI6Im1lc3NhZ2U6cmVhZCIsImV4cCI6NDY4Mzg4MzIxMX0.cM7Eq9H20503czYVy1aVo8MqTQd8YsYGpv_lAV4PKr3y8NgvvosNjCSUs8rrGjQ0Sp3c4iXK6UVXq8pOJVeWXbSZa1IKAsIhiMIcg2xPFM6e71MVdX4bo255Yh8Nuh0p3xxP9isK_iAKNdMuVBOGfe9KATlmp2dOi0OpAjwSmxPJD1A7AC5f62YIe3Yx2gO6mbfANZJWQ7TxlUuCT_D5FEqg2FfYFqlFaluqWd_2X-esIsiDTxa1R9oF5XwgT6tsgvS7iYSiJw_uNKX0yU4eyLzYuIhnN_hVsr4jOZqPlsqCrkEohOGZg_Jir-7tLxZu0PqoH4ejC24FeDtC9xVa0w diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScp.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScp.token new file mode 100644 index 0000000000..3c2a281152 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageReadScp.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJzY3AiOlsibWVzc2FnZTpyZWFkIl0sImV4cCI6NDY4Mzg5Nzc3Nn0.LtMVtIiRIwSyc3aX35Zl0JVwLTcQZAB3dyBOMHNaHCKUljwMrf20a_gT79LfhjDzE_fUVUmFiAO32W1vFnYpZSVaMDUgeIOIOpxfoe9shj_uYenAwIS-_UxqGVIJiJoXNZh_MK80ShNpvsQwamxWEEOAMBtpWNiVYNDMdfgho9n3o5_Z7Gjy8RLBo1tbDREbO9kTFwGIxm_EYpezmRCRq4w1DdS6UDW321hkwMxPnCMSWOvp-hRpmgY2yjzLgPJ6Aucmg9TJ8jloAP1DjJoF1gRR7NTAk8LOGkSjTzVYDYMbCF51YdpojhItSk80YzXiEsv1mTz4oMM49jXBmfXFMA diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageWriteScp.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageWriteScp.token new file mode 100644 index 0000000000..7cdb29ea59 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidMessageWriteScp.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJzY3AiOlsibWVzc2FnZTp3cml0ZSJdLCJleHAiOjQ2ODM4OTY0OTl9.mxAFzoNjjo-7E4D_XYVme69Y7F-J--q41x6lHDTSOxzVNfQqtJ-U-N4pn7St5jElm9y3mSUxTtmwCnukaVVZkeI8aJjUc8V8nxUAsiZIDvQWjr9uW4xUIcE6MiwC0A9rhY-3I87u6No-KBTxyT80zLnCjtS2XpTId-NSd3vcYmM7Vzn4-8KoR_m-7XrjvrO69HlRrH2uUAXGnr1sn6vLp7YruupqKrHqa0e9pIpN-VRzC8Bx2LQP9mVMlQy4b1hx5MdjOTV3HUSnWiT-93z4rTMOoHScKDwmzFYoS7e00F5hyd4jzbpHdpDKnjLdwPQYz_HCmQ5MV21-Q4Q1jparIg diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidNoScopes.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidNoScopes.token new file mode 100644 index 0000000000..7d4a3251d2 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ValidNoScopes.token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJleHAiOjQ2ODM4Mjg2NzR9.LV_i9lzN_gAB2MUuZHJKm2tOfa3xWq_qfE2lx67eoYJZsY_20Ma98A3Hh2k0wnb_mNn6jfQhXbqvUy1llmQtsx3gMNhN2Axfe3UccSKYEb2Ow5OFlrMFYby1d_D4GfXKUFKq8jyMWVlrjk_XrfJyfzeo0MyZVzURSOXv1Ehbl5-xAS_N72jiAI7cIHlHGm93Hwdk8h7Tkkf_5t2dOMJM0mh0fOT9ou3J2_ngaNDfvlAmBLxHQiJ6JrFH5njqe4lSBTxJocDcgZwGVKd0WvV4W-jwA267tZjssDFmS3xZ9hoDO_M-EjlOiEPuWLd9nQCGJpBJ3z3WeC4qrKYghHTNLA diff --git a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle new file mode 100644 index 0000000000..b05fa58afe --- /dev/null +++ b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle @@ -0,0 +1,14 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-oauth2-core') + compile project(':spring-security-web') + compile springCoreDependency + + optional project(':spring-security-oauth2-jose') + + testCompile 'com.squareup.okhttp3:mockwebserver' + + provided 'javax.servlet:javax.servlet-api' +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationToken.java new file mode 100644 index 0000000000..ed84ab6347 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationToken.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2018 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; + +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} that contains a + * Bearer Token. + * + * Used by {@link BearerTokenAuthenticationFilter} to prepare an authentication attempt and supported + * by {@link JwtAuthenticationProvider}. + * + * @author Josh Cummings + * @since 5.1 + */ +public class BearerTokenAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private String token; + + /** + * Create a {@code BearerTokenAuthenticationToken} using the provided parameter(s) + * + * @param token - the bearer token + */ + public BearerTokenAuthenticationToken(String token) { + super(Collections.emptyList()); + + Assert.hasText(token, "token cannot be empty"); + + this.token = token; + } + + /** + * Get the Bearer Token + * @return the token that proves the caller's authority to perform the {@link javax.servlet.http.HttpServletRequest} + */ + public String getToken() { + return this.token; + } + + /** + * {@inheritDoc} + */ + @Override + public Object getCredentials() { + return this.getToken(); + } + + /** + * {@inheritDoc} + */ + @Override + public Object getPrincipal() { + return this.getToken(); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenError.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenError.java new file mode 100644 index 0000000000..7b3abbaeee --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenError.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2018 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; + +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.util.Assert; + +/** + * A representation of a Bearer Token Error. + * + * @author Vedran Pavic + * @author Josh Cummings + * @since 5.1 + * @see BearerTokenErrorCodes + * @see RFC 6750 Section 3: The WWW-Authenticate + * Response Header Field + */ +public final class BearerTokenError extends OAuth2Error { + + private final HttpStatus httpStatus; + + private final String scope; + + /** + * Create a {@code BearerTokenError} using the provided parameters + * + * @param errorCode the error code + * @param httpStatus the HTTP status + */ + public BearerTokenError(String errorCode, HttpStatus httpStatus, String description, String errorUri) { + this(errorCode, httpStatus, description, errorUri, null); + } + + /** + * Create a {@code BearerTokenError} using the provided parameters + * + * @param errorCode the error code + * @param httpStatus the HTTP status + * @param description the description + * @param errorUri the URI + * @param scope the scope + */ + public BearerTokenError(String errorCode, HttpStatus httpStatus, String description, String errorUri, String scope) { + super(errorCode, description, errorUri); + Assert.notNull(httpStatus, "httpStatus cannot be null"); + + Assert.isTrue(isDescriptionValid(description), + "description contains invalid ASCII characters, it must conform to RFC 6750"); + Assert.isTrue(isErrorCodeValid(errorCode), + "errorCode contains invalid ASCII characters, it must conform to RFC 6750"); + Assert.isTrue(isErrorUriValid(errorUri), + "errorUri contains invalid ASCII characters, it must conform to RFC 6750"); + Assert.isTrue(isScopeValid(scope), + "scope contains invalid ASCII characters, it must conform to RFC 6750"); + + this.httpStatus = httpStatus; + this.scope = scope; + } + + /** + * Return the HTTP status. + * @return the HTTP status + */ + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + /** + * Return the scope. + * @return the scope + */ + public String getScope() { + return this.scope; + } + + private static boolean isDescriptionValid(String description) { + return description == null || + description.chars().allMatch(c -> + withinTheRangeOf(c, 0x20, 0x21) || + withinTheRangeOf(c, 0x23, 0x5B) || + withinTheRangeOf(c, 0x5D, 0x7E)); + } + + private static boolean isErrorCodeValid(String errorCode) { + return errorCode.chars().allMatch(c -> + withinTheRangeOf(c, 0x20, 0x21) || + withinTheRangeOf(c, 0x23, 0x5B) || + withinTheRangeOf(c, 0x5D, 0x7E)); + } + + private static boolean isErrorUriValid(String errorUri) { + return errorUri == null || + errorUri.chars().allMatch(c -> + c == 0x21 || + withinTheRangeOf(c, 0x23, 0x5B) || + withinTheRangeOf(c, 0x5D, 0x7E)); + } + + private static boolean isScopeValid(String scope) { + return scope == null || + scope.chars().allMatch(c -> + withinTheRangeOf(c, 0x20, 0x21) || + withinTheRangeOf(c, 0x23, 0x5B) || + withinTheRangeOf(c, 0x5D, 0x7E)); + } + + private static boolean withinTheRangeOf(int c, int min, int max) { + return c >= min && c <= max; + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorCodes.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorCodes.java new file mode 100644 index 0000000000..06cb884868 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorCodes.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2018 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; + +/** + * Standard error codes defined by the OAuth 2.0 Authorization Framework: Bearer Token Usage. + * + * @author Vedran Pavic + * @since 5.1 + * @see RFC 6750 Section 3.1: Error Codes + */ +public interface BearerTokenErrorCodes { + + /** + * {@code invalid_request} - The request is missing a required parameter, includes an unsupported parameter or + * parameter value, repeats the same parameter, uses more than one method for including an access token, or is + * otherwise malformed. + */ + String INVALID_REQUEST = "invalid_request"; + + /** + * {@code invalid_token} - The access token provided is expired, revoked, malformed, or invalid for other + * reasons. + */ + String INVALID_TOKEN = "invalid_token"; + + /** + * {@code insufficient_scope} - The request requires higher privileges than provided by the access token. + */ + String INSUFFICIENT_SCOPE = "insufficient_scope"; + +} 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 new file mode 100644 index 0000000000..896ac031d8 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/AbstractOAuth2TokenAuthenticationToken.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2018 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.Map; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.Assert; + +/** + * Base class for {@link AbstractAuthenticationToken} implementations + * that expose common attributes between different OAuth 2.0 Access Token Formats. + * + *

+ * For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via + * {@link #getTokenAttributes()} or an "Introspected" OAuth 2.0 Access Token + * could expose the attributes of the Introspection Response via {@link #getTokenAttributes()}. + * + * @author Joe Grandja + * @since 5.1 + * @see OAuth2AccessToken + * @see Jwt + * @see 2.2 Introspection Response + */ +public abstract class AbstractOAuth2TokenAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private T token; + + /** + * Sub-class constructor. + */ + protected AbstractOAuth2TokenAuthenticationToken(T token) { + + this(token, null); + } + + /** + * Sub-class constructor. + * + * @param authorities the authorities assigned to the Access Token + */ + protected AbstractOAuth2TokenAuthenticationToken( + T token, + Collection authorities) { + + super(authorities); + + Assert.notNull(token, "token cannot be null"); + this.token = token; + } + + /** + * {@inheritDoc} + */ + @Override + public Object getPrincipal() { + return this.getToken(); + } + + /** + * {@inheritDoc} + */ + @Override + public Object getCredentials() { + return this.getToken(); + } + + /** + * Get the token bound to this {@link Authentication}. + */ + public final T getToken() { + return this.token; + } + + /** + * Returns the attributes of the access token. + * + * @return a {@code Map} of the attributes in the access token. + */ + public abstract Map getTokenAttributes(); +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java new file mode 100644 index 0000000000..68c022ff7d --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java @@ -0,0 +1,151 @@ +/* + * Copyright 2002-2018 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.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +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.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * An {@link AuthenticationProvider} implementation of the {@link Jwt}-encoded + * Bearer Tokens + * for protecting OAuth 2.0 Resource Servers. + *

+ *

+ * This {@link AuthenticationProvider} is responsible for decoding and verifying a {@link Jwt}-encoded access token, + * returning its claims 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" or "scp" attribute, then + * if a {@link String}, then split by spaces and return, or + * if a {@link Collection}, then simply return + * 2. Take the resulting {@link Collection} of {@link String}s and prepend the "SCOPE_" keyword, adding + * as {@link GrantedAuthority}s. + * + * @author Josh Cummings + * @author Joe Grandja + * @since 5.1 + * @see AuthenticationProvider + * @see JwtDecoder + */ +public final class JwtAuthenticationProvider implements AuthenticationProvider { + private final JwtDecoder jwtDecoder; + + private static final Collection WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES = + Arrays.asList("scope", "scp"); + + private static final String SCOPE_AUTHORITY_PREFIX = "SCOPE_"; + + public JwtAuthenticationProvider(JwtDecoder jwtDecoder) { + Assert.notNull(jwtDecoder, "jwtDecoder cannot be null"); + + this.jwtDecoder = jwtDecoder; + } + + /** + * Decode and validate the + * 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 { + BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication; + + Jwt jwt; + try { + jwt = this.jwtDecoder.decode(bearer.getToken()); + } catch (JwtException failed) { + OAuth2Error invalidToken; + try { + invalidToken = invalidToken(failed.getMessage()); + } catch ( IllegalArgumentException malformed ) { + // some third-party library error messages are not suitable for RFC 6750's error message charset + invalidToken = invalidToken("An error occurred while attempting to decode the Jwt: Invalid token"); + } + throw new OAuth2AuthenticationException(invalidToken, failed); + } + + Collection authorities = + this.getScopes(jwt) + .stream() + .map(authority -> SCOPE_AUTHORITY_PREFIX + authority) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities); + + token.setDetails(bearer.getDetails()); + + return token; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static OAuth2Error invalidToken(String message) { + return new BearerTokenError( + BearerTokenErrorCodes.INVALID_TOKEN, + HttpStatus.UNAUTHORIZED, + message, + "https://tools.ietf.org/html/rfc6750#section-3.1"); + } + + private static Collection getScopes(Jwt jwt) { + for ( String attributeName : WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES ) { + Object scopes = jwt.getClaims().get(attributeName); + if (scopes instanceof String) { + if (StringUtils.hasText((String) scopes)) { + return Arrays.asList(((String) scopes).split(" ")); + } else { + return Collections.emptyList(); + } + } else if (scopes instanceof Collection) { + return (Collection) scopes; + } + } + + return Collections.emptyList(); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java new file mode 100644 index 0000000000..8358125b42 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationToken.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2018 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.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.core.TransientAuthentication; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * An implementation of an {@link AbstractOAuth2TokenAuthenticationToken} + * representing a {@link Jwt} {@code Authentication}. + * + * @author Joe Grandja + * @since 5.1 + * @see AbstractOAuth2TokenAuthenticationToken + * @see Jwt + */ +@TransientAuthentication +public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + /** + * Constructs a {@code JwtAuthenticationToken} using the provided parameters. + * + * @param jwt the JWT + */ + public JwtAuthenticationToken(Jwt jwt) { + super(jwt); + } + + /** + * Constructs a {@code JwtAuthenticationToken} using the provided parameters. + * + * @param jwt the JWT + * @param authorities the authorities assigned to the JWT + */ + public JwtAuthenticationToken(Jwt jwt, Collection authorities) { + super(jwt, authorities); + this.setAuthenticated(true); + } + + /** + * {@inheritDoc} + */ + @Override + public Map getTokenAttributes() { + return this.getToken().getClaims(); + } + + /** + * The {@link Jwt}'s subject, if any + */ + @Override + public String getName() { + return this.getToken().getSubject(); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/package-info.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/package-info.java new file mode 100644 index 0000000000..0b0a403f62 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2018 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. + */ + +/** + * OAuth 2.0 Resource Server {@code Authentication}s and supporting classes and interfaces. + */ +package org.springframework.security.oauth2.server.resource.authentication; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/package-info.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/package-info.java new file mode 100644 index 0000000000..43e164f978 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2018 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. + */ + +/** + * OAuth 2.0 Resource Server core classes and interfaces providing support. + */ +package org.springframework.security.oauth2.server.resource; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java new file mode 100644 index 0000000000..ce6e4214d0 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2018 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.web; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.util.StringUtils; + +/** + * An {@link AuthenticationEntryPoint} implementation used to commence authentication of protected resource requests + * using {@link BearerTokenAuthenticationFilter}. + *

+ * Uses information provided by {@link BearerTokenError} to set HTTP response status code and populate + * {@code WWW-Authenticate} HTTP header. + * + * @author Vedran Pavic + * @since 5.1 + * @see BearerTokenError + * @see RFC 6750 Section 3: The WWW-Authenticate + * Response Header Field + */ +public final class BearerTokenAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private String realmName; + + /** + * Collect error details from the provided parameters and format according to + * RFC 6750, specifically {@code error}, {@code error_description}, {@code error_uri}, and {@scope scope}. + * + * @param request that resulted in an AuthenticationException + * @param response so that the user agent can begin authentication + * @param authException that caused the invocation + */ + @Override + public void commence( + HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + + HttpStatus status = HttpStatus.UNAUTHORIZED; + + Map parameters = new LinkedHashMap<>(); + + if (this.realmName != null) { + parameters.put("realm", this.realmName); + } + + if (authException instanceof OAuth2AuthenticationException) { + OAuth2Error error = ((OAuth2AuthenticationException) authException).getError(); + + parameters.put("error", error.getErrorCode()); + + if (StringUtils.hasText(error.getDescription())) { + parameters.put("error_description", error.getDescription()); + } + + if (StringUtils.hasText(error.getUri())) { + parameters.put("error_uri", error.getUri()); + } + + if (error instanceof BearerTokenError) { + BearerTokenError bearerTokenError = (BearerTokenError) error; + + if (StringUtils.hasText(bearerTokenError.getScope())) { + parameters.put("scope", bearerTokenError.getScope()); + } + + status = ((BearerTokenError) error).getHttpStatus(); + } + } + + String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters); + + response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); + response.setStatus(status.value()); + } + + /** + * Set the default realm name to use in the bearer token error response + * + * @param realmName + */ + public final void setRealmName(String realmName) { + this.realmName = realmName; + } + + private static String computeWWWAuthenticateHeaderValue(Map parameters) { + String wwwAuthenticate = "Bearer"; + if (!parameters.isEmpty()) { + wwwAuthenticate += parameters.entrySet().stream() + .map(attribute -> attribute.getKey() + "=\"" + attribute.getValue() + "\"") + .collect(Collectors.joining(", ", " ", "")); + } + + return wwwAuthenticate; + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilter.java new file mode 100644 index 0000000000..5137d1d1a1 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilter.java @@ -0,0 +1,143 @@ +/* + * Copyright 2002-2018 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.web; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Authenticates requests that contain an OAuth 2.0 + * Bearer Token. + * + * This filter should be wired with an {@link AuthenticationManager} that can authenticate a + * {@link BearerTokenAuthenticationToken}. + * + * @author Josh Cummings + * @author Vedran Pavic + * @author Joe Grandja + * @since 5.1 + * @see The OAuth 2.0 Authorization Framework: Bearer Token Usage + * @see JwtAuthenticationProvider + */ +public final class BearerTokenAuthenticationFilter extends OncePerRequestFilter { + private final AuthenticationManager authenticationManager; + + private final AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + + private BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver(); + + private AuthenticationEntryPoint authenticationEntryPoint = new BearerTokenAuthenticationEntryPoint(); + + /** + * Construct a {@code BearerTokenAuthenticationFilter} using the provided parameter(s) + * @param authenticationManager + */ + public BearerTokenAuthenticationFilter(AuthenticationManager authenticationManager) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationManager = authenticationManager; + } + + /** + * Extract any Bearer Token from + * the request and attempt an authentication. + * + * @param request + * @param response + * @param filterChain + * @throws ServletException + * @throws IOException + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final boolean debug = this.logger.isDebugEnabled(); + + String token; + + try { + token = this.bearerTokenResolver.resolve(request); + } catch ( OAuth2AuthenticationException invalid ) { + this.authenticationEntryPoint.commence(request, response, invalid); + return; + } + + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + BearerTokenAuthenticationToken authenticationRequest = new BearerTokenAuthenticationToken(token); + + authenticationRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); + + try { + Authentication authenticationResult = this.authenticationManager.authenticate(authenticationRequest); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authenticationResult); + SecurityContextHolder.setContext(context); + + filterChain.doFilter(request, response); + } catch (AuthenticationException failed) { + SecurityContextHolder.clearContext(); + + if (debug) { + this.logger.debug("Authentication request for failed: " + failed); + } + + this.authenticationEntryPoint.commence(request, response, failed); + } + } + + /** + * Set the {@link BearerTokenResolver} to use. Defaults to {@link DefaultBearerTokenResolver}. + * @param bearerTokenResolver the {@code BearerTokenResolver} to use + */ + public final void setBearerTokenResolver(BearerTokenResolver bearerTokenResolver) { + Assert.notNull(bearerTokenResolver, "bearerTokenResolver cannot be null"); + this.bearerTokenResolver = bearerTokenResolver; + } + + /** + * Set the {@link AuthenticationEntryPoint} to use. Defaults to {@link BearerTokenAuthenticationEntryPoint}. + * @param authenticationEntryPoint the {@code AuthenticationEntryPoint} to use + */ + public final void setAuthenticationEntryPoint(final AuthenticationEntryPoint authenticationEntryPoint) { + Assert.notNull(authenticationEntryPoint, "authenticationEntryPoint cannot be null"); + this.authenticationEntryPoint = authenticationEntryPoint; + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenResolver.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenResolver.java new file mode 100644 index 0000000000..b73be65ee3 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2018 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.web; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; + +/** + * A strategy for resolving Bearer Tokens + * from the {@link HttpServletRequest}. + * + * @author Vedran Pavic + * @since 5.1 + * @see RFC 6750 Section 2: Authenticated Requests + */ +public interface BearerTokenResolver { + + /** + * Resolve any Bearer Token + * value from the request. + * + * @param request the request + * @return the Bearer Token value or {@code null} if none found + * @throws OAuth2AuthenticationException if the found token is invalid + */ + String resolve(HttpServletRequest request); + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolver.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolver.java new file mode 100644 index 0000000000..c4532fa029 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolver.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2018 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.web; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.util.StringUtils; + +/** + * The default {@link BearerTokenResolver} implementation based on RFC 6750. + * + * @author Vedran Pavic + * @since 5.1 + * @see RFC 6750 Section 2: Authenticated Requests + */ +public final class DefaultBearerTokenResolver implements BearerTokenResolver { + + private static final Pattern authorizationPattern = Pattern.compile("^Bearer (?[a-zA-Z0-9-._~+/]+)=*$"); + + private boolean allowFormEncodedBodyParameter = false; + + private boolean allowUriQueryParameter = false; + + /** + * {@inheritDoc} + */ + @Override + public String resolve(HttpServletRequest request) { + String authorizationHeaderToken = resolveFromAuthorizationHeader(request); + String parameterToken = resolveFromRequestParameters(request); + if (authorizationHeaderToken != null) { + if (parameterToken != null) { + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_REQUEST, + HttpStatus.BAD_REQUEST, + "Found multiple bearer tokens in the request", + "https://tools.ietf.org/html/rfc6750#section-3.1"); + throw new OAuth2AuthenticationException(error); + } + return authorizationHeaderToken; + } + else if (parameterToken != null && isParameterTokenSupportedForRequest(request)) { + return parameterToken; + } + return null; + } + + /** + * Set if transport of access token using form-encoded body parameter is supported. Defaults to {@code false}. + * @param allowFormEncodedBodyParameter if the form-encoded body parameter is supported + */ + public void setAllowFormEncodedBodyParameter(boolean allowFormEncodedBodyParameter) { + this.allowFormEncodedBodyParameter = allowFormEncodedBodyParameter; + } + + /** + * Set if transport of access token using URI query parameter is supported. Defaults to {@code false}. + * + * The spec recommends against using this mechanism for sending bearer tokens, and even goes as far as + * stating that it was only included for completeness. + * + * @param allowUriQueryParameter if the URI query parameter is supported + */ + public void setAllowUriQueryParameter(boolean allowUriQueryParameter) { + this.allowUriQueryParameter = allowUriQueryParameter; + } + + private static String resolveFromAuthorizationHeader(HttpServletRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer")) { + Matcher matcher = authorizationPattern.matcher(authorization); + + if (!matcher.matches()) { + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN, + HttpStatus.UNAUTHORIZED, + "Bearer token is malformed", + "https://tools.ietf.org/html/rfc6750#section-3.1"); + throw new OAuth2AuthenticationException(error); + } + + return matcher.group("token"); + } + return null; + } + + private static String resolveFromRequestParameters(HttpServletRequest request) { + String[] values = request.getParameterValues("access_token"); + if (values == null || values.length == 0) { + return null; + } + + if (values.length == 1) { + return values[0]; + } + + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_REQUEST, + HttpStatus.BAD_REQUEST, + "Found multiple bearer tokens in the request", + "https://tools.ietf.org/html/rfc6750#section-3.1"); + throw new OAuth2AuthenticationException(error); + } + + private boolean isParameterTokenSupportedForRequest(HttpServletRequest request) { + return ((this.allowFormEncodedBodyParameter && "POST".equals(request.getMethod())) + || (this.allowUriQueryParameter && "GET".equals(request.getMethod()))); + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandler.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandler.java new file mode 100644 index 0000000000..fa3212d373 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandler.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2018 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.web.access; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.util.StringUtils; + +/** + * Translates any {@link AccessDeniedException} into an HTTP response in accordance with + * RFC 6750 Section 3: The WWW-Authenticate. + * + * So long as the class can prove that the request has a valid OAuth 2.0 {@link Authentication}, then will return an + * insufficient scope error; otherwise, + * it will simply indicate the scheme (Bearer) and any configured realm. + * + * @author Josh Cummings + * @since 5.1 + */ +public final class BearerTokenAccessDeniedHandler implements AccessDeniedHandler { + + private static final Collection WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES = + Arrays.asList("scope", "scp"); + + private String realmName; + + /** + * Collect error details from the provided parameters and format according to + * RFC 6750, specifically {@code error}, {@code error_description}, {@code error_uri}, and {@scope scope}. + * + * @param request that resulted in an AccessDeniedException + * @param response so that the user agent can be advised of the failure + * @param accessDeniedException that caused the invocation + * + */ + @Override + public void handle( + HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException, ServletException { + + Map parameters = new LinkedHashMap<>(); + + if (this.realmName != null) { + parameters.put("realm", this.realmName); + } + + if (request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken) { + AbstractOAuth2TokenAuthenticationToken token = + (AbstractOAuth2TokenAuthenticationToken) request.getUserPrincipal(); + + String scope = getScope(token); + + parameters.put("error", BearerTokenErrorCodes.INSUFFICIENT_SCOPE); + parameters.put("error_description", + String.format("The token provided has insufficient scope [%s] for this request", scope)); + parameters.put("error_uri", "https://tools.ietf.org/html/rfc6750#section-3.1"); + + if (StringUtils.hasText(scope)) { + parameters.put("scope", scope); + } + } + + String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters); + + response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); + response.setStatus(HttpStatus.FORBIDDEN.value()); + } + + /** + * Set the default realm name to use in the bearer token error response + * + * @param realmName + */ + public final void setRealmName(String realmName) { + this.realmName = realmName; + } + + private static String getScope(AbstractOAuth2TokenAuthenticationToken token) { + + Map attributes = token.getTokenAttributes(); + + for (String attributeName : WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES) { + Object scopes = attributes.get(attributeName); + if (scopes instanceof String) { + return (String) scopes; + } else if (scopes instanceof Collection) { + Collection coll = (Collection) scopes; + return (String) coll.stream() + .map(String::valueOf) + .collect(Collectors.joining(" ")); + } + } + + return ""; + } + + private static String computeWWWAuthenticateHeaderValue(Map parameters) { + String wwwAuthenticate = "Bearer"; + if (!parameters.isEmpty()) { + wwwAuthenticate += parameters.entrySet().stream() + .map(attribute -> attribute.getKey() + "=\"" + attribute.getValue() + "\"") + .collect(Collectors.joining(", ", " ", "")); + } + + return wwwAuthenticate; + } +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/package-info.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/package-info.java new file mode 100644 index 0000000000..948c0ca58f --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2018 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. + */ + +/** + * OAuth 2.0 Resource Server access denial classes and interfaces. + */ +package org.springframework.security.oauth2.server.resource.web.access; diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/package-info.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/package-info.java new file mode 100644 index 0000000000..5391fba2f4 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2018 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. + */ + +/** + * OAuth 2.0 Resource Server {@code Filter}'s and supporting classes and interfaces. + */ +package org.springframework.security.oauth2.server.resource.web; diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationTokenTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationTokenTests.java new file mode 100644 index 0000000000..cb5fcc426b --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenAuthenticationTokenTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2018 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; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link BearerTokenAuthenticationToken} + * + * @author Josh Cummings + */ +public class BearerTokenAuthenticationTokenTests { + @Test + public void constructorWhenTokenIsNullThenThrowsException() { + assertThatCode(() -> new BearerTokenAuthenticationToken(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token cannot be empty"); + } + + @Test + public void constructorWhenTokenIsEmptyThenThrowsException() { + assertThatCode(() -> new BearerTokenAuthenticationToken("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token cannot be empty"); + } + + @Test + public void constructorWhenTokenHasValueThenConstructedCorrectly() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token"); + + assertThat(token.getToken()).isEqualTo("token"); + assertThat(token.getPrincipal()).isEqualTo("token"); + assertThat(token.getCredentials()).isEqualTo("token"); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorTests.java new file mode 100644 index 0000000000..6ac6b651fc --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/BearerTokenErrorTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2002-2018 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; + +import org.junit.Test; + +import org.springframework.http.HttpStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link BearerTokenError} + * + * @author Vedran Pavic + * @author Josh Cummings + */ +public class BearerTokenErrorTests { + + private static final String TEST_ERROR_CODE = "test-code"; + + private static final HttpStatus TEST_HTTP_STATUS = HttpStatus.UNAUTHORIZED; + + private static final String TEST_DESCRIPTION = "test-description"; + + private static final String TEST_URI = "http://example.com"; + + private static final String TEST_SCOPE = "test-scope"; + + @Test + public void constructorWithErrorCodeWhenErrorCodeIsValidThenCreated() { + BearerTokenError error = new BearerTokenError(TEST_ERROR_CODE, TEST_HTTP_STATUS, null, null); + + assertThat(error.getErrorCode()).isEqualTo(TEST_ERROR_CODE); + assertThat(error.getHttpStatus()).isEqualTo(TEST_HTTP_STATUS); + assertThat(error.getDescription()).isNull(); + assertThat(error.getUri()).isNull(); + assertThat(error.getScope()).isNull(); + } + + @Test + public void constructorWithErrorCodeAndHttpStatusWhenErrorCodeIsNullThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(null, TEST_HTTP_STATUS, null, null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("errorCode cannot be empty"); + } + + @Test + public void constructorWithErrorCodeAndHttpStatusWhenErrorCodeIsEmptyThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError("", TEST_HTTP_STATUS, null, null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("errorCode cannot be empty"); + } + + @Test + public void constructorWithErrorCodeAndHttpStatusWhenHttpStatusIsNullThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE, null, null, null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("httpStatus cannot be null"); + } + + @Test + public void constructorWithAllParametersWhenAllParametersAreValidThenCreated() { + BearerTokenError error = new BearerTokenError(TEST_ERROR_CODE, TEST_HTTP_STATUS, TEST_DESCRIPTION, TEST_URI, + TEST_SCOPE); + + assertThat(error.getErrorCode()).isEqualTo(TEST_ERROR_CODE); + assertThat(error.getHttpStatus()).isEqualTo(TEST_HTTP_STATUS); + assertThat(error.getDescription()).isEqualTo(TEST_DESCRIPTION); + assertThat(error.getUri()).isEqualTo(TEST_URI); + assertThat(error.getScope()).isEqualTo(TEST_SCOPE); + } + + @Test + public void constructorWithAllParametersWhenErrorCodeIsNullThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(null, TEST_HTTP_STATUS, TEST_DESCRIPTION, TEST_URI, TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("errorCode cannot be empty"); + } + + @Test + public void constructorWithAllParametersWhenErrorCodeIsEmptyThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError("", TEST_HTTP_STATUS, TEST_DESCRIPTION, TEST_URI, TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("errorCode cannot be empty"); + } + + @Test + public void constructorWithAllParametersWhenHttpStatusIsNullThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE, null, TEST_DESCRIPTION, TEST_URI, TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("httpStatus cannot be null"); + } + + @Test + public void constructorWithAllParametersWhenErrorCodeIsInvalidThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE + "\"", TEST_HTTP_STATUS, TEST_DESCRIPTION, + TEST_URI, TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("errorCode") + .hasMessageContaining("RFC 6750"); + } + + @Test + public void constructorWithAllParametersWhenDescriptionIsInvalidThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE, TEST_HTTP_STATUS, TEST_DESCRIPTION + "\"", + TEST_URI, TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("description") + .hasMessageContaining("RFC 6750"); + } + + @Test + public void constructorWithAllParametersWhenErrorUriIsInvalidThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE, TEST_HTTP_STATUS, TEST_DESCRIPTION, + TEST_URI + "\"", TEST_SCOPE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("errorUri") + .hasMessageContaining("RFC 6750"); + } + + @Test + public void constructorWithAllParametersWhenScopeIsInvalidThenThrowIllegalArgumentException() { + assertThatCode(() -> new BearerTokenError(TEST_ERROR_CODE, TEST_HTTP_STATUS, TEST_DESCRIPTION, + TEST_URI, TEST_SCOPE + "\"")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("scope") + .hasMessageContaining("RFC 6750"); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProviderTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProviderTests.java new file mode 100644 index 0000000000..51e898a5ea --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProviderTests.java @@ -0,0 +1,230 @@ +/* + * Copyright 2002-2018 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.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; + +import org.assertj.core.util.Maps; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link JwtAuthenticationProvider} + * + * @author Josh Cummings + */ +@RunWith(MockitoJUnitRunner.class) +public class JwtAuthenticationProviderTests { + @Mock + JwtDecoder jwtDecoder; + + JwtAuthenticationProvider provider; + + @Before + public void setup() { + this.provider = + new JwtAuthenticationProvider(this.jwtDecoder); + } + + @Test + public void authenticateWhenJwtDecodesThenAuthenticationHasAttributesContainedInJwt() { + BearerTokenAuthenticationToken token = this.authentication(); + + Map claims = new HashMap<>(); + claims.put("name", "value"); + Jwt jwt = this.jwt(claims); + + when(this.jwtDecoder.decode("token")).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + assertThat(authentication.getTokenAttributes()).isEqualTo(claims); + } + + @Test + public void authenticateWhenJwtDecodeFailsThenRespondsWithInvalidToken() { + BearerTokenAuthenticationToken token = this.authentication(); + + when(this.jwtDecoder.decode("token")).thenThrow(JwtException.class); + + assertThatCode(() -> this.provider.authenticate(token)) + .matches(failed -> failed instanceof OAuth2AuthenticationException) + .matches(errorCode(BearerTokenErrorCodes.INVALID_TOKEN)); + } + + @Test + public void authenticateWhenTokenHasScopeAttributeThenTranslatedToAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Jwt jwt = this.jwt(Maps.newHashMap("scope", "message:read message:write")); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_message:read"), + new SimpleGrantedAuthority("SCOPE_message:write")); + } + + @Test + public void authenticateWhenTokenHasEmptyScopeAttributeThenTranslatedToNoAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Jwt jwt = this.jwt(Maps.newHashMap("scope", "")); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + @Test + public void authenticateWhenTokenHasScpAttributeThenTranslatedToAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Jwt jwt = this.jwt(Maps.newHashMap("scp", Arrays.asList("message:read", "message:write"))); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_message:read"), + new SimpleGrantedAuthority("SCOPE_message:write")); + } + + @Test + public void authenticateWhenTokenHasEmptyScpAttributeThenTranslatedToNoAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Jwt jwt = this.jwt(Maps.newHashMap("scp", Arrays.asList())); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + @Test + public void authenticateWhenTokenHasBothScopeAndScpThenScopeAttributeIsTranslatedToAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Map claims = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write")); + claims.put("scope", "missive:read missive:write"); + Jwt jwt = this.jwt(claims); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_missive:read"), + new SimpleGrantedAuthority("SCOPE_missive:write")); + } + + @Test + public void authenticateWhenTokenHasEmptyScopeAndNonEmptyScpThenScopeAttributeIsTranslatedToNoAuthorities() { + BearerTokenAuthenticationToken token = this.authentication(); + + Map claims = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write")); + claims.put("scope", ""); + Jwt jwt = this.jwt(claims); + + when(this.jwtDecoder.decode(token.getToken())).thenReturn(jwt); + + JwtAuthenticationToken authentication = + (JwtAuthenticationToken) this.provider.authenticate(token); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + @Test + public void authenticateWhenDecoderThrowsIncompatibleErrorMessageThenWrapsWithGenericOne() { + BearerTokenAuthenticationToken token = this.authentication(); + + when(this.jwtDecoder.decode(token.getToken())).thenThrow(new JwtException("with \"invalid\" chars")); + + assertThatCode(() -> this.provider.authenticate(token)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasFieldOrPropertyWithValue( + "error.description", + "An error occurred while attempting to decode the Jwt: Invalid token"); + } + + @Test + public void supportsWhenBearerTokenAuthenticationTokenThenReturnsTrue() { + assertThat(this.provider.supports(BearerTokenAuthenticationToken.class)).isTrue(); + } + + private BearerTokenAuthenticationToken authentication() { + return new BearerTokenAuthenticationToken("token"); + } + + private Jwt jwt(Map claims) { + Map headers = new HashMap<>(); + headers.put("alg", JwsAlgorithms.RS256); + + return new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims); + } + + private Predicate errorCode(String errorCode) { + return failed -> + ((OAuth2AuthenticationException) failed).getError().getErrorCode() == errorCode; + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationTokenTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationTokenTests.java new file mode 100644 index 0000000000..d75a8c1629 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationTokenTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2018 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.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.util.Maps; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link JwtAuthenticationToken} + * + * @author Josh Cummings + */ +@RunWith(MockitoJUnitRunner.class) +public class JwtAuthenticationTokenTests { + + @Test + public void getNameWhenJwtHasSubjectThenReturnsSubject() { + Jwt jwt = this.jwt(Maps.newHashMap("sub", "Carl")); + + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt); + + assertThat(token.getName()).isEqualTo("Carl"); + } + + @Test + public void getNameWhenJwtHasNoSubjectThenReturnsNull() { + Jwt jwt = this.jwt(Maps.newHashMap("claim", "value")); + + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt); + + assertThat(token.getName()).isNull(); + } + + @Test + public void constructorWhenJwtIsNullThenThrowsException() { + assertThatCode(() -> new JwtAuthenticationToken(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token cannot be null"); + } + + @Test + public void constructorWhenUsingCorrectParametersThenConstructedCorrectly() { + Collection authorities = Arrays.asList(new SimpleGrantedAuthority("test")); + Map claims = Maps.newHashMap("claim", "value"); + Jwt jwt = this.jwt(claims); + + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities); + + assertThat(token.getAuthorities()).isEqualTo(authorities); + assertThat(token.getPrincipal()).isEqualTo(jwt); + assertThat(token.getCredentials()).isEqualTo(jwt); + assertThat(token.getToken()).isEqualTo(jwt); + assertThat(token.getTokenAttributes()).isEqualTo(claims); + assertThat(token.isAuthenticated()).isTrue(); + } + + @Test + public void constructorWhenUsingOnlyJwtThenConstructedCorrectly() { + Map claims = Maps.newHashMap("claim", "value"); + Jwt jwt = this.jwt(claims); + + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt); + + assertThat(token.getAuthorities()).isEmpty(); + assertThat(token.getPrincipal()).isEqualTo(jwt); + assertThat(token.getCredentials()).isEqualTo(jwt); + assertThat(token.getToken()).isEqualTo(jwt); + assertThat(token.getTokenAttributes()).isEqualTo(claims); + assertThat(token.isAuthenticated()).isFalse(); + } + + private Jwt jwt(Map claims) { + Map headers = new HashMap<>(); + headers.put("alg", JwsAlgorithms.RS256); + + return new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java new file mode 100644 index 0000000000..0515ccf225 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java @@ -0,0 +1,202 @@ +/* + * Copyright 2002-2018 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.web; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link BearerTokenAuthenticationEntryPoint}. + * + * @author Vedran Pavic + * @author Josh Cummings + */ +public class BearerTokenAuthenticationEntryPointTests { + + private BearerTokenAuthenticationEntryPoint authenticationEntryPoint; + + @Before + public void setUp() { + this.authenticationEntryPoint = new BearerTokenAuthenticationEntryPoint(); + } + + @Test + public void commenceWhenNoBearerTokenErrorThenStatus401AndAuthHeader() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer"); + } + + @Test + public void commenceWhenNoBearerTokenErrorAndRealmSetThenStatus401AndAuthHeaderWithRealm() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + this.authenticationEntryPoint.setRealmName("test"); + this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer realm=\"test\""); + } + + @Test + public void commenceWhenInvalidRequestErrorThenStatus400AndHeaderWithError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError( + BearerTokenErrorCodes.INVALID_REQUEST, + HttpStatus.BAD_REQUEST, + null, + null); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_request\""); + } + + @Test + public void commenceWhenInvalidRequestErrorThenStatus400AndHeaderWithErrorDetails() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_REQUEST, HttpStatus.BAD_REQUEST, + "The access token expired", null, null); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getHeader("WWW-Authenticate")) + .isEqualTo("Bearer error=\"invalid_request\", error_description=\"The access token expired\""); + } + + @Test + public void commenceWhenInvalidRequestErrorThenStatus400AndHeaderWithErrorUri() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_REQUEST, HttpStatus.BAD_REQUEST, + null, "http://example.com", null); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getHeader("WWW-Authenticate")) + .isEqualTo("Bearer error=\"invalid_request\", error_uri=\"http://example.com\""); + } + + @Test + public void commenceWhenInvalidTokenErrorThenStatus401AndHeaderWithError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED, + null, null); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_token\""); + } + + @Test + public void commenceWhenInsufficientScopeErrorThenStatus403AndHeaderWithError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INSUFFICIENT_SCOPE, HttpStatus.FORBIDDEN, + null, null); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\""); + } + + @Test + public void commenceWhenInsufficientScopeErrorThenStatus403AndHeaderWithErrorAndScope() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INSUFFICIENT_SCOPE, HttpStatus.FORBIDDEN, + null, null, "test.read test.write"); + + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")) + .isEqualTo("Bearer error=\"insufficient_scope\", scope=\"test.read test.write\""); + } + + @Test + public void commenceWhenInsufficientScopeAndRealmSetThenStatus403AndHeaderWithErrorAndAllDetails() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + BearerTokenError error = new BearerTokenError(BearerTokenErrorCodes.INSUFFICIENT_SCOPE, HttpStatus.FORBIDDEN, + "Insufficient scope", "http://example.com", "test.read test.write"); + + this.authenticationEntryPoint.setRealmName("test"); + this.authenticationEntryPoint.commence(request, response, + new OAuth2AuthenticationException(error)); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo( + "Bearer realm=\"test\", error=\"insufficient_scope\", error_description=\"Insufficient scope\", " + + "error_uri=\"http://example.com\", scope=\"test.read test.write\""); + } + + @Test + public void setRealmNameWhenNullRealmNameThenNoExceptionThrown() { + assertThatCode(() -> this.authenticationEntryPoint.setRealmName(null)) + .doesNotThrowAnyException(); + } + +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilterTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilterTests.java new file mode 100644 index 0000000000..56bc183b4c --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationFilterTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2002-2018 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.web; + +import java.io.IOException; +import javax.servlet.ServletException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +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.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.security.web.AuthenticationEntryPoint; + +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.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests {@link BearerTokenAuthenticationFilterTests} + * + * @author Josh Cummings + */ +@RunWith(MockitoJUnitRunner.class) +public class BearerTokenAuthenticationFilterTests { + @Mock + AuthenticationEntryPoint authenticationEntryPoint; + + @Mock + AuthenticationManager authenticationManager; + + @Mock + BearerTokenResolver bearerTokenResolver; + + MockHttpServletRequest request; + + MockHttpServletResponse response; + + MockFilterChain filterChain; + + @InjectMocks + BearerTokenAuthenticationFilter filter; + + @Before + public void httpMocks() { + this.request = new MockHttpServletRequest(); + this.response = new MockHttpServletResponse(); + this.filterChain = new MockFilterChain(); + } + + @Before + public void setterMocks() { + this.filter.setAuthenticationEntryPoint(this.authenticationEntryPoint); + this.filter.setBearerTokenResolver(this.bearerTokenResolver); + } + + @Test + public void doFilterWhenBearerTokenPresentThenAuthenticates() throws ServletException, IOException { + when(this.bearerTokenResolver.resolve(this.request)).thenReturn("token"); + + this.filter.doFilter(this.request, this.response, this.filterChain); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BearerTokenAuthenticationToken.class); + + verify(this.authenticationManager).authenticate(captor.capture()); + + assertThat(captor.getValue().getPrincipal()).isEqualTo("token"); + } + + @Test + public void doFilterWhenNoBearerTokenPresentThenDoesNotAuthenticate() + throws ServletException, IOException { + + when(this.bearerTokenResolver.resolve(this.request)).thenReturn(null); + + dontAuthenticate(); + } + + @Test + public void doFilterWhenMalformedBearerTokenThenPropagatesError() throws ServletException, IOException { + BearerTokenError error = new BearerTokenError( + BearerTokenErrorCodes.INVALID_REQUEST, + HttpStatus.BAD_REQUEST, + "description", + "uri"); + + OAuth2AuthenticationException exception = new OAuth2AuthenticationException(error); + + when(this.bearerTokenResolver.resolve(this.request)).thenThrow(exception); + + dontAuthenticate(); + + verify(this.authenticationEntryPoint).commence(this.request, this.response, exception); + } + + @Test + public void doFilterWhenAuthenticationFailsThenPropagatesError() throws ServletException, IOException { + BearerTokenError error = new BearerTokenError( + BearerTokenErrorCodes.INVALID_TOKEN, + HttpStatus.UNAUTHORIZED, + "description", + "uri" + ); + + OAuth2AuthenticationException exception = new OAuth2AuthenticationException(error); + + when(this.bearerTokenResolver.resolve(this.request)).thenReturn("token"); + when(this.authenticationManager.authenticate(any(BearerTokenAuthenticationToken.class))) + .thenThrow(exception); + + this.filter.doFilter(this.request, this.response, this.filterChain); + + verify(this.authenticationEntryPoint).commence(this.request, this.response, exception); + } + + @Test + public void setAuthenticationEntryPointWhenNullThenThrowsException() { + assertThatCode(() -> this.filter.setAuthenticationEntryPoint(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authenticationEntryPoint cannot be null"); + } + + @Test + public void setBearerTokenResolverWhenNullThenThrowsException() { + assertThatCode(() -> this.filter.setBearerTokenResolver(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bearerTokenResolver cannot be null"); + } + + @Test + public void constructorWhenNullAuthenticationManagerThenThrowsException() { + assertThatCode(() -> new BearerTokenAuthenticationFilter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authenticationManager cannot be null"); + } + + private void dontAuthenticate() + throws ServletException, IOException { + + this.filter.doFilter(this.request, this.response, this.filterChain); + + verifyNoMoreInteractions(this.authenticationManager); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolverTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolverTests.java new file mode 100644 index 0000000000..32518f4d1a --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/DefaultBearerTokenResolverTests.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-2018 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.web; + +import java.util.Base64; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link DefaultBearerTokenResolver}. + * + * @author Vedran Pavic + */ +public class DefaultBearerTokenResolverTests { + + private static final String TEST_TOKEN = "test-token"; + + private DefaultBearerTokenResolver resolver; + + @Before + public void setUp() { + this.resolver = new DefaultBearerTokenResolver(); + } + + @Test + public void resolveWhenValidHeaderIsPresentThenTokenIsResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + TEST_TOKEN); + + assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN); + } + + @Test + public void resolveWhenNoHeaderIsPresentThenTokenIsNotResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + assertThat(this.resolver.resolve(request)).isNull(); + } + + @Test + public void resolveWhenHeaderWithWrongSchemeIsPresentThenTokenIsNotResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString("test:test".getBytes())); + + assertThat(this.resolver.resolve(request)).isNull(); + } + + @Test + public void resolveWhenHeaderWithMissingTokenIsPresentThenAuthenticationExceptionIsThrown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer "); + + assertThatCode(() -> this.resolver.resolve(request)).isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining(("Bearer token is malformed")); + } + + @Test + public void resolveWhenHeaderWithInvalidCharactersIsPresentThenAuthenticationExceptionIsThrown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer an\"invalid\"token"); + + assertThatCode(() -> this.resolver.resolve(request)).isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining(("Bearer token is malformed")); + } + + @Test + public void resolveWhenValidHeaderIsPresentTogetherWithFormParameterThenAuthenticationExceptionIsThrown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + TEST_TOKEN); + request.setMethod("POST"); + request.setContentType("application/x-www-form-urlencoded"); + request.addParameter("access_token", TEST_TOKEN); + + assertThatCode(() -> this.resolver.resolve(request)).isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Found multiple bearer tokens in the request"); + } + + @Test + public void resolveWhenValidHeaderIsPresentTogetherWithQueryParameterThenAuthenticationExceptionIsThrown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + TEST_TOKEN); + request.setMethod("GET"); + request.addParameter("access_token", TEST_TOKEN); + + assertThatCode(() -> this.resolver.resolve(request)).isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Found multiple bearer tokens in the request"); + } + + @Test + public void resolveWhenRequestContainsTwoAccessTokenParametersThenAuthenticationExceptionIsThrown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("access_token", "token1", "token2"); + + assertThatCode(() -> this.resolver.resolve(request)).isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Found multiple bearer tokens in the request"); + } + + @Test + public void resolveWhenFormParameterIsPresentAndSupportedThenTokenIsResolved() { + this.resolver.setAllowFormEncodedBodyParameter(true); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setContentType("application/x-www-form-urlencoded"); + request.addParameter("access_token", TEST_TOKEN); + + assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN); + } + + @Test + public void resolveWhenFormParameterIsPresentAndNotSupportedThenTokenIsNotResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setContentType("application/x-www-form-urlencoded"); + request.addParameter("access_token", TEST_TOKEN); + + assertThat(this.resolver.resolve(request)).isNull(); + } + + @Test + public void resolveWhenQueryParameterIsPresentAndSupportedThenTokenIsResolved() { + this.resolver.setAllowUriQueryParameter(true); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.addParameter("access_token", TEST_TOKEN); + + assertThat(this.resolver.resolve(request)).isEqualTo(TEST_TOKEN); + } + + @Test + public void resolveWhenQueryParameterIsPresentAndNotSupportedThenTokenIsNotResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.addParameter("access_token", TEST_TOKEN); + + assertThat(this.resolver.resolve(request)).isNull(); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandlerTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandlerTests.java new file mode 100644 index 0000000000..fd9b598a39 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/access/BearerTokenAccessDeniedHandlerTests.java @@ -0,0 +1,250 @@ +/* + * Copyright 2002-2018 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.web.access; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.assertj.core.util.Maps; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for {@link BearerTokenAccessDeniedHandlerTests} + * + * @author Josh Cummings + */ +public class BearerTokenAccessDeniedHandlerTests { + private BearerTokenAccessDeniedHandler accessDeniedHandler; + + @Before + public void setUp() { + this.accessDeniedHandler = new BearerTokenAccessDeniedHandler(); + } + + @Test + public void handleWhenNotOAuth2AuthenticatedThenStatus403() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication authentication = new TestingAuthenticationToken("user", "pass"); + request.setUserPrincipal(authentication); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer"); + } + + @Test + public void handleWhenNotOAuth2AuthenticatedAndRealmSetThenStatus403AndAuthHeaderWithRealm() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication authentication = new TestingAuthenticationToken("user", "pass"); + request.setUserPrincipal(authentication); + + this.accessDeniedHandler.setRealmName("test"); + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer realm=\"test\""); + } + + @Test + public void handleWhenTokenHasNoScopesThenInsufficientScopeError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication token = new TestingOAuth2TokenAuthenticationToken(Collections.emptyMap()); + request.setUserPrincipal(token); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""); + } + + + @Test + public void handleWhenTokenHasScopeAttributeThenInsufficientScopeErrorWithScopes() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scope", "message:read message:write"); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " + + "scope=\"message:read message:write\""); + } + + @Test + public void handleWhenTokenHasEmptyScopeAttributeThenInsufficientScopeError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scope", ""); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""); + } + + @Test + public void handleWhenTokenHasScpAttributeThenInsufficientScopeErrorWithScopes() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write")); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " + + "scope=\"message:read message:write\""); + } + + @Test + public void handleWhenTokenHasEmptyScpAttributeThenInsufficientScopeError() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scp", Collections.emptyList()); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""); + } + + @Test + public void handleWhenTokenHasBothScopeAndScpAttributesTheInsufficientErrorBasedOnScopeAttribute() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write")); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + attributes.put("scope", "missive:read missive:write"); + + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [missive:read missive:write] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " + + "scope=\"missive:read missive:write\""); + } + + @Test + public void handleWhenTokenHasScopeAttributeAndRealmIsSetThenInsufficientScopeErrorWithScopesAndRealm() + throws Exception { + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Map attributes = Maps.newHashMap("scope", "message:read message:write"); + Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes); + request.setUserPrincipal(token); + + this.accessDeniedHandler.setRealmName("test"); + this.accessDeniedHandler.handle(request, response, null); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer realm=\"test\", " + + "error=\"insufficient_scope\", " + + "error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " + + "scope=\"message:read message:write\""); + } + + @Test + public void setRealmNameWhenNullRealmNameThenNoExceptionThrown() { + assertThatCode(() -> this.accessDeniedHandler.setRealmName(null)) + .doesNotThrowAnyException(); + } + + static class TestingOAuth2TokenAuthenticationToken + extends AbstractOAuth2TokenAuthenticationToken { + + private Map attributes; + + protected TestingOAuth2TokenAuthenticationToken(Map attributes) { + super(new TestingOAuth2Token("token")); + this.attributes = attributes; + } + + @Override + public Map getTokenAttributes() { + return this.attributes; + } + + static class TestingOAuth2Token extends AbstractOAuth2Token { + public TestingOAuth2Token(String tokenValue) { + super(tokenValue); + } + } + } +} diff --git a/samples/boot/oauth2resourceserver/README.adoc b/samples/boot/oauth2resourceserver/README.adoc new file mode 100644 index 0000000000..d3ba0b8238 --- /dev/null +++ b/samples/boot/oauth2resourceserver/README.adoc @@ -0,0 +1,104 @@ += 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 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=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0IiwiZXhwIjo0NjgzODA1MTI4fQ.ULEPdHG-MK5GlrTQMhgqcyug2brTIZaJIrahUeq9zaiwUSdW83fJ7W1IDd2Z3n4a25JY2uhEcoV95lMfccHR6y_2DLrNvfta22SumY9PEDF2pido54LXG6edIGgarnUbJdR4rpRe_5oRGVa8gDx8FnuZsNv6StSZHAzw5OsuevSTJ1UbJm4UfX3wiahFOQ2OI6G-r5TB2rQNdiPHuNyzG5yznUqRIZ7-GCoMqHMaC-1epKxiX8gYXRROuUYTtcMNa86wh7OVDmvwVmFioRcR58UWBRoO1XQexTtOQq_t8KYsrPZhb9gkyW8x2bAQF-d0J0EJY8JslaH6n4RBaZISww +``` + +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=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOiJtZXNzYWdlOnJlYWQiLCJleHAiOjQ2ODM4MDUxNDF9.h-j6FKRFdnTdmAueTZCdep45e6DPwqM68ZQ8doIJ1exi9YxAlbWzOwId6Bd0L5YmCmp63gGQgsBUBLzwnZQ8kLUgUOBEC3UzSWGRqMskCY9_k9pX0iomX6IfF3N0PaYs0WPC4hO1s8wfZQ-6hKQ4KigFi13G9LMLdH58PRMK0pKEvs3gCbHJuEPw-K5ORlpdnleUTQIwINafU57cmK3KocTeknPAM_L716sCuSYGvDl6xUTXO7oPdrXhS_EhxLP6KxrpI1uD4Ea_5OWTh7S0Wx5LLDfU6wBG1DowN20d374zepOIEkR-Jnmr_QlR44vmRqS5ncrF-1R0EGcPX49U6A + +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 JWTs that either use the "scope" or "scp" attribute._ + +_Additionally, remember that if your authorization server is running locally on port 8080, you'll need to change the sample's port in the `application.yml` by adding something like `server.port: 8082`._ + +To change the sample to point at your Authorization Server, simply find this property in the `application.yml`: + +```yaml +sample.jwk-set-uri: mock://localhost:8081/.well-known/jwks.json +``` + +And change the property to your Authorization Server's JWK set endpoint: + +```yaml +sample.jwk-set-uri: https://dev-123456.oktapreview.com/oauth2/default/v1/keys +``` + +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/spring-security-samples-boot-oauth2resourceserver.gradle b/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle new file mode 100644 index 0000000000..2135bb0af6 --- /dev/null +++ b/samples/boot/oauth2resourceserver/spring-security-samples-boot-oauth2resourceserver.gradle @@ -0,0 +1,13 @@ +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.squareup.okhttp3:mockwebserver' + + testCompile project(':spring-security-test') + testCompile 'org.springframework.boot:spring-boot-starter-test' +} diff --git a/samples/boot/oauth2resourceserver/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java b/samples/boot/oauth2resourceserver/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java new file mode 100644 index 0000000000..1978862067 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2017 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 = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0IiwiZXhwIjo0NjgzODA1MTI4fQ.ULEPdHG-MK5GlrTQMhgqcyug2brTIZaJIrahUeq9zaiwUSdW83fJ7W1IDd2Z3n4a25JY2uhEcoV95lMfccHR6y_2DLrNvfta22SumY9PEDF2pido54LXG6edIGgarnUbJdR4rpRe_5oRGVa8gDx8FnuZsNv6StSZHAzw5OsuevSTJ1UbJm4UfX3wiahFOQ2OI6G-r5TB2rQNdiPHuNyzG5yznUqRIZ7-GCoMqHMaC-1epKxiX8gYXRROuUYTtcMNa86wh7OVDmvwVmFioRcR58UWBRoO1XQexTtOQq_t8KYsrPZhb9gkyW8x2bAQF-d0J0EJY8JslaH6n4RBaZISww"; + String messageReadToken = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOiJtZXNzYWdlOnJlYWQiLCJleHAiOjQ2ODM4MDUxNDF9.h-j6FKRFdnTdmAueTZCdep45e6DPwqM68ZQ8doIJ1exi9YxAlbWzOwId6Bd0L5YmCmp63gGQgsBUBLzwnZQ8kLUgUOBEC3UzSWGRqMskCY9_k9pX0iomX6IfF3N0PaYs0WPC4hO1s8wfZQ-6hKQ4KigFi13G9LMLdH58PRMK0pKEvs3gCbHJuEPw-K5ORlpdnleUTQIwINafU57cmK3KocTeknPAM_L716sCuSYGvDl6xUTXO7oPdrXhS_EhxLP6KxrpI1uD4Ea_5OWTh7S0Wx5LLDfU6wBG1DowN20d374zepOIEkR-Jnmr_QlR44vmRqS5ncrF-1R0EGcPX49U6A"; + + @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/src/integration-test/resources/application-test.yml b/samples/boot/oauth2resourceserver/src/integration-test/resources/application-test.yml new file mode 100644 index 0000000000..04878b289c --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/integration-test/resources/application-test.yml @@ -0,0 +1 @@ +sample.jwk-set-uri: mock://localhost:0/.well-known/jwks.json diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerApplication.java b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerApplication.java new file mode 100644 index 0000000000..f9cc432b1b --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerApplication.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2018 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/src/main/java/sample/OAuth2ResourceServerController.java b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerController.java new file mode 100644 index 0000000000..9cb92c21d6 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2018 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.security.oauth2.jwt.Jwt; +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 Jwt jwt) { + return String.format("Hello, %s!", jwt.getSubject()); + } + + @GetMapping("/message") + public String message() { + return "secret message"; + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java new file mode 100644 index 0000000000..91a44c7223 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2018 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("${sample.jwk-set-uri}") + String jwkSetUri; + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .antMatchers("/message/**").access("hasAuthority('SCOPE_message:read')") + .anyRequest().authenticated() + .and() + .oauth2() + .resourceServer() + .jwt() + .jwkSetUri(this.jwkSetUri); + // @formatter:on + } +} diff --git a/samples/boot/oauth2resourceserver/src/main/java/sample/provider/MockProvider.java b/samples/boot/oauth2resourceserver/src/main/java/sample/provider/MockProvider.java new file mode 100644 index 0000000000..b3e16318ec --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/java/sample/provider/MockProvider.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2018 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.provider; + +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.PreDestroy; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * This is a miminal mock server that serves as a placeholder for a real Authorization Server (AS). + * + * For the sample to work, the AS used must support a JWK endpoint. + * + * For the integration tests to work, the AS used must be able to issue a token + * with the following characteristics: + * + * - The token has the "message:read" scope + * - The token has a "sub" of "subject" + * - The token is signed by a RS256 private key whose public key counterpart is served from the JWK endpoint of the AS. + * + * There is also a test that verifies insufficient scope. In that case, the token should have the following characteristics: + * + * - The token is missing the "message:read" scope + * - The token is signed by a RS256 private key whose public key counterpart is served from the JWK endpoint of the AS. + * + * @author Josh Cummings + */ +public class MockProvider implements EnvironmentPostProcessor { + private MockWebServer server = new MockWebServer(); + + private static final MockResponse JWKS_RESPONSE = response( + "{\"keys\":[{\"p\":\"2p-ViY7DE9ZrdWQb544m0Jp7Cv03YCSljqfim9pD4ALhObX0OrAznOiowTjwBky9JGffMwDBVSfJSD9TSU7aH2sbbfi0bZLMdekKAuimudXwUqPDxrrg0BCyvCYgLmKjbVT3zcdylWSog93CNTxGDPzauu-oc0XPNKCXnaDpNvE\",\"kty\":\"RSA\",\"q\":\"sP_QYavrpBvSJ86uoKVGj2AGl78CSsAtpf1ybSY5TwUlorXSdqapRbY69Y271b0aMLzlleUn9ZTBO1dlKV2_dw_lPADHVia8z3pxL-8sUhIXLsgj4acchMk4c9YX-sFh07xENnyZ-_TXm3llPLuL67HUfBC2eKe800TmCYVWc9U\",\"d\":\"bn1nFxCQT4KLTHqo8mo9HvHD0cRNRNdWcKNnnEQkCF6tKbt-ILRyQGP8O40axLd7CoNVG9c9p_-g4-2kwCtLJNv_STLtwfpCY7VN5o6-ZIpfTjiW6duoPrLWq64Hm_4LOBQTiZfUPcLhsuJRHbWqakj-kV_YbUyC2Ocf_dd8IAQcSrAU2SCcDebhDCWwRUFvaa9V5eq0851S9goaA-AJz-JXyePH6ZFr8JxmWkWxYZ5kdcMD-sm9ZbxE0CaEk32l4fE4hR-L8x2dDtjWA-ahKCZ091z-gV3HWtR2JOjvxoNRjxUo3UxaGiFJHWNIl0EYUJZu1Cb-5wIlEI7wPx5mwQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"qi\":\"qS0OK48M2CIAA6_4Wdw4EbCaAfcTLf5Oy9t5BOF_PFUKqoSpZ6JsT5H0a_4zkjt-oI969v78OTlvBKbmEyKO-KeytzHBAA5CsLmVcz0THrMSg6oXZqu66MPnvWoZN9FEN5TklPOvBFm8Bg1QZ3k-YMVaM--DLvhaYR95_mqaz50\",\"dp\":\"Too2NozLGD1XrXyhabZvy1E0EuaVFj0UHQPDLSpkZ_2g3BK6Art6T0xmE8RYtmqrKIEIdlI3IliAvyvAx_1D7zWTTRaj-xlZyqJFrnXWL7zj8UxT8PkB-r2E-ILZ3NAi1gxIWezlBTZ8M6NfObDFmbTc_3tJkN_raISo8z_ziIE\",\"dq\":\"U0yhSkY5yOsa9YcMoigGVBWSJLpNHtbg5NypjHrPv8OhWbkOSq7WvSstBkFk5AtyFvvfZLMLIkWWxxGzV0t6f1MoxBtttLrYYyCxwihiiGFhLbAdSuZ1wnxcqA9bC7UVECvrQmVTpsMs8UupfHKbQBpZ8OWAqrnuYNNtG4_4Bt0\",\"n\":\"lygtuZj0lJjqOqIWocF8Bb583QDdq-aaFg8PesOp2-EDda6GqCpL-_NZVOflNGX7XIgjsWHcPsQHsV9gWuOzSJ0iEuWvtQ6eGBP5M6m7pccLNZfwUse8Cb4Ngx3XiTlyuqM7pv0LPyppZusfEHVEdeelou7Dy9k0OQ_nJTI3b2E1WBoHC58CJ453lo4gcBm1efURN3LIVc1V9NQY_ESBKVdwqYyoJPEanURLVGRd6cQKn6YrCbbIRHjqAyqOE-z3KmgDJnPriljfR5XhSGyM9eqD9Xpy6zu_MAeMJJfSArp857zLPk-Wf5VP9STAcjyfdBIybMKnwBYr2qHMT675hQ\"}]}", + 200 + ); + + private static final MockResponse NOT_FOUND_RESPONSE = response( + "{ \"message\" : \"This mock authorization server responds to just one request: GET /.well-known/jwks.json.\" }", + 404 + ); + + public MockProvider() throws IOException { + Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + if ("/.well-known/jwks.json".equals(request.getPath())) { + return JWKS_RESPONSE; + } + + return NOT_FOUND_RESPONSE; + } + }; + + this.server.setDispatcher(dispatcher); + } + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + String uri = environment.getProperty("sample.jwk-set-uri", "mock://localhost:0"); + + if (uri.startsWith("mock://")) { + try { + this.server.start(URI.create(uri).getPort()); + } catch (IOException e) { + throw new IllegalStateException(e); + } + + Map properties = new HashMap<>(); + String url = this.server.url("/.well-known/jwks.json").toString(); + properties.put("sample.jwk-set-uri", url); + + MapPropertySource propertySource = new MapPropertySource("mock", properties); + environment.getPropertySources().addFirst(propertySource); + } + } + + @PreDestroy + public void shutdown() throws IOException { + this.server.shutdown(); + } + + 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/src/main/resources/META-INF/spring.factories b/samples/boot/oauth2resourceserver/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..34562aa3b0 --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=sample.provider.MockProvider diff --git a/samples/boot/oauth2resourceserver/src/main/resources/application.yml b/samples/boot/oauth2resourceserver/src/main/resources/application.yml new file mode 100644 index 0000000000..f61da202df --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/main/resources/application.yml @@ -0,0 +1 @@ +sample.jwk-set-uri: mock://localhost:8081/.well-known/jwks.json