Add ResponseValidator

Issue gh-14264
Closes gh-16915
This commit is contained in:
Josh Cummings 2025-04-09 14:24:00 -06:00
parent 47e1fc045f
commit 3e686abf50
No known key found for this signature in database
GPG Key ID: 869B37A20E876129
4 changed files with 179 additions and 7 deletions

View File

@ -359,6 +359,30 @@ provider.setResponseValidator((responseToken) -> {
});
----
When using `OpenSaml5AuthenticationProvider`, you can do the same with less boilerplate:
[source,java]
----
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
ResponseValidator responseValidator = ResponseValidator.withDefaults(myCustomValidator);
provider.setResponseValidator(responseValidator);
----
You can also customize which validation steps Spring Security should do.
For example, if you want to skip `Response#InResponseTo` validation, you can call ``ResponseValidator``'s constructor, excluding `InResponseToValidator` from the list:
[source,java]
----
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
ResponseValidator responseValidator = new ResponseValidator(new DestinationValidator(), new IssuerValidator());
provider.setResponseValidator(responseValidator);
----
[TIP]
====
OpenSAML performs `Asssertion#InResponseTo` validation in its `BearerSubjectConfirmationValidator` class, which is configurable using <<_performing_additional_assertion_validation, setAssertionValidator>>.
====
== Performing Additional Assertion Validation
`OpenSaml4AuthenticationProvider` performs minimal validation on SAML 2.0 Assertions.
After verifying the signature, it will:

View File

