diff --git a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle index b05fa58afe..7fac327e25 100644 --- a/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle +++ b/oauth2/oauth2-resource-server/spring-security-oauth2-resource-server.gradle @@ -7,8 +7,13 @@ dependencies { compile springCoreDependency optional project(':spring-security-oauth2-jose') - - testCompile 'com.squareup.okhttp3:mockwebserver' + optional 'io.projectreactor:reactor-core' + optional 'org.springframework:spring-webflux' provided 'javax.servlet:javax.servlet-api' + + testCompile 'com.squareup.okhttp3:mockwebserver' + testCompile 'com.fasterxml.jackson.core:jackson-databind' + testCompile 'io.projectreactor.netty:reactor-netty' + testCompile 'io.projectreactor:reactor-test' } 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 new file mode 100644 index 0000000000..84b40cbe17 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManager.java @@ -0,0 +1,72 @@ +/* + * 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 org.springframework.http.HttpStatus; +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.JwtException; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +/** + * A {@link ReactiveAuthenticationManager} for Jwt tokens. + * + * @author Rob Winch + * @since 5.1 + */ +public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager { + private final JwtConverter jwtConverter = new JwtConverter(); + + private final ReactiveJwtDecoder jwtDecoder; + + public JwtReactiveAuthenticationManager(ReactiveJwtDecoder jwtDecoder) { + Assert.notNull(jwtDecoder, "jwtDecoder cannot be null"); + this.jwtDecoder = jwtDecoder; + } + + @Override + public Mono authenticate(Authentication authentication) { + return Mono.justOrEmpty(authentication) + .filter(a -> a instanceof BearerTokenAuthenticationToken) + .cast(BearerTokenAuthenticationToken.class) + .map(BearerTokenAuthenticationToken::getToken) + .flatMap(this.jwtDecoder::decode) + .map(this.jwtConverter::convert) + .cast(Authentication.class) + .onErrorMap(JwtException.class, this::onError); + } + + private OAuth2AuthenticationException onError(JwtException e) { + OAuth2Error invalidRequest = invalidToken(e.getMessage()); + return new OAuth2AuthenticationException(invalidRequest, e.getMessage()); + } + + private static OAuth2Error invalidToken(String message) { + return new BearerTokenError( + BearerTokenErrorCodes.INVALID_TOKEN, + HttpStatus.UNAUTHORIZED, + message, + "https://tools.ietf.org/html/rfc6750#section-3.1"); + } +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManagerTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManagerTests.java new file mode 100644 index 0000000000..ac4ca5b298 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtReactiveAuthenticationManagerTests.java @@ -0,0 +1,118 @@ +/* + * 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 org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +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; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * @author Rob Winch + * @since 5.1 + */ +@RunWith(MockitoJUnitRunner.class) +public class JwtReactiveAuthenticationManagerTests { + @Mock + private ReactiveJwtDecoder jwtDecoder; + + private JwtReactiveAuthenticationManager manager; + + private Jwt jwt; + + @Before + public void setup() { + this.manager = new JwtReactiveAuthenticationManager(this.jwtDecoder); + + Map claims = new HashMap<>(); + claims.put("scope", "message:read message:write"); + Instant issuedAt = Instant.now(); + Instant expiresAt = Instant.from(issuedAt).plusSeconds(3600); + this.jwt = new Jwt("jwt", issuedAt, expiresAt, claims, claims); + } + + @Test + public void constructorWhenJwtDecoderNullThenIllegalArgumentException() { + this.jwtDecoder = null; + assertThatCode(() -> new JwtReactiveAuthenticationManager(this.jwtDecoder)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void authenticateWhenWrongTypeThenEmpty() { + TestingAuthenticationToken token = new TestingAuthenticationToken("foo", "bar"); + + assertThat(this.manager.authenticate(token).block()).isNull(); + } + + @Test + public void authenticateWhenEmptyJwtThenEmpty() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token-1"); + when(this.jwtDecoder.decode(token.getToken())).thenReturn(Mono.empty()); + + assertThat(this.manager.authenticate(token).block()).isNull(); + } + + @Test + public void authenticateWhenJwtExceptionThenOAuth2AuthenticationException() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token-1"); + when(this.jwtDecoder.decode(any())).thenReturn(Mono.error(new JwtException("Oops"))); + + assertThatCode(() -> this.manager.authenticate(token).block()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void authenticateWhenNotJwtExceptionThenPropagates() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token-1"); + when(this.jwtDecoder.decode(any())).thenReturn(Mono.error(new RuntimeException("Oops"))); + + assertThatCode(() -> this.manager.authenticate(token).block()) + .isInstanceOf(RuntimeException.class); + } + + @Test + public void authenticateWhenJwtThenSuccess() { + BearerTokenAuthenticationToken token = new BearerTokenAuthenticationToken("token-1"); + when(this.jwtDecoder.decode(token.getToken())).thenReturn(Mono.just(this.jwt)); + + Authentication authentication = this.manager.authenticate(token).block(); + + assertThat(authentication).isNotNull(); + assertThat(authentication.isAuthenticated()).isTrue(); + assertThat(authentication.getAuthorities()).extracting(GrantedAuthority::getAuthority).containsOnly("SCOPE_message:read", "SCOPE_message:write"); + } +}