diff --git a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle index 02cf9aea6f..8ab64e76dd 100644 --- a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle +++ b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle @@ -9,4 +9,6 @@ dependencies { compile("org.opensaml:opensaml-saml-impl") provided 'javax.servlet:javax.servlet-api' + + testCompile powerMock2Dependencies } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java index 8def38f4d4..2ca8c9380f 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java @@ -16,16 +16,12 @@ package org.springframework.security.saml2.provider.service.authentication; import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.AuthenticationServiceException; -import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.saml2.Saml2Exception; import org.springframework.security.saml2.credentials.Saml2X509Credential; import org.springframework.util.Assert; @@ -79,18 +75,59 @@ import java.util.Set; import static java.lang.String.format; import static java.util.Collections.singleton; import static java.util.Collections.singletonList; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.DECRYPTION_ERROR; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.INVALID_DESTINATION; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.INVALID_ISSUER; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.MALFORMED_RESPONSE_DATA; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.SUBJECT_NOT_FOUND; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.UNKNOWN_RESPONSE_CLASS; +import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.USERNAME_NOT_FOUND; import static org.springframework.util.Assert.notNull; import static org.springframework.util.StringUtils.hasText; /** + * Implementation of {@link AuthenticationProvider} for SAML authentications when receiving a + * {@code Response} object containing an {@code Assertion}. This implementation uses + * the {@code OpenSAML 3} library. + * + *

+ * The {@link OpenSamlAuthenticationProvider} supports {@link Saml2AuthenticationToken} objects + * that contain a SAML response in its decoded XML format {@link Saml2AuthenticationToken#getSaml2Response()} + * along with the information about the asserting party, the identity provider (IDP), as well as + * the relying party, the service provider (SP, this application). + *

+ *

+ * The {@link Saml2AuthenticationToken} will be processed into a SAML Response object. + * The SAML response object can be signed. If the Response is signed, a signature will not be required on the assertion. + *

+ *

+ * While a response object can contain a list of assertion, this provider will only leverage + * the first valid assertion for the purpose of authentication. Assertions that do not pass validation + * will be ignored. If no valid assertions are found a {@link Saml2AuthenticationException} is thrown. + *

+ *

+ * This provider supports two types of encrypted SAML elements + *

+ * If the assertion is encrypted, then signature validation on the assertion is no longer required. + *

+ *

+ * This provider does not perform an X509 certificate validation on the configured asserting party, IDP, verification + * certificates. + *

* @since 5.2 + * @see SAML 2 StatusResponse + * @see OpenSAML 3 */ public final class OpenSamlAuthenticationProvider implements AuthenticationProvider { private static Log logger = LogFactory.getLog(OpenSamlAuthenticationProvider.class); private final OpenSamlImplementation saml = OpenSamlImplementation.getInstance(); - private Converter> authoritiesExtractor = (a -> singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + private Converter> authoritiesExtractor = + (a -> singletonList(new SimpleGrantedAuthority("ROLE_USER"))); private GrantedAuthoritiesMapper authoritiesMapper = (a -> a); private Duration responseTimeValidationSkew = Duration.ofMinutes(5); @@ -138,20 +175,16 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi Saml2AuthenticationToken token = (Saml2AuthenticationToken) authentication; String xml = token.getSaml2Response(); Response samlResponse = getSaml2Response(xml); - Assertion assertion = validateSaml2Response(token, token.getRecipientUri(), samlResponse); - final String username = getUsername(token, assertion); - if (username == null) { - throw new UsernameNotFoundException("Assertion [" + - assertion.getID() + - "] is missing a user identifier"); - } + String username = getUsername(token, assertion); return new Saml2Authentication( () -> username, token.getSaml2Response(), this.authoritiesMapper.mapAuthorities(getAssertionAuthorities(assertion)) ); - }catch (Saml2Exception | IllegalArgumentException e) { - throw new AuthenticationServiceException(e.getMessage(), e); + } catch (Saml2AuthenticationException e) { + throw e; + } catch (Exception e) { + throw authException(Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR, e.getMessage(), e); } } @@ -167,88 +200,111 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi return this.authoritiesExtractor.convert(assertion); } - private String getUsername(Saml2AuthenticationToken token, Assertion assertion) { - final Subject subject = assertion.getSubject(); + private String getUsername(Saml2AuthenticationToken token, Assertion assertion) throws Saml2AuthenticationException { + String username = null; + Subject subject = assertion.getSubject(); if (subject == null) { - return null; + throw authException(SUBJECT_NOT_FOUND, "Assertion [" + assertion.getID() + "] is missing a subject"); } if (subject.getNameID() != null) { - return subject.getNameID().getValue(); + username = subject.getNameID().getValue(); } - if (subject.getEncryptedID() != null) { + else if (subject.getEncryptedID() != null) { NameID nameId = decrypt(token, subject.getEncryptedID()); - return nameId.getValue(); + username = nameId.getValue(); } - return null; + if (username == null) { + throw authException(USERNAME_NOT_FOUND, "Assertion [" + assertion.getID() + "] is missing a user identifier"); + } + return username; } private Assertion validateSaml2Response(Saml2AuthenticationToken token, String recipient, - Response samlResponse) throws AuthenticationException { + Response samlResponse) throws Saml2AuthenticationException { + //optional validation if the response contains a destination if (hasText(samlResponse.getDestination()) && !recipient.equals(samlResponse.getDestination())) { - throw new Saml2Exception("Invalid SAML response destination: " + samlResponse.getDestination()); + throw authException(INVALID_DESTINATION, "Invalid SAML response destination: " + samlResponse.getDestination()); } - final String issuer = samlResponse.getIssuer().getValue(); + String issuer = samlResponse.getIssuer().getValue(); if (logger.isDebugEnabled()) { - logger.debug("Processing SAML response from " + issuer); + logger.debug("Validating SAML response from " + issuer); } - if (token == null) { - throw new Saml2Exception(format("SAML 2 Provider for %s was not found.", issuer)); + if (!hasText(issuer) || (!issuer.equals(token.getIdpEntityId()))) { + String message = String.format("Response issuer '%s' doesn't match '%s'", issuer, token.getIdpEntityId()); + throw authException(INVALID_ISSUER, message); } + Saml2AuthenticationException lastValidationError = null; + boolean responseSigned = hasValidSignature(samlResponse, token); for (Assertion a : samlResponse.getAssertions()) { if (logger.isDebugEnabled()) { logger.debug("Checking plain assertion validity " + a); } - if (isValidAssertion(recipient, a, token, !responseSigned)) { - if (logger.isDebugEnabled()) { - logger.debug("Found valid assertion. Skipping potential others."); - } + try { + validateAssertion(recipient, a, token, !responseSigned); return a; + } catch (Saml2AuthenticationException e) { + lastValidationError = e; } } for (EncryptedAssertion ea : samlResponse.getEncryptedAssertions()) { if (logger.isDebugEnabled()) { logger.debug("Checking encrypted assertion validity " + ea); } - - Assertion a = decrypt(token, ea); - if (isValidAssertion(recipient, a, token, false)) { - if (logger.isDebugEnabled()) { - logger.debug("Found valid encrypted assertion. Skipping potential others."); - } + try { + Assertion a = decrypt(token, ea); + validateAssertion(recipient, a, token, false); return a; + } catch (Saml2AuthenticationException e) { + lastValidationError = e; } } - throw new InsufficientAuthenticationException("Unable to find a valid assertion"); + if (lastValidationError != null) { + throw lastValidationError; + } + else { + throw authException(MALFORMED_RESPONSE_DATA, "No assertions found in response."); + } } - private boolean hasValidSignature(SignableSAMLObject samlResponse, Saml2AuthenticationToken token) { - if (!samlResponse.isSigned()) { + private boolean hasValidSignature(SignableSAMLObject samlObject, Saml2AuthenticationToken token) { + if (!samlObject.isSigned()) { + if (logger.isDebugEnabled()) { + logger.debug("SAML object is not signed, no signatures found"); + } return false; } - final List verificationKeys = getVerificationKeys(token); + List verificationKeys = getVerificationCertificates(token); if (verificationKeys.isEmpty()) { return false; } - for (X509Certificate key : verificationKeys) { - final Credential credential = getVerificationCredential(key); + for (X509Certificate certificate : verificationKeys) { + Credential credential = getVerificationCredential(certificate); try { - SignatureValidator.validate(samlResponse.getSignature(), credential); + SignatureValidator.validate(samlObject.getSignature(), credential); + if (logger.isDebugEnabled()) { + logger.debug("Valid signature found in SAML object:"+samlObject.getClass().getName()); + } return true; } catch (SignatureException ignored) { - logger.debug("Signature validation failed", ignored); + if (logger.isTraceEnabled()) { + logger.trace("Signature validation failed with cert:"+certificate.toString(), ignored); + } + else if (logger.isDebugEnabled()) { + logger.debug("Signature validation failed with cert:"+certificate.toString()); + } } } return false; } - private boolean isValidAssertion(String recipient, Assertion a, Saml2AuthenticationToken token, boolean signatureRequired) { - final SAML20AssertionValidator validator = getAssertionValidator(token); + private void validateAssertion(String recipient, Assertion a, Saml2AuthenticationToken token, boolean signatureRequired) { + SAML20AssertionValidator validator = getAssertionValidator(token); Map validationParams = new HashMap<>(); validationParams.put(SAML2AssertionValidationParameters.SIGNATURE_REQUIRED, false); validationParams.put( @@ -267,55 +323,78 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi if (logger.isDebugEnabled()) { logger.debug(format("Assertion [%s] does not a valid signature.", a.getID())); } - return false; + throw authException(Saml2ErrorCodes.INVALID_SIGNATURE, "Assertion doesn't have a valid signature."); } + //ensure that OpenSAML doesn't attempt signature validation, already performed a.setSignature(null); - // validation for recipient + //remainder of assertion validation ValidationContext vctx = new ValidationContext(validationParams); try { - final ValidationResult result = validator.validate(a, vctx); - final boolean valid = result.equals(ValidationResult.VALID); + ValidationResult result = validator.validate(a, vctx); + boolean valid = result.equals(ValidationResult.VALID); if (!valid) { if (logger.isDebugEnabled()) { - logger.debug(format("Failed to validate assertion from %s with user %s", token.getIdpEntityId(), - getUsername(token, a) - )); + logger.debug(format("Failed to validate assertion from %s", token.getIdpEntityId())); } + throw authException(Saml2ErrorCodes.INVALID_ASSERTION, vctx.getValidationFailureMessage()); } - return valid; } catch (AssertionValidationException e) { if (logger.isDebugEnabled()) { logger.debug("Failed to validate assertion:", e); } - return false; + throw authException(Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR, e.getMessage(), e); } } - private Response getSaml2Response(String xml) throws Saml2Exception, AuthenticationException { - final Object result = this.saml.resolve(xml); - if (result == null) { - throw new AuthenticationCredentialsNotFoundException("SAMLResponse returned null object"); + private Response getSaml2Response(String xml) throws Saml2Exception, Saml2AuthenticationException { + try { + Object result = this.saml.resolve(xml); + if (result instanceof Response) { + return (Response) result; + } + else { + throw authException(UNKNOWN_RESPONSE_CLASS, "Invalid response class:" + result.getClass().getName()); + } + } catch (Saml2Exception x) { + throw authException(MALFORMED_RESPONSE_DATA, x.getMessage(), x); } - else if (result instanceof Response) { - return (Response) result; - } - throw new IllegalArgumentException("Invalid response class:"+result.getClass().getName()); + + } + + private Saml2Error validationError(String code, String description) { + return new Saml2Error( + code, + description + ); + } + + private Saml2AuthenticationException authException(String code, String description) throws Saml2AuthenticationException { + return new Saml2AuthenticationException( + validationError(code, description) + ); + } + + + private Saml2AuthenticationException authException(String code, String description, Exception cause) throws Saml2AuthenticationException { + return new Saml2AuthenticationException( + validationError(code, description), + cause + ); } private SAML20AssertionValidator getAssertionValidator(Saml2AuthenticationToken provider) { List conditions = Collections.singletonList(new AudienceRestrictionConditionValidator()); - final BearerSubjectConfirmationValidator subjectConfirmationValidator = - new BearerSubjectConfirmationValidator(); + BearerSubjectConfirmationValidator subjectConfirmationValidator = new BearerSubjectConfirmationValidator(); List subjects = Collections.singletonList(subjectConfirmationValidator); List statements = Collections.emptyList(); Set credentials = new HashSet<>(); - for (X509Certificate key : getVerificationKeys(provider)) { - final Credential cred = getVerificationCredential(key); + for (X509Certificate key : getVerificationCertificates(provider)) { + Credential cred = getVerificationCredential(key); credentials.add(cred); } CredentialResolver credentialsResolver = new CollectionCredentialResolver(credentials); @@ -345,37 +424,38 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi return decrypter; } - private Assertion decrypt(Saml2AuthenticationToken token, EncryptedAssertion assertion) { - Saml2Exception last = null; + private Assertion decrypt(Saml2AuthenticationToken token, EncryptedAssertion assertion) + throws Saml2AuthenticationException { + Saml2AuthenticationException last = null; List decryptionCredentials = getDecryptionCredentials(token); if (decryptionCredentials.isEmpty()) { - throw new Saml2Exception("No valid decryption credentials found."); + throw authException(DECRYPTION_ERROR, "No valid decryption credentials found."); } for (Saml2X509Credential key : decryptionCredentials) { - final Decrypter decrypter = getDecrypter(key); + Decrypter decrypter = getDecrypter(key); try { return decrypter.decrypt(assertion); } catch (DecryptionException e) { - last = new Saml2Exception(e); + last = authException(DECRYPTION_ERROR, e.getMessage(), e); } } throw last; } - private NameID decrypt(Saml2AuthenticationToken token, EncryptedID assertion) { - Saml2Exception last = null; + private NameID decrypt(Saml2AuthenticationToken token, EncryptedID assertion) throws Saml2AuthenticationException { + Saml2AuthenticationException last = null; List decryptionCredentials = getDecryptionCredentials(token); if (decryptionCredentials.isEmpty()) { - throw new Saml2Exception("No valid decryption credentials found."); + throw authException(DECRYPTION_ERROR, "No valid decryption credentials found."); } for (Saml2X509Credential key : decryptionCredentials) { - final Decrypter decrypter = getDecrypter(key); + Decrypter decrypter = getDecrypter(key); try { return (NameID) decrypter.decrypt(assertion); } catch (DecryptionException e) { - last = new Saml2Exception(e); + last = authException(DECRYPTION_ERROR, e.getMessage(), e); } } throw last; @@ -391,7 +471,7 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi return result; } - private List getVerificationKeys(Saml2AuthenticationToken token) { + private List getVerificationCertificates(Saml2AuthenticationToken token) { List result = new LinkedList<>(); for (Saml2X509Credential c : token.getX509Credentials()) { if (c.isSignatureVerficationCredential()) { diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java new file mode 100644 index 0000000000..86709cec1e --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.util.Assert; + +/** + * This exception is thrown for all SAML 2.0 related {@link Authentication} errors. + * + *

+ * There are a number of scenarios where an error may occur, for example: + *

    + *
  • The response or assertion request is missing or malformed
  • + *
  • Missing or invalid subject
  • + *
  • Missing or invalid signatures
  • + *
  • The time period validation for the assertion fails
  • + *
  • One of the assertion conditions was not met
  • + *
  • Decryption failed
  • + *
  • Unable to locate a subject identifier, commonly known as username
  • + *
+ * + * @since 5.2 + */ +public class Saml2AuthenticationException extends AuthenticationException { + private Saml2Error error; + + /** + * Constructs a {@code Saml2AuthenticationException} using the provided parameters. + * + * @param error the {@link Saml2Error SAML 2.0 Error} + */ + public Saml2AuthenticationException(Saml2Error error) { + this(error, error.getDescription()); + } + + /** + * Constructs a {@code Saml2AuthenticationException} using the provided parameters. + * + * @param error the {@link Saml2Error SAML 2.0 Error} + * @param cause the root cause + */ + public Saml2AuthenticationException(Saml2Error error, Throwable cause) { + this(error, cause.getMessage(), cause); + } + + /** + * Constructs a {@code Saml2AuthenticationException} using the provided parameters. + * + * @param error the {@link Saml2Error SAML 2.0 Error} + * @param message the detail message + */ + public Saml2AuthenticationException(Saml2Error error, String message) { + super(message); + this.setError(error); + } + + /** + * Constructs a {@code Saml2AuthenticationException} using the provided parameters. + * + * @param error the {@link Saml2Error SAML 2.0 Error} + * @param message the detail message + * @param cause the root cause + */ + public Saml2AuthenticationException(Saml2Error error, String message, Throwable cause) { + super(message, cause); + this.setError(error); + } + + /** + * Returns the {@link Saml2Error SAML 2.0 Error}. + * + * @return the {@link Saml2Error} + */ + public Saml2Error getError() { + return this.error; + } + + private void setError(Saml2Error error) { + Assert.notNull(error, "error cannot be null"); + this.error = error; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("Saml2AuthenticationException{"); + sb.append("error=").append(error); + sb.append('}'); + return sb.toString(); + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Error.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Error.java new file mode 100644 index 0000000000..786ae0e011 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Error.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.authentication; + +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.util.Assert; + +import java.io.Serializable; + +/** + * A representation of an SAML 2.0 Error. + * + *

+ * At a minimum, an error response will contain an error code. + * The commonly used error code are defined in this class + * or a new codes can be defined in the future as arbitrary strings. + *

+ * @since 5.2 + */ +public class Saml2Error implements Serializable { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private final String errorCode; + private final String description; + + /** + * Constructs a {@code Saml2Error} using the provided parameters. + * + * @param errorCode the error code + * @param description the error description + */ + public Saml2Error(String errorCode, String description) { + Assert.hasText(errorCode, "errorCode cannot be empty"); + this.errorCode = errorCode; + this.description = description; + } + + /** + * Returns the error code. + * + * @return the error code + */ + public final String getErrorCode() { + return this.errorCode; + } + + /** + * Returns the error description. + * + * @return the error description + */ + public final String getDescription() { + return this.description; + } + + @Override + public String toString() { + return "[" + this.getErrorCode() + "] " + + (this.getDescription() != null ? this.getDescription() : ""); + } +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ErrorCodes.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ErrorCodes.java new file mode 100644 index 0000000000..2b98a5334c --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ErrorCodes.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.authentication; + +/** + * A list of SAML known 2 error codes used during SAML authentication. + * + * @since 5.2 + */ +public interface Saml2ErrorCodes { + /** + * SAML Data does not represent a SAML 2 Response object. + * A valid XML object was received, but that object was not a + * SAML 2 Response object of type {@code ResponseType} per specification + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=46 + */ + String UNKNOWN_RESPONSE_CLASS = "unknown_response_class"; + /** + * The response data is malformed or incomplete. + * An invalid XML object was received, and XML unmarshalling failed. + */ + String MALFORMED_RESPONSE_DATA = "malformed_response_data"; + /** + * Response destination does not match the request URL. + * A SAML 2 response object was received at a URL that + * did not match the URL stored in the {code Destination} attribute + * in the Response object. + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=38 + */ + String INVALID_DESTINATION = "invalid_destination"; + /** + * The assertion was not valid. + * The assertion used for authentication failed validation. + * Details around the failure will be present in the error description. + */ + String INVALID_ASSERTION = "invalid_assertion"; + /** + * The signature of response or assertion was invalid. + * Either the response or the assertion was missing a signature + * or the signature could not be verified using the system's + * configured credentials. Most commonly the IDP's + * X509 certificate. + */ + String INVALID_SIGNATURE = "invalid_signature"; + /** + * The assertion did not contain a subject element. + * The subject element, type SubjectType, contains + * a {@code NameID} or an {@code EncryptedID} that is used + * to assign the authenticated principal an identifier, + * typically a username. + * + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=18 + */ + String SUBJECT_NOT_FOUND = "subject_not_found"; + /** + * The subject did not contain a user identifier + * The assertion contained a subject element, but the subject + * element did not have a {@code NameID} or {@code EncryptedID} + * element + * + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=18 + */ + String USERNAME_NOT_FOUND = "username_not_found"; + /** + * The system failed to decrypt an assertion or a name identifier. + * This error code will be thrown if the decryption of either a + * {@code EncryptedAssertion} or {@code EncryptedID} fails. + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=17 + */ + String DECRYPTION_ERROR = "decryption_error"; + /** + * An Issuer element contained a value that didn't + * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=15 + */ + String INVALID_ISSUER = "invalid_issuer"; + /** + * An error happened during validation. + * Used when internal, non classified, errors are caught during the + * authentication process. + */ + String INTERNAL_VALIDATION_ERROR = "internal_validation_error"; +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/credentials/Saml2X509CredentialTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/credentials/Saml2X509CredentialTests.java new file mode 100644 index 0000000000..292619040d --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/credentials/Saml2X509CredentialTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.credentials; + +import org.springframework.security.converter.RsaKeyConverters; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.io.ByteArrayInputStream; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.DECRYPTION; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.ENCRYPTION; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.SIGNING; +import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.VERIFICATION; + +public class Saml2X509CredentialTests { + + @Rule + public ExpectedException exception = ExpectedException.none(); + + private Saml2X509Credential credential; + private PrivateKey key; + private X509Certificate certificate; + + @Before + public void setup() throws Exception { + String keyData = "-----BEGIN PRIVATE KEY-----\n" + + "MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE\n" + + "VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK\n" + + "cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6\n" + + "Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn\n" + + "x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5\n" + + "wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd\n" + + "vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY\n" + + "8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX\n" + + "oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx\n" + + "EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0\n" + + "KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt\n" + + "YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr\n" + + "9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM\n" + + "INrtuLp4YHbgk1mi\n" + + "-----END PRIVATE KEY-----"; + key = RsaKeyConverters.pkcs8().convert(new ByteArrayInputStream(keyData.getBytes(UTF_8))); + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + String certificateData = "-----BEGIN CERTIFICATE-----\n" + + "MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC\n" + + "VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG\n" + + "A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD\n" + + "DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1\n" + + "MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES\n" + + "MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN\n" + + "TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s\n" + + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos\n" + + "vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM\n" + + "+U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG\n" + + "y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi\n" + + "XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+\n" + + "qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD\n" + + "RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" + + "-----END CERTIFICATE-----"; + certificate = (X509Certificate) factory + .generateCertificate(new ByteArrayInputStream(certificateData.getBytes(UTF_8))); + } + + @Test + public void constructorWhenRelyingPartyWithCredentialsThenItSucceeds() { + new Saml2X509Credential(key, certificate, SIGNING); + new Saml2X509Credential(key, certificate, SIGNING, DECRYPTION); + new Saml2X509Credential(key, certificate, DECRYPTION); + } + + @Test + public void constructorWhenAssertingPartyWithCredentialsThenItSucceeds() { + new Saml2X509Credential(certificate, VERIFICATION); + new Saml2X509Credential(certificate, VERIFICATION, ENCRYPTION); + new Saml2X509Credential(certificate, ENCRYPTION); + } + + @Test + public void constructorWhenRelyingPartyWithoutCredentialsThenItFails() { + exception.expect(IllegalArgumentException.class); + new Saml2X509Credential(null, (X509Certificate) null, SIGNING); + } + + @Test + public void constructorWhenRelyingPartyWithoutPrivateKeyThenItFails() { + exception.expect(IllegalArgumentException.class); + new Saml2X509Credential(null, certificate, SIGNING); + } + + @Test + public void constructorWhenRelyingPartyWithoutCertificateThenItFails() { + exception.expect(IllegalArgumentException.class); + new Saml2X509Credential(key, null, SIGNING); + } + + @Test + public void constructorWhenAssertingPartyWithoutCertificateThenItFails() { + exception.expect(IllegalArgumentException.class); + new Saml2X509Credential(null, SIGNING); + } + + @Test + public void constructorWhenRelyingPartyWithEncryptionUsageThenItFails() { + exception.expect(IllegalStateException.class); + new Saml2X509Credential(key, certificate, ENCRYPTION); + } + + @Test + public void constructorWhenRelyingPartyWithVerificationUsageThenItFails() { + exception.expect(IllegalStateException.class); + new Saml2X509Credential(key, certificate, VERIFICATION); + } + + @Test + public void constructorWhenAssertingPartyWithSigningUsageThenItFails() { + exception.expect(IllegalStateException.class); + new Saml2X509Credential(certificate, SIGNING); + } + + @Test + public void constructorWhenAssertingPartyWithDecryptionUsageThenItFails() { + exception.expect(IllegalStateException.class); + new Saml2X509Credential(certificate, DECRYPTION); + } + + +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProviderTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProviderTests.java new file mode 100644 index 0000000000..ac0cfc5e5e --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProviderTests.java @@ -0,0 +1,456 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.Saml2Exception; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.opensaml.saml.common.assertion.AssertionValidationException; +import org.opensaml.saml.common.assertion.ValidationContext; +import org.opensaml.saml.common.assertion.ValidationResult; +import org.opensaml.saml.saml2.assertion.SAML20AssertionValidator; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.EncryptedAssertion; +import org.opensaml.saml.saml2.core.EncryptedID; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Subject; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.util.Collections; + +import static java.util.Collections.emptyList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.powermock.api.mockito.PowerMockito.doReturn; +import static org.powermock.api.mockito.PowerMockito.mock; +import static org.powermock.api.mockito.PowerMockito.when; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.util.StringUtils.hasText; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({OpenSamlImplementation.class, OpenSamlAuthenticationProvider.class}) +public class OpenSamlAuthenticationProviderTests { + + private OpenSamlAuthenticationProvider provider; + private OpenSamlImplementation saml; + + @Rule + ExpectedException exception = ExpectedException.none(); + private Saml2AuthenticationToken token; + + @Before + public void setup() { + saml = PowerMockito.mock(OpenSamlImplementation.class); + PowerMockito.mockStatic(OpenSamlImplementation.class); + when(OpenSamlImplementation.getInstance()).thenReturn(saml); + + provider = new OpenSamlAuthenticationProvider(); + token = new Saml2AuthenticationToken( + "responseXml", + "recipientUri", + "idpEntityId", + "localSpEntityId", + emptyList() + ); + } + + @Test + public void supportsWhenSaml2AuthenticationTokenThenReturnTrue() { + + assertTrue( + OpenSamlAuthenticationProvider.class + "should support " + token.getClass(), + provider.supports(token.getClass()) + ); + } + + @Test + public void supportsWhenNotSaml2AuthenticationTokenThenReturnFalse() { + assertTrue( + OpenSamlAuthenticationProvider.class + "should not support " + Authentication.class, + !provider.supports(Authentication.class) + ); + } + + @Test + public void authenticateWhenUnknownDataClassThenThrowAuthenticationException() { + when(saml.resolve(any(String.class))).thenReturn(mock(Assertion.class)); + exception.expect(authenticationMatcher(Saml2ErrorCodes.UNKNOWN_RESPONSE_CLASS)); + provider.authenticate(token); + } + + @Test + public void authenticateWhenXmlErrorThenThrowAuthenticationException() { + when(saml.resolve(any(String.class))).thenThrow(new Saml2Exception("test")); + exception.expect(authenticationMatcher(Saml2ErrorCodes.MALFORMED_RESPONSE_DATA)); + provider.authenticate(token); + } + + @Test + public void authenticateWhenInvalidDestinationThenThrowAuthenticationException() { + final Response response = mock(Response.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn("invalidRecipient"); + exception.expect(authenticationMatcher(Saml2ErrorCodes.INVALID_DESTINATION)); + provider.authenticate(token); + } + + @Test + public void authenticateWhenNoAssertionsPresentThenThrowAuthenticationException() { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(emptyList()); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.MALFORMED_RESPONSE_DATA, + "No assertions found in response." + ) + ); + provider.authenticate(token); + } + + @Test + public void authenticateWhenInvalidSignatureThenThrowAuthenticationException() { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.INVALID_SIGNATURE + ) + ); + provider.authenticate(token); + } + + @Test + public void authenticateWhenOpenSAMLValidationErrorThenThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + final SAML20AssertionValidator validator = mock(SAML20AssertionValidator.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(true).when( + spyProvider, + "hasValidSignature", + any(Assertion.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(validator).when(spyProvider, "getAssertionValidator", any(Saml2AuthenticationToken.class)); + when(validator.validate( + any(Assertion.class), + any(ValidationContext.class) + )).thenReturn(ValidationResult.INVALID); + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.INVALID_ASSERTION + ) + ); + spyProvider.authenticate(token); + } + + @Test + public void authenticateWhenInternalErrorThenCatchAndThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + final SAML20AssertionValidator validator = mock(SAML20AssertionValidator.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(true).when( + spyProvider, + "hasValidSignature", + any(Assertion.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(validator).when(spyProvider, "getAssertionValidator", any(Saml2AuthenticationToken.class)); + when(validator.validate( + any(Assertion.class), + any(ValidationContext.class) + )).thenThrow(new AssertionValidationException()); + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR + ) + ); + spyProvider.authenticate(token); + } + + @Test + public void authenticateWhenMissingSubjectThenThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(true).when( + spyProvider, + "hasValidSignature", + any(Assertion.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + PowerMockito.doNothing() + .when( + spyProvider, + "validateAssertion", + anyString(), + any(Assertion.class), + any(Saml2AuthenticationToken.class), + anyBoolean() + ); + + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.SUBJECT_NOT_FOUND + ) + ); + spyProvider.authenticate(token); + } + + @Test + public void authenticateWhenUsernameMissingThenThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + final Subject subject = mock(Subject.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + when(assertion.getSubject()).thenReturn(subject); + + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(true).when( + spyProvider, + "hasValidSignature", + any(Assertion.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + PowerMockito.doNothing() + .when( + spyProvider, + "validateAssertion", + anyString(), + any(Assertion.class), + any(Saml2AuthenticationToken.class), + anyBoolean() + ); + + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.USERNAME_NOT_FOUND + ) + ); + spyProvider.authenticate(token); + } + + @Test + public void authenticateWhenDecryptionKeysAreMissingThenThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final Assertion assertion = mock(Assertion.class); + final Subject subject = mock(Subject.class); + final EncryptedID nameID = mock(EncryptedID.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getAssertions()).thenReturn(Collections.singletonList(assertion)); + when(response.getEncryptedAssertions()).thenReturn(emptyList()); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + when(assertion.getSubject()).thenReturn(subject); + when(subject.getEncryptedID()).thenReturn(nameID); + + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(true).when( + spyProvider, + "hasValidSignature", + any(Assertion.class), + any(Saml2AuthenticationToken.class) + ); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + PowerMockito.doNothing() + .when( + spyProvider, + "validateAssertion", + anyString(), + any(Assertion.class), + any(Saml2AuthenticationToken.class), + anyBoolean() + ); + + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.DECRYPTION_ERROR, + "No valid decryption credentials found." + ) + ); + spyProvider.authenticate(token); + } + + @Test + public void authenticateWhenDecryptionKeyIsMissingThenThrowAuthenticationException() throws Exception { + final Response response = mock(Response.class); + final Issuer issuer = mock(Issuer.class); + final EncryptedAssertion assertion = mock(EncryptedAssertion.class); + when(saml.resolve(any(String.class))).thenReturn(response); + when(response.getDestination()).thenReturn(token.getRecipientUri()); + when(response.isSigned()).thenReturn(false); + when(response.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(token.getIdpEntityId()); + when(response.getEncryptedAssertions()).thenReturn(Collections.singletonList(assertion)); + + OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider); + doReturn(false).when( + spyProvider, + "hasValidSignature", + any(Response.class), + any(Saml2AuthenticationToken.class) + ); + + exception.expect( + authenticationMatcher( + Saml2ErrorCodes.DECRYPTION_ERROR, + "No valid decryption credentials found." + ) + ); + spyProvider.authenticate(token); + } + + + private BaseMatcher authenticationMatcher(String code) { + return authenticationMatcher(code, null); + } + + private BaseMatcher authenticationMatcher(String code, String description) { + return new BaseMatcher() { + private Object value = null; + + @Override + public boolean matches(Object item) { + if (!(item instanceof Saml2AuthenticationException)) { + value = item; + return false; + } + Saml2AuthenticationException ex = (Saml2AuthenticationException) item; + if (!code.equals(ex.getError().getErrorCode())) { + value = item; + return false; + } + if (hasText(description)) { + if (!description.equals(ex.getError().getDescription())) { + value = item; + return false; + } + } + return true; + } + + @Override + public void describeTo(Description description) { + description.appendText("Expecting a " + Saml2AuthenticationException.class.getName() + + " with code:" + code + " and description:" + description + ) + .appendValue(value); + } + }; + } + +} diff --git a/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java index f405126680..cf0c5dfd93 100644 --- a/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java +++ b/samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java @@ -22,11 +22,15 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.ComponentScan; import org.springframework.http.MediaType; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.util.AssertionErrors; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; import net.shibboleth.utilities.java.support.xml.SerializeSupport; +import org.hamcrest.Matcher; import org.joda.time.DateTime; import org.junit.Test; import org.junit.runner.RunWith; @@ -62,9 +66,11 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.UUID; +import javax.servlet.http.HttpSession; import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.startsWith; import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildConditions; import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildIssuer; @@ -74,6 +80,9 @@ import static org.springframework.security.samples.OpenSamlActionTestingSupport. import static org.springframework.security.samples.OpenSamlActionTestingSupport.encryptNameId; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.security.web.WebAttributes.AUTHENTICATION_EXCEPTION; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -86,6 +95,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. public class Saml2LoginIntegrationTests { static final String LOCAL_SP_ENTITY_ID = "http://localhost:8080/saml2/service-provider-metadata/simplesamlphp"; + static final String USERNAME = "testuser@spring.security.saml"; @Autowired MockMvc mockMvc; @@ -97,21 +107,21 @@ public class Saml2LoginIntegrationTests { } @Test - public void redirectToLoginPageSingleProvider() throws Exception { + public void applicationAccessWhenSingleProviderAndUnauthenticatedThenRedirectsToAuthNRequest() throws Exception { mockMvc.perform(get("http://localhost:8080/some/url")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost:8080/saml2/authenticate/simplesamlphp")); } @Test - public void testAuthNRequest() throws Exception { + public void authenticateRequestWhenUnauthenticatedThenRespondsWithRedirectAuthNRequestXML() throws Exception { mockMvc.perform(get("http://localhost:8080/saml2/authenticate/simplesamlphp")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", startsWith("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php?SAMLRequest="))); } @Test - public void testRelayState() throws Exception { + public void authenticateRequestWhenRelayStateThenRespondsWithRedirectAndEncodedRelayState() throws Exception { mockMvc.perform( get("http://localhost:8080/saml2/authenticate/simplesamlphp") .param("RelayState", "relay state value with spaces") @@ -122,96 +132,136 @@ public class Saml2LoginIntegrationTests { } @Test - public void signedResponse() throws Exception { - final String username = "testuser@spring.security.saml"; - Assertion assertion = buildAssertion(username); + public void authenticateWhenResponseIsSignedThenItSucceeds() throws Exception { + Assertion assertion = buildAssertion(USERNAME); Response response = buildResponse(assertion); signXmlObject(response, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); - String xml = toXml(response); - mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) - .andExpect(authenticated().withUsername(username)); + sendResponse(response, "/") + .andExpect(authenticated().withUsername(USERNAME)); } @Test - public void signedAssertion() throws Exception { - final String username = "testuser@spring.security.saml"; - Assertion assertion = buildAssertion(username); + public void authenticateWhenAssertionIsThenItSignedSucceeds() throws Exception { + Assertion assertion = buildAssertion(USERNAME); Response response = buildResponse(assertion); signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); - String xml = toXml(response); - final ResultActions actions = mockMvc - .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) - .andExpect(authenticated().withUsername(username)); + sendResponse(response, "/") + .andExpect(authenticated().withUsername(USERNAME)); } @Test - public void unsigned() throws Exception { - Assertion assertion = buildAssertion("testuser@spring.security.saml"); + public void authenticateWhenXmlObjectIsNotSignedThenItFails() throws Exception { + Assertion assertion = buildAssertion(USERNAME); Response response = buildResponse(assertion); - String xml = toXml(response); - mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/login?error")) + sendResponse(response, "/login?error") .andExpect(unauthenticated()); } @Test - public void signedResponseEncryptedAssertion() throws Exception { - final String username = "testuser@spring.security.saml"; - Assertion assertion = buildAssertion(username); + public void authenticateWhenResponseIsSignedAndAssertionIsEncryptedThenItSucceeds() throws Exception { + Assertion assertion = buildAssertion(USERNAME); EncryptedAssertion encryptedAssertion = OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate)); Response response = buildResponse(encryptedAssertion); signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); - String xml = toXml(response); - final ResultActions actions = mockMvc - .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) - .andExpect(authenticated().withUsername(username)); + sendResponse(response, "/") + .andExpect(authenticated().withUsername(USERNAME)); } @Test - public void unsignedResponseEncryptedAssertion() throws Exception { - final String username = "testuser@spring.security.saml"; - Assertion assertion = buildAssertion(username); + public void authenticateWhenResponseIsNotSignedAndAssertionIsEncryptedThenItSucceeds() throws Exception { + Assertion assertion = buildAssertion(USERNAME); EncryptedAssertion encryptedAssertion = OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate)); Response response = buildResponse(encryptedAssertion); - String xml = toXml(response); - final ResultActions actions = mockMvc - .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) - .andExpect(authenticated().withUsername(username)); + sendResponse(response, "/") + .andExpect(authenticated().withUsername(USERNAME)); } @Test - public void signedResponseEncryptedNameId() throws Exception { - final String username = "testuser@spring.security.saml"; - Assertion assertion = buildAssertion(username); + public void authenticateWhenResponseIsSignedAndNameIDisEncryptedThenItSucceeds() throws Exception { + Assertion assertion = buildAssertion(USERNAME); final EncryptedID nameId = encryptNameId(assertion.getSubject().getNameID(), decodeCertificate(spCertificate)); assertion.getSubject().setEncryptedID(nameId); assertion.getSubject().setNameID(null); Response response = buildResponse(assertion); signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + sendResponse(response, "/") + .andExpect(authenticated().withUsername(USERNAME)); + } + + @Test + public void authenticateWhenSignatureKeysDontMatchThenItFails() throws Exception { + Assertion assertion = buildAssertion(USERNAME); + Response response = buildResponse(assertion); + signXmlObject(assertion, getSigningCredential(spCertificate, spPrivateKey, UsageType.SIGNING)); + sendResponse(response, "/login?error") + .andExpect( + saml2AuthenticationExceptionMatcher( + "invalid_signature", + equalTo("Assertion doesn't have a valid signature.") + ) + ); + } + + @Test + public void authenticateWhenNotOnOrAfterDontMatchThenItFails() throws Exception { + Assertion assertion = buildAssertion(USERNAME); + assertion.getConditions().setNotOnOrAfter(DateTime.now().minusDays(1)); + Response response = buildResponse(assertion); + signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + sendResponse(response, "/login?error") + .andExpect( + saml2AuthenticationExceptionMatcher( + "invalid_assertion", + containsString("Assertion 'assertion' with NotOnOrAfter condition of") + ) + ); + } + + @Test + public void authenticateWhenNotOnOrBeforeDontMatchThenItFails() throws Exception { + Assertion assertion = buildAssertion(USERNAME); + assertion.getConditions().setNotBefore(DateTime.now().plusDays(1)); + Response response = buildResponse(assertion); + signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + sendResponse(response, "/login?error") + .andExpect( + saml2AuthenticationExceptionMatcher( + "invalid_assertion", + containsString("Assertion 'assertion' with NotBefore condition of") + ) + ); + } + + @Test + public void authenticateWhenIssuerIsInvalidThenItFails() throws Exception { + Assertion assertion = buildAssertion(USERNAME); + Response response = buildResponse(assertion); + response.getIssuer().setValue("invalid issuer"); + signXmlObject(response, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING)); + sendResponse(response, "/login?error") + .andExpect(unauthenticated()) + .andExpect( + saml2AuthenticationExceptionMatcher( + "invalid_issuer", + containsString( + "Response issuer 'invalid issuer' doesn't match "+ + "'https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php'" + ) + ) + ); + } + + private ResultActions sendResponse( + Response response, + String redirectUrl) throws Exception { String xml = toXml(response); - final ResultActions actions = mockMvc - .perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) - .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/")) - .andExpect(authenticated().withUsername(username)); + return mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8)))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(redirectUrl)); } private Response buildResponse(Assertion assertion) { @@ -359,4 +409,42 @@ public class Saml2LoginIntegrationTests { "RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" + "-----END CERTIFICATE-----"; + private String spPrivateKey = "-----BEGIN PRIVATE KEY-----\n" + + "MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE\n" + + "VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK\n" + + "cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6\n" + + "Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn\n" + + "x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5\n" + + "wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd\n" + + "vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY\n" + + "8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX\n" + + "oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx\n" + + "EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0\n" + + "KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt\n" + + "YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr\n" + + "9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM\n" + + "INrtuLp4YHbgk1mi\n" + + "-----END PRIVATE KEY-----"; + + private static ResultMatcher saml2AuthenticationExceptionMatcher( + String code, + Matcher message + ) { + return result -> { + final HttpSession session = result.getRequest().getSession(false); + AssertionErrors.assertNotNull("HttpSession", session); + Object exception = session.getAttribute(AUTHENTICATION_EXCEPTION); + AssertionErrors.assertNotNull(AUTHENTICATION_EXCEPTION, exception); + if (!(exception instanceof Saml2AuthenticationException)) { + AssertionErrors.fail( + "Invalid exception type", + Saml2AuthenticationException.class, + exception.getClass().getName() + ); + } + Saml2AuthenticationException se = (Saml2AuthenticationException) exception; + assertEquals("SAML 2 Error Code", code, se.getError().getErrorCode()); + assertTrue("SAML 2 Error Description", message.matches(se.getError().getDescription())); + }; + } }