diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java index 6e62372dea3..9cccb7b0869 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/Jwt.java @@ -15,13 +15,19 @@ */ package org.springframework.security.oauth2.jwt; -import org.springframework.security.oauth2.core.AbstractOAuth2Token; -import org.springframework.util.Assert; - +import java.net.URL; import java.time.Instant; +import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.util.Assert; /** * An implementation of an {@link AbstractOAuth2Token} representing a JSON Web Token (JWT). @@ -41,6 +47,8 @@ * @see JSON Web Encryption (JWE) */ public class Jwt extends AbstractOAuth2Token implements JwtClaimAccessor { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + private final Map headers; private final Map claims; @@ -80,4 +88,139 @@ public Map getHeaders() { public Map getClaims() { return this.claims; } + + public static Builder builder() { + return new Builder<>(); + } + + /** + * Helps configure a {@link Jwt} + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ + public static class Builder> { + protected String tokenValue; + protected final Map claims = new HashMap<>(); + protected final Map headers = new HashMap<>(); + + protected Builder() { + } + + public T tokenValue(String tokenValue) { + this.tokenValue = tokenValue; + return downcast(); + } + + public T claim(String name, Object value) { + this.claims.put(name, value); + return downcast(); + } + + public T clearClaims(Map claims) { + this.claims.clear(); + return downcast(); + } + + /** + * Adds to existing claims (does not replace existing ones) + * @param claims claims to add + * @return this builder to further configure + */ + public T claims(Map claims) { + this.claims.putAll(claims); + return downcast(); + } + + public T header(String name, Object value) { + this.headers.put(name, value); + return downcast(); + } + + public T clearHeaders(Map headers) { + this.headers.clear(); + return downcast(); + } + + /** + * Adds to existing headers (does not replace existing ones) + * @param headers headers to add + * @return this builder to further configure + */ + public T headers(Map headers) { + headers.entrySet().stream().forEach(e -> this.header(e.getKey(), e.getValue())); + return downcast(); + } + + public Jwt build() { + final JwtClaimSet claimSet = new JwtClaimSet(claims); + return new Jwt( + this.tokenValue, + claimSet.getClaimAsInstant(JwtClaimNames.IAT), + claimSet.getClaimAsInstant(JwtClaimNames.EXP), + this.headers, + claimSet); + } + + public T audience(Stream audience) { + this.claim(JwtClaimNames.AUD, audience.collect(Collectors.toList())); + return downcast(); + } + + public T audience(Collection audience) { + return audience(audience.stream()); + } + + public T audience(String... audience) { + return audience(Stream.of(audience)); + } + + public T expiresAt(Instant expiresAt) { + this.claim(JwtClaimNames.EXP, expiresAt.getEpochSecond()); + return downcast(); + } + + public T jti(String jti) { + this.claim(JwtClaimNames.JTI, jti); + return downcast(); + } + + public T issuedAt(Instant issuedAt) { + this.claim(JwtClaimNames.IAT, issuedAt.getEpochSecond()); + return downcast(); + } + + public T issuer(URL issuer) { + this.claim(JwtClaimNames.ISS, issuer.toExternalForm()); + return downcast(); + } + + public T notBefore(Instant notBefore) { + this.claim(JwtClaimNames.NBF, notBefore.getEpochSecond()); + return downcast(); + } + + public T subject(String subject) { + this.claim(JwtClaimNames.SUB, subject); + return downcast(); + } + + @SuppressWarnings("unchecked") + protected T downcast() { + return (T) this; + } + } + + private static final class JwtClaimSet extends HashMap implements JwtClaimAccessor { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + public JwtClaimSet(Map claims) { + super(claims); + } + + @Override + public Map getClaims() { + return this; + } + + } } diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtBuilderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtBuilderTests.java new file mode 100644 index 00000000000..47ba3b22e0e --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtBuilderTests.java @@ -0,0 +1,59 @@ +/* + * 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 + * + * 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.oauth2.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +/** + * Tests for {@link Jwt.Builder}. + */ +public class JwtBuilderTests { + + @Test() + public void builderCanBeReused() { + final Jwt.Builder tokensBuilder = Jwt.builder(); + + final Jwt first = tokensBuilder + .tokenValue("V1") + .header("TEST_HEADER_1", "H1") + .claim("TEST_CLAIM_1", "C1") + .build(); + + final Jwt second = tokensBuilder + .tokenValue("V2") + .header("TEST_HEADER_1", "H2") + .header("TEST_HEADER_2", "H3") + .claim("TEST_CLAIM_1", "C2") + .claim("TEST_CLAIM_2", "C3") + .build(); + + assertThat(first.getHeaders()).hasSize(1); + assertThat(first.getHeaders().get("TEST_HEADER_1")).isEqualTo("H1"); + assertThat(first.getClaims()).hasSize(1); + assertThat(first.getClaims().get("TEST_CLAIM_1")).isEqualTo("C1"); + assertThat(first.getTokenValue()).isEqualTo("V1"); + + assertThat(second.getHeaders()).hasSize(2); + assertThat(second.getHeaders().get("TEST_HEADER_1")).isEqualTo("H2"); + assertThat(second.getHeaders().get("TEST_HEADER_2")).isEqualTo("H3"); + assertThat(second.getClaims()).hasSize(2); + assertThat(second.getClaims().get("TEST_CLAIM_1")).isEqualTo("C2"); + assertThat(second.getClaims().get("TEST_CLAIM_2")).isEqualTo("C3"); + assertThat(second.getTokenValue()).isEqualTo("V2"); + } +} 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 index e9ff686ea2b..0c7b997ddcb 100644 --- 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 @@ -17,7 +17,11 @@ import java.util.Collection; import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.core.convert.converter.Converter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.security.core.Transient; @@ -71,4 +75,73 @@ public Map getTokenAttributes() { public String getName() { return this.getToken().getSubject(); } + + public static Builder builder(Converter> authoritiesConverter) { + return new Builder<>(Jwt.builder(), authoritiesConverter); + } + + public static Builder builder() { + return builder(new JwtGrantedAuthoritiesConverter()); + } + + /** + * Helps configure a {@link JwtAuthenticationToken} + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ + public static class Builder> { + + private Converter> authoritiesConverter; + + private final Jwt.Builder jwt; + + protected Builder(Jwt.Builder principalBuilder, Converter> authoritiesConverter) { + this.authoritiesConverter = authoritiesConverter; + this.jwt = principalBuilder; + } + + public T authoritiesConverter(Converter> authoritiesConverter) { + this.authoritiesConverter = authoritiesConverter; + return downcast(); + } + + public T token(Consumer> jwtBuilderConsumer) { + jwtBuilderConsumer.accept(jwt); + return downcast(); + } + + public T name(String name) { + jwt.subject(name); + return downcast(); + } + + /** + * Shortcut to set "scope" claim with a space separated string containing provided scope collection + * @param scopes strings to join with spaces and set as "scope" claim + * @return this builder to further configure + */ + public T scopes(String... scopes) { + jwt.claim("scope", Stream.of(scopes).collect(Collectors.joining(" "))); + return downcast(); + } + + public JwtAuthenticationToken build() { + final Jwt token = jwt.build(); + return new JwtAuthenticationToken(token, getAuthorities(token)); + } + + protected Jwt getToken() { + return jwt.build(); + } + + protected Collection getAuthorities(Jwt token) { + return authoritiesConverter.convert(token); + } + + @SuppressWarnings("unchecked") + protected T downcast() { + return (T) this; + } + } } diff --git a/samples/boot/oauth2resourceserver/src/test/java/sample/OAuth2ResourceServerControllerTests.java b/samples/boot/oauth2resourceserver/src/test/java/sample/OAuth2ResourceServerControllerTests.java new file mode 100644 index 00000000000..e9fb7b88a8f --- /dev/null +++ b/samples/boot/oauth2resourceserver/src/test/java/sample/OAuth2ResourceServerControllerTests.java @@ -0,0 +1,71 @@ +/* + * 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 static org.hamcrest.CoreMatchers.is; +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.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +/** + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2.0 + * + */ +@RunWith(SpringRunner.class) +@WebMvcTest(OAuth2ResourceServerController.class) +public class OAuth2ResourceServerControllerTests { + + @Autowired + MockMvc mockMvc; + + @MockBean + JwtDecoder jwtDecoder; + + @Test + public void indexGreetsAuthenticatedUser() throws Exception { + mockMvc.perform(get("/").with(jwt().name("ch4mpy"))) + .andExpect(content().string(is("Hello, ch4mpy!"))); + } + + @Test + public void messageCanBeReadWithScopeMessageReadAuthority() throws Exception { + mockMvc.perform(get("/message").with(jwt().scopes("message:read"))) + .andExpect(content().string(is("secret message"))); + + mockMvc.perform(get("/message").with(jwt().authorities(new SimpleGrantedAuthority(("SCOPE_message:read"))))) + .andExpect(content().string(is("secret message"))); + } + + @Test + public void messageCanNotBeReadWithoutScopeMessageReadAuthority() throws Exception { + mockMvc.perform(get("/message").with(jwt())) + .andExpect(status().isForbidden()); + } + +} diff --git a/test/spring-security-test.gradle b/test/spring-security-test.gradle index 35174aa77cd..16fe5886d38 100644 --- a/test/spring-security-test.gradle +++ b/test/spring-security-test.gradle @@ -7,6 +7,8 @@ dependencies { compile 'org.springframework:spring-test' optional project(':spring-security-config') + optional project(':spring-security-oauth2-resource-server') + optional project(':spring-security-oauth2-jose') optional 'io.projectreactor:reactor-core' optional 'org.springframework:spring-webflux' diff --git a/test/src/main/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilder.java b/test/src/main/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilder.java new file mode 100644 index 00000000000..655d8ae5e57 --- /dev/null +++ b/test/src/main/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilder.java @@ -0,0 +1,140 @@ +/* + * 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.support; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.util.StringUtils; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ +public class JwtAuthenticationTokenTestingBuilder> + extends + JwtAuthenticationToken.Builder { + + private static final String[] DEFAULT_SCOPES = { "USER" }; + + private final Set addedAuthorities; + + public JwtAuthenticationTokenTestingBuilder(Converter> authoritiesConverter) { + super(new JwtTestingBuilder(), authoritiesConverter); + this.addedAuthorities = new HashSet<>(); + scopes(DEFAULT_SCOPES); + } + + public JwtAuthenticationTokenTestingBuilder() { + this(new JwtGrantedAuthoritiesConverter()); + } + + /** + * How to extract authorities from token + * @param authoritiesConverter JWT to granted-authorities converter + * @return this builder to further configure + */ + public T authorities(Converter> authoritiesConverter) { + return authoritiesConverter(authoritiesConverter); + } + + /** + * Adds authorities to what is extracted from the token.
+ * Please consider using {@link #authorities(Converter)} instead. + * @param authorities authorities to add to token ones + * @return this builder to further configure + */ + public T authorities(Stream authorities) { + addedAuthorities.addAll(authorities.collect(Collectors.toSet())); + return downcast(); + } + + /** + * Adds authorities to what is extracted from the token.
+ * Please consider using {@link #authorities(Converter)} instead. + * @param authorities authorities to add to token ones + * @return this builder to further configure + */ + public T authorities(GrantedAuthority... authorities) { + return authorities(Stream.of(authorities)); + } + + /** + * Adds authorities to what is extracted from the token.
+ * Please consider using {@link #authorities(Converter)} instead. + * @param authorities authorities to add to token ones + * @return this builder to further configure + */ + public T authorities(String... authorities) { + return authorities(Stream.of(authorities).map(SimpleGrantedAuthority::new)); + } + + @Override + public JwtAuthenticationToken build() { + final Jwt token = getToken(); + + return new JwtAuthenticationToken(token, getAuthorities(token)); + } + + @Override + protected Collection getAuthorities(Jwt token) { + final Collection principalAuthorities = super.getAuthorities(token); + + return addedAuthorities.isEmpty() ? principalAuthorities + : Stream.concat(principalAuthorities.stream(), addedAuthorities.stream()).collect(Collectors.toSet()); + } + + /** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ + static class JwtTestingBuilder extends Jwt.Builder { + + private static final String DEFAULT_SUBJECT = "user"; + + private static final String DEFAULT_TOKEN_VALUE = "test.jwt.value"; + + private static final String DEFAULT_HEADER_NAME = "test-header"; + + private static final String DEFAULT_HEADER_VALUE = "test-header-value"; + + public JwtTestingBuilder() { + super(); + } + + @Override + public Jwt build() { + final Object subjectClaim = claims.get(JwtClaimNames.SUB); + if (!StringUtils.hasLength(tokenValue)) { + tokenValue(DEFAULT_TOKEN_VALUE); + } + if (!StringUtils.hasLength((String) subjectClaim)) { + claim(JwtClaimNames.SUB, DEFAULT_SUBJECT); + } + if (headers.size() == 0) { + header(DEFAULT_HEADER_NAME, DEFAULT_HEADER_VALUE); + } + return super.build(); + } + } +} 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 2f19a5742ab..1fb45042c3f 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,11 @@ package org.springframework.security.test.web.reactive.server; +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -26,6 +31,9 @@ import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.test.support.JwtAuthenticationTokenTestingBuilder; import org.springframework.security.web.server.csrf.CsrfWebFilter; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.test.web.reactive.server.MockServerConfigurer; @@ -35,12 +43,8 @@ import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; -import reactor.core.publisher.Mono; -import java.util.Collection; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Supplier; +import reactor.core.publisher.Mono; /** * Test utilities for working with Spring Security and @@ -109,6 +113,23 @@ public static UserExchangeMutator mockUser(String username) { return new UserExchangeMutator(username); } + /** + * Updates the ServerWebExchange to establish a {@link SecurityContext} that has a + * {@link JwtAuthenticationToken} for the + * {@link Authentication} and a {@link Jwt} for the + * {@link Authentication#getPrincipal()}. All details are + * declarative and do not require the JWT to be valid. + * + * @return the {@link JwtMutator} to further configure or use + */ + public static JwtMutator mockJwt() { + return new JwtMutator(); + } + + public static JwtMutator mockJwt(Consumer> jwt) { + return new JwtMutator().token(jwt); + } + public static CsrfMutator csrf() { return new CsrfMutator(); } @@ -294,4 +315,31 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain webFilterCha return webFilterChain.filter(exchange); } } + + /** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ + public static class JwtMutator extends JwtAuthenticationTokenTestingBuilder + implements + WebTestClientConfigurer, MockServerConfigurer { + + @Override + public void beforeServerCreated(WebHttpHandlerBuilder builder) { + mockAuthentication(build()).beforeServerCreated(builder); + } + + @Override + public void afterConfigureAdded(WebTestClient.MockServerSpec serverSpec) { + mockAuthentication(build()).afterConfigureAdded(serverSpec); + } + + @Override + public void afterConfigurerAdded( + WebTestClient.Builder builder, + @Nullable WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable ClientHttpConnector connector) { + mockAuthentication(build()).afterConfigurerAdded(builder, httpHandlerBuilder, connector); + } + } } 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 ee13abffa4b..2ff1c704eaa 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 @@ -26,6 +26,7 @@ import java.util.Base64; import java.util.Collection; import java.util.List; +import java.util.function.Consumer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -45,7 +46,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.test.context.TestSecurityContextHolder; +import org.springframework.security.test.support.JwtAuthenticationTokenTestingBuilder; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; import org.springframework.security.test.web.support.WebTestUtils; import org.springframework.security.web.context.HttpRequestResponseHolder; @@ -195,6 +199,37 @@ public static RequestPostProcessor user(UserDetails user) { return new UserDetailsRequestPostProcessor(user); } + /** + * Establish a {@link SecurityContext} that has a + * {@link JwtAuthenticationToken} for the + * {@link Authentication} and a {@link Jwt} for the + * {@link Authentication#getPrincipal()}. All details are + * declarative and do not require the JWT 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: + *

+ * + *
    + *
  • Invoking apply {@link SecurityMockMvcConfigurers#springSecurity()}
  • + *
  • Adding Spring Security's FilterChainProxy to MockMvc
  • + *
  • Manually adding {@link SecurityContextPersistenceFilter} to the MockMvc + * instance may make sense when using MockMvcBuilders standaloneSetup
  • + *
+ * + * @return the {@link JwtRequestPostProcessor} for additional customization + */ + public static JwtRequestPostProcessor jwt() { + return new JwtRequestPostProcessor(); + } + + public static JwtRequestPostProcessor jwt(Consumer> jwt) { + return jwt().token(jwt); + } + /** * Establish a {@link SecurityContext} that uses the specified {@link Authentication} * for the {@link Authentication#getPrincipal()} and a custom {@link UserDetails}. All @@ -555,7 +590,7 @@ private static String md5Hex(String a2) { * Support class for {@link RequestPostProcessor}'s that establish a Spring Security * context */ - private static abstract class SecurityContextRequestPostProcessorSupport { + static class SecurityContextRequestPostProcessorSupport { /** * Saves the specified {@link Authentication} into an empty @@ -564,7 +599,7 @@ private static abstract class SecurityContextRequestPostProcessorSupport { * @param authentication the {@link Authentication} to save * @param request the {@link HttpServletRequest} to use */ - final void save(Authentication authentication, HttpServletRequest request) { + static final void save(Authentication authentication, HttpServletRequest request) { SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); save(securityContext, request); @@ -576,7 +611,7 @@ final void save(Authentication authentication, HttpServletRequest request) { * @param securityContext the {@link SecurityContext} to save * @param request the {@link HttpServletRequest} to use */ - final void save(SecurityContext securityContext, HttpServletRequest request) { + static final void save(SecurityContext securityContext, HttpServletRequest request) { SecurityContextRepository securityContextRepository = WebTestUtils .getSecurityContextRepository(request); boolean isTestRepository = securityContextRepository instanceof TestSecurityContextRepository; @@ -604,7 +639,7 @@ final void save(SecurityContext securityContext, HttpServletRequest request) { * stateless mode */ static class TestSecurityContextRepository implements SecurityContextRepository { - private final static String ATTR_NAME = TestSecurityContextRepository.class + final static String ATTR_NAME = TestSecurityContextRepository.class .getName().concat(".REPO"); private final SecurityContextRepository delegate; @@ -716,8 +751,6 @@ private AuthenticationRequestPostProcessor(Authentication authentication) { @Override public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(this.authentication); save(this.authentication, request); return request; } @@ -907,4 +940,20 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) private SecurityMockMvcRequestPostProcessors() { } + + /** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ + public static class JwtRequestPostProcessor extends JwtAuthenticationTokenTestingBuilder + implements + RequestPostProcessor { + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + SecurityContextRequestPostProcessorSupport.save(build(), request); + return request; + } + + } } diff --git a/test/src/test/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilderTests.java b/test/src/test/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilderTests.java new file mode 100644 index 00000000000..e153f91491c --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/support/JwtAuthenticationTokenTestingBuilderTests.java @@ -0,0 +1,83 @@ +/* + * 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.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ +public class JwtAuthenticationTokenTestingBuilderTests { + + @Test + public void untouchedBuilderSetsDefaultValues() { + final JwtAuthenticationToken actual = new JwtAuthenticationTokenTestingBuilder<>().build(); + + assertThat(actual.getName()).isEqualTo("user"); + assertThat(actual.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_USER")); + assertThat(actual.getPrincipal()).isInstanceOf(Jwt.class); + assertThat(actual.getCredentials()).isInstanceOf(Jwt.class); + assertThat(actual.getDetails()).isNull(); + + // Token default values are tested in JwtTestingBuilderTests + assertThat(actual.getToken()).isEqualTo(new JwtAuthenticationTokenTestingBuilder.JwtTestingBuilder().build()); + } + + @Test + public void nameOverridesDefaultValue() { + assertThat(new JwtAuthenticationTokenTestingBuilder<>().name("ch4mpy").build().getName()).isEqualTo("ch4mpy"); + } + + @Test + public void authoritiesAddsToDefaultValue() { + assertThat(new JwtAuthenticationTokenTestingBuilder<>().authorities("TEST").build().getAuthorities()) + .containsExactlyInAnyOrder(new SimpleGrantedAuthority("SCOPE_USER"), new SimpleGrantedAuthority("TEST")); + } + + @Test + public void scopesOveridesDefaultValue() { + assertThat(new JwtAuthenticationTokenTestingBuilder<>().scopes("TEST").build().getAuthorities()) + .containsExactly(new SimpleGrantedAuthority("SCOPE_TEST")); + } + + @Test + public void nameSetsAuthenticationNameAndTokenSubjectClaim() { + final JwtAuthenticationToken actual = new JwtAuthenticationTokenTestingBuilder<>().name("ch4mpy").build(); + + assertThat(actual.getName()).isEqualTo("ch4mpy"); + assertThat(actual.getTokenAttributes().get(JwtClaimNames.SUB)).isEqualTo("ch4mpy"); + } + + @Test + public void buildMergesConvertedClaimsAndAuthorities() { + final JwtAuthenticationToken actual = new JwtAuthenticationTokenTestingBuilder<>().name("ch4mpy") + .authorities(new SimpleGrantedAuthority("TEST_AUTHORITY")) + .scopes("scope:claim") + .build(); + + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_scope:claim")); + } + +} diff --git a/test/src/test/java/org/springframework/security/test/support/JwtTestingBuilderTests.java b/test/src/test/java/org/springframework/security/test/support/JwtTestingBuilderTests.java new file mode 100644 index 00000000000..75923fac639 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/support/JwtTestingBuilderTests.java @@ -0,0 +1,56 @@ +/* + * 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.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; + +import org.junit.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.test.support.JwtAuthenticationTokenTestingBuilder.JwtTestingBuilder; + +/** + * + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +public class JwtTestingBuilderTests { + + @Test + public void testDefaultValuesAreSet() { + final Jwt actual = new JwtTestingBuilder().build(); + + assertThat(actual.getTokenValue()).isEqualTo("test.jwt.value"); + assertThat(actual.getClaimAsString(JwtClaimNames.SUB)).isEqualTo("user"); + assertThat(actual.getHeaders()).hasSize(1); + } + + @Test + public void iatClaimAndExpClaimSetIssuedAtAndExpiresAt() { + final Jwt actual = new JwtTestingBuilder() + .claim(JwtClaimNames.IAT, Instant.parse("2019-03-21T13:52:25Z")) + .claim(JwtClaimNames.EXP, Instant.parse("2019-03-22T13:52:25Z")) + .build(); + + assertThat(actual.getIssuedAt()).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getExpiresAt()).isEqualTo(Instant.parse("2019-03-22T13:52:25Z")); + assertThat(actual.getClaimAsInstant(JwtClaimNames.IAT)).isEqualTo(Instant.parse("2019-03-21T13:52:25Z")); + assertThat(actual.getClaimAsInstant(JwtClaimNames.EXP)).isEqualTo(Instant.parse("2019-03-22T13:52:25Z")); + } + +} diff --git a/test/src/test/java/org/springframework/security/test/web/reactive/server/JwtMutatorTests.java b/test/src/test/java/org/springframework/security/test/web/reactive/server/JwtMutatorTests.java new file mode 100644 index 00000000000..fb1ee4bd669 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/reactive/server/JwtMutatorTests.java @@ -0,0 +1,65 @@ +/* + * 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.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockJwt; + +import org.junit.Test; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ +public class JwtMutatorTests { +// @formatter:off + @Test + public void defaultJwtConfigurerConfiguresAuthenticationDefaultNameAndAuthorities() { + TestController.clientBuilder() + .apply(mockJwt()).build() + .get().uri("/greet").exchange() + .expectStatus().isOk() + .expectBody().toString().equals("Hello user!"); + + TestController.clientBuilder() + .apply(mockJwt()).build() + .get().uri("/authorities").exchange() + .expectStatus().isOk() + .expectBody().toString().equals("[\"ROLE_USER\"]"); + } + + @Test + public void nameAndScopesConfigureAuthenticationNameAndAuthorities() { + TestController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scopes("message:read")).build() + .get().uri("/greet").exchange() + .expectStatus().isOk() + .expectBody().toString().equals("Hello ch4mpy!"); + + TestController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scopes("message:read")).build() + .get().uri("/authorities").exchange() + .expectStatus().isOk() + .expectBody().toString().equals("[\"SCOPE_message:read\"]"); + + TestController.clientBuilder() + .apply(mockJwt().name("ch4mpy").scopes("message:read")).build() + .get().uri("/jwt").exchange() + .expectStatus().isOk() + .expectBody().toString().equals( + "Hello,ch4mpy! You are sucessfully authenticated and granted with [message:read] scopes using a JavaWebToken."); + } +// @formatter:on +} diff --git a/test/src/test/java/org/springframework/security/test/web/reactive/server/TestController.java b/test/src/test/java/org/springframework/security/test/web/reactive/server/TestController.java new file mode 100644 index 00000000000..449fd6d37ea --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/reactive/server/TestController.java @@ -0,0 +1,78 @@ +/* + * 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.reactive.server; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +import java.security.Principal; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter; +import org.springframework.security.web.server.csrf.CsrfWebFilter; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ +@RestController +public class TestController { + + @GetMapping("/greet") + public String greet(final Principal authentication) { + return String.format("Hello, %s!", authentication.getName()); + } + + @GetMapping("/authorities") + public String authentication(final Authentication authentication) { + return authentication.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()) + .toString(); + } + + @GetMapping("/jwt") + // TODO: investigate why "@AuthenticationPrincipal Jwt token" does not work here + public String jwt(final Authentication authentication) { + final Jwt token = (Jwt) authentication.getPrincipal(); + final String scopes = token.getClaimAsString("scope"); + + return String.format( + "Hello, %s! You are sucessfully authenticated and granted with %s scopes using a Jwt.", + token.getSubject(), + scopes); + } + + public static WebTestClient.Builder clientBuilder() { + return WebTestClient.bindToController(new TestController()) + .webFilter(new CsrfWebFilter(), new SecurityContextServerWebExchangeWebFilter()) + .apply(springSecurity()) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + } + + public static WebTestClient client() { + return (WebTestClient) clientBuilder().build(); + } +} diff --git a/test/src/test/java/org/springframework/security/test/web/servlet/request/JwtRequestPostProcessorTests.java b/test/src/test/java/org/springframework/security/test/web/servlet/request/JwtRequestPostProcessorTests.java new file mode 100644 index 00000000000..fcf40a91224 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/servlet/request/JwtRequestPostProcessorTests.java @@ -0,0 +1,67 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.SecurityContextRequestPostProcessorSupport.TestSecurityContextRepository; + +/** + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + * @since 5.2 + */ +public class JwtRequestPostProcessorTests { + @Mock + MockHttpServletRequest request; + + final static String TEST_NAME = "ch4mpy"; + final static String[] TEST_AUTHORITIES = { "TEST_AUTHORITY" }; + + @Before + public void setup() throws Exception { + request = new MockHttpServletRequest(); + } + + @Test + public void nameAndAuthoritiesAndClaimsConfigureSecurityContextAuthentication() { + final JwtRequestPostProcessor rpp = + jwt().name(TEST_NAME).authorities(TEST_AUTHORITIES).scopes("test:claim"); + + final JwtAuthenticationToken actual = (JwtAuthenticationToken) authentication(rpp.postProcessRequest(request)); + + assertThat(actual.getName()).isEqualTo(TEST_NAME); + assertThat(actual.getAuthorities()).containsExactlyInAnyOrder( + new SimpleGrantedAuthority("TEST_AUTHORITY"), + new SimpleGrantedAuthority("SCOPE_test:claim")); + assertThat(actual.getTokenAttributes().get("scope")).isEqualTo("test:claim"); + } + + static Authentication authentication(final MockHttpServletRequest req) { + final SecurityContext securityContext = (SecurityContext) req.getAttribute(TestSecurityContextRepository.ATTR_NAME); + return securityContext == null ? null : securityContext.getAuthentication(); + } + +}