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 f2f5d2bf04..bc6b5c7feb 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 @@ -56,6 +56,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeReactiveAuthenticationManager; @@ -91,6 +92,8 @@ import org.springframework.security.oauth2.server.resource.web.access.server.Bea import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter; import org.springframework.security.web.PortMapper; +import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; import org.springframework.security.web.server.MatcherSecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -99,6 +102,7 @@ import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AnonymousAuthenticationWebFilter; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.authentication.HttpBasicServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; @@ -108,6 +112,7 @@ import org.springframework.security.web.server.authentication.ServerAuthenticati import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter; import org.springframework.security.web.server.authentication.ServerHttpBasicAuthenticationConverter; +import org.springframework.security.web.server.authentication.ServerX509AuthenticationConverter; import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.LogoutWebFilter; import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; @@ -241,6 +246,8 @@ public class ServerHttpSecurity { private HttpBasicSpec httpBasic; + private X509Spec x509; + private final RequestCacheSpec requestCache = new RequestCacheSpec(); private FormLoginSpec formLogin; @@ -578,6 +585,93 @@ public class ServerHttpSecurity { return this.formLogin; } + /** + * Configures x509 authentication using a certificate provided by a client. + * + *
+	 *  @Bean
+	 *  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+	 *      http
+	 *          .x509()
+	 *          	.authenticationManager(authenticationManager)
+	 *              .principalExtractor(principalExtractor);
+	 *      return http.build();
+	 *  }
+	 * 
+ * + * Note that if extractor is not specified, {@link SubjectDnX509PrincipalExtractor} will be used. + * If authenticationManager is not specified, {@link ReactivePreAuthenticatedAuthenticationManager} will be used. + * + * @return the {@link X509Spec} to customize + * @author Alexey Nesterov + * @since 5.2 + */ + public X509Spec x509() { + if (this.x509 == null) { + this.x509 = new X509Spec(); + } + + return this.x509; + } + + /** + * Configures X509 authentication + * + * @author Alexey Nesterov + * @since 5.2 + * @see #x509() + */ + public class X509Spec { + + private X509PrincipalExtractor principalExtractor; + private ReactiveAuthenticationManager authenticationManager; + + public X509Spec principalExtractor(X509PrincipalExtractor principalExtractor) { + this.principalExtractor = principalExtractor; + return this; + } + + public X509Spec authenticationManager(ReactiveAuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + return this; + } + + public ServerHttpSecurity and() { + return ServerHttpSecurity.this; + } + + protected void configure(ServerHttpSecurity http) { + ReactiveAuthenticationManager authenticationManager = getAuthenticationManager(); + X509PrincipalExtractor principalExtractor = getPrincipalExtractor(); + + AuthenticationWebFilter filter = new AuthenticationWebFilter(authenticationManager); + filter.setServerAuthenticationConverter(new ServerX509AuthenticationConverter(principalExtractor)); + http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); + } + + private X509PrincipalExtractor getPrincipalExtractor() { + if (this.principalExtractor != null) { + return this.principalExtractor; + } + + return new SubjectDnX509PrincipalExtractor(); + } + + private ReactiveAuthenticationManager getAuthenticationManager() { + if (this.authenticationManager != null) { + return this.authenticationManager; + } + + ReactiveUserDetailsService userDetailsService = getBean(ReactiveUserDetailsService.class); + ReactivePreAuthenticatedAuthenticationManager authenticationManager = new ReactivePreAuthenticatedAuthenticationManager(userDetailsService); + + return authenticationManager; + } + + private X509Spec() { + } + } + public OAuth2LoginSpec oauth2Login() { if (this.oauth2Login == null) { this.oauth2Login = new OAuth2LoginSpec(); @@ -1508,6 +1602,9 @@ public class ServerHttpSecurity { if (this.httpsRedirectSpec != null) { this.httpsRedirectSpec.configure(this); } + if (this.x509 != null) { + this.x509.configure(this); + } if (this.csrf != null) { this.csrf.configure(this); } diff --git a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java index fc22c8c471..9e45b0c92f 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java @@ -19,6 +19,7 @@ package org.springframework.security.config.web.server; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Arrays; @@ -34,6 +35,8 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; +import org.springframework.security.web.server.authentication.ServerX509AuthenticationConverter; import reactor.core.publisher.Mono; import reactor.test.publisher.TestPublisher; @@ -279,6 +282,43 @@ public class ServerHttpSecurityTests { assertThat(result.getResponseCookies().getFirst("SESSION")).isNull(); } + @Test + @SuppressWarnings("unchecked") + public void addsX509FilterWhenX509AuthenticationIsConfigured() { + X509PrincipalExtractor mockExtractor = mock(X509PrincipalExtractor.class); + ReactiveAuthenticationManager mockAuthenticationManager = mock(ReactiveAuthenticationManager.class); + + this.http.x509() + .principalExtractor(mockExtractor) + .authenticationManager(mockAuthenticationManager) + .and(); + + SecurityWebFilterChain securityWebFilterChain = this.http.build(); + WebFilter x509WebFilter = securityWebFilterChain.getWebFilters().filter(this::isX509Filter).blockFirst(); + + assertThat(x509WebFilter).isNotNull(); + } + + @Test + public void addsX509FilterWhenX509AuthenticationIsConfiguredWithDefaults() { + this.http.x509(); + + SecurityWebFilterChain securityWebFilterChain = this.http.build(); + WebFilter x509WebFilter = securityWebFilterChain.getWebFilters().filter(this::isX509Filter).blockFirst(); + + assertThat(x509WebFilter).isNotNull(); + } + + private boolean isX509Filter(WebFilter filter) { + try { + Object converter = ReflectionTestUtils.getField(filter, "authenticationConverter"); + return converter.getClass().isAssignableFrom(ServerX509AuthenticationConverter.class); + } catch (IllegalArgumentException e) { + // field doesn't exist + return false; + } + } + private Optional getWebFilter(SecurityWebFilterChain filterChain, Class filterClass) { return (Optional) filterChain.getWebFilters() .filter(Objects::nonNull) diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManager.java b/web/src/main/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManager.java new file mode 100644 index 0000000000..5081f3f1d5 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManager.java @@ -0,0 +1,76 @@ +/* + * 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.web.server.authentication; + +import org.springframework.security.authentication.AccountStatusUserDetailsChecker; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UserDetailsChecker; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import reactor.core.publisher.Mono; + +/** + * Reactive version of {@link org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider} + * + * This manager receives a {@link PreAuthenticatedAuthenticationToken}, checks that associated account is not disabled, + * expired, or blocked, and returns new authenticated {@link PreAuthenticatedAuthenticationToken}. + * + * If no {@link UserDetailsChecker} is provided, a default {@link AccountStatusUserDetailsChecker} will be + * created. + * + * @author Alexey Nesterov + * @since 5.2 + */ +public class ReactivePreAuthenticatedAuthenticationManager + implements ReactiveAuthenticationManager { + + private final ReactiveUserDetailsService userDetailsService; + private final UserDetailsChecker userDetailsChecker; + + public ReactivePreAuthenticatedAuthenticationManager(ReactiveUserDetailsService userDetailsService) { + this(userDetailsService, new AccountStatusUserDetailsChecker()); + } + + public ReactivePreAuthenticatedAuthenticationManager( + ReactiveUserDetailsService userDetailsService, + UserDetailsChecker userDetailsChecker) { + this.userDetailsService = userDetailsService; + this.userDetailsChecker = userDetailsChecker; + } + + @Override + public Mono authenticate(Authentication authentication) { + return Mono.just(authentication) + .filter(this::supports) + .map(Authentication::getName) + .flatMap(userDetailsService::findByUsername) + .switchIfEmpty(Mono.error(() -> new UsernameNotFoundException("User not found"))) + .doOnNext(userDetailsChecker::check) + .map(ud -> { + PreAuthenticatedAuthenticationToken result = new PreAuthenticatedAuthenticationToken( + ud, authentication.getCredentials(), ud.getAuthorities()); + result.setDetails(authentication.getDetails()); + + return result; + }); + } + + private boolean supports(Authentication authentication) { + return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication.getClass()); + } +} diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverter.java b/web/src/main/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverter.java new file mode 100644 index 0000000000..9757150c1d --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverter.java @@ -0,0 +1,73 @@ +/* + * 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 + * + * 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.web.server.authentication; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.security.cert.X509Certificate; + +/** + * Converts from a {@link SslInfo} provided by a request to an {@link PreAuthenticatedAuthenticationToken} that can be authenticated. + * + * @author Alexey Nesterov + * @since 5.2 + */ +public class ServerX509AuthenticationConverter implements ServerAuthenticationConverter { + + protected final Log logger = LogFactory.getLog(getClass()); + private final X509PrincipalExtractor principalExtractor; + + public ServerX509AuthenticationConverter(@NonNull X509PrincipalExtractor principalExtractor) { + this.principalExtractor = principalExtractor; + } + + @Override + public Mono convert(ServerWebExchange exchange) { + SslInfo sslInfo = exchange.getRequest().getSslInfo(); + if (sslInfo == null) { + if (logger.isDebugEnabled()) { + logger.debug("No SslInfo provided with a request, skipping x509 authentication"); + } + + return Mono.empty(); + } + + if (sslInfo.getPeerCertificates() == null || sslInfo.getPeerCertificates().length == 0) { + if (logger.isDebugEnabled()) { + logger.debug("No peer certificates found in SslInfo, skipping x509 authentication"); + } + + return Mono.empty(); + } + + X509Certificate clientCertificate = sslInfo.getPeerCertificates()[0]; + Object principal = this.principalExtractor.extractPrincipal(clientCertificate); + + PreAuthenticatedAuthenticationToken authRequest = new PreAuthenticatedAuthenticationToken( + principal, clientCertificate); + + return Mono.just(authRequest); + } +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManagerTest.java b/web/src/test/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManagerTest.java new file mode 100644 index 0000000000..16925d7b9e --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/ReactivePreAuthenticatedAuthenticationManagerTest.java @@ -0,0 +1,104 @@ +/* + * 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.web.server.authentication; + +import org.junit.Test; +import org.springframework.security.authentication.AccountExpiredException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import reactor.core.publisher.Mono; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Alexey Nesterov + * @since 5.2 + */ +public class ReactivePreAuthenticatedAuthenticationManagerTest { + + private ReactiveUserDetailsService mockUserDetailsService + = mock(ReactiveUserDetailsService.class); + + private ReactivePreAuthenticatedAuthenticationManager manager + = new ReactivePreAuthenticatedAuthenticationManager(mockUserDetailsService); + + private final User validAccount = new User("valid", "", Collections.emptySet()); + private final User nonExistingAccount = new User("non existing", "", Collections.emptySet()); + private final User disabledAccount = new User("disabled", "", false, true, true, true, Collections.emptySet()); + private final User expiredAccount = new User("expired", "", true, false, true, true, Collections.emptySet()); + private final User accountWithExpiredCredentials = new User("credentials expired", "", true, true, false, true, Collections.emptySet()); + private final User lockedAccount = new User("locked", "", true, true, true, false, Collections.emptySet()); + + @Test + public void returnsAuthenticatedTokenForValidAccount() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(validAccount)); + + Authentication authentication = manager.authenticate(tokenForUser(validAccount.getUsername())).block(); + assertThat(authentication.isAuthenticated()).isEqualTo(true); + } + + @Test(expected = UsernameNotFoundException.class) + public void returnsNullForNonExistingAccount() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.empty()); + + manager.authenticate(tokenForUser(nonExistingAccount.getUsername())).block(); + } + + @Test(expected = LockedException.class) + public void throwsExceptionForLockedAccount() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(lockedAccount)); + + manager.authenticate(tokenForUser(lockedAccount.getUsername())).block(); + } + + @Test(expected = DisabledException.class) + public void throwsExceptionForDisabledAccount() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(disabledAccount)); + + manager.authenticate(tokenForUser(disabledAccount.getUsername())).block(); + } + + @Test(expected = AccountExpiredException.class) + public void throwsExceptionForExpiredAccount() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(expiredAccount)); + + manager.authenticate(tokenForUser(expiredAccount.getUsername())).block(); + } + + + @Test(expected = CredentialsExpiredException.class) + public void throwsExceptionForAccountWithExpiredCredentials() { + when(mockUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(accountWithExpiredCredentials)); + + manager.authenticate(tokenForUser(accountWithExpiredCredentials.getUsername())).block(); + } + + private Authentication tokenForUser(String username) { + return new PreAuthenticatedAuthenticationToken(username, null); + } +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverterTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverterTests.java new file mode 100644 index 0000000000..33b24df8d8 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/ServerX509AuthenticationConverterTests.java @@ -0,0 +1,94 @@ +/* + * 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 + * + * 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.web.server.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ServerX509AuthenticationConverterTests { + + @Mock + private X509PrincipalExtractor principalExtractor; + + @InjectMocks + private ServerX509AuthenticationConverter converter; + + private X509Certificate certificate; + + private MockServerHttpRequest.BaseBuilder request; + + @Before + public void setUp() throws Exception { + request = MockServerHttpRequest.get("/"); + + certificate = X509TestUtils.buildTestCertificate(); + when(principalExtractor.extractPrincipal(any())).thenReturn("Luke Taylor"); + } + + @Test + public void shouldReturnNullForInvalidCertificate() { + Authentication authentication = converter.convert(MockServerWebExchange.from(request.build())).block(); + + assertThat(authentication).isNull(); + } + + @Test + public void shouldReturnAuthenticationForValidCertificate() { + request.sslInfo(new MockSslInfo(certificate)); + + Authentication authentication = converter.convert(MockServerWebExchange.from(request.build())).block(); + + assertThat(authentication.getName()).isEqualTo("Luke Taylor"); + assertThat(authentication.getCredentials()).isEqualTo(certificate); + } + + class MockSslInfo implements SslInfo { + + private final X509Certificate[] peerCertificates; + + MockSslInfo(X509Certificate... peerCertificates) { + this.peerCertificates = peerCertificates; + } + + @Override + public String getSessionId() { + return "mock-session-id"; + } + + @Override + public X509Certificate[] getPeerCertificates() { + return this.peerCertificates; + } + } +}