From bd593a343127e67f53843c473a5411370bf2bf67 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Thu, 27 Feb 2020 11:27:30 -0700 Subject: [PATCH] Add Opaque Token WebTestClient Support Fixes gh-7827 --- .../server/SecurityMockServerConfigurers.java | 180 ++++++++++++++++++ ...yMockServerConfigurerOpaqueTokenTests.java | 171 +++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurerOpaqueTokenTests.java diff --git a/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java b/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java index cd20a2db11..24bbaf8a28 100644 --- a/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java +++ b/test/src/main/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurers.java @@ -16,6 +16,7 @@ package org.springframework.security.test.web.reactive.server; +import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -26,7 +27,9 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; +import com.nimbusds.oauth2.sdk.util.StringUtils; import reactor.core.publisher.Mono; import org.springframework.core.convert.converter.Converter; @@ -47,7 +50,9 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository; 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; @@ -58,8 +63,10 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; 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.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.security.web.server.csrf.CsrfWebFilter; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; @@ -153,6 +160,20 @@ public class SecurityMockServerConfigurers { return new JwtMutator(); } + /** + * Updates the ServerWebExchange to establish a {@link SecurityContext} that has a + * {@link BearerTokenAuthentication} for the + * {@link Authentication} and an {@link OAuth2AuthenticatedPrincipal} for the + * {@link Authentication#getPrincipal()}. All details are + * declarative and do not require the token to be valid. + * + * @return the {@link OpaqueTokenMutator} to further configure or use + * @since 5.3 + */ + public static OpaqueTokenMutator mockOpaqueToken() { + return new OpaqueTokenMutator(); + } + /** * Updates the ServerWebExchange to establish a {@link SecurityContext} that has a * {@link OAuth2AuthenticationToken} for the @@ -516,6 +537,165 @@ public class SecurityMockServerConfigurers { } } + /** + * @author Josh Cummings + * @since 5.3 + */ + public final static class OpaqueTokenMutator implements WebTestClientConfigurer, MockServerConfigurer { + private Supplier> attributes = this::defaultAttributes; + private Supplier> authorities = this::defaultAuthorities; + + private Supplier principal = this::defaultPrincipal; + + private OpaqueTokenMutator() { } + + /** + * Mutate the attributes using the given {@link Consumer} + * + * @param attributesConsumer The {@link Consumer} for mutating the {@Map} of attributes + * @return the {@link OpaqueTokenMutator} for further configuration + */ + public OpaqueTokenMutator attributes(Consumer> attributesConsumer) { + Assert.notNull(attributesConsumer, "attributesConsumer cannot be null"); + this.attributes = () -> { + Map attributes = defaultAttributes(); + attributesConsumer.accept(attributes); + return attributes; + }; + this.principal = this::defaultPrincipal; + return this; + } + + /** + * Use the provided authorities in the resulting principal + * @param authorities the authorities to use + * @return the {@link OpaqueTokenMutator} for further configuration + */ + public OpaqueTokenMutator authorities(Collection authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = () -> authorities; + this.principal = this::defaultPrincipal; + return this; + } + + /** + * Use the provided authorities in the resulting principal + * @param authorities the authorities to use + * @return the {@link OpaqueTokenMutator} for further configuration + */ + public OpaqueTokenMutator authorities(GrantedAuthority... authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = () -> Arrays.asList(authorities); + this.principal = this::defaultPrincipal; + return this; + } + + /** + * Use the provided scopes as the authorities in the resulting principal + * @param scopes the scopes to use + * @return the {@link OpaqueTokenMutator} for further configuration + */ + public OpaqueTokenMutator scopes(String... scopes) { + Assert.notNull(scopes, "scopes cannot be null"); + this.authorities = () -> getAuthorities(Arrays.asList(scopes)); + this.principal = this::defaultPrincipal; + return this; + } + + /** + * Use the provided principal + * @param principal the principal to use + * @return the {@link OpaqueTokenMutator} for further configuration + */ + public OpaqueTokenMutator principal(OAuth2AuthenticatedPrincipal principal) { + Assert.notNull(principal, "principal cannot be null"); + this.principal = () -> principal; + return this; + } + + @Override + public void beforeServerCreated(WebHttpHandlerBuilder builder) { + configurer().beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(WebTestClient.MockServerSpec serverSpec) { + configurer().afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + WebTestClient.Builder builder, + @Nullable WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable ClientHttpConnector connector) { + httpHandlerBuilder.filter((exchange, chain) -> { + CsrfWebFilter.skipExchange(exchange); + return chain.filter(exchange); + }); + configurer().afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + + private T configurer() { + OAuth2AuthenticatedPrincipal principal = this.principal.get(); + OAuth2AccessToken accessToken = getOAuth2AccessToken(principal); + BearerTokenAuthentication token = new BearerTokenAuthentication + (principal, accessToken, principal.getAuthorities()); + return mockAuthentication(token); + } + + private Map defaultAttributes() { + Map attributes = new HashMap<>(); + attributes.put(OAuth2IntrospectionClaimNames.SUBJECT, "user"); + attributes.put(OAuth2IntrospectionClaimNames.SCOPE, "read"); + return attributes; + } + + private Collection defaultAuthorities() { + Map attributes = this.attributes.get(); + 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 OAuth2AuthenticatedPrincipal defaultPrincipal() { + return new DefaultOAuth2AuthenticatedPrincipal + (this.attributes.get(), this.authorities.get()); + } + + private Collection getAuthorities(Collection scopes) { + return scopes.stream() + .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) + .collect(Collectors.toList()); + } + + private OAuth2AccessToken getOAuth2AccessToken(OAuth2AuthenticatedPrincipal principal) { + Instant expiresAt = getInstant(principal.getAttributes(), "exp"); + Instant issuedAt = getInstant(principal.getAttributes(), "iat"); + return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", issuedAt, expiresAt); + } + + 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"); + } + } + /** * @author Josh Cummings * @since 5.3 diff --git a/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurerOpaqueTokenTests.java b/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurerOpaqueTokenTests.java new file mode 100644 index 0000000000..ae11d619df --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/reactive/server/SecurityMockServerConfigurerOpaqueTokenTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2020 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.reactive.server; + +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; +import org.springframework.security.web.reactive.result.method.annotation.CurrentSecurityContextArgumentResolver; +import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.oauth2.core.TestOAuth2AuthenticatedPrincipals.active; +import static org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionClaimNames.SUBJECT; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOpaqueToken; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +/** + * @author Josh Cummings + * @since 5.3 + */ +@RunWith(MockitoJUnitRunner.class) +public class SecurityMockServerConfigurerOpaqueTokenTests extends AbstractMockServerConfigurersTests { + private GrantedAuthority authority1 = new SimpleGrantedAuthority("one"); + + private GrantedAuthority authority2 = new SimpleGrantedAuthority("two"); + + private WebTestClient client = WebTestClient + .bindToController(securityContextController) + .webFilter(new SecurityContextServerWebExchangeWebFilter()) + .argumentResolvers(resolvers -> resolvers.addCustomResolver( + new CurrentSecurityContextArgumentResolver(new ReactiveAdapterRegistry()))) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + @Test + public void mockOpaqueTokenWhenUsingDefaultsThenBearerTokenAuthentication() { + this.client + .mutateWith(mockOpaqueToken()) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat(context.getAuthentication()).isInstanceOf( + BearerTokenAuthentication.class); + BearerTokenAuthentication token = (BearerTokenAuthentication) context.getAuthentication(); + assertThat(token.getAuthorities()).isNotEmpty(); + assertThat(token.getToken()).isNotNull(); + assertThat(token.getTokenAttributes().get(SUBJECT)).isEqualTo("user"); + } + + @Test + public void mockOpaqueTokenWhenAuthoritiesThenBearerTokenAuthentication() { + this.client + .mutateWith(mockOpaqueToken() + .authorities(this.authority1, this.authority2)) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat((List) context.getAuthentication().getAuthorities()) + .containsOnly(this.authority1, this.authority2); + } + + @Test + public void mockOpaqueTokenWhenScopesThenBearerTokenAuthentication() { + this.client + .mutateWith(mockOpaqueToken().scopes("scoped", "authorities")) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat((List) context.getAuthentication().getAuthorities()) + .containsOnly(new SimpleGrantedAuthority("SCOPE_scoped"), + new SimpleGrantedAuthority("SCOPE_authorities")); + } + + @Test + public void mockOpaqueTokenWhenAttributesThenBearerTokenAuthentication() { + String sub = new String("my-subject"); + this.client + .mutateWith(mockOpaqueToken() + .attributes(attributes -> attributes.put(SUBJECT, sub))) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat(context.getAuthentication()).isInstanceOf(BearerTokenAuthentication.class); + BearerTokenAuthentication token = (BearerTokenAuthentication) context.getAuthentication(); + assertThat(token.getTokenAttributes().get(SUBJECT)).isSameAs(sub); + } + + @Test + public void mockOpaqueTokenWhenPrincipalThenBearerTokenAuthentication() { + OAuth2AuthenticatedPrincipal principal = active(); + this.client + .mutateWith(mockOpaqueToken() + .principal(principal)) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat(context.getAuthentication()).isInstanceOf(BearerTokenAuthentication.class); + BearerTokenAuthentication token = (BearerTokenAuthentication) context.getAuthentication(); + assertThat(token.getPrincipal()).isSameAs(principal); + } + + @Test + public void mockOpaqueTokenWhenPrincipalSpecifiedThenLastCalledTakesPrecedence() { + OAuth2AuthenticatedPrincipal principal = active(a -> a.put("scope", "user")); + + this.client + .mutateWith(mockOpaqueToken() + .attributes(a -> a.put(SUBJECT, "foo")) + .principal(principal)) + .get() + .exchange() + .expectStatus().isOk(); + + SecurityContext context = securityContextController.removeSecurityContext(); + assertThat(context.getAuthentication()).isInstanceOf(BearerTokenAuthentication.class); + BearerTokenAuthentication token = (BearerTokenAuthentication) context.getAuthentication(); + assertThat((String) ((OAuth2AuthenticatedPrincipal) token.getPrincipal()).getAttribute(SUBJECT)) + .isEqualTo(principal.getAttribute(SUBJECT)); + + this.client + .mutateWith(mockOpaqueToken() + .principal(principal) + .attributes(a -> a.put(SUBJECT, "bar"))) + .get() + .exchange() + .expectStatus().isOk(); + + context = securityContextController.removeSecurityContext(); + assertThat(context.getAuthentication()).isInstanceOf(BearerTokenAuthentication.class); + token = (BearerTokenAuthentication) context.getAuthentication(); + assertThat((String) ((OAuth2AuthenticatedPrincipal) token.getPrincipal()).getAttribute(SUBJECT)) + .isEqualTo("bar"); + } +}