From 3869b13e682c98370e2766eaba7a4d56d0159dff Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:35:28 -0600 Subject: [PATCH] Add ResponseAuthenticationConverter Aside from simplifying configuration, this commit also makes it possible to provide a response authentication converter that doesn't need the NameID element to be present. Closes gh-12136 --- .../modules/ROOT/pages/migration-7/saml2.adoc | 105 ++++++++++ .../servlet/saml2/login/authentication.adoc | 195 ++++++++++++++++++ .../ROOT/pages/servlet/saml2/logout.adoc | 4 +- .../BaseOpenSamlAuthenticationProvider.java | 31 ++- .../OpenSaml4AuthenticationProvider.java | 1 + .../OpenSaml5AuthenticationProvider.java | 103 ++++++++- .../OpenSaml5AuthenticationProviderTests.java | 46 +++++ 7 files changed, 470 insertions(+), 15 deletions(-) diff --git a/docs/modules/ROOT/pages/migration-7/saml2.adoc b/docs/modules/ROOT/pages/migration-7/saml2.adoc index 9a8cb60080..eed236a64b 100644 --- a/docs/modules/ROOT/pages/migration-7/saml2.adoc +++ b/docs/modules/ROOT/pages/migration-7/saml2.adoc @@ -58,3 +58,108 @@ Xml:: ---- ====== + +== Validate Response After Validating Assertions + +In Spring Security 6, the order of authenticating a `` is as follows: + +1. Verify the Response Signature, if any +2. Decrypt the Response +3. Validate Response attributes, like Destination and Issuer +4. For each assertion, verify the signature, decrypt, and then validate its fields +5. Check to ensure that the response has at least one assertion with a name field + +This ordering sometimes poses challenges since some response validation is being done in Step 3 and some in Step 5. +Specifically, this poses a chellenge when an application doesn't have a name field and doesn't need it to be validated. + +In Spring Security 7, this is simplified by moving response validation to after assertion validation and combining the two separate validation steps 3 and 5. +When this is complete, response validation will no longer check for the existence of the `NameID` attribute and rely on ``ResponseAuthenticationConverter``s to do this. + +This will add support ``ResponseAuthenticationConverter``s that don't use the `NameID` element in their `Authentication` instance and so don't need it validated. + +To opt-in to this behavior in advance, use `OpenSaml5AuthenticationProvider#setValidateResponseAfterAssertions` to `true` like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); +provider.setValidateResponseAfterAssertions(true); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +val provider = OpenSaml5AuthenticationProvider() +provider.setValidateResponseAfterAssertions(true) +---- +====== + +This will change the authentication steps as follows: + +1. Verify the Response Signature, if any +2. Decrypt the Response +3. For each assertion, verify the signature, decrypt, and then validate its fields +4. Validate Response attributes, like Destination and Issuer + +Note that if you have a custom response authentication converter, then you are now responsible to check if the `NameID` element exists in the event that you need it. + +Alternatively to updating your response authentication converter, you can specify a custom `ResponseValidator` that adds back in the check for the `NameID` element as follows: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); +provider.setValidateResponseAfterAssertions(true); +ResponseValidator responseValidator = ResponseValidator.withDefaults((responseToken) -> { + Response response = responseToken.getResponse(); + Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); + Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, + "Assertion [" + firstAssertion.getID() + "] is missing a subject"); + Saml2ResponseValidationResult failed = Saml2ResponseValidationResult.failure(error); + if (assertion.getSubject() == null) { + return failed; + } + if (assertion.getSubject().getNameID() == null) { + return failed; + } + if (assertion.getSubject().getNameID().getValue() == null) { + return failed; + } + return Saml2ResponseValidationResult.success(); +}); +provider.setResponseValidator(responseValidator); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +val provider = OpenSaml5AuthenticationProvider() +provider.setValidateResponseAfterAssertions(true) +val responseValidator = ResponseValidator.withDefaults { responseToken: ResponseToken -> + val response = responseToken.getResponse() + val assertion = CollectionUtils.firstElement(response.getAssertions()) + val error = Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, + "Assertion [" + firstAssertion.getID() + "] is missing a subject") + val failed = Saml2ResponseValidationResult.failure(error) + if (assertion.getSubject() == null) { + return@withDefaults failed + } + if (assertion.getSubject().getNameID() == null) { + return@withDefaults failed + } + if (assertion.getSubject().getNameID().getValue() == null) { + return@withDefaults failed + } + return@withDefaults Saml2ResponseValidationResult.success() +} +provider.setResponseValidator(responseValidator) +---- +====== diff --git a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc index f4feb3c1f6..1c517914dc 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc @@ -250,12 +250,135 @@ class SecurityConfig { ---- ====== +== Converting an `Assertion` into an `Authentication` + +`OpenSamlXAuthenticationProvider#setResponseAuthenticationConverter` provides a way for you to change how it converts your assertion into an `Authentication` instance. + +You can set a custom converter in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Autowired + Converter authenticationConverter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + OpenSaml5AuthenticationProvider authenticationProvider = new OpenSaml5AuthenticationProvider(); + authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter); + + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated()) + .saml2Login((saml2) -> saml2 + .authenticationManager(new ProviderManager(authenticationProvider)) + ); + return http.build(); + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebSecurity +open class SecurityConfig { + @Autowired + var authenticationConverter: Converter? = null + + @Bean + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + val authenticationProvider = OpenSaml5AuthenticationProvider() + authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter) + http { + authorizeRequests { + authorize(anyRequest, authenticated) + } + saml2Login { + authenticationManager = ProviderManager(authenticationProvider) + } + } + return http.build() + } +} +---- +====== + +The ensuing examples all build off of this common construct to show you different ways this converter comes in handy. + [[servlet-saml2login-opensamlauthenticationprovider-userdetailsservice]] == Coordinating with a `UserDetailsService` Or, perhaps you would like to include user details from a legacy `UserDetailsService`. In that case, the response authentication converter can come in handy, as can be seen below: +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +class MyUserDetailsResponseAuthenticationConverter implements Converter { + private final ResponseAuthenticationConverter delegate = new ResponseAuthenticationConverter(); + private final UserDetailsService userDetailsService; + + MyUserDetailsResponseAuthenticationConverter(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public Saml2Authentication convert(ResponseToken responseToken) { + Saml2Authentication authentication = this.delegate.convert(responseToken); <1> + UserDetails principal = this.userDetailsService.loadByUsername(username); <2> + String saml2Response = authentication.getSaml2Response(); + Collection authorities = principal.getAuthorities(); + return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3> + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAuthenticationConverter, + UserDetailsService userDetailsService): Converter { + + @Override + open fun convert(responseToken: ResponseToken): Saml2Authentication { + val authentication = this.delegate.convert(responseToken) <1> + val principal = this.userDetailsService.loadByUsername(username) <2> + val saml2Response = authentication.getSaml2Response() + val authorities = principal.getAuthorities() + return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3> + } + +} +---- +====== +<1> First, call the default converter, which extracts attributes and authorities from the response +<2> Second, call the xref:servlet/authentication/passwords/user-details-service.adoc#servlet-authentication-userdetailsservice[`UserDetailsService`] using the relevant information +<3> Third, return an authentication that includes the user details + +[TIP] +==== +If your `UserDetailsService` returns a value that also implements `AuthenticatedPrincipal`, then you don't need a custom authentication implementation. +==== + +Or, if you are using OpenSaml 4, then you can achieve something similar as follows: + [tabs] ====== Java:: @@ -336,6 +459,78 @@ open class SecurityConfig { It's not required to call ``OpenSaml4AuthenticationProvider``'s default authentication converter. It returns a `Saml2AuthenticatedPrincipal` containing the attributes it extracted from ``AttributeStatement``s as well as the single `ROLE_USER` authority. +=== Configuring the Principal Name + +Sometimes, the principal name is not in the `` element. +In that case, you can configure the `ResponseAuthenticationConverter` with a custom strategy like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +ResponseAuthenticationConverter authenticationConverter() { + ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter(); + authenticationConverter.setPrincipalNameConverter((assertion) -> { + // ... work with OpenSAML's Assertion object to extract the principal + }); + return authenticationConverter; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authenticationConverter(): ResponseAuthenticationConverter { + val authenticationConverter: ResponseAuthenticationConverter = ResponseAuthenticationConverter() + authenticationConverter.setPrincipalNameConverter { assertion -> + // ... work with OpenSAML's Assertion object to extract the principal + } + return authenticationConverter +} +---- +====== + +=== Configuring a Principal's Granted Authorities + +Spring Security automatically grants `ROLE_USER` when using `OpenSamlXAuhenticationProvider`. +With `OpenSaml5AuthenticationProvider`, you can configure a different set of granted authorities like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +ResponseAuthenticationConverter authenticationConverter() { + ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter(); + authenticationConverter.setPrincipalNameConverter((assertion) -> { + // ... grant the needed authorities based on attributes in the assertion + }); + return authenticationConverter; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authenticationConverter(): ResponseAuthenticationConverter { + val authenticationConverter = ResponseAuthenticationConverter() + authenticationConverter.setPrincipalNameConverter{ assertion -> + // ... grant the needed authorities based on attributes in the assertion + } + return authenticationConverter +} +---- +====== + [[servlet-saml2login-opensamlauthenticationprovider-additionalvalidation]] == Performing Additional Response Validation diff --git a/docs/modules/ROOT/pages/servlet/saml2/logout.adoc b/docs/modules/ROOT/pages/servlet/saml2/logout.adoc index 03e7bb153e..edac04e737 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/logout.adoc @@ -339,7 +339,7 @@ It's common to need to set other values in the `` than the By default, Spring Security will issue a `` and supply: -* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation` +* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation` * The `ID` attribute - a GUID * The `` element - from `RelyingPartyRegistration#getEntityId` * The `` element - from `Authentication#getName` @@ -424,7 +424,7 @@ It's common to need to set other values in the `` than the By default, Spring Security will issue a `` and supply: -* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation` +* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation` * The `ID` attribute - a GUID * The `` element - from `RelyingPartyRegistration#getEntityId` * The `` element - `SUCCESS` diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java index e0a0d3a6e7..2aba4d78a1 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java @@ -110,6 +110,8 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { private Converter responseAuthenticationConverter = createDefaultResponseAuthenticationConverter(); + private boolean validateResponseAfterAssertions = false; + private static final Set includeChildStatusCodes = new HashSet<>( Arrays.asList(StatusCode.REQUESTER, StatusCode.RESPONDER, StatusCode.VERSION_MISMATCH)); @@ -143,6 +145,10 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { this.responseAuthenticationConverter = responseAuthenticationConverter; } + void setValidateResponseAfterAssertions(boolean validateResponseAfterAssertions) { + this.validateResponseAfterAssertions = validateResponseAfterAssertions; + } + static Converter createDefaultResponseValidator() { return (responseToken) -> { Response response = responseToken.getResponse(); @@ -321,7 +327,9 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, "Did not decrypt response [" + response.getID() + "] since it is not signed")); } - result = result.concat(this.responseValidator.convert(responseToken)); + if (!this.validateResponseAfterAssertions) { + result = result.concat(this.responseValidator.convert(responseToken)); + } boolean allAssertionsSigned = true; for (Assertion assertion : response.getAssertions()) { AssertionToken assertionToken = new AssertionToken(assertion, token); @@ -337,11 +345,16 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { + "Please either sign the response or all of the assertions."; result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, description)); } - Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions()); - if (firstAssertion != null && !hasName(firstAssertion)) { - Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, - "Assertion [" + firstAssertion.getID() + "] is missing a subject"); - result = result.concat(error); + if (this.validateResponseAfterAssertions) { + result = result.concat(this.responseValidator.convert(responseToken)); + } + else { + Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions()); + if (firstAssertion != null && !hasName(firstAssertion)) { + Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, + "Assertion [" + firstAssertion.getID() + "] is missing a subject"); + result = result.concat(error); + } } if (result.hasErrors()) { @@ -422,7 +435,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { }; } - private boolean hasName(Assertion assertion) { + static boolean hasName(Assertion assertion) { if (assertion == null) { return false; } @@ -435,7 +448,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { return assertion.getSubject().getNameID().getValue() != null; } - private static Map> getAssertionAttributes(Assertion assertion) { + static Map> getAssertionAttributes(Assertion assertion) { MultiValueMap attributeMap = new LinkedMultiValueMap<>(); for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) { for (Attribute attribute : attributeStatement.getAttributes()) { @@ -452,7 +465,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { return new LinkedHashMap<>(attributeMap); // gh-11785 } - private static List getSessionIndexes(Assertion assertion) { + static List getSessionIndexes(Assertion assertion) { List sessionIndexes = new ArrayList<>(); for (AuthnStatement statement : assertion.getAuthnStatements()) { sessionIndexes.add(statement.getSessionIndex()); diff --git a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java index b0eede6742..f1509a560d 100644 --- a/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java @@ -85,6 +85,7 @@ public final class OpenSaml4AuthenticationProvider implements AuthenticationProv */ public OpenSaml4AuthenticationProvider() { this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml4Template()); + this.delegate.setValidateResponseAfterAssertions(false); } /** diff --git a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java index 985797de23..e3cde7974e 100644 --- a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java @@ -58,12 +58,15 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ResponseValidatorResult; import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -118,6 +121,7 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml5Template()); setResponseValidator(ResponseValidator.withDefaults()); setAssertionValidator(AssertionValidator.withDefaults()); + setResponseAuthenticationConverter(new ResponseAuthenticationConverter()); } /** @@ -300,6 +304,21 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv (token) -> responseAuthenticationConverter.convert(new ResponseToken(token))); } + /** + * Indicate when to validate response attributes, like {@code Destination} and + * {@code Issuer}. By default, this value is set to false, meaning that response + * attributes are validated first. Setting this value to {@code true} allows you to + * use a response authentication converter that doesn't rely on the {@code NameID} + * element in the {@link Response}'s assertion. + * @param validateResponseAfterAssertions when to validate response attributes + * @since 6.5 + * @see #setResponseAuthenticationConverter + * @see ResponseAuthenticationConverter + */ + public void setValidateResponseAfterAssertions(boolean validateResponseAfterAssertions) { + this.delegate.setValidateResponseAfterAssertions(validateResponseAfterAssertions); + } + /** * Construct a default strategy for validating the SAML 2.0 Response * @return the default response validator strategy @@ -373,12 +392,11 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv * Construct a default strategy for converting a SAML 2.0 Response and * {@link Authentication} token into a {@link Saml2Authentication} * @return the default response authentication converter strategy + * @deprecated please use {@link ResponseAuthenticationConverter} instead */ + @Deprecated public static Converter createDefaultResponseAuthenticationConverter() { - Converter delegate = BaseOpenSamlAuthenticationProvider - .createDefaultResponseAuthenticationConverter(); - return (token) -> delegate - .convert(new BaseOpenSamlAuthenticationProvider.ResponseToken(token.getResponse(), token.getToken())); + return new ResponseAuthenticationConverter(); } /** @@ -852,4 +870,81 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv } + /** + * A default implementation of {@link OpenSaml5AuthenticationProvider}'s response + * authentication converter. It will take the principal name from the + * {@link org.opensaml.saml.saml2.core.NameID} element. It will also extract the + * assertion attributes and session indexes. You can either configure the principal + * name converter and granted authorities converter in this class or you can + * post-process this class's result through delegation. + * + * @author Josh Cummings + * @since 6.5 + */ + public static final class ResponseAuthenticationConverter implements Converter { + + private Converter principalNameConverter = ResponseAuthenticationConverter::authenticatedPrincipal; + + private Converter> grantedAuthoritiesConverter = ResponseAuthenticationConverter::grantedAuthorities; + + @Override + public Saml2Authentication convert(ResponseToken responseToken) { + Response response = responseToken.response; + Saml2AuthenticationToken token = responseToken.token; + Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); + String username = this.principalNameConverter.convert(assertion); + Map> attributes = BaseOpenSamlAuthenticationProvider.getAssertionAttributes(assertion); + List sessionIndexes = BaseOpenSamlAuthenticationProvider.getSessionIndexes(assertion); + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, attributes, + sessionIndexes); + String registrationId = responseToken.token.getRelyingPartyRegistration().getRegistrationId(); + principal.setRelyingPartyRegistrationId(registrationId); + return new Saml2Authentication(principal, token.getSaml2Response(), + this.grantedAuthoritiesConverter.convert(assertion)); + } + + /** + * Use this strategy to extract the principal name from the {@link Assertion}. By + * default, this will retrieve it from the + * {@link org.opensaml.saml.saml2.core.Subject}'s + * {@link org.opensaml.saml.saml2.core.NameID} value. + * + *

+ * Note that because of this, if there is no + * {@link org.opensaml.saml.saml2.core.NameID} present, then the default throws an + * exception. + *

+ * @param principalNameConverter the conversion strategy to use + */ + public void setPrincipalNameConverter(Converter principalNameConverter) { + Assert.notNull(principalNameConverter, "principalNameConverter cannot be null"); + this.principalNameConverter = principalNameConverter; + } + + /** + * Use this strategy to grant authorities to a principal given the first + * {@link Assertion} in the response. By default, this will grant + * {@code ROLE_USER}. + * @param grantedAuthoritiesConverter the conversion strategy to use + */ + public void setGrantedAuthoritiesConverter( + Converter> grantedAuthoritiesConverter) { + Assert.notNull(grantedAuthoritiesConverter, "grantedAuthoritiesConverter cannot be null"); + this.grantedAuthoritiesConverter = grantedAuthoritiesConverter; + } + + private static String authenticatedPrincipal(Assertion assertion) { + if (!BaseOpenSamlAuthenticationProvider.hasName(assertion)) { + throw new Saml2AuthenticationException(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND, + "Assertion [" + assertion.getID() + "] is missing a subject")); + } + return assertion.getSubject().getNameID().getValue(); + } + + private static Collection grantedAuthorities(Assertion assertion) { + return AuthorityUtils.createAuthorityList("ROLE_USER"); + } + + } + } diff --git a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java index 3c324875a9..071a8af128 100644 --- a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java +++ b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java @@ -22,6 +22,7 @@ import java.io.ObjectOutputStream; import java.time.Duration; import java.time.Instant; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -71,12 +72,15 @@ import org.opensaml.xmlsec.signature.support.SignatureConstants; import org.springframework.core.convert.converter.Converter; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ResponseValidatorResult; import org.springframework.security.saml2.core.TestSaml2X509Credentials; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.AssertionValidator; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseAuthenticationConverter; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseToken; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseValidator; import org.springframework.security.saml2.provider.service.authentication.TestCustomOpenSaml5Objects.CustomOpenSamlObject; @@ -92,6 +96,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; /** * Tests for {@link OpenSaml5AuthenticationProvider} @@ -660,6 +665,47 @@ public class OpenSaml5AuthenticationProviderTests { verify(authenticationConverter).convert(any()); } + @Test + public void authenticateWhenResponseAuthenticationConverterComponentConfiguredThenUses() { + Converter> grantedAuthoritiesConverter = mock(Converter.class); + given(grantedAuthoritiesConverter.convert(any())).willReturn(AuthorityUtils.createAuthorityList("CUSTOM")); + ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter(); + authenticationConverter.setGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + provider.setResponseAuthenticationConverter(authenticationConverter); + Response response = TestOpenSamlObjects.signedResponseWithOneAssertion(); + Saml2AuthenticationToken token = token(response, verifying(registration())); + Authentication authentication = provider.authenticate(token); + assertThat(AuthorityUtils.authorityListToSet(authentication.getAuthorities())).containsExactly("CUSTOM"); + verify(grantedAuthoritiesConverter).convert(any()); + } + + @Test + public void authenticateWhenValidateResponseAfterAssertionsThenCanHaveResponseAuthenticationConverterThatDoesntNeedANameID() { + Converter responseAuthenticationConverter = mock(Converter.class); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + provider.setValidateResponseAfterAssertions(true); + provider.setResponseAuthenticationConverter(responseAuthenticationConverter); + Response response = TestOpenSamlObjects + .signedResponseWithOneAssertion((r) -> r.getAssertions().get(0).setSubject(null)); + Saml2AuthenticationToken token = token(response, verifying(registration())); + provider.authenticate(token); + verify(responseAuthenticationConverter).convert(any()); + } + + @Test + public void authenticateWhenValidateResponseBeforeAssertionsThenMustHaveNameID() { + Converter responseAuthenticationConverter = mock(Converter.class); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + provider.setValidateResponseAfterAssertions(false); + provider.setResponseAuthenticationConverter(responseAuthenticationConverter); + Response response = TestOpenSamlObjects + .signedResponseWithOneAssertion((r) -> r.getAssertions().get(0).setSubject(null)); + Saml2AuthenticationToken token = token(response, verifying(registration())); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)); + verifyNoInteractions(responseAuthenticationConverter); + } + @Test public void setResponseAuthenticationConverterWhenNullThenIllegalArgument() { // @formatter:off