瀏覽代碼

Add x509 support for Reactive Security

[gh #5038]
Alexey Nesterov 6 年之前
父節點
當前提交
9a67441507

+ 97 - 0
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.
+	 *
+	 * <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);
 		}

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

+ 76 - 0
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<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());
+	}
+}

+ 73 - 0
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<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);
+	}
+}

+ 104 - 0
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);
+	}
+}

+ 94 - 0
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;
+		}
+	}
+}