@ -183,7 +183,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
};
}
private static List<String> getStatusCodes(Response response) {
static List<String> getStatusCodes(Response response) {
if (response.getStatus() == null) {
return List.of(StatusCode.SUCCESS);
}
@ -206,7 +206,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
return List.of(parentStatusCodeValue, childStatusCodeValue);
}
private static boolean isSuccess(List<String> statusCodes) {
static boolean isSuccess(List<String> statusCodes) {
if (statusCodes.size() != 1) {
return false;
}
@ -215,7 +215,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
return StatusCode.SUCCESS.equals(statusCode);
}
private static Saml2ResponseValidatorResult validateInResponseTo(AbstractSaml2AuthenticationRequest storedRequest,
static Saml2ResponseValidatorResult validateInResponseTo(AbstractSaml2AuthenticationRequest storedRequest,
String inResponseTo) {
if (!StringUtils.hasText(inResponseTo)) {
return Saml2ResponseValidatorResult.success();

View File

@ -53,6 +53,7 @@ import org.opensaml.xmlsec.signature.support.SignaturePrevalidator;
import org.opensaml.xmlsec.signature.support.SignatureTrustEngine;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
@ -60,6 +61,7 @@ import org.springframework.security.core.AuthenticationException;
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.StringUtils;
@ -114,6 +116,7 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
*/
public OpenSaml5AuthenticationProvider() {
this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml5Template());
setResponseValidator(ResponseValidator.withDefaults());
setAssertionValidator(AssertionValidator.withDefaults());
}
@ -301,12 +304,11 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
* Construct a default strategy for validating the SAML 2.0 Response
* @return the default response validator strategy
* @since 5.6
* @deprecated please use {@link ResponseValidator#withDefaults()} instead
*/
@Deprecated
public static Converter<ResponseToken, Saml2ResponseValidatorResult> createDefaultResponseValidator() {
Converter<BaseOpenSamlAuthenticationProvider.ResponseToken, Saml2ResponseValidatorResult> delegate = BaseOpenSamlAuthenticationProvider
.createDefaultResponseValidator();
return (token) -> delegate
.convert(new BaseOpenSamlAuthenticationProvider.ResponseToken(token.getResponse(), token.getToken()));
return ResponseValidator.withDefaults();
}
/**
@ -459,6 +461,135 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
}
/**
* A response validator that checks the {@code InResponseTo} value against the
* correlating {@link AbstractSaml2AuthenticationRequest}
*
* @since 6.5
*/
public static final class InResponseToValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
@Override
@NonNull
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
AbstractSaml2AuthenticationRequest request = responseToken.getToken().getAuthenticationRequest();
Response response = responseToken.getResponse();
String inResponseTo = response.getInResponseTo();
return BaseOpenSamlAuthenticationProvider.validateInResponseTo(request, inResponseTo);
}
}
/**
* A response validator that compares the {@code Destination} value to the configured
* {@link RelyingPartyRegistration#getAssertionConsumerServiceLocation()}
*
* @since 6.5
*/
public static final class DestinationValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
@Override
@NonNull
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
Response response = responseToken.getResponse();
Saml2AuthenticationToken token = responseToken.getToken();
String destination = response.getDestination();
String location = token.getRelyingPartyRegistration().getAssertionConsumerServiceLocation();
if (StringUtils.hasText(destination) && !destination.equals(location)) {
String message = "Invalid destination [" + destination + "] for SAML response [" + response.getID()
+ "]";
return Saml2ResponseValidatorResult
.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, message));
}
return Saml2ResponseValidatorResult.success();
}
}
/**
* A response validator that compares the {@code Issuer} value to the configured
* {@link AssertingPartyMetadata#getEntityId()}
*
* @since 6.5
*/
public static final class IssuerValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
@Override
@NonNull
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
Response response = responseToken.getResponse();
Saml2AuthenticationToken token = responseToken.getToken();
String issuer = response.getIssuer().getValue();
String assertingPartyEntityId = token.getRelyingPartyRegistration()
.getAssertingPartyMetadata()
.getEntityId();
if (!StringUtils.hasText(issuer) || !issuer.equals(assertingPartyEntityId)) {
String message = String.format("Invalid issuer [%s] for SAML response [%s]", issuer, response.getID());
return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, message));
}
return Saml2ResponseValidatorResult.success();
}
}
/**
* A composite response validator that confirms a {@code SUCCESS} status, that there
* is at least one assertion, and any other configured converters
*
* @since 6.5
* @see InResponseToValidator
* @see DestinationValidator
* @see IssuerValidator
*/
public static final class ResponseValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
private static final List<Converter<ResponseToken, Saml2ResponseValidatorResult>> DEFAULTS = List
.of(new InResponseToValidator(), new DestinationValidator(), new IssuerValidator());
private final List<Converter<ResponseToken, Saml2ResponseValidatorResult>> validators;
@SafeVarargs
public ResponseValidator(Converter<ResponseToken, Saml2ResponseValidatorResult>... validators) {
this.validators = List.of(validators);
Assert.notEmpty(this.validators, "validators cannot be empty");
}
public static ResponseValidator withDefaults() {
return new ResponseValidator(new InResponseToValidator(), new DestinationValidator(),
new IssuerValidator());
}
@SafeVarargs
public static ResponseValidator withDefaults(
Converter<ResponseToken, Saml2ResponseValidatorResult>... validators) {
List<Converter<ResponseToken, Saml2ResponseValidatorResult>> defaults = new ArrayList<>(DEFAULTS);
defaults.addAll(List.of(validators));
return new ResponseValidator(defaults.toArray(Converter[]::new));
}
@Override
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
Response response = responseToken.getResponse();
Collection<Saml2Error> errors = new ArrayList<>();
List<String> statusCodes = BaseOpenSamlAuthenticationProvider.getStatusCodes(response);
if (!BaseOpenSamlAuthenticationProvider.isSuccess(statusCodes)) {
for (String statusCode : statusCodes) {
String message = String.format("Invalid status [%s] for SAML response [%s]", statusCode,
response.getID());
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, message));
}
}
for (Converter<ResponseToken, Saml2ResponseValidatorResult> validator : this.validators) {
errors.addAll(validator.convert(responseToken).getErrors());
}
if (response.getAssertions().isEmpty()) {
errors.add(new Saml2Error(Saml2ErrorCodes.MALFORMED_RESPONSE_DATA, "No assertions found in response."));
}
return Saml2ResponseValidatorResult.failure(errors);
}
}
/**
* A default implementation of {@link OpenSaml5AuthenticationProvider}'s assertion
* validator. This does not check the signature as signature verification is performed

View File

@ -78,6 +78,7 @@ 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.ResponseToken;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseValidator;
import org.springframework.security.saml2.provider.service.authentication.TestCustomOpenSaml5Objects.CustomOpenSamlObject;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
@ -754,6 +755,22 @@ public class OpenSaml5AuthenticationProviderTests {
verify(validator).convert(any(OpenSaml5AuthenticationProvider.ResponseToken.class));
}
@Test
public void authenticateWhenCustomSetOfResponseValidatorsThenUses() {
Converter<OpenSaml5AuthenticationProvider.ResponseToken, Saml2ResponseValidatorResult> validator = mock(
Converter.class);
given(validator.convert(any()))
.willReturn(Saml2ResponseValidatorResult.failure(new Saml2Error("error", "description")));
ResponseValidator responseValidator = new ResponseValidator(validator);
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
provider.setResponseValidator(responseValidator);
Response response = TestOpenSamlObjects.signedResponseWithOneAssertion();
Saml2AuthenticationToken token = token(response, verifying(registration()));
assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token))
.withMessageContaining("description");
verify(validator).convert(any());
}
@Test
public void authenticateWhenResponseStatusIsNotSuccessThenOnlyReturnParentStatusCodes() {
Saml2AuthenticationToken token = TestSaml2AuthenticationTokens.token();