Browse Source

Add Support SubjectX500PrincipalExtractor

Closes gh-16980

Signed-off-by: Max Batischev <mblancer@mail.ru>
Max Batischev 3 months ago
parent
commit
aba437d469

+ 27 - 1
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.PreAuthenticatedA
 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 @@ import org.springframework.security.web.context.RequestAttributeSecurityContextR
  *
  * @author Rob Winch
  * @author Ngoc Nhan
+ * @author Max Batischev
  * @since 3.2
  */
 public final class X509Configurer<H extends HttpSecurityBuilder<H>>
@@ -161,14 +163,38 @@ public final class X509Configurer<H extends HttpSecurityBuilder<H>>
 	 * @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<H> 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<H> 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();

+ 15 - 1
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.PreAuthenticatedG
 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 @@ final class AuthenticationConfigBuilder {
 			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);

+ 6 - 6
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.BearerToke
 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 class ServerHttpSecurity {
 	 *  }
 	 * </pre>
 	 *
-	 * 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 class ServerHttpSecurity {
 	 *  }
 	 * </pre>
 	 *
-	 * 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 @@ public class ServerHttpSecurity {
 			if (this.principalExtractor != null) {
 				return this.principalExtractor;
 			}
-			return new SubjectDnX509PrincipalExtractor();
+			return new SubjectX500PrincipalExtractor();
 		}
 
 		private ReactiveAuthenticationManager getAuthenticationManager() {

+ 3 - 0
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.

+ 6 - 0
config/src/main/resources/org/springframework/security/config/spring-security-7.0.xsd

@@ -2917,6 +2917,12 @@
                 </xs:documentation>
          </xs:annotation>
       </xs:attribute>
+      <xs:attribute name="extract-principal-name-from-email" type="xs:token">
+         <xs:annotation>
+            <xs:documentation>If true then DN will be extracted from EMAIlADDRESS
+                </xs:documentation>
+         </xs:annotation>
+      </xs:attribute>
   </xs:attributeGroup>
   <xs:element name="jee">
       <xs:annotation>

+ 38 - 1
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 class X509ConfigurerTests {
 		// @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 @@ public class X509ConfigurerTests {
 
 	}
 
+	@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 {

+ 17 - 0
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-----

+ 3 - 0
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]]
 == <filter-chain-map>

+ 5 - 1
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 @@ import org.springframework.util.Assert;
  * "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 class SubjectDnX509PrincipalExtractor implements X509PrincipalExtractor,
 	@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);

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

+ 2 - 2
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 @@ import org.springframework.security.web.authentication.preauth.AbstractPreAuthen
  */
 public class X509AuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
 
-	private X509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor();
+	private X509PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor();
 
 	@Override
 	protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {

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