Skip to content

Commit 3e686ab

Browse files
committed
Add ResponseValidator
Issue gh-14264 Closes gh-16915
1 parent 47e1fc0 commit 3e686ab

File tree

4 files changed

+179
-7
lines changed

4 files changed

+179
-7
lines changed

docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc

+24
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,30 @@ provider.setResponseValidator((responseToken) -> {
359359
});
360360
----
361361

362+
When using `OpenSaml5AuthenticationProvider`, you can do the same with less boilerplate:
363+
364+
[source,java]
365+
----
366+
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
367+
ResponseValidator responseValidator = ResponseValidator.withDefaults(myCustomValidator);
368+
provider.setResponseValidator(responseValidator);
369+
----
370+
371+
You can also customize which validation steps Spring Security should do.
372+
For example, if you want to skip `Response#InResponseTo` validation, you can call ``ResponseValidator``'s constructor, excluding `InResponseToValidator` from the list:
373+
374+
[source,java]
375+
----
376+
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
377+
ResponseValidator responseValidator = new ResponseValidator(new DestinationValidator(), new IssuerValidator());
378+
provider.setResponseValidator(responseValidator);
379+
----
380+
381+
[TIP]
382+
====
383+
OpenSAML performs `Asssertion#InResponseTo` validation in its `BearerSubjectConfirmationValidator` class, which is configurable using <<_performing_additional_assertion_validation, setAssertionValidator>>.
384+
====
385+
362386
== Performing Additional Assertion Validation
363387
`OpenSaml4AuthenticationProvider` performs minimal validation on SAML 2.0 Assertions.
364388
After verifying the signature, it will:

saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ static Converter<ResponseToken, Saml2ResponseValidatorResult> createDefaultRespo
183183
};
184184
}
185185

186-
private static List<String> getStatusCodes(Response response) {
186+
static List<String> getStatusCodes(Response response) {
187187
if (response.getStatus() == null) {
188188
return List.of(StatusCode.SUCCESS);
189189
}
@@ -206,7 +206,7 @@ private static List<String> getStatusCodes(Response response) {
206206
return List.of(parentStatusCodeValue, childStatusCodeValue);
207207
}
208208

209-
private static boolean isSuccess(List<String> statusCodes) {
209+
static boolean isSuccess(List<String> statusCodes) {
210210
if (statusCodes.size() != 1) {
211211
return false;
212212
}
@@ -215,7 +215,7 @@ private static boolean isSuccess(List<String> statusCodes) {
215215
return StatusCode.SUCCESS.equals(statusCode);
216216
}
217217

218-
private static Saml2ResponseValidatorResult validateInResponseTo(AbstractSaml2AuthenticationRequest storedRequest,
218+
static Saml2ResponseValidatorResult validateInResponseTo(AbstractSaml2AuthenticationRequest storedRequest,
219219
String inResponseTo) {
220220
if (!StringUtils.hasText(inResponseTo)) {
221221
return Saml2ResponseValidatorResult.success();

saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java

+135-4
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@
5353
import org.opensaml.xmlsec.signature.support.SignatureTrustEngine;
5454

5555
import org.springframework.core.convert.converter.Converter;
56+
import org.springframework.lang.NonNull;
5657
import org.springframework.security.authentication.AbstractAuthenticationToken;
5758
import org.springframework.security.authentication.AuthenticationProvider;
5859
import org.springframework.security.core.Authentication;
5960
import org.springframework.security.core.AuthenticationException;
6061
import org.springframework.security.saml2.core.Saml2Error;
6162
import org.springframework.security.saml2.core.Saml2ErrorCodes;
6263
import org.springframework.security.saml2.core.Saml2ResponseValidatorResult;
64+
import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata;
6365
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
6466
import org.springframework.util.Assert;
6567
import org.springframework.util.StringUtils;
@@ -114,6 +116,7 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
114116
*/
115117
public OpenSaml5AuthenticationProvider() {
116118
this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml5Template());
119+
setResponseValidator(ResponseValidator.withDefaults());
117120
setAssertionValidator(AssertionValidator.withDefaults());
118121
}
119122

@@ -301,12 +304,11 @@ public void setResponseAuthenticationConverter(
301304
* Construct a default strategy for validating the SAML 2.0 Response
302305
* @return the default response validator strategy
303306
* @since 5.6
307+
* @deprecated please use {@link ResponseValidator#withDefaults()} instead
304308
*/
309+
@Deprecated
305310
public static Converter<ResponseToken, Saml2ResponseValidatorResult> createDefaultResponseValidator() {
306-
Converter<BaseOpenSamlAuthenticationProvider.ResponseToken, Saml2ResponseValidatorResult> delegate = BaseOpenSamlAuthenticationProvider
307-
.createDefaultResponseValidator();
308-
return (token) -> delegate
309-
.convert(new BaseOpenSamlAuthenticationProvider.ResponseToken(token.getResponse(), token.getToken()));
311+
return ResponseValidator.withDefaults();
310312
}
311313

312314
/**
@@ -459,6 +461,135 @@ public Saml2AuthenticationToken getToken() {
459461

460462
}
461463

464+
/**
465+
* A response validator that checks the {@code InResponseTo} value against the
466+
* correlating {@link AbstractSaml2AuthenticationRequest}
467+
*
468+
* @since 6.5
469+
*/
470+
public static final class InResponseToValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
471+
472+
@Override
473+
@NonNull
474+
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
475+
AbstractSaml2AuthenticationRequest request = responseToken.getToken().getAuthenticationRequest();
476+
Response response = responseToken.getResponse();
477+
String inResponseTo = response.getInResponseTo();
478+
return BaseOpenSamlAuthenticationProvider.validateInResponseTo(request, inResponseTo);
479+
}
480+
481+
}
482+
483+
/**
484+
* A response validator that compares the {@code Destination} value to the configured
485+
* {@link RelyingPartyRegistration#getAssertionConsumerServiceLocation()}
486+
*
487+
* @since 6.5
488+
*/
489+
public static final class DestinationValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
490+
491+
@Override
492+
@NonNull
493+
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
494+
Response response = responseToken.getResponse();
495+
Saml2AuthenticationToken token = responseToken.getToken();
496+
String destination = response.getDestination();
497+
String location = token.getRelyingPartyRegistration().getAssertionConsumerServiceLocation();
498+
if (StringUtils.hasText(destination) && !destination.equals(location)) {
499+
String message = "Invalid destination [" + destination + "] for SAML response [" + response.getID()
500+
+ "]";
501+
return Saml2ResponseValidatorResult
502+
.failure(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, message));
503+
}
504+
return Saml2ResponseValidatorResult.success();
505+
}
506+
507+
}
508+
509+
/**
510+
* A response validator that compares the {@code Issuer} value to the configured
511+
* {@link AssertingPartyMetadata#getEntityId()}
512+
*
513+
* @since 6.5
514+
*/
515+
public static final class IssuerValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
516+
517+
@Override
518+
@NonNull
519+
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
520+
Response response = responseToken.getResponse();
521+
Saml2AuthenticationToken token = responseToken.getToken();
522+
String issuer = response.getIssuer().getValue();
523+
String assertingPartyEntityId = token.getRelyingPartyRegistration()
524+
.getAssertingPartyMetadata()
525+
.getEntityId();
526+
if (!StringUtils.hasText(issuer) || !issuer.equals(assertingPartyEntityId)) {
527+
String message = String.format("Invalid issuer [%s] for SAML response [%s]", issuer, response.getID());
528+
return Saml2ResponseValidatorResult.failure(new Saml2Error(Saml2ErrorCodes.INVALID_ISSUER, message));
529+
}
530+
return Saml2ResponseValidatorResult.success();
531+
}
532+
533+
}
534+
535+
/**
536+
* A composite response validator that confirms a {@code SUCCESS} status, that there
537+
* is at least one assertion, and any other configured converters
538+
*
539+
* @since 6.5
540+
* @see InResponseToValidator
541+
* @see DestinationValidator
542+
* @see IssuerValidator
543+
*/
544+
public static final class ResponseValidator implements Converter<ResponseToken, Saml2ResponseValidatorResult> {
545+
546+
private static final List<Converter<ResponseToken, Saml2ResponseValidatorResult>> DEFAULTS = List
547+
.of(new InResponseToValidator(), new DestinationValidator(), new IssuerValidator());
548+
549+
private final List<Converter<ResponseToken, Saml2ResponseValidatorResult>> validators;
550+
551+
@SafeVarargs
552+
public ResponseValidator(Converter<ResponseToken, Saml2ResponseValidatorResult>... validators) {
553+
this.validators = List.of(validators);
554+
Assert.notEmpty(this.validators, "validators cannot be empty");
555+
}
556+
557+
public static ResponseValidator withDefaults() {
558+
return new ResponseValidator(new InResponseToValidator(), new DestinationValidator(),
559+
new IssuerValidator());
560+
}
561+
562+
@SafeVarargs
563+
public static ResponseValidator withDefaults(
564+
Converter<ResponseToken, Saml2ResponseValidatorResult>... validators) {
565+
List<Converter<ResponseToken, Saml2ResponseValidatorResult>> defaults = new ArrayList<>(DEFAULTS);
566+
defaults.addAll(List.of(validators));
567+
return new ResponseValidator(defaults.toArray(Converter[]::new));
568+
}
569+
570+
@Override
571+
public Saml2ResponseValidatorResult convert(ResponseToken responseToken) {
572+
Response response = responseToken.getResponse();
573+
Collection<Saml2Error> errors = new ArrayList<>();
574+
List<String> statusCodes = BaseOpenSamlAuthenticationProvider.getStatusCodes(response);
575+
if (!BaseOpenSamlAuthenticationProvider.isSuccess(statusCodes)) {
576+
for (String statusCode : statusCodes) {
577+
String message = String.format("Invalid status [%s] for SAML response [%s]", statusCode,
578+
response.getID());
579+
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_RESPONSE, message));
580+
}
581+
}
582+
for (Converter<ResponseToken, Saml2ResponseValidatorResult> validator : this.validators) {
583+
errors.addAll(validator.convert(responseToken).getErrors());
584+
}
585+
if (response.getAssertions().isEmpty()) {
586+
errors.add(new Saml2Error(Saml2ErrorCodes.MALFORMED_RESPONSE_DATA, "No assertions found in response."));
587+
}
588+
return Saml2ResponseValidatorResult.failure(errors);
589+
}
590+
591+
}
592+
462593
/**
463594
* A default implementation of {@link OpenSaml5AuthenticationProvider}'s assertion
464595
* validator. This does not check the signature as signature verification is performed

saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java

+17
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
import org.springframework.security.saml2.core.TestSaml2X509Credentials;
7979
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.AssertionValidator;
8080
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseToken;
81+
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseValidator;
8182
import org.springframework.security.saml2.provider.service.authentication.TestCustomOpenSaml5Objects.CustomOpenSamlObject;
8283
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
8384
import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
@@ -754,6 +755,22 @@ public void authenticateWhenCustomResponseValidatorThenUses() {
754755
verify(validator).convert(any(OpenSaml5AuthenticationProvider.ResponseToken.class));
755756
}
756757

758+
@Test
759+
public void authenticateWhenCustomSetOfResponseValidatorsThenUses() {
760+
Converter<OpenSaml5AuthenticationProvider.ResponseToken, Saml2ResponseValidatorResult> validator = mock(
761+
Converter.class);
762+
given(validator.convert(any()))
763+
.willReturn(Saml2ResponseValidatorResult.failure(new Saml2Error("error", "description")));
764+
ResponseValidator responseValidator = new ResponseValidator(validator);
765+
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
766+
provider.setResponseValidator(responseValidator);
767+
Response response = TestOpenSamlObjects.signedResponseWithOneAssertion();
768+
Saml2AuthenticationToken token = token(response, verifying(registration()));
769+
assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token))
770+
.withMessageContaining("description");
771+
verify(validator).convert(any());
772+
}
773+
757774
@Test
758775
public void authenticateWhenResponseStatusIsNotSuccessThenOnlyReturnParentStatusCodes() {
759776
Saml2AuthenticationToken token = TestSaml2AuthenticationTokens.token();

0 commit comments

Comments
 (0)