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
This commit is contained in:
Josh Cummings
2025-04-07 16:35:28 -06:00
parent 3e686abf50
commit 3869b13e68
7 changed files with 470 additions and 15 deletions

View File

@@ -110,6 +110,8 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
private Converter<ResponseToken, ? extends AbstractAuthenticationToken> responseAuthenticationConverter = createDefaultResponseAuthenticationConverter();
private boolean validateResponseAfterAssertions = false;
private static final Set<String> 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<ResponseToken, Saml2ResponseValidatorResult> 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<String, List<Object>> getAssertionAttributes(Assertion assertion) {
static Map<String, List<Object>> getAssertionAttributes(Assertion assertion) {
MultiValueMap<String, Object> 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<String> getSessionIndexes(Assertion assertion) {
static List<String> getSessionIndexes(Assertion assertion) {
List<String> sessionIndexes = new ArrayList<>();
for (AuthnStatement statement : assertion.getAuthnStatements()) {
sessionIndexes.add(statement.getSessionIndex());

View File

@@ -85,6 +85,7 @@ public final class OpenSaml4AuthenticationProvider implements AuthenticationProv
*/
public OpenSaml4AuthenticationProvider() {
this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml4Template());
this.delegate.setValidateResponseAfterAssertions(false);
}
/**

View File

@@ -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<ResponseToken, Saml2Authentication> createDefaultResponseAuthenticationConverter() {
Converter<BaseOpenSamlAuthenticationProvider.ResponseToken, Saml2Authentication> 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<ResponseToken, Saml2Authentication> {
private Converter<Assertion, String> principalNameConverter = ResponseAuthenticationConverter::authenticatedPrincipal;
private Converter<Assertion, Collection<GrantedAuthority>> 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<String, List<Object>> attributes = BaseOpenSamlAuthenticationProvider.getAssertionAttributes(assertion);
List<String> 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.
*
* <p>
* Note that because of this, if there is no
* {@link org.opensaml.saml.saml2.core.NameID} present, then the default throws an
* exception.
* </p>
* @param principalNameConverter the conversion strategy to use
*/
public void setPrincipalNameConverter(Converter<Assertion, String> 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<Assertion, Collection<GrantedAuthority>> 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<GrantedAuthority> grantedAuthorities(Assertion assertion) {
return AuthorityUtils.createAuthorityList("ROLE_USER");
}
}
}

View File

@@ -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<Assertion, Collection<GrantedAuthority>> 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<ResponseToken, Saml2Authentication> 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<ResponseToken, Saml2Authentication> 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