Add x509 support for Reactive Security

[gh #5038]
This commit is contained in:
Alexey Nesterov 2018-12-24 13:33:29 +03:00 committed by Rob Winch
parent 0957ecb1e9
commit 9a67441507
6 changed files with 484 additions and 0 deletions

View File

@ -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.
*
* <pre class="code">
* &#064;Bean
* public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
* http
* .x509()
* .authenticationManager(authenticationManager)
* .principalExtractor(principalExtractor);
* return http.build();
* }
* </pre>
*
* 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);
}

View File

@ -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 <T extends WebFilter> Optional<T> getWebFilter(SecurityWebFilterChain filterChain, Class<T> filterClass) {
return (Optional<T>) filterChain.getWebFilters()
.filter(Objects::nonNull)

View File

@ -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<Authentication> 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());
}
}

View File

@ -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<Authentication> 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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}