diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java index bf3fb9f57d..234a16f4cd 100644 --- a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerController.java @@ -18,6 +18,8 @@ package sample; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** @@ -35,4 +37,9 @@ public class OAuth2ResourceServerController { public String message() { return "secret message"; } + + @PostMapping("/message") + public String createMessage(@RequestBody String message) { + return String.format("Message was created. Content: %s", message); + } } diff --git a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java index ae7cf6736c..42d424fd7a 100644 --- a/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java +++ b/samples/boot/oauth2resourceserver-opaque/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java @@ -16,6 +16,7 @@ package sample; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; 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; @@ -36,7 +37,8 @@ public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfig http .authorizeRequests(authorizeRequests -> authorizeRequests - .mvcMatchers("/message/**").hasAuthority("SCOPE_message:read") + .antMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message:read") + .antMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message:write") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2ResourceServer -> diff --git a/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTests.java b/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTests.java new file mode 100644 index 0000000000..77c766649d --- /dev/null +++ b/samples/boot/oauth2resourceserver-opaque/src/test/java/sample/OAuth2ResourceServerControllerTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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.WebMvcTest; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.CoreMatchers.is; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +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.status; + +/** + * @author Josh Cummings + * @since 5.3 + */ +@RunWith(SpringRunner.class) +@WebMvcTest(OAuth2ResourceServerController.class) +public class OAuth2ResourceServerControllerTests { + + @Autowired + MockMvc mvc; + + @Test + public void indexGreetsAuthenticatedUser() throws Exception { + this.mvc.perform(get("/").with(opaqueToken().attribute("sub", "ch4mpy"))) + .andExpect(content().string(is("Hello, ch4mpy!"))); + } + + @Test + public void messageCanBeReadWithScopeMessageReadAuthority() throws Exception { + this.mvc.perform(get("/message").with(opaqueToken().scopes("message:read"))) + .andExpect(content().string(is("secret message"))); + + this.mvc.perform(get("/message") + .with(jwt().authorities(new SimpleGrantedAuthority(("SCOPE_message:read"))))) + .andExpect(content().string(is("secret message"))); + } + + @Test + public void messageCanNotBeReadWithoutScopeMessageReadAuthority() throws Exception { + this.mvc.perform(get("/message").with(opaqueToken())) + .andExpect(status().isForbidden()); + } + + @Test + public void messageCanNotBeCreatedWithoutAnyScope() throws Exception { + this.mvc.perform(post("/message") + .content("Hello message") + .with(opaqueToken())) + .andExpect(status().isForbidden()); + } + + @Test + public void messageCanNotBeCreatedWithScopeMessageReadAuthority() throws Exception { + this.mvc.perform(post("/message") + .content("Hello message") + .with(opaqueToken().scopes("message:read"))) + .andExpect(status().isForbidden()); + } + + @Test + public void messageCanBeCreatedWithScopeMessageWriteAuthority() throws Exception { + this.mvc.perform(post("/message") + .content("Hello message") + .with(opaqueToken().scopes("message:write"))) + .andExpect(status().isOk()) + .andExpect(content().string(is("Message was created. Content: Hello message"))); + } +} diff --git a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java index 6bcb01c5b2..4e7f0a2a64 100644 --- a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java +++ b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java @@ -21,18 +21,24 @@ import java.nio.charset.StandardCharsets; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import com.nimbusds.oauth2.sdk.util.StringUtils; + import org.springframework.core.convert.converter.Converter; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; @@ -55,7 +61,9 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; @@ -63,8 +71,10 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames; import org.springframework.security.test.context.TestSecurityContextHolder; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; import org.springframework.security.test.web.support.WebTestUtils; @@ -246,6 +256,34 @@ public final class SecurityMockMvcRequestPostProcessors { return new JwtRequestPostProcessor(); } + /** + * Establish a {@link SecurityContext} that has a + * {@link BearerTokenAuthentication} for the + * {@link Authentication} and a {@link OAuth2AuthenticatedPrincipal} for the + * {@link Authentication#getPrincipal()}. All details are + * declarative and do not require the token to be valid + * + *

+ * The support works by associating the authentication to the HttpServletRequest. To associate + * the request to the SecurityContextHolder you need to ensure that the + * SecurityContextPersistenceFilter is associated with the MockMvc instance. A few + * ways to do this are: + *

+ * + * + * + * @return the {@link OpaqueTokenRequestPostProcessor} for additional customization + * @since 5.3 + */ + public static OpaqueTokenRequestPostProcessor opaqueToken() { + return new OpaqueTokenRequestPostProcessor(); + } + /** * Establish a {@link SecurityContext} that uses the specified {@link Authentication} * for the {@link Authentication#getPrincipal()} and a custom {@link UserDetails}. All @@ -1070,6 +1108,146 @@ public final class SecurityMockMvcRequestPostProcessors { } + /** + * @author Josh Cummings + * @since 5.3 + */ + public final static class OpaqueTokenRequestPostProcessor implements RequestPostProcessor { + private final Map attributes = new HashMap<>(); + private Converter, Instant> expiresAtConverter = + attributes -> getInstant(attributes, "exp"); + private Converter, Instant> issuedAtConverter = + attributes -> getInstant(attributes, "iat"); + private Converter, Collection> authoritiesConverter = + attributes -> getAuthorities(attributes); + + private OAuth2AuthenticatedPrincipal principal; + + private OpaqueTokenRequestPostProcessor() { + this.attributes.put(OAuth2IntrospectionClaimNames.SUBJECT, "user"); + this.attributes.put(OAuth2IntrospectionClaimNames.SCOPE, "read"); + } + + /** + * Add the provided attribute to the resulting principal + * @param name the attribute name + * @param value the attribute value + * @return the {@link OpaqueTokenRequestPostProcessor} for further configuration + */ + public OpaqueTokenRequestPostProcessor attribute(String name, Object value) { + Assert.notNull(name, "name cannot be null"); + this.attributes.put(name, value); + return this; + } + + /** + * Use the provided authorities in the resulting principal + * @param authorities the authorities to use + * @return the {@link OpaqueTokenRequestPostProcessor} for further configuration + */ + public OpaqueTokenRequestPostProcessor authorities(Collection authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authoritiesConverter = attributes -> authorities; + return this; + } + + /** + * Use the provided authorities in the resulting principal + * @param authorities the authorities to use + * @return the {@link OpaqueTokenRequestPostProcessor} for further configuration + */ + public OpaqueTokenRequestPostProcessor authorities(GrantedAuthority... authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authoritiesConverter = attributes -> Arrays.asList(authorities); + return this; + } + + /** + * Use the provided scopes as the authorities in the resulting principal + * @param scopes the scopes to use + * @return the {@link OpaqueTokenRequestPostProcessor} for further configuration + */ + public OpaqueTokenRequestPostProcessor scopes(String... scopes) { + Assert.notNull(scopes, "scopes cannot be null"); + this.authoritiesConverter = attributes -> getAuthorities(Arrays.asList(scopes)); + return this; + } + + /** + * Use the provided principal + * + * Providing the principal takes precedence over + * any authorities or attributes provided via {@link #attribute(String, Object)}, + * {@link #authorities} or {@link #scopes}. + * + * @param principal the principal to use + * @return the {@link OpaqueTokenRequestPostProcessor} for further configuration + */ + public OpaqueTokenRequestPostProcessor principal(OAuth2AuthenticatedPrincipal principal) { + Assert.notNull(principal, "principal cannot be null"); + this.principal = principal; + return this; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + CsrfFilter.skipRequest(request); + OAuth2AuthenticatedPrincipal principal = getPrincipal(); + OAuth2AccessToken accessToken = getOAuth2AccessToken(principal); + BearerTokenAuthentication token = new BearerTokenAuthentication + (principal, accessToken, principal.getAuthorities()); + return new AuthenticationRequestPostProcessor(token).postProcessRequest(request); + } + + private OAuth2AuthenticatedPrincipal getPrincipal() { + if (this.principal != null) { + return this.principal; + } + + return new DefaultOAuth2AuthenticatedPrincipal + (this.attributes, this.authoritiesConverter.convert(this.attributes)); + } + + private Collection getAuthorities(Map attributes) { + Object scope = attributes.get(OAuth2IntrospectionClaimNames.SCOPE); + if (scope == null) { + return Collections.emptyList(); + } + if (scope instanceof Collection) { + return getAuthorities((Collection) scope); + } + String scopes = scope.toString(); + if (StringUtils.isBlank(scopes)) { + return Collections.emptyList(); + } + return getAuthorities(Arrays.asList(scopes.split(" "))); + } + + private Collection getAuthorities(Collection scopes) { + return scopes.stream() + .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) + .collect(Collectors.toList()); + } + + private Instant getInstant(Map attributes, String name) { + Object value = attributes.get(name); + if (value == null) { + return null; + } + if (value instanceof Instant) { + return (Instant) value; + } + throw new IllegalArgumentException(name + " attribute must be of type Instant"); + } + + private OAuth2AccessToken getOAuth2AccessToken(OAuth2AuthenticatedPrincipal principal) { + Instant expiresAt = this.expiresAtConverter.convert(principal.getAttributes()); + Instant issuedAt = this.issuedAtConverter.convert(principal.getAttributes()); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", issuedAt, expiresAt); + } + } + /** * @author Josh Cummings * @since 5.3 diff --git a/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOpaqueTokenTests.java b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOpaqueTokenTests.java new file mode 100644 index 0000000000..e9cb814c80 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOpaqueTokenTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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.test.web.servlet.request; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +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.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.mockito.Mockito.mock; +import static org.powermock.api.mockito.PowerMockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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.status; + +/** + * Tests for {@link SecurityMockMvcRequestPostProcessors#opaqueToken()} + * + * @author Josh Cummings + * @since 5.3 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@WebAppConfiguration +public class SecurityMockMvcRequestPostProcessorsOpaqueTokenTests { + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @Before + public void setup() { + // @formatter:off + this.mvc = MockMvcBuilders + .webAppContextSetup(this.context) + .apply(springSecurity()) + .build(); + // @formatter:on + } + + @Test + public void opaqueTokenWhenUsingDefaultsThenProducesDefaultAuthentication() + throws Exception { + + this.mvc.perform(get("/name").with(opaqueToken())) + .andExpect(content().string("user")); + this.mvc.perform(get("/admin/scopes").with(opaqueToken())) + .andExpect(status().isForbidden()); + } + + @Test + public void opaqueTokenWhenAuthoritiesSpecifiedThenGrantsAccess() throws Exception { + this.mvc.perform(get("/admin/scopes") + .with(opaqueToken().scopes("admin", "read"))) + .andExpect(content().string("[\"SCOPE_admin\",\"SCOPE_read\"]")); + } + + @Test + public void opaqueTokenWhenAttributeSpecifiedThenUserHasAttribute() throws Exception { + this.mvc.perform(get("/opaque-token/iss") + .with(opaqueToken().attribute("iss", "https://idp.example.org"))) + .andExpect(content().string("https://idp.example.org")); + } + + @Test + public void opaqueTokenWhenPrincipalSpecifiedThenAuthenticationHasPrincipal() throws Exception { + Collection authorities = Collections.singleton(new SimpleGrantedAuthority("SCOPE_read")); + OAuth2AuthenticatedPrincipal principal = mock(OAuth2AuthenticatedPrincipal.class); + when(principal.getName()).thenReturn("ben"); + when(principal.getAuthorities()).thenReturn(authorities); + + this.mvc.perform(get("/name").with(opaqueToken().principal(principal))) + .andExpect(content().string("ben")); + } + + @EnableWebSecurity + @EnableWebMvc + static class OAuth2LoginConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .authorizeRequests() + .mvcMatchers("/admin/**").hasAuthority("SCOPE_admin") + .anyRequest().hasAuthority("SCOPE_read") + .and() + .oauth2ResourceServer() + .opaqueToken() + .introspector(mock(OpaqueTokenIntrospector.class)); + } + + @RestController + static class PrincipalController { + @GetMapping("/name") + String name(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { + return principal.getName(); + } + + @GetMapping("/opaque-token/{attribute}") + String tokenAttribute(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal, + @PathVariable("attribute") String attribute) { + + return principal.getAttribute(attribute); + } + + @GetMapping("/admin/scopes") + List scopes(@AuthenticationPrincipal(expression = "authorities") + Collection authorities) { + + return authorities.stream().map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } + } + } +}