From 22bd8f1c1f6742e9551429ff3fe041af837a1cc7 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Fri, 5 Oct 2018 15:41:12 -0600 Subject: [PATCH] Reactive Jwt Authentication Converter Support Fixes: gh-5092 --- .../config/web/server/ServerHttpSecurity.java | 34 ++++- .../server/OAuth2ResourceServerSpecTests.java | 54 ++++++++ .../JwtReactiveAuthenticationManager.java | 20 ++- ...tiveJwtAuthenticationConverterAdapter.java | 43 ++++++ ...wtAuthenticationConverterAdapterTests.java | 129 ++++++++++++++++++ 5 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapter.java create mode 100644 oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapterTests.java diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 3fa3213cf8..c37b664da1 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -38,8 +38,10 @@ import org.springframework.context.ApplicationContext; import org.springframework.core.Ordered; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager; @@ -70,9 +72,12 @@ import org.springframework.security.oauth2.client.web.server.ServerOAuth2Authori import org.springframework.security.oauth2.client.web.server.authentication.OAuth2LoginAuthenticationWebFilter; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter; @@ -900,6 +905,8 @@ public class ServerHttpSecurity { public class JwtSpec { private ReactiveAuthenticationManager authenticationManager; private ReactiveJwtDecoder jwtDecoder; + private Converter> jwtAuthenticationConverter + = new ReactiveJwtAuthenticationConverterAdapter(new JwtAuthenticationConverter()); private BearerTokenServerWebExchangeMatcher bearerTokenServerWebExchangeMatcher = new BearerTokenServerWebExchangeMatcher(); @@ -915,6 +922,21 @@ public class ServerHttpSecurity { return this; } + /** + * Configures the {@link Converter} to use for converting a {@link Jwt} into + * an {@link AbstractAuthenticationToken}. + * + * @param jwtAuthenticationConverter the converter to use + * @return the {@code JwtSpec} for additional configuration + * @since 5.1.1 + */ + public JwtSpec jwtAuthenticationConverter + (Converter> jwtAuthenticationConverter) { + Assert.notNull(jwtAuthenticationConverter, "jwtAuthenticationConverter cannot be null"); + this.jwtAuthenticationConverter = jwtAuthenticationConverter; + return this; + } + /** * Configures the {@link ReactiveJwtDecoder} to use * @param jwtDecoder the decoder to use @@ -976,14 +998,24 @@ public class ServerHttpSecurity { return this.jwtDecoder; } + protected Converter> + getJwtAuthenticationConverter() { + + return this.jwtAuthenticationConverter; + } + private ReactiveAuthenticationManager getAuthenticationManager() { if (this.authenticationManager != null) { return this.authenticationManager; } ReactiveJwtDecoder jwtDecoder = getJwtDecoder(); - ReactiveAuthenticationManager authenticationManager = + Converter> jwtAuthenticationConverter = + getJwtAuthenticationConverter(); + JwtReactiveAuthenticationManager authenticationManager = new JwtReactiveAuthenticationManager(jwtDecoder); + authenticationManager.setJwtAuthenticationConverter(jwtAuthenticationConverter); + return authenticationManager; } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java index 534c4bd866..4aaa74d885 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java @@ -23,7 +23,10 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.RSAPublicKeySpec; import java.time.Instant; +import java.util.Collection; import java.util.Collections; +import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.PreDestroy; import okhttp3.mockwebserver.MockResponse; @@ -39,15 +42,21 @@ import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.test.SpringTestRule; import org.springframework.security.core.Authentication; +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.jose.jws.JwsAlgorithms; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.reactive.server.WebTestClient; @@ -213,6 +222,16 @@ public class OAuth2ResourceServerSpecTests { .expectStatus().isForbidden(); } + @Test + public void getWhenSignedAndCustomConverterThenConverts() { + this.spring.register(CustomJwtAuthenticationConverterConfig.class, RootController.class).autowire(); + + this.client.get() + .headers(headers -> headers.setBearerAuth(this.messageReadToken)) + .exchange() + .expectStatus().isOk(); + } + @Test public void getJwtDecoderWhenBeanWiredAndDslWiredThenDslTakesPrecedence() { GenericWebApplicationContext context = autowireWebServerGenericWebApplicationContext(); @@ -386,6 +405,41 @@ public class OAuth2ResourceServerSpecTests { } } + @EnableWebFlux + @EnableWebFluxSecurity + static class CustomJwtAuthenticationConverterConfig { + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange() + .anyExchange().hasAuthority("message:read") + .and() + .oauth2ResourceServer() + .jwt() + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + .publicKey(publicKey()); + // @formatter:on + + return http.build(); + } + + @Bean + Converter> jwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter() { + @Override + protected Collection extractAuthorities(Jwt jwt) { + String[] claims = ((String) jwt.getClaims().get("scope")).split(" "); + return Stream.of(claims).map(SimpleGrantedAuthority::new).collect(Collectors.toList()); + } + }; + + return new ReactiveJwtAuthenticationConverterAdapter(converter); + } + } + + + @RestController static class RootController { @GetMapping diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManager.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManager.java index 64dc9d1038..cd4fe8f156 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManager.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManager.java @@ -18,11 +18,14 @@ package org.springframework.security.oauth2.server.resource.authentication; import reactor.core.publisher.Mono; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; 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.JwtException; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; @@ -37,7 +40,8 @@ import org.springframework.util.Assert; * @since 5.1 */ public final class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager { - private final JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + private Converter> jwtAuthenticationConverter + = new ReactiveJwtAuthenticationConverterAdapter(new JwtAuthenticationConverter()); private final ReactiveJwtDecoder jwtDecoder; @@ -53,11 +57,23 @@ public final class JwtReactiveAuthenticationManager implements ReactiveAuthentic .cast(BearerTokenAuthenticationToken.class) .map(BearerTokenAuthenticationToken::getToken) .flatMap(this.jwtDecoder::decode) - .map(this.jwtAuthenticationConverter::convert) + .flatMap(this.jwtAuthenticationConverter::convert) .cast(Authentication.class) .onErrorMap(JwtException.class, this::onError); } + /** + * Use the given {@link Converter} for converting a {@link Jwt} into an {@link AbstractAuthenticationToken}. + * + * @param jwtAuthenticationConverter the {@link Converter} to use + */ + public void setJwtAuthenticationConverter( + Converter> jwtAuthenticationConverter) { + + Assert.notNull(jwtAuthenticationConverter, "jwtAuthenticationConverter cannot be null"); + this.jwtAuthenticationConverter = jwtAuthenticationConverter; + } + private OAuth2AuthenticationException onError(JwtException e) { OAuth2Error invalidRequest = invalidToken(e.getMessage()); return new OAuth2AuthenticationException(invalidRequest, e.getMessage()); diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapter.java new file mode 100644 index 0000000000..89d9dd44db --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapter.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.authentication; + +import reactor.core.publisher.Mono; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.Assert; + +/** + * A reactive {@link Converter} for adapting a non-blocking imperative {@link Converter} + * + * @author Josh Cummings + * @since 5.1.1 + */ +public class ReactiveJwtAuthenticationConverterAdapter implements Converter> { + private final JwtAuthenticationConverter delegate; + + public ReactiveJwtAuthenticationConverterAdapter(JwtAuthenticationConverter delegate) { + Assert.notNull(delegate, "delegate cannot be null"); + this.delegate = delegate; + } + + public final Mono convert(Jwt jwt) { + return Mono.just(jwt).map(this.delegate::convert); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapterTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapterTests.java new file mode 100644 index 0000000000..0bb1379fec --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/ReactiveJwtAuthenticationConverterAdapterTests.java @@ -0,0 +1,129 @@ +/* + * 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.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +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; + +/** + * Tests for {@link ReactiveJwtAuthenticationConverterAdapter} + * + * @author Josh Cummings + */ +public class ReactiveJwtAuthenticationConverterAdapterTests { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + ReactiveJwtAuthenticationConverterAdapter jwtAuthenticationConverter = + new ReactiveJwtAuthenticationConverterAdapter(converter); + + @Test + public void convertWhenTokenHasScopeAttributeThenTranslatedToAuthorities() { + Jwt jwt = this.jwt(Collections.singletonMap("scope", "message:read message:write")); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_message:read"), + new SimpleGrantedAuthority("SCOPE_message:write")); + } + + @Test + public void convertWhenTokenHasEmptyScopeAttributeThenTranslatedToNoAuthorities() { + Jwt jwt = this.jwt(Collections.singletonMap("scope", "")); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + @Test + public void convertWhenTokenHasScpAttributeThenTranslatedToAuthorities() { + Jwt jwt = this.jwt(Collections.singletonMap("scp", Arrays.asList("message:read", "message:write"))); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_message:read"), + new SimpleGrantedAuthority("SCOPE_message:write")); + } + + @Test + public void convertWhenTokenHasEmptyScpAttributeThenTranslatedToNoAuthorities() { + Jwt jwt = this.jwt(Collections.singletonMap("scp", Arrays.asList())); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + @Test + public void convertWhenTokenHasBothScopeAndScpThenScopeAttributeIsTranslatedToAuthorities() { + Map claims = new HashMap<>(); + claims.put("scp", Arrays.asList("message:read", "message:write")); + claims.put("scope", "missive:read missive:write"); + Jwt jwt = this.jwt(claims); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly( + new SimpleGrantedAuthority("SCOPE_missive:read"), + new SimpleGrantedAuthority("SCOPE_missive:write")); + } + + @Test + public void convertWhenTokenHasEmptyScopeAndNonEmptyScpThenScopeAttributeIsTranslatedToNoAuthorities() { + Map claims = new HashMap<>(); + claims.put("scp", Arrays.asList("message:read", "message:write")); + claims.put("scope", ""); + Jwt jwt = this.jwt(claims); + + AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt).block(); + + Collection authorities = authentication.getAuthorities(); + + assertThat(authorities).containsExactly(); + } + + 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); + } +}