From 3e686abf50da71b5f00aefb2a37f425b201c804f Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:24:00 -0600 Subject: [PATCH] Add ResponseValidator Issue gh-14264 Closes gh-16915 --- .../servlet/saml2/login/authentication.adoc | 24 +++ .../BaseOpenSamlAuthenticationProvider.java | 6 +- .../OpenSaml5AuthenticationProvider.java | 139 +++++++++++++++++- .../OpenSaml5AuthenticationProviderTests.java | 17 +++ 4 files changed, 179 insertions(+), 7 deletions(-) diff --git a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc index 9fc64c2638..f4feb3c1f6 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc @@ -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: 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 5199f43af9..e0a0d3a6e7 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 @@ -183,7 +183,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider { }; } - private static List getStatusCodes(Response response) { + static List 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 statusCodes) { + static boolean isSuccess(List 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(); 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 f6ca75e1cc..985797de23 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 @@ -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 createDefaultResponseValidator() { - Converter 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 { + + @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 { + + @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 { + + @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 { + + private static final List> DEFAULTS = List + .of(new InResponseToValidator(), new DestinationValidator(), new IssuerValidator()); + + private final List> validators; + + @SafeVarargs + public ResponseValidator(Converter... 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... validators) { + List> 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 errors = new ArrayList<>(); + List 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 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 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 a0ef0de8cf..3c324875a9 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 @@ -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 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();