From f6ed1db7024d3d3adf3e859563e55427cb54da97 Mon Sep 17 00:00:00 2001 From: Rafiullah Hamedy Date: Sat, 11 May 2019 18:42:06 -0400 Subject: [PATCH] Introduced ReactiveAuthenticationManagerResolver Suitable for multi-tenant reactive applications needing to branch authentication strategies based on request details. --- .../config/web/server/ServerHttpSecurity.java | 31 ++++++-- .../server/OAuth2ResourceServerSpecTests.java | 52 ++++++++++++++ ...ReactiveAuthenticationManagerResolver.java | 32 +++++++++ .../AuthenticationWebFilter.java | 29 ++++++-- .../AuthenticationWebFilterTests.java | 72 ++++++++++++++++++- 5 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerResolver.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 e9ad440c67b..ee7c5e7b341 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 @@ -43,9 +43,11 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager; import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager; import org.springframework.security.authorization.AuthorizationDecision; @@ -230,6 +232,7 @@ * * @author Rob Winch * @author Vedran Pavic + * @author Rafiullah Hamedy * @since 5.0 */ public class ServerHttpSecurity { @@ -1124,6 +1127,7 @@ public class OAuth2ResourceServerSpec { private JwtSpec jwt; private OpaqueTokenSpec opaqueToken; + private ReactiveAuthenticationManagerResolver authenticationManagerResolver; /** * Configures the {@link ServerAccessDeniedHandler} to use for requests authenticating with @@ -1168,6 +1172,20 @@ public OAuth2ResourceServerSpec bearerTokenConverter(ServerAuthenticationConvert return this; } + /** + * Configures the {@link ReactiveAuthenticationManagerResolver} + * + * @param authenticationManagerResolver the {@link ReactiveAuthenticationManagerResolver} + * @return the {@link OAuth2ResourceServerSpec} for additional configuration + * @since 5.2 + */ + public OAuth2ResourceServerSpec authenticationManagerResolver( + ReactiveAuthenticationManagerResolver authenticationManagerResolver) { + Assert.notNull(authenticationManagerResolver, "authenticationManagerResolver cannot be null"); + this.authenticationManagerResolver = authenticationManagerResolver; + return this; + } + public JwtSpec jwt() { if (this.jwt == null) { this.jwt = new JwtSpec(); @@ -1195,18 +1213,21 @@ protected void configure(ServerHttpSecurity http) { "same time"); } - if (this.jwt == null && this.opaqueToken == null) { + if (this.jwt == null && this.opaqueToken == null && this.authenticationManagerResolver == null) { throw new IllegalStateException("Jwt and Opaque Token are the only supported formats for bearer tokens " + "in Spring Security and neither was found. Make sure to configure JWT " + "via http.oauth2ResourceServer().jwt() or Opaque Tokens via " + "http.oauth2ResourceServer().opaqueToken()."); } - if (this.jwt != null) { + if (this.authenticationManagerResolver != null) { + AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(this.authenticationManagerResolver); + oauth2.setServerAuthenticationConverter(bearerTokenConverter); + oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint)); + http.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION); + } else if (this.jwt != null) { this.jwt.configure(http); - } - - if (this.opaqueToken != null) { + } else if (this.opaqueToken != null) { this.opaqueToken.configure(http); } } 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 01de7e8dead..a9427cb36a6 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 @@ -50,8 +50,10 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.test.SpringTestRule; import org.springframework.security.core.Authentication; @@ -228,6 +230,28 @@ public void getWhenUsingCustomAuthenticationManagerThenUsesItAccordingly() { .expectHeader().value(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer error=\"mock-failure\"")); } + @Test + public void getWhenUsingCustomAuthenticationManagerResolverThenUsesItAccordingly() { + this.spring.register(CustomAuthenticationManagerResolverConfig.class).autowire(); + + ReactiveAuthenticationManagerResolver authenticationManagerResolver = + this.spring.getContext().getBean(ReactiveAuthenticationManagerResolver.class); + + ReactiveAuthenticationManager authenticationManager = + this.spring.getContext().getBean(ReactiveAuthenticationManager.class); + + when(authenticationManagerResolver.resolve(any(ServerHttpRequest.class))) + .thenReturn(Mono.just(authenticationManager)); + when(authenticationManager.authenticate(any(Authentication.class))) + .thenReturn(Mono.error(new OAuth2AuthenticationException(new OAuth2Error("mock-failure")))); + + this.client.get() + .headers(headers -> headers.setBearerAuth(this.messageReadToken)) + .exchange() + .expectStatus().isUnauthorized() + .expectHeader().value(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer error=\"mock-failure\"")); + } + @Test public void postWhenSignedThenReturnsOk() { this.spring.register(PublicKeyConfig.class, RootController.class).autowire(); @@ -507,6 +531,34 @@ ReactiveAuthenticationManager authenticationManager() { } } + @EnableWebFlux + @EnableWebFluxSecurity + static class CustomAuthenticationManagerResolverConfig { + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange() + .pathMatchers("/**/message/**").hasAnyAuthority("SCOPE_message:read") + .and() + .oauth2ResourceServer() + .authenticationManagerResolver(authenticationManagerResolver()); + // @formatter:on + + return http.build(); + } + + @Bean + ReactiveAuthenticationManagerResolver authenticationManagerResolver() { + return mock(ReactiveAuthenticationManagerResolver.class); + } + + @Bean + ReactiveAuthenticationManager authenticationManager() { + return mock(ReactiveAuthenticationManager.class); + } + } + @EnableWebFlux @EnableWebFluxSecurity static class CustomBearerTokenServerAuthenticationConverter { diff --git a/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerResolver.java b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerResolver.java new file mode 100644 index 00000000000..25c4fc82415 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerResolver.java @@ -0,0 +1,32 @@ +/* + * 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.authentication; + +import org.springframework.security.authentication.ReactiveAuthenticationManager; + +import reactor.core.publisher.Mono; + +/** + * An interface for resolving a {@link ReactiveAuthenticationManager} based on the provided context + * + * @author Rafiullah Hamedy + * @since 5.2 + */ +@FunctionalInterface +public interface ReactiveAuthenticationManagerResolver { + Mono resolve(C context); +} \ No newline at end of file diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java index 97fbc631cb4..330157ec763 100644 --- a/web/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * 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. @@ -17,7 +17,9 @@ import java.util.function.Function; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.ReactiveSecurityContextHolder; @@ -51,6 +53,11 @@ * The {@link ReactiveAuthenticationManager} specified in * {@link #AuthenticationWebFilter(ReactiveAuthenticationManager)} is used to perform authentication. * + *
  • + * The {@link ReactiveAuthenticationManagerResolver} specified in + * {@link #AuthenticationWebFilter(ReactiveAuthenticationManagerResolver)} is used to resolve the appropriate + * authentication manager from context to perform authentication. + *
  • *
  • * If authentication is successful, {@link ServerAuthenticationSuccessHandler} is invoked and the authentication * is set on {@link ReactiveSecurityContextHolder}, else {@link ServerAuthenticationFailureHandler} is invoked @@ -58,11 +65,11 @@ * * * @author Rob Winch + * @author Rafiullah Hamedy * @since 5.0 */ public class AuthenticationWebFilter implements WebFilter { - - private final ReactiveAuthenticationManager authenticationManager; + private final ReactiveAuthenticationManagerResolver authenticationManagerResolver; private ServerAuthenticationSuccessHandler authenticationSuccessHandler = new WebFilterChainServerAuthenticationSuccessHandler(); @@ -80,7 +87,17 @@ public class AuthenticationWebFilter implements WebFilter { */ public AuthenticationWebFilter(ReactiveAuthenticationManager authenticationManager) { Assert.notNull(authenticationManager, "authenticationManager cannot be null"); - this.authenticationManager = authenticationManager; + this.authenticationManagerResolver = request -> Mono.just(authenticationManager); + } + + /** + * Creates an instance + * @param authenticationManagerResolver the authentication manager resolver to use + * @since 5.2 + */ + public AuthenticationWebFilter(ReactiveAuthenticationManagerResolver authenticationManagerResolver) { + Assert.notNull(authenticationManagerResolver, "authenticationResolverManager cannot be null"); + this.authenticationManagerResolver = authenticationManagerResolver; } @Override @@ -95,7 +112,9 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { private Mono authenticate(ServerWebExchange exchange, WebFilterChain chain, Authentication token) { WebFilterExchange webFilterExchange = new WebFilterExchange(exchange, chain); - return this.authenticationManager.authenticate(token) + + return this.authenticationManagerResolver.resolve(exchange.getRequest()) + .flatMap(authenticationManager -> authenticationManager.authenticate(token)) .switchIfEmpty(Mono.defer(() -> Mono.error(new IllegalStateException("No provider found for " + token.getClass())))) .flatMap(authentication -> onAuthenticationSuccess(authentication, webFilterExchange)) .onErrorResume(AuthenticationException.class, e -> this.authenticationFailureHandler diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java index 20bdac445c9..5fb520ba99d 100644 --- a/web/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * 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. @@ -23,8 +23,10 @@ import org.mockito.junit.MockitoJUnitRunner; import reactor.core.publisher.Mono; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; @@ -40,6 +42,7 @@ /** * @author Rob Winch + * @author Rafiullah Hamedy * @since 5.0 */ @RunWith(MockitoJUnitRunner.class) @@ -54,6 +57,8 @@ public class AuthenticationWebFilterTests { private ServerAuthenticationFailureHandler failureHandler; @Mock private ServerSecurityContextRepository securityContextRepository; + @Mock + private ReactiveAuthenticationManagerResolver authenticationManagerResolver; private AuthenticationWebFilter filter; @@ -85,6 +90,25 @@ public void filterWhenDefaultsAndNoAuthenticationThenContinues() { assertThat(result.getResponseCookies()).isEmpty(); } + @Test + public void filterWhenAuthenticationManagerResolverDefaultsAndNoAuthenticationThenContinues() { + this.filter = new AuthenticationWebFilter(this.authenticationManagerResolver); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(this.filter) + .build(); + + EntityExchangeResult result = client.get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).consumeWith(b -> assertThat(b.getResponseBody()).isEqualTo("ok")) + .returnResult(); + + verifyZeroInteractions(this.authenticationManagerResolver); + assertThat(result.getResponseCookies()).isEmpty(); + } + @Test public void filterWhenDefaultsAndAuthenticationSuccessThenContinues() { when(this.authenticationManager.authenticate(any())).thenReturn(Mono.just(new TestingAuthenticationToken("test", "this", "ROLE"))); @@ -106,6 +130,29 @@ public void filterWhenDefaultsAndAuthenticationSuccessThenContinues() { assertThat(result.getResponseCookies()).isEmpty(); } + @Test + public void filterWhenAuthenticationManagerResolverDefaultsAndAuthenticationSuccessThenContinues() { + when(this.authenticationManager.authenticate(any())).thenReturn(Mono.just(new TestingAuthenticationToken("test", "this", "ROLE"))); + when(this.authenticationManagerResolver.resolve(any())).thenReturn(Mono.just(this.authenticationManager)); + + this.filter = new AuthenticationWebFilter(this.authenticationManagerResolver); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(this.filter) + .build(); + + EntityExchangeResult result = client + .get() + .uri("/") + .headers(headers -> headers.setBasicAuth("test", "this")) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).consumeWith(b -> assertThat(b.getResponseBody()).isEqualTo("ok")) + .returnResult(); + + assertThat(result.getResponseCookies()).isEmpty(); + } + @Test public void filterWhenDefaultsAndAuthenticationFailThenUnauthorized() { when(this.authenticationManager.authenticate(any())).thenReturn(Mono.error(new BadCredentialsException("failed"))); @@ -127,6 +174,29 @@ public void filterWhenDefaultsAndAuthenticationFailThenUnauthorized() { assertThat(result.getResponseCookies()).isEmpty(); } + @Test + public void filterWhenAuthenticationManagerResolverDefaultsAndAuthenticationFailThenUnauthorized() { + when(this.authenticationManager.authenticate(any())).thenReturn(Mono.error(new BadCredentialsException("failed"))); + when(this.authenticationManagerResolver.resolve(any())).thenReturn(Mono.just(this.authenticationManager)); + + this.filter = new AuthenticationWebFilter(this.authenticationManagerResolver); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(this.filter) + .build(); + + EntityExchangeResult result = client + .get() + .uri("/") + .headers(headers -> headers.setBasicAuth("test", "this")) + .exchange() + .expectStatus().isUnauthorized() + .expectHeader().valueMatches("WWW-Authenticate", "Basic realm=\"Realm\"") + .expectBody().isEmpty(); + + assertThat(result.getResponseCookies()).isEmpty(); + } + @Test public void filterWhenConvertEmptyThenOk() { when(this.authenticationConverter.convert(any())).thenReturn(Mono.empty());