From 6cbbbb6327e8f8891d5035920b2c783905b758f1 Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Fri, 16 May 2025 14:07:10 +0300 Subject: [PATCH 1/7] Add Support SubjectX500PrincipalExtractor Closes gh-16980 Signed-off-by: Max Batischev --- .../web/configurers/X509Configurer.java | 28 +++++- .../http/AuthenticationConfigBuilder.java | 16 +++- .../config/web/server/ServerHttpSecurity.java | 12 +-- .../security/config/spring-security-7.0.rnc | 3 + .../security/config/spring-security-7.0.xsd | 6 ++ .../web/configurers/X509ConfigurerTests.java | 39 +++++++- config/src/test/resources/max.cer | 17 ++++ .../servlet/appendix/namespace/http.adoc | 3 + .../x509/SubjectDnX509PrincipalExtractor.java | 6 +- .../x509/SubjectX500PrincipalExtractor.java | 88 +++++++++++++++++++ .../x509/X509AuthenticationFilter.java | 4 +- .../SubjectX500PrincipalExtractorTests.java | 66 ++++++++++++++ 12 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 config/src/test/resources/max.cer create mode 100644 web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index a3818e2a9ac..b9b766a14d8 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -33,6 +33,7 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; import org.springframework.security.web.authentication.preauth.PreAuthenticatedGrantedAuthoritiesWebAuthenticationDetails; import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; @@ -74,6 +75,7 @@ * * @author Rob Winch * @author Ngoc Nhan + * @author Max Batischev * @since 3.2 */ public final class X509Configurer> @@ -161,14 +163,38 @@ public X509Configurer authenticationUserDetailsService( * @param subjectPrincipalRegex the regex to extract the user principal from the * certificate (i.e. "CN=(.*?)(?:,|$)"). * @return the {@link X509Configurer} for further customizations + * @deprecated Please use {{@link #extractPrincipalNameFromEmail(boolean)}} instead */ + @Deprecated public X509Configurer subjectPrincipalRegex(String subjectPrincipalRegex) { + if (this.x509PrincipalExtractor instanceof SubjectX500PrincipalExtractor) { + throw new IllegalStateException( + "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " + + "Please use one or the other."); + } SubjectDnX509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor(); principalExtractor.setSubjectDnRegex(subjectPrincipalRegex); this.x509PrincipalExtractor = principalExtractor; return this; } + /** + * If true then DN will be extracted from EMAIlADDRESS, defaults to {@code false} + * @param extractPrincipalNameFromEmail whether to extract DN from EMAIlADDRESS + * @since 7.0 + */ + public X509Configurer extractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { + if (this.x509PrincipalExtractor instanceof SubjectDnX509PrincipalExtractor) { + throw new IllegalStateException( + "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " + + "Please use one or the other."); + } + SubjectX500PrincipalExtractor extractor = new SubjectX500PrincipalExtractor(); + extractor.setExtractPrincipalNameFromEmail(extractPrincipalNameFromEmail); + this.x509PrincipalExtractor = extractor; + return this; + } + @Override public void init(H http) { PreAuthenticatedAuthenticationProvider authenticationProvider = new PreAuthenticatedAuthenticationProvider(); diff --git a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java index f997e5fa5e5..9b6ee210acc 100644 --- a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -57,6 +57,7 @@ import org.springframework.security.web.authentication.preauth.j2ee.J2eeBasedPreAuthenticatedWebAuthenticationDetailsSource; import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter; @@ -522,12 +523,25 @@ void createX509Filter(BeanReference authManager, filterBuilder.addPropertyValue("securityContextHolderStrategy", authenticationFilterSecurityContextHolderStrategyRef); String regex = x509Elt.getAttribute("subject-principal-regex"); + String extractPrincipalNameFromEmail = x509Elt.getAttribute("extract-principal-name-from-email"); + if (StringUtils.hasText(regex) && StringUtils.hasText(extractPrincipalNameFromEmail)) { + throw new IllegalStateException( + "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " + + "Please use one or the other."); + } if (StringUtils.hasText(regex)) { BeanDefinitionBuilder extractor = BeanDefinitionBuilder .rootBeanDefinition(SubjectDnX509PrincipalExtractor.class); extractor.addPropertyValue("subjectDnRegex", regex); filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); } + if (StringUtils.hasText(extractPrincipalNameFromEmail)) { + BeanDefinitionBuilder extractor = BeanDefinitionBuilder + .rootBeanDefinition(SubjectX500PrincipalExtractor.class); + extractor.addPropertyValue("extractPrincipalNameFromEmail", + Boolean.parseBoolean(extractPrincipalNameFromEmail)); + filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); + } injectAuthenticationDetailsSource(x509Elt, filterBuilder); filter = (RootBeanDefinition) filterBuilder.getBeanDefinition(); createPrauthEntryPoint(x509Elt); 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 a4063fced4b..f0760bc64e2 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 @@ -119,7 +119,7 @@ import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; import org.springframework.security.web.PortMapper; import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; @@ -944,8 +944,8 @@ public ServerHttpSecurity formLogin(Customizer formLoginCustomize * } * * - * Note that if extractor is not specified, {@link SubjectDnX509PrincipalExtractor} - * will be used. If authenticationManager is not specified, + * Note that if extractor is not specified, {@link SubjectX500PrincipalExtractor} will + * be used. If authenticationManager is not specified, * {@link ReactivePreAuthenticatedAuthenticationManager} will be used. * @return the {@link X509Spec} to customize * @since 5.2 @@ -979,8 +979,8 @@ public X509Spec x509() { * } * * - * Note that if extractor is not specified, {@link SubjectDnX509PrincipalExtractor} - * will be used. If authenticationManager is not specified, + * Note that if extractor is not specified, {@link SubjectX500PrincipalExtractor} will + * be used. If authenticationManager is not specified, * {@link ReactivePreAuthenticatedAuthenticationManager} will be used. * @param x509Customizer the {@link Customizer} to provide more options for the * {@link X509Spec} @@ -4181,7 +4181,7 @@ private X509PrincipalExtractor getPrincipalExtractor() { if (this.principalExtractor != null) { return this.principalExtractor; } - return new SubjectDnX509PrincipalExtractor(); + return new SubjectX500PrincipalExtractor(); } private ReactiveAuthenticationManager getAuthenticationManager() { diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc index bbf8622dfe2..1236a2aec53 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc @@ -1053,6 +1053,9 @@ x509.attlist &= x509.attlist &= ## Reference to an AuthenticationDetailsSource which will be used by the authentication filter attribute authentication-details-source-ref {xsd:token}? +x509.attlist &= + ## If true then DN will be extracted from EMAIlADDRESS + attribute extract-principal-name-from-email {xsd:token}? jee = ## Adds a J2eePreAuthenticatedProcessingFilter to the filter chain to provide integration with container authentication. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd index 2e3d6cf2758..ed82f3468bf 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd @@ -2917,6 +2917,12 @@ + + + If true then DN will be extracted from EMAIlADDRESS + + + diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java index 206c0b7ecee..38983691e15 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -123,6 +123,16 @@ public void x509WhenSubjectPrincipalRegexInLambdaThenUsesRegexToExtractPrincipal // @formatter:on } + @Test + public void x509WhenExtractPrincipalNameFromEmailIsTrueThenUsesEmailAddressToExtractPrincipal() throws Exception { + this.spring.register(EmailPrincipalConfig.class).autowire(); + X509Certificate certificate = loadCert("max.cer"); + // @formatter:off + this.mvc.perform(get("/").with(x509(certificate))) + .andExpect(authenticated().withUsername("maxbatischev@gmail.com")); + // @formatter:on + } + @Test public void x509WhenUserDetailsServiceNotConfiguredThenUsesBean() throws Exception { this.spring.register(UserDetailsServiceBeanConfig.class).autowire(); @@ -277,6 +287,33 @@ UserDetailsService userDetailsService() { } + @Configuration + @EnableWebSecurity + static class EmailPrincipalConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .x509((x509) -> + x509.extractPrincipalNameFromEmail(true) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("maxbatischev@gmail.com") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + } + @Configuration @EnableWebSecurity static class UserDetailsServiceBeanConfig { diff --git a/config/src/test/resources/max.cer b/config/src/test/resources/max.cer new file mode 100644 index 00000000000..bd79b1f096b --- /dev/null +++ b/config/src/test/resources/max.cer @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICojCCAgugAwIBAgIBADANBgkqhkiG9w0BAQ0FADBuMQswCQYDVQQGEwJydTEP +MA0GA1UECAwGTW9zY293MQ8wDQYDVQQKDAZTcHJpbmcxFjAUBgNVBAMMDU1heCBC +YXRpc2NoZXYxJTAjBgkqhkiG9w0BCQEWFm1heGJhdGlzY2hldkBnbWFpbC5jb20w +HhcNMjUwNTE0MTcyODM5WhcNMjYwNTE0MTcyODM5WjBuMQswCQYDVQQGEwJydTEP +MA0GA1UECAwGTW9zY293MQ8wDQYDVQQKDAZTcHJpbmcxFjAUBgNVBAMMDU1heCBC +YXRpc2NoZXYxJTAjBgkqhkiG9w0BCQEWFm1heGJhdGlzY2hldkBnbWFpbC5jb20w +gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALVZ2K/iOINeHZ4XAV3QmNRgS+iB +Vw0fW07uzYkCoSZU1lOBQE0k8+fdM2+X9AsgwfRCE3tUZquPApEKynB5V9Seh+bR +vc9aj7PunMyN+zjRU6X7/BL3VqLfrJLSc15bQaSN1phJ6NT+BTXPTuiPbXldnJLC +wVo6PView83yZ335AgMBAAGjUDBOMB0GA1UdDgQWBBQhyQfxL2ZYotcS8AmMJtli +2IRAMTAfBgNVHSMEGDAWgBQhyQfxL2ZYotcS8AmMJtli2IRAMTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBDQUAA4GBAIIIJxpsTPtUEnePAqqgVFWDKC2CExhtCBYL +MjLSC+7E9OlfuuX1joAsD4Yv86k4Ox836D0KQtINtg3y6D8O+HSylhVg1xtOiK7l +ElXVRepB8GcX3vf9F58v9s++cSDvXf8vJu/O7nI4fv9C5SfUtMY4JPh/3MTsyl8O +tgxTKjvO +-----END CERTIFICATE----- diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 8979d5ad290..812706ae1de 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2229,6 +2229,9 @@ Defines a regular expression which will be used to extract the username from the Allows a specific `UserDetailsService` to be used with X.509 in the case where multiple instances are configured. If not set, an attempt will be made to locate a suitable instance automatically and use that. +[[nsa-x509-extract-principal-name-from-email]] +* **extract-principal-name-from-email** +If true then DN will be extracted from EMAIlADDRESS. [[nsa-filter-chain-map]] == diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java index b690af59c03..ffa217d8d52 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.web.authentication.preauth.x509; +import java.security.Principal; import java.security.cert.X509Certificate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,7 +44,9 @@ * "EMAILADDRESS=jimi@hendrix.org, CN=..." giving a user name "jimi@hendrix.org" * * @author Luke Taylor + * @deprecated Please use {@link SubjectX500PrincipalExtractor} instead */ +@Deprecated public class SubjectDnX509PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { protected final Log logger = LogFactory.getLog(getClass()); @@ -59,6 +62,7 @@ public SubjectDnX509PrincipalExtractor() { @Override public Object extractPrincipal(X509Certificate clientCert) { // String subjectDN = clientCert.getSubjectX500Principal().getName(); + Principal principal = clientCert.getSubjectDN(); String subjectDN = clientCert.getSubjectDN().getName(); this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); Matcher matcher = this.subjectDnPattern.matcher(subjectDN); diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java new file mode 100644 index 00000000000..4b735c05287 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2025 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.authentication.preauth.x509; + +import java.security.cert.X509Certificate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.security.auth.x500.X500Principal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.util.Assert; + +/** + * Obtains the principal from a certificate using RFC2253 and RFC1779 formats. By default, + * RFC2253 is used: DN is extracted from CN. If extractPrincipalNameFromEmail is true then + * format RFC1779 will be used: DN is extracted from EMAIlADDRESS. + * + * @author Max Batischev + * @since 7.0 + */ +public final class SubjectX500PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { + + private final Log logger = LogFactory.getLog(getClass()); + + private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + + private boolean extractPrincipalNameFromEmail = false; + + private final Pattern cnSubjectDnPattern = Pattern.compile("CN=(.*?)(?:,|$)", Pattern.CASE_INSENSITIVE); + + private final Pattern emailSubjectDnPattern = Pattern.compile("OID.1.2.840.113549.1.9.1=(.*?)(?:,|$)", + Pattern.CASE_INSENSITIVE); + + @Override + public Object extractPrincipal(X509Certificate clientCert) { + Assert.notNull(clientCert, "clientCert cannot be null"); + X500Principal principal = clientCert.getSubjectX500Principal(); + String subjectDN = this.extractPrincipalNameFromEmail ? principal.getName("RFC1779") : principal.getName(); + this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); + Matcher matcher = this.extractPrincipalNameFromEmail ? this.emailSubjectDnPattern.matcher(subjectDN) + : this.cnSubjectDnPattern.matcher(subjectDN); + if (!matcher.find()) { + throw new BadCredentialsException(this.messages.getMessage("SubjectX500PrincipalExtractor.noMatching", + new Object[] { subjectDN }, "No matching pattern was found in subject DN: {0}")); + } + String principalName = matcher.group(1); + this.logger.debug(LogMessage.format("Extracted Principal name is '%s'", principalName)); + return principalName; + } + + @Override + public void setMessageSource(MessageSource messageSource) { + Assert.notNull(messageSource, "messageSource cannot be null"); + this.messages = new MessageSourceAccessor(messageSource); + } + + /** + * If true then DN will be extracted from EMAIlADDRESS, defaults to {@code false} + * @param extractPrincipalNameFromEmail whether to extract DN from EMAIlADDRESS + */ + public void setExtractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { + this.extractPrincipalNameFromEmail = extractPrincipalNameFromEmail; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java index c49cc6ab2f6..02618d126db 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/X509AuthenticationFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2025 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. @@ -28,7 +28,7 @@ */ public class X509AuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter { - private X509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor(); + private X509PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); @Override protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java new file mode 100644 index 00000000000..ebc1064fdc3 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2025 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.authentication.preauth.x509; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link SubjectX500PrincipalExtractor}. + * + * @author Max Batischev + */ +public class SubjectX500PrincipalExtractorTests { + + private final SubjectX500PrincipalExtractor extractor = new SubjectX500PrincipalExtractor(); + + @Test + void extractWhenCnPatternSetThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificate()); + + assertThat(principal).isEqualTo("Luke Taylor"); + } + + @Test + void extractWhenEmailPatternSetThenExtractsPrincipalName() throws Exception { + this.extractor.setExtractPrincipalNameFromEmail(true); + + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificate()); + + assertThat(principal).isEqualTo("luke@monkeymachine"); + } + + @Test + void extractWhenCnAtEndThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertificateWithCnAtEnd()); + + assertThat(principal).isEqualTo("Duke"); + } + + @Test + void setMessageSourceWhenNullThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.setMessageSource(null)); + } + + @Test + void extractWhenCertificateIsNullThenFails() { + assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.extractPrincipal(null)); + } + +} From 79e8877a2b664e0814d642cc6d14afe903691f39 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:03:20 -0500 Subject: [PATCH 2/7] Remove unused statement --- .../preauth/x509/SubjectDnX509PrincipalExtractor.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java index ffa217d8d52..8e77082e3ca 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectDnX509PrincipalExtractor.java @@ -16,7 +16,6 @@ package org.springframework.security.web.authentication.preauth.x509; -import java.security.Principal; import java.security.cert.X509Certificate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -62,7 +61,6 @@ public SubjectDnX509PrincipalExtractor() { @Override public Object extractPrincipal(X509Certificate clientCert) { // String subjectDN = clientCert.getSubjectX500Principal().getName(); - Principal principal = clientCert.getSubjectDN(); String subjectDN = clientCert.getSubjectDN().getName(); this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); Matcher matcher = this.subjectDnPattern.matcher(subjectDN); From f238a43cad4d5775c644c2defa781386accbb5bc Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:41:56 -0500 Subject: [PATCH 3/7] Encapsulate extractPrincipalNameFromEmail property This simplifies the logic when extracting the principal and allows more flexibility in the future by allowing the format and regex to be added as setters. --- .../x509/SubjectX500PrincipalExtractor.java | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java index 4b735c05287..bb8aa26589d 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java @@ -39,29 +39,31 @@ * format RFC1779 will be used: DN is extracted from EMAIlADDRESS. * * @author Max Batischev + * @author Rob Winch * @since 7.0 */ public final class SubjectX500PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { private final Log logger = LogFactory.getLog(getClass()); - private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + private static final Pattern EMAIL_SUBJECT_DN_PATTERN = Pattern.compile("OID.1.2.840.113549.1.9.1=(.*?)(?:,|$)", + Pattern.CASE_INSENSITIVE); - private boolean extractPrincipalNameFromEmail = false; + private static final Pattern CN_SUBJECT_DN_PATTERN = Pattern.compile("CN=(.*?)(?:,|$)", Pattern.CASE_INSENSITIVE); - private final Pattern cnSubjectDnPattern = Pattern.compile("CN=(.*?)(?:,|$)", Pattern.CASE_INSENSITIVE); + private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); - private final Pattern emailSubjectDnPattern = Pattern.compile("OID.1.2.840.113549.1.9.1=(.*?)(?:,|$)", - Pattern.CASE_INSENSITIVE); + private Pattern subjectDnPattern = CN_SUBJECT_DN_PATTERN; + + private String x500PrincipalFormat = X500Principal.RFC2253; @Override public Object extractPrincipal(X509Certificate clientCert) { Assert.notNull(clientCert, "clientCert cannot be null"); X500Principal principal = clientCert.getSubjectX500Principal(); - String subjectDN = this.extractPrincipalNameFromEmail ? principal.getName("RFC1779") : principal.getName(); + String subjectDN = principal.getName(this.x500PrincipalFormat); this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); - Matcher matcher = this.extractPrincipalNameFromEmail ? this.emailSubjectDnPattern.matcher(subjectDN) - : this.cnSubjectDnPattern.matcher(subjectDN); + Matcher matcher = this.subjectDnPattern.matcher(subjectDN); if (!matcher.find()) { throw new BadCredentialsException(this.messages.getMessage("SubjectX500PrincipalExtractor.noMatching", new Object[] { subjectDN }, "No matching pattern was found in subject DN: {0}")); @@ -82,7 +84,14 @@ public void setMessageSource(MessageSource messageSource) { * @param extractPrincipalNameFromEmail whether to extract DN from EMAIlADDRESS */ public void setExtractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { - this.extractPrincipalNameFromEmail = extractPrincipalNameFromEmail; + if (extractPrincipalNameFromEmail) { + this.subjectDnPattern = EMAIL_SUBJECT_DN_PATTERN; + this.x500PrincipalFormat = X500Principal.RFC1779; + } + else { + this.subjectDnPattern = CN_SUBJECT_DN_PATTERN; + this.x500PrincipalFormat = X500Principal.RFC2253; + } } } From f75028e12f29d8911fba800b23706a41ed66eca7 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:13:10 -0500 Subject: [PATCH 4/7] Update SubjectX500PrincipalExtractor Javadoc - Provide more details on how the principalName is extracted - Update to specify an OID is used for emailAddress --- .../x509/SubjectX500PrincipalExtractor.java | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java index bb8aa26589d..9472be2cc76 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java @@ -34,9 +34,10 @@ import org.springframework.util.Assert; /** - * Obtains the principal from a certificate using RFC2253 and RFC1779 formats. By default, - * RFC2253 is used: DN is extracted from CN. If extractPrincipalNameFromEmail is true then - * format RFC1779 will be used: DN is extracted from EMAIlADDRESS. + * Extracts the principal from the {@link X500Principal#getName(String)} returned by + * {@link X509Certificate#getSubjectX500Principal()} passed into + * {@link #extractPrincipal(X509Certificate)} depending on the value of + * {@link #setExtractPrincipalNameFromEmail(boolean)}. * * @author Max Batischev * @author Rob Winch @@ -80,8 +81,26 @@ public void setMessageSource(MessageSource messageSource) { } /** - * If true then DN will be extracted from EMAIlADDRESS, defaults to {@code false} - * @param extractPrincipalNameFromEmail whether to extract DN from EMAIlADDRESS + * Sets if the principal name should be extracted from the emailAddress or CN + * attribute (default). + * + * By default, the format {@link X500Principal#RFC2253} is passed to + * {@link X500Principal#getName(String)} and the principal is extracted from the CN + * attribute as defined in + * Converting + * AttributeTypeAndValue of RFC2253. + * + * If {@link #setExtractPrincipalNameFromEmail(boolean)} is {@code true}, then the + * format {@link X500Principal#RFC2253} is passed to + * {@link X500Principal#getName(String)} and the principal is extracted from the + * OID.1.2.840.113549.1.9.1 + * (emailAddress) attribute as defined in + * Section 2.3 of + * RFC1779. + * @param extractPrincipalNameFromEmail whether to extract the principal from the + * emailAddress (default false) + * @see RFC2253 + * @see RFC1779 */ public void setExtractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { if (extractPrincipalNameFromEmail) { From 9c1f2ced570dcfe5d2735f61f142f027bc648bbc Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:22:20 -0500 Subject: [PATCH 5/7] Use principalExtractor reference instead of properties --- .../web/configurers/X509Configurer.java | 27 +--- .../http/AuthenticationConfigBuilder.java | 16 +-- .../security/config/annotation/web/X509Dsl.kt | 1 + .../security/config/spring-security-7.0.rnc | 3 - .../security/config/spring-security-7.0.xsd | 6 - .../web/configurers/X509ConfigurerTests.java | 119 ++++++++++++------ config/src/test/resources/max.cer | 17 --- .../servlet/appendix/namespace/http.adoc | 3 - 8 files changed, 85 insertions(+), 107 deletions(-) delete mode 100644 config/src/test/resources/max.cer diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index b9b766a14d8..d6241a0fbe5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -33,7 +33,6 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; import org.springframework.security.web.authentication.preauth.PreAuthenticatedGrantedAuthoritiesWebAuthenticationDetails; import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; -import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; @@ -75,7 +74,6 @@ * * @author Rob Winch * @author Ngoc Nhan - * @author Max Batischev * @since 3.2 */ public final class X509Configurer> @@ -163,38 +161,17 @@ public X509Configurer authenticationUserDetailsService( * @param subjectPrincipalRegex the regex to extract the user principal from the * certificate (i.e. "CN=(.*?)(?:,|$)"). * @return the {@link X509Configurer} for further customizations - * @deprecated Please use {{@link #extractPrincipalNameFromEmail(boolean)}} instead + * @deprecated Please use {{@link #x509PrincipalExtractor(X509PrincipalExtractor)} + * instead */ @Deprecated public X509Configurer subjectPrincipalRegex(String subjectPrincipalRegex) { - if (this.x509PrincipalExtractor instanceof SubjectX500PrincipalExtractor) { - throw new IllegalStateException( - "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " - + "Please use one or the other."); - } SubjectDnX509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor(); principalExtractor.setSubjectDnRegex(subjectPrincipalRegex); this.x509PrincipalExtractor = principalExtractor; return this; } - /** - * If true then DN will be extracted from EMAIlADDRESS, defaults to {@code false} - * @param extractPrincipalNameFromEmail whether to extract DN from EMAIlADDRESS - * @since 7.0 - */ - public X509Configurer extractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { - if (this.x509PrincipalExtractor instanceof SubjectDnX509PrincipalExtractor) { - throw new IllegalStateException( - "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " - + "Please use one or the other."); - } - SubjectX500PrincipalExtractor extractor = new SubjectX500PrincipalExtractor(); - extractor.setExtractPrincipalNameFromEmail(extractPrincipalNameFromEmail); - this.x509PrincipalExtractor = extractor; - return this; - } - @Override public void init(H http) { PreAuthenticatedAuthenticationProvider authenticationProvider = new PreAuthenticatedAuthenticationProvider(); diff --git a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java index 9b6ee210acc..f997e5fa5e5 100644 --- a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2025 the original author or authors. + * Copyright 2002-2023 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. @@ -57,7 +57,6 @@ import org.springframework.security.web.authentication.preauth.j2ee.J2eeBasedPreAuthenticatedWebAuthenticationDetailsSource; import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; -import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter; @@ -523,25 +522,12 @@ void createX509Filter(BeanReference authManager, filterBuilder.addPropertyValue("securityContextHolderStrategy", authenticationFilterSecurityContextHolderStrategyRef); String regex = x509Elt.getAttribute("subject-principal-regex"); - String extractPrincipalNameFromEmail = x509Elt.getAttribute("extract-principal-name-from-email"); - if (StringUtils.hasText(regex) && StringUtils.hasText(extractPrincipalNameFromEmail)) { - throw new IllegalStateException( - "Cannot use subjectPrincipalRegex and extractPrincipalNameFromEmail together. " - + "Please use one or the other."); - } if (StringUtils.hasText(regex)) { BeanDefinitionBuilder extractor = BeanDefinitionBuilder .rootBeanDefinition(SubjectDnX509PrincipalExtractor.class); extractor.addPropertyValue("subjectDnRegex", regex); filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); } - if (StringUtils.hasText(extractPrincipalNameFromEmail)) { - BeanDefinitionBuilder extractor = BeanDefinitionBuilder - .rootBeanDefinition(SubjectX500PrincipalExtractor.class); - extractor.addPropertyValue("extractPrincipalNameFromEmail", - Boolean.parseBoolean(extractPrincipalNameFromEmail)); - filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); - } injectAuthenticationDetailsSource(x509Elt, filterBuilder); filter = (RootBeanDefinition) filterBuilder.getBeanDefinition(); createPrauthEntryPoint(x509Elt); diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt index f36897604a4..514ace50b38 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt @@ -51,6 +51,7 @@ class X509Dsl { var authenticationDetailsSource: AuthenticationDetailsSource? = null var userDetailsService: UserDetailsService? = null var authenticationUserDetailsService: AuthenticationUserDetailsService? = null + @Deprecated("Use x509PrincipalExtractor instead") var subjectPrincipalRegex: String? = null diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc index 1236a2aec53..bbf8622dfe2 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc @@ -1053,9 +1053,6 @@ x509.attlist &= x509.attlist &= ## Reference to an AuthenticationDetailsSource which will be used by the authentication filter attribute authentication-details-source-ref {xsd:token}? -x509.attlist &= - ## If true then DN will be extracted from EMAIlADDRESS - attribute extract-principal-name-from-email {xsd:token}? jee = ## Adds a J2eePreAuthenticatedProcessingFilter to the filter chain to provide integration with container authentication. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd index ed82f3468bf..2e3d6cf2758 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd @@ -2917,12 +2917,6 @@ - - - If true then DN will be extracted from EMAIlADDRESS - - - diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java index 38983691e15..63a3a963df6 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/X509ConfigurerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2025 the original author or authors. + * Copyright 2002-2023 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. @@ -43,7 +43,9 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; @@ -123,16 +125,6 @@ public void x509WhenSubjectPrincipalRegexInLambdaThenUsesRegexToExtractPrincipal // @formatter:on } - @Test - public void x509WhenExtractPrincipalNameFromEmailIsTrueThenUsesEmailAddressToExtractPrincipal() throws Exception { - this.spring.register(EmailPrincipalConfig.class).autowire(); - X509Certificate certificate = loadCert("max.cer"); - // @formatter:off - this.mvc.perform(get("/").with(x509(certificate))) - .andExpect(authenticated().withUsername("maxbatischev@gmail.com")); - // @formatter:on - } - @Test public void x509WhenUserDetailsServiceNotConfiguredThenUsesBean() throws Exception { this.spring.register(UserDetailsServiceBeanConfig.class).autowire(); @@ -165,6 +157,28 @@ public void x509WhenStatelessSessionManagementThenDoesNotCreateSession() throws // @formatter:on } + @Test + public void x509WhenSubjectX500PrincipalExtractor() throws Exception { + this.spring.register(SubjectX500PrincipalExtractorConfig.class).autowire(); + X509Certificate certificate = loadCert("rod.cer"); + // @formatter:off + this.mvc.perform(get("/").with(x509(certificate))) + .andExpect((result) -> assertThat(result.getRequest().getSession(false)).isNull()) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + public void x509WhenSubjectX500PrincipalExtractorBean() throws Exception { + this.spring.register(SubjectX500PrincipalExtractorEmailConfig.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.mvc.perform(get("/").with(x509(certificate))) + .andExpect((result) -> assertThat(result.getRequest().getSession(false)).isNull()) + .andExpect(authenticated().withUsername("luke@monkeymachine")); + // @formatter:on + } + private T loadCert(String location) { try (InputStream is = new ClassPathResource(location).getInputStream()) { CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); @@ -287,33 +301,6 @@ UserDetailsService userDetailsService() { } - @Configuration - @EnableWebSecurity - static class EmailPrincipalConfig { - - @Bean - SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - // @formatter:off - http - .x509((x509) -> - x509.extractPrincipalNameFromEmail(true) - ); - // @formatter:on - return http.build(); - } - - @Bean - UserDetailsService userDetailsService() { - UserDetails user = User.withDefaultPasswordEncoder() - .username("maxbatischev@gmail.com") - .password("password") - .roles("USER", "ADMIN") - .build(); - return new InMemoryUserDetailsManager(user); - } - - } - @Configuration @EnableWebSecurity static class UserDetailsServiceBeanConfig { @@ -397,4 +384,60 @@ UserDetailsService userDetailsService() { } + @Configuration + @EnableWebSecurity + static class SubjectX500PrincipalExtractorConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(new SubjectX500PrincipalExtractor()) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("rod") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + } + + @Configuration + @EnableWebSecurity + static class SubjectX500PrincipalExtractorEmailConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(principalExtractor) + ); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("luke@monkeymachine") + .password("password") + .roles("USER", "ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + } + } diff --git a/config/src/test/resources/max.cer b/config/src/test/resources/max.cer deleted file mode 100644 index bd79b1f096b..00000000000 --- a/config/src/test/resources/max.cer +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICojCCAgugAwIBAgIBADANBgkqhkiG9w0BAQ0FADBuMQswCQYDVQQGEwJydTEP -MA0GA1UECAwGTW9zY293MQ8wDQYDVQQKDAZTcHJpbmcxFjAUBgNVBAMMDU1heCBC -YXRpc2NoZXYxJTAjBgkqhkiG9w0BCQEWFm1heGJhdGlzY2hldkBnbWFpbC5jb20w -HhcNMjUwNTE0MTcyODM5WhcNMjYwNTE0MTcyODM5WjBuMQswCQYDVQQGEwJydTEP -MA0GA1UECAwGTW9zY293MQ8wDQYDVQQKDAZTcHJpbmcxFjAUBgNVBAMMDU1heCBC -YXRpc2NoZXYxJTAjBgkqhkiG9w0BCQEWFm1heGJhdGlzY2hldkBnbWFpbC5jb20w -gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALVZ2K/iOINeHZ4XAV3QmNRgS+iB -Vw0fW07uzYkCoSZU1lOBQE0k8+fdM2+X9AsgwfRCE3tUZquPApEKynB5V9Seh+bR -vc9aj7PunMyN+zjRU6X7/BL3VqLfrJLSc15bQaSN1phJ6NT+BTXPTuiPbXldnJLC -wVo6PView83yZ335AgMBAAGjUDBOMB0GA1UdDgQWBBQhyQfxL2ZYotcS8AmMJtli -2IRAMTAfBgNVHSMEGDAWgBQhyQfxL2ZYotcS8AmMJtli2IRAMTAMBgNVHRMEBTAD -AQH/MA0GCSqGSIb3DQEBDQUAA4GBAIIIJxpsTPtUEnePAqqgVFWDKC2CExhtCBYL -MjLSC+7E9OlfuuX1joAsD4Yv86k4Ox836D0KQtINtg3y6D8O+HSylhVg1xtOiK7l -ElXVRepB8GcX3vf9F58v9s++cSDvXf8vJu/O7nI4fv9C5SfUtMY4JPh/3MTsyl8O -tgxTKjvO ------END CERTIFICATE----- diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 812706ae1de..8979d5ad290 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2229,9 +2229,6 @@ Defines a regular expression which will be used to extract the username from the Allows a specific `UserDetailsService` to be used with X.509 in the case where multiple instances are configured. If not set, an attempt will be made to locate a suitable instance automatically and use that. -[[nsa-x509-extract-principal-name-from-email]] -* **extract-principal-name-from-email** -If true then DN will be extracted from EMAIlADDRESS. [[nsa-filter-chain-map]] == From 485d4ca48efcf940e5ef930ab5f2c21388064e66 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:05:43 -0500 Subject: [PATCH 6/7] Add x509@principal-extractor-ref Enables customizing the X500PrincipalExtractor --- .../http/AuthenticationConfigBuilder.java | 18 ++++++-- .../security/config/spring-security-7.0.rnc | 3 ++ .../security/config/spring-security-7.0.xsd | 6 +++ .../config/http/MiscHttpConfigTests.java | 23 +++++++++++ ...pConfigTests-X509PrincipalExtractorRef.xml | 41 +++++++++++++++++++ ...alExtractorRefAndSubjectPrincipalRegex.xml | 41 +++++++++++++++++++ .../servlet/appendix/namespace/http.adoc | 4 ++ 7 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml diff --git a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java index f997e5fa5e5..55efc57a17d 100644 --- a/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/AuthenticationConfigBuilder.java @@ -521,11 +521,23 @@ void createX509Filter(BeanReference authManager, filterBuilder.addPropertyValue("authenticationManager", authManager); filterBuilder.addPropertyValue("securityContextHolderStrategy", authenticationFilterSecurityContextHolderStrategyRef); - String regex = x509Elt.getAttribute("subject-principal-regex"); - if (StringUtils.hasText(regex)) { + String principalExtractorRef = x509Elt.getAttribute("principal-extractor-ref"); + String subjectPrincipalRegex = x509Elt.getAttribute("subject-principal-regex"); + boolean hasPrincipalExtractorRef = StringUtils.hasText(principalExtractorRef); + boolean hasSubjectPrincipalRegex = StringUtils.hasText(subjectPrincipalRegex); + if (hasPrincipalExtractorRef && hasSubjectPrincipalRegex) { + this.pc.getReaderContext() + .error("The attribute 'principal-extractor-ref' cannot be used together with the 'subject-principal-regex' attribute within <" + + Elements.X509 + ">", this.pc.extractSource(x509Elt)); + } + if (hasPrincipalExtractorRef) { + RuntimeBeanReference principalExtractor = new RuntimeBeanReference(principalExtractorRef); + filterBuilder.addPropertyValue("principalExtractor", principalExtractor); + } + if (hasSubjectPrincipalRegex) { BeanDefinitionBuilder extractor = BeanDefinitionBuilder .rootBeanDefinition(SubjectDnX509PrincipalExtractor.class); - extractor.addPropertyValue("subjectDnRegex", regex); + extractor.addPropertyValue("subjectDnRegex", subjectPrincipalRegex); filterBuilder.addPropertyValue("principalExtractor", extractor.getBeanDefinition()); } injectAuthenticationDetailsSource(x509Elt, filterBuilder); diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc index bbf8622dfe2..d9a8d4f9d5f 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc @@ -1053,6 +1053,9 @@ x509.attlist &= x509.attlist &= ## Reference to an AuthenticationDetailsSource which will be used by the authentication filter attribute authentication-details-source-ref {xsd:token}? +x509.attlist &= + ## Reference to an X509PrincipalExtractor which will be used by the authentication filter + attribute principal-extractor-ref {xsd:token}? jee = ## Adds a J2eePreAuthenticatedProcessingFilter to the filter chain to provide integration with container authentication. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd index 2e3d6cf2758..4ff414800b7 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd @@ -2917,6 +2917,12 @@ + + + Reference to an X509PrincipalExtractor which will be used by the authentication filter + + + diff --git a/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java b/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java index 5221350cde0..10927906ea1 100644 --- a/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/MiscHttpConfigTests.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import java.security.AccessController; import java.security.Principal; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; @@ -91,6 +92,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; @@ -398,6 +400,27 @@ public void configureWhenUsingX509ThenAddsX509FilterCorrectly() { .containsSubsequence(CsrfFilter.class, X509AuthenticationFilter.class, ExceptionTranslationFilter.class); } + @Test + public void getWhenUsingX509PrincipalExtractorRef() throws Exception { + this.spring.configLocations(xml("X509PrincipalExtractorRef")).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + RequestPostProcessor x509 = x509(certificate); + // @formatter:off + this.mvc.perform(get("/protected").with(x509)) + .andExpect(status().isOk()); + // @formatter:on + } + + @Test + public void getWhenUsingX509PrincipalExtractorRefAndSubjectPrincipalRegex() throws Exception { + String xmlResourceName = "X509PrincipalExtractorRefAndSubjectPrincipalRegex"; + // @formatter:off + assertThatExceptionOfType(BeanDefinitionParsingException.class) + .isThrownBy(() -> this.spring.configLocations(xml(xmlResourceName)).autowire()) + .withMessage("Configuration problem: The attribute 'principal-extractor-ref' cannot be used together with the 'subject-principal-regex' attribute within \n" + "Offending resource: class path resource [org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml]"); + // @formatter:on + } + @Test public void getWhenUsingX509AndPropertyPlaceholderThenSubjectPrincipalRegexIsConfigured() throws Exception { System.setProperty("subject_principal_regex", "OU=(.*?)(?:,|$)"); diff --git a/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml new file mode 100644 index 00000000000..51a3a1bbf14 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRef.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml new file mode 100644 index 00000000000..1fc7e0f12c7 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/MiscHttpConfigTests-X509PrincipalExtractorRefAndSubjectPrincipalRegex.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 8979d5ad290..555a1b4323f 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -2218,6 +2218,10 @@ A `PreAuthenticatedAuthenticationProvider` will also be created which delegates * **authentication-details-source-ref** A reference to an `AuthenticationDetailsSource` +[[nsa-x509-principal-extractor-ref]] +* **principal-extractor-ref** +Reference to an `X509PrincipalExtractor` which will be used by the authentication filter. + [[nsa-x509-subject-principal-regex]] * **subject-principal-regex** From 24d4711b7045e85e5916ca1d4f23bb3f7bee2f59 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Wed, 11 Jun 2025 08:41:38 -0500 Subject: [PATCH 7/7] Update x509 Reference - Use include-code - Demo how to customize SubjectX500PrincipalExtractor --- docs/antora.yml | 1 + .../pages/reactive/authentication/x509.adoc | 94 +---------- .../pages/servlet/authentication/x509.adoc | 46 +++--- .../reactivex509/CustomX509Configuration.java | 74 +++++++++ .../DefaultX509Configuration.java | 67 ++++++++ .../reactivex509/X509ConfigurationTests.java | 148 ++++++++++++++++++ .../CustomX509Configuration.java | 75 +++++++++ .../DefaultX509Configuration.java | 67 ++++++++ .../X509ConfigurationTests.java | 103 ++++++++++++ .../reactivex509/CustomX509Configuration.kt | 74 +++++++++ .../reactivex509/DefaultX509Configuration.kt | 64 ++++++++ .../reactivex509/X509ConfigurationTests.kt | 131 ++++++++++++++++ .../CustomX509Configuration.kt | 69 ++++++++ .../DefaultX509Configuration.kt | 64 ++++++++ .../X509ConfigurationTests.kt | 75 +++++++++ .../CustomX509Configuration.xml | 45 ++++++ .../DefaultX509Configuration.xml | 39 +++++ 17 files changed, 1120 insertions(+), 116 deletions(-) create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt create mode 100644 docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml create mode 100644 docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml diff --git a/docs/antora.yml b/docs/antora.yml index 02262db72ea..c6a145aaabf 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -18,3 +18,4 @@ asciidoc: gh-url: "https://github.com/spring-projects/spring-security/tree/{gh-tag}" include-java: 'example$docs-src/test/java/org/springframework/security/docs' include-kotlin: 'example$docs-src/test/kotlin/org/springframework/security/kt/docs' + include-xml: 'example$docs-src/test/resources/org/springframework/security/docs' diff --git a/docs/modules/ROOT/pages/reactive/authentication/x509.adoc b/docs/modules/ROOT/pages/reactive/authentication/x509.adoc index a077dd5c65b..947084423c9 100644 --- a/docs/modules/ROOT/pages/reactive/authentication/x509.adoc +++ b/docs/modules/ROOT/pages/reactive/authentication/x509.adoc @@ -5,98 +5,16 @@ Similar to xref:servlet/authentication/x509.adoc#servlet-x509[Servlet X.509 auth The following example shows a reactive x509 security configuration: -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -@Bean -public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - http - .x509(withDefaults()) - .authorizeExchange(exchanges -> exchanges - .anyExchange().permitAll() - ); - return http.build(); -} ----- +include-code::./DefaultX509Configuration[tag=springSecurity,indent=0] -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -@Bean -fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - x509 { } - authorizeExchange { - authorize(anyExchange, authenticated) - } - } -} ----- -====== - -In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. The default principal extractor is `SubjectDnX509PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. +In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. +The default principal extractor is `SubjectX500PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. +The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. The following example demonstrates how these defaults can be overridden: -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -@Bean -public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - SubjectDnX509PrincipalExtractor principalExtractor = - new SubjectDnX509PrincipalExtractor(); - - principalExtractor.setSubjectDnRegex("OU=(.*?)(?:,|$)"); - - ReactiveAuthenticationManager authenticationManager = authentication -> { - authentication.setAuthenticated("Trusted Org Unit".equals(authentication.getName())); - return Mono.just(authentication); - }; - - http - .x509(x509 -> x509 - .principalExtractor(principalExtractor) - .authenticationManager(authenticationManager) - ) - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ); - return http.build(); -} ----- - -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -@Bean -fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { - val customPrincipalExtractor = SubjectDnX509PrincipalExtractor() - customPrincipalExtractor.setSubjectDnRegex("OU=(.*?)(?:,|$)") - val customAuthenticationManager = ReactiveAuthenticationManager { authentication: Authentication -> - authentication.isAuthenticated = "Trusted Org Unit" == authentication.name - Mono.just(authentication) - } - return http { - x509 { - principalExtractor = customPrincipalExtractor - authenticationManager = customAuthenticationManager - } - authorizeExchange { - authorize(anyExchange, authenticated) - } - } -} ----- -====== +include-code::./CustomX509Configuration[tag=springSecurity,indent=0] -In the previous example, a username is extracted from the OU field of a client certificate instead of CN, and account lookup using `ReactiveUserDetailsService` is not performed at all. Instead, if the provided certificate issued to an OU named "`Trusted Org Unit`", a request is authenticated. +In the previous example, a username is extracted from the `emailAddress` field of a client certificate instead of CN, and account lookup uses a custom `ReactiveAuthenticationManager` instance. For an example of configuring Netty and `WebClient` or `curl` command-line tool to use mutual TLS and enable X.509 authentication, see https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/authentication/x509. diff --git a/docs/modules/ROOT/pages/servlet/authentication/x509.adoc b/docs/modules/ROOT/pages/servlet/authentication/x509.adoc index 26dbb1a9008..2d670a632e5 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/x509.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/x509.adoc @@ -14,37 +14,27 @@ You should get this working before trying it out with Spring Security. The Spring Security X.509 module extracts the certificate by using a filter. It maps the certificate to an application user and loads that user's set of granted authorities for use with the standard Spring Security infrastructure. - +[[servlet-x509-config]] == Adding X.509 Authentication to Your Web Application -Enabling X.509 client authentication is very straightforward. -To do so, add the `` element to your http security namespace configuration: -[source,xml] ----- - -... - ; - ----- +Similar to xref:reactive/authentication/x509.adoc[Reactive X.509 authentication], the servlet x509 authentication filter allows extracting an authentication token from a certificate provided by a client. + +The following example shows a reactive x509 security configuration: + +include-code::./DefaultX509Configuration[tag=springSecurity,indent=0] + +In the preceding configuration, when neither `principalExtractor` nor `authenticationManager` is provided, defaults are used. +The default principal extractor is `SubjectX500PrincipalExtractor`, which extracts the CN (common name) field from a certificate provided by a client. +The default authentication manager is `ReactivePreAuthenticatedAuthenticationManager`, which performs user account validation, checking that a user account with a name extracted by `principalExtractor` exists and that it is not locked, disabled, or expired. + +The following example demonstrates how these defaults can be overridden: + +include-code::./CustomX509Configuration[tag=springSecurity,indent=0] + +In the previous example, a username is extracted from the `emailAddress` field of a client certificate instead of CN, and account lookup uses a custom `ReactiveAuthenticationManager` instance. + +For an example of configuring Netty and `WebClient` or `curl` command-line tool to use mutual TLS and enable X.509 authentication, see https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/authentication/x509. -The element has two optional attributes: - -* `subject-principal-regex`. -The regular expression used to extract a username from the certificate's subject name. -The default value is shown in the preceding listing. -This is the username that is passed to the `UserDetailsService` to load the authorities for the user. -* `user-service-ref`. -This is the bean ID of the `UserDetailsService` to be used with X.509. -It is not needed if there is only one defined in your application context. - -The `subject-principal-regex` should contain a single group. -For example, the default expression (`CN=(.*?)`) matches the common name field. -So, if the subject name in the certificate is "CN=Jimi Hendrix, OU=...", this gives a user name of "Jimi Hendrix". -The matches are case insensitive. -So "emailAddress=(+.*?+)," matches "EMAILADDRESS=jimi@hendrix.org,CN=...", giving a user name "jimi@hendrix.org". -If the client presents a certificate and a valid username is successfully extracted, there should be a valid `Authentication` object in the security context. -If no certificate is found or no corresponding user could be found, the security context remains empty. -This means that you can use X.509 authentication with other options, such as a form-based login. [[x509-ssl-config]] == Setting up SSL in Tomcat diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java new file mode 100644 index 00000000000..8f7f8c8278c --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/CustomX509Configuration.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2025 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.docs.reactive.authentication.reactivex509; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +@EnableWebFlux +public class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + + // @formatter:off + UserDetails user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + ReactiveUserDetailsService users = new MapReactiveUserDetailsService(user); + ReactiveAuthenticationManager authenticationManager = new ReactivePreAuthenticatedAuthenticationManager(users); + + // @formatter:off + http + .x509(x509 -> x509 + .principalExtractor(principalExtractor) + .authenticationManager(authenticationManager) + ) + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + +} diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java new file mode 100644 index 00000000000..0fd17fa1d8a --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/DefaultX509Configuration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 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.docs.reactive.authentication.reactivex509; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@EnableWebFluxSecurity +@EnableWebFlux +public class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + SecurityWebFilterChain springSecurity(ServerHttpSecurity http) { + // @formatter:off + http + .x509(Customizer.withDefaults()) + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + ReactiveUserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new MapReactiveUserDetailsService(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java new file mode 100644 index 00000000000..475787da4f6 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/reactivex509/X509ConfigurationTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2025 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.docs.reactive.authentication.reactivex509; + +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension.class) +public class X509ConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + WebTestClient client; + + @Autowired + void setSpringSecurityFilterChain(WebFilter springSecurityFilterChain) { + this.client = WebTestClient.bindToController(WebTestClientBuilder.Http200RestController.class) + .webFilter(springSecurityFilterChain) + .apply(springSecurity()) + .configureClient() + .build(); + } + + @Test + void x509WhenDefaultX509Configuration() throws Exception { + this.spring.register(DefaultX509Configuration.class).autowire(); + X509Certificate certificate = loadCert("rod.cer"); + // @formatter:off + this.client + .mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk(); + // @formatter:on + } + + @Test + void x509WhenCustomX509Configuration() throws Exception { + this.spring.register(CustomX509Configuration.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.client + .mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk(); + // @formatter:on + } + + private static @NotNull WebTestClientConfigurer x509(X509Certificate certificate) { + return (builder, httpHandlerBuilder, connector) -> { + builder.apply(new WebTestClientConfigurer() { + @Override + public void afterConfigurerAdded(WebTestClient.Builder builder, + @Nullable WebHttpHandlerBuilder httpHandlerBuilder, + @Nullable ClientHttpConnector connector) { + SslInfo sslInfo = new SslInfo() { + @Override + public @Nullable String getSessionId() { + return "sessionId"; + } + + @Override + public X509Certificate @Nullable [] getPeerCertificates() { + return new X509Certificate[] { certificate }; + } + }; + httpHandlerBuilder.filters((filters) -> filters.add(0, new SslInfoOverrideWebFilter(sslInfo))); + } + }); + }; + } + + private static class SslInfoOverrideWebFilter implements WebFilter { + private final SslInfo sslInfo; + + private SslInfoOverrideWebFilter(SslInfo sslInfo) { + this.sslInfo = sslInfo; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpRequest sslInfoRequest = exchange.getRequest().mutate().sslInfo(sslInfo) + .build(); + ServerWebExchange sslInfoExchange = exchange.mutate().request(sslInfoRequest).build(); + return chain.filter(sslInfoExchange); + } + } + + private T loadCert(String location) { + try (InputStream is = new ClassPathResource(location).getInputStream()) { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + return (T) certFactory.generateCertificate(is); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java new file mode 100644 index 00000000000..70b46b7c544 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2025 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.docs.servlet.authentication.servletx509config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception { + SubjectX500PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor(); + principalExtractor.setExtractPrincipalNameFromEmail(true); + + + // @formatter:off + http + .x509((x509) -> x509 + .x509PrincipalExtractor(principalExtractor) + ) + .authorizeHttpRequests((exchanges) -> exchanges + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + UserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java new file mode 100644 index 00000000000..a3472353869 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 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.docs.servlet.authentication.servletx509config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception { + // @formatter:off + http + .x509(Customizer.withDefaults()) + .authorizeHttpRequests(exchanges -> exchanges + .anyRequest().authenticated() + ); + // @formatter:on + return http.build(); + } + // end::springSecurity[] + + @Bean + UserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java new file mode 100644 index 00000000000..c6aa6f6d41b --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/servletx509config/X509ConfigurationTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2025 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.docs.servlet.authentication.servletx509config; + +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClientConfigurer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension.class) +public class X509ConfigurationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + void x509WhenDefaultX509Configuration() throws Exception { + this.spring.register(DefaultX509Configuration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509("rod.cer"))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + void x509WhenDefaultX509ConfigurationXml() throws Exception { + this.spring.testConfigLocations("DefaultX509Configuration.xml").autowire(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509("rod.cer"))) + .andExpect(authenticated().withUsername("rod")); + // @formatter:on + } + + @Test + void x509WhenCustomX509Configuration() throws Exception { + this.spring.register(CustomX509Configuration.class, Http200Controller.class).autowire(); + X509Certificate certificate = X509TestUtils.buildTestCertificate(); + // @formatter:off + this.mockMvc.perform(get("/").with(x509(certificate))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("luke@monkeymachine")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt new file mode 100644 index 00000000000..78c4fbe34bf --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/CustomX509Configuration.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2025 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.kt.docs.reactive.authentication.reactivex509 + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.invoke +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.http +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService +import org.springframework.security.core.userdetails.ReactiveUserDetailsService +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.ReactivePreAuthenticatedAuthenticationManager +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebFlux +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class CustomX509Configuration { + + // tag::springSecurity[] + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + val extractor = SubjectX500PrincipalExtractor() + extractor.setExtractPrincipalNameFromEmail(true) + + // @formatter:off + val user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build() + // @formatter:on + + val users: ReactiveUserDetailsService = MapReactiveUserDetailsService(user) + val authentication: ReactiveAuthenticationManager = ReactivePreAuthenticatedAuthenticationManager(users) + + return http { + x509 { + principalExtractor = extractor + authenticationManager = authentication + } + authorizeExchange { + authorize(anyExchange, authenticated) + } + } + } + // end::springSecurity[] + + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt new file mode 100644 index 00000000000..61e5d65db5c --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/DefaultX509Configuration.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2025 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.kt.docs.reactive.authentication.reactivex509 + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.invoke +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebFlux +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class DefaultX509Configuration { + + // tag::springSecurity[] + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + x509 { } + authorizeExchange { + authorize(anyExchange, authenticated) + } + } + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): MapReactiveUserDetailsService { + // @formatter:off + val user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build() + // @formatter:on + + return MapReactiveUserDetailsService(user) + } + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt new file mode 100644 index 00000000000..4802660f06b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/reactivex509/X509ConfigurationTests.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2025 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.kt.docs.reactive.authentication.reactivex509 + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.ClassPathResource +import org.springframework.http.client.reactive.ClientHttpConnector +import org.springframework.http.server.reactive.SslInfo +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder.Http200RestController +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.WebTestClientConfigurer +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import org.springframework.web.server.adapter.WebHttpHandlerBuilder +import reactor.core.publisher.Mono +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.function.Consumer + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension::class) +class X509ConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + var client: WebTestClient? = null + + @Autowired + fun setSpringSecurityFilterChain(springSecurityFilterChain: WebFilter) { + this.client = WebTestClient + .bindToController(Http200RestController::class.java) + .webFilter(springSecurityFilterChain) + .apply(SecurityMockServerConfigurers.springSecurity()) + .configureClient() + .build() + } + + @Test + fun x509WhenDefaultX509Configuration() { + this.spring.register(DefaultX509Configuration::class.java).autowire() + val certificate = loadCert("rod.cer") + // @formatter:off + this.client!!.mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + // @formatter:on + } + + @Test + fun x509WhenCustomX509Configuration() { + this.spring.register(CustomX509Configuration::class.java).autowire() + val certificate = X509TestUtils.buildTestCertificate() + // @formatter:off + this.client!!.mutateWith(x509(certificate)) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + // @formatter:on + } + + private class SslInfoOverrideWebFilter(private val sslInfo: SslInfo) : WebFilter { + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val sslInfoRequest = exchange.getRequest().mutate().sslInfo(sslInfo) + .build() + val sslInfoExchange = exchange.mutate().request(sslInfoRequest).build() + return chain.filter(sslInfoExchange) + } + } + + private fun loadCert(location: String): T { + try { + ClassPathResource(location).getInputStream().use { `is` -> + val certFactory = CertificateFactory.getInstance("X.509") + return certFactory.generateCertificate(`is`) as T + } + } catch (ex: Exception) { + throw IllegalArgumentException(ex) + } + } + + companion object { + private fun x509(certificate: X509Certificate): WebTestClientConfigurer { + return WebTestClientConfigurer { builder: WebTestClient.Builder, httpHandlerBuilder: WebHttpHandlerBuilder, connector: ClientHttpConnector? -> + + val sslInfo: SslInfo = object : SslInfo { + override fun getSessionId(): String { + return "sessionId" + } + + override fun getPeerCertificates(): Array { + return arrayOf(certificate) + } + } + httpHandlerBuilder.filters(Consumer { filters: MutableList -> + filters.add( + 0, + SslInfoOverrideWebFilter(sslInfo) + ) + }) + } + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt new file mode 100644 index 00000000000..b079ce26fb5 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/CustomX509Configuration.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2025 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.kt.docs.servlet.authentication.servlet509config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class CustomX509Configuration { + // tag::springSecurity[] + @Bean + fun springSecurity(http: HttpSecurity): DefaultSecurityFilterChain? { + val principalExtractor = SubjectX500PrincipalExtractor() + principalExtractor.setExtractPrincipalNameFromEmail(true) + + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + x509 { + x509PrincipalExtractor = principalExtractor + } + } + return http.build() + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): UserDetailsService { + // @formatter:off + val user = User + .withUsername("luke@monkeymachine") + .password("password") + .roles("USER") + .build() + // @formatter:on + return InMemoryUserDetailsManager(user) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt new file mode 100644 index 00000000000..3c777ebe32c --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/DefaultX509Configuration.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2025 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.kt.docs.servlet.authentication.servlet509config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +/** + * Demonstrates custom configuration for x509 reactive configuration. + * + * @author Rob Winch + */ +@EnableWebMvc +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class DefaultX509Configuration { + // tag::springSecurity[] + @Bean + fun springSecurity(http: HttpSecurity): DefaultSecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + x509 { } + } + // @formatter:on + return http.build() + } + // end::springSecurity[] + + @Bean + fun userDetailsService(): UserDetailsService { + // @formatter:off + val user = User + .withUsername("rod") + .password("password") + .roles("USER") + .build() + // @formatter:on + return InMemoryUserDetailsManager(user) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt new file mode 100644 index 00000000000..edaf5030392 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/servletx509config/X509ConfigurationTests.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2025 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.kt.docs.servlet.authentication.servlet509config + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated +import org.springframework.security.web.authentication.preauth.x509.X509TestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension::class) +class X509ConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @Throws(Exception::class) + fun x509WhenDefaultX509Configuration() { + this.spring.register(DefaultX509Configuration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("rod")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun x509WhenCustomX509Configuration() { + this.spring.register(CustomX509Configuration::class.java, Http200Controller::class.java).autowire() + val certificate = X509TestUtils.buildTestCertificate() + // @formatter:off + this.mockMvc!!.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509(certificate))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("luke@monkeymachine")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml new file mode 100644 index 00000000000..cc2772e34bb --- /dev/null +++ b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/CustomX509Configuration.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml new file mode 100644 index 00000000000..62621d1cabe --- /dev/null +++ b/docs/src/test/resources/org/springframework/security/docs/servlet/authentication/servletx509config/DefaultX509Configuration.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + +