package org.pac4j.saml.sso.impl; import java.net.URI; import java.net.URISyntaxException; import java.util.HashSet; import java.util.List; import java.util.Set; import org.joda.time.DateTime; import org.opensaml.core.criterion.EntityIdCriterion; import org.opensaml.core.xml.XMLObject; import org.opensaml.saml.common.SAMLObject; import org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext; import org.opensaml.saml.common.xml.SAMLConstants; import org.opensaml.saml.criterion.EntityRoleCriterion; import org.opensaml.saml.criterion.ProtocolCriterion; import org.opensaml.saml.saml2.core.Audience; import org.opensaml.saml.saml2.core.AudienceRestriction; import org.opensaml.saml.saml2.core.Conditions; import org.opensaml.saml.saml2.core.EncryptedID; import org.opensaml.saml.saml2.core.Issuer; import org.opensaml.saml.saml2.core.LogoutRequest; import org.opensaml.saml.saml2.core.NameID; import org.opensaml.saml.saml2.core.NameIDType; import org.opensaml.saml.saml2.core.Response; import org.opensaml.saml.saml2.core.StatusCode; import org.opensaml.saml.saml2.core.SubjectConfirmationData; import org.opensaml.saml.saml2.encryption.Decrypter; import org.opensaml.saml.saml2.metadata.Endpoint; import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator; import org.opensaml.security.SecurityException; import org.opensaml.security.credential.UsageType; import org.opensaml.security.criteria.UsageCriterion; import org.opensaml.xmlsec.encryption.support.DecryptionException; import org.opensaml.xmlsec.signature.Signature; import org.opensaml.xmlsec.signature.support.SignatureException; import org.opensaml.xmlsec.signature.support.SignatureTrustEngine; import org.pac4j.core.credentials.Credentials; import org.pac4j.saml.context.SAML2MessageContext; import org.pac4j.saml.crypto.SAML2SignatureTrustEngineProvider; import org.pac4j.saml.exceptions.SAMLException; import org.pac4j.saml.sso.SAML2ResponseValidator; import org.pac4j.saml.storage.SAMLMessageStorage; import org.pac4j.saml.util.UriUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import net.shibboleth.utilities.java.support.net.BasicURLComparator; import net.shibboleth.utilities.java.support.net.URIComparator; import net.shibboleth.utilities.java.support.resolver.CriteriaSet; /** * Validator for SAML logout response * * @author Matthieu Taggiasco * @since 2.0.0 */ public class SAML2LogoutResponseValidator implements SAML2ResponseValidator { private final static Logger logger = LoggerFactory.getLogger(SAML2LogoutResponseValidator.class); /* maximum skew in seconds between SP and IDP clocks */ private int acceptedSkew = 120; private final SAML2SignatureTrustEngineProvider signatureTrustEngineProvider; private final URIComparator uriComparator; public SAML2LogoutResponseValidator(final SAML2SignatureTrustEngineProvider engine) { this(engine, new BasicURLComparator()); } public SAML2LogoutResponseValidator(final SAML2SignatureTrustEngineProvider engine, final URIComparator uriComparator) { this.signatureTrustEngineProvider = engine; this.uriComparator = uriComparator; } /** * Validates the SAML protocol response and the SAML SSO response. * The method decrypt encrypted assertions if any. * * @param context the context */ @Override public Credentials validate(final SAML2MessageContext context) { final SAMLObject message = context.getMessage(); if (!(message instanceof Response)) { throw new SAMLException("Response instance is an unsupported type"); } final Response response = (Response) message; final SignatureTrustEngine engine = this.signatureTrustEngineProvider.build(); validateSamlProtocolResponse(response, context, engine); return null; } /** * Validates the SAML protocol response: * - IssueInstant * - Issuer * - StatusCode * - Signature * * @param response the response * @param context the context * @param engine the engine */ protected final void validateSamlProtocolResponse(final Response response, final SAML2MessageContext context, final SignatureTrustEngine engine) { if (!StatusCode.SUCCESS.equals(response.getStatus().getStatusCode().getValue())) { String status = response.getStatus().getStatusCode().getValue(); if (response.getStatus().getStatusMessage() != null) { status += " / " + response.getStatus().getStatusMessage().getMessage(); } throw new SAMLException("Logout response is not success ; actual " + status); } if (response.getSignature() != null) { final String entityId = context.getSAMLPeerEntityContext().getEntityId(); validateSignature(response.getSignature(), entityId, engine); context.getSAMLPeerEntityContext().setAuthenticated(true); } if (!isIssueInstantValid(response.getIssueInstant())) { throw new SAMLException("Response issue instant is too old or in the future"); } final SAMLMessageStorage messageStorage = context.getSAMLMessageStorage(); if (messageStorage != null && response.getInResponseTo() != null) { final XMLObject xmlObject = messageStorage.retrieveMessage(response.getInResponseTo()); if (xmlObject == null) { throw new SAMLException("InResponseToField of the Response doesn't correspond to sent message " + response.getInResponseTo()); } else if (!(xmlObject instanceof LogoutRequest)) { throw new SAMLException("Sent request was of different type than the expected LogoutRequest " + response.getInResponseTo()); } } verifyEndpoint(context.getSAMLEndpointContext().getEndpoint(), response.getDestination()); if (response.getIssuer() != null) { validateIssuer(response.getIssuer(), context); } } protected final void verifyEndpoint(final Endpoint endpoint, final String destination) { try { if (destination != null && !uriComparator.compare(destination, endpoint.getLocation()) && !uriComparator.compare(destination, endpoint.getResponseLocation())) { throw new SAMLException("Intended destination " + destination + " doesn't match any of the endpoint URLs on endpoint " + endpoint.getLocation()); } } catch (final Exception e) { throw new SAMLException(e); } } /** * Validate issuer format and value. * * @param issuer the issuer * @param context the context */ protected final void validateIssuer(final Issuer issuer, final SAML2MessageContext context) { if (issuer.getFormat() != null && !issuer.getFormat().equals(NameIDType.ENTITY)) { throw new SAMLException("Issuer type is not entity but " + issuer.getFormat()); } final String entityId = context.getSAMLPeerEntityContext().getEntityId(); if (entityId == null || !entityId.equals(issuer.getValue())) { throw new SAMLException("Issuer " + issuer.getValue() + " does not match idp entityId " + entityId); } } /** * Decrypts an EncryptedID, using a decrypter. * * @param encryptedId The EncryptedID to be decrypted. * @param decrypter The decrypter to use. * * @return Decrypted ID or {@code null} if any input is {@code null}. * * @throws SAMLException If the input ID cannot be decrypted. */ protected final NameID decryptEncryptedId(final EncryptedID encryptedId, final Decrypter decrypter) throws SAMLException { if (encryptedId == null) { return null; } if (decrypter == null) { logger.warn("Encrypted attributes returned, but no keystore was provided."); return null; } try { final NameID decryptedId = (NameID) decrypter.decrypt(encryptedId); return decryptedId; } catch (final DecryptionException e) { throw new SAMLException("Decryption of an EncryptedID failed.", e); } } /** * Validate Bearer subject confirmation data * - notBefore * - NotOnOrAfter * - recipient * * @param data the data * @param context the context * @return true if all Bearer subject checks are passing */ protected final boolean isValidBearerSubjectConfirmationData(final SubjectConfirmationData data, final SAML2MessageContext context) { if (data == null) { logger.debug("SubjectConfirmationData cannot be null for Bearer confirmation"); return false; } if (data.getNotBefore() != null) { logger.debug("SubjectConfirmationData notBefore must be null for Bearer confirmation"); return false; } if (data.getNotOnOrAfter() == null) { logger.debug("SubjectConfirmationData notOnOrAfter cannot be null for Bearer confirmation"); return false; } if (data.getNotOnOrAfter().plusSeconds(acceptedSkew).isBeforeNow()) { logger.debug("SubjectConfirmationData notOnOrAfter is too old"); return false; } try { if (data.getRecipient() == null) { logger.debug("SubjectConfirmationData recipient cannot be null for Bearer confirmation"); return false; } else { final Endpoint endpoint = context.getSAMLEndpointContext().getEndpoint(); if (endpoint == null) { logger.warn("No endpoint was found in the SAML endpoint context"); return false; } final URI recipientUri = new URI(data.getRecipient()); final URI appEndpointUri = new URI(endpoint.getLocation()); if (!UriUtils.urisEqualAfterPortNormalization(recipientUri, appEndpointUri)) { logger.debug("SubjectConfirmationData recipient {} does not match SP assertion consumer URL, found. SP ACS URL from context: {}", recipientUri, appEndpointUri); return false; } } } catch (URISyntaxException use) { logger.error("Unable to check SubjectConfirmationData recipient, a URI has invalid syntax.", use); return false; } return true; } /** * Validate assertionConditions * - notBefore * - notOnOrAfter * * @param conditions the conditions * @param context the context */ protected final void validateAssertionConditions(final Conditions conditions, final SAML2MessageContext context) { if (conditions == null) { throw new SAMLException("Assertion conditions cannot be null"); } if (conditions.getNotBefore() != null && conditions.getNotBefore().minusSeconds(acceptedSkew).isAfterNow()) { throw new SAMLException("Assertion condition notBefore is not valid"); } if (conditions.getNotOnOrAfter() != null && conditions.getNotOnOrAfter().plusSeconds(acceptedSkew).isBeforeNow()) { throw new SAMLException("Assertion condition notOnOrAfter is not valid"); } final String entityId = context.getSAMLSelfEntityContext().getEntityId(); validateAudienceRestrictions(conditions.getAudienceRestrictions(), entityId); } /** * Validate audience by matching the SP entityId. * * @param audienceRestrictions the audience restrictions * @param spEntityId the sp entity id */ protected final void validateAudienceRestrictions(final List<AudienceRestriction> audienceRestrictions, final String spEntityId) { if (audienceRestrictions == null || audienceRestrictions.isEmpty()) { throw new SAMLException("Audience restrictions cannot be null or empty"); } final Set<String> audienceUris = new HashSet<String>(); for (final AudienceRestriction audienceRestriction : audienceRestrictions) { if (audienceRestriction.getAudiences() != null) { for (final Audience audience : audienceRestriction.getAudiences()) { audienceUris.add(audience.getAudienceURI()); } } } if (!audienceUris.contains(spEntityId)) { throw new SAMLException("Assertion audience " + audienceUris + " does not match SP configuration " + spEntityId); } } /** * Validate assertion signature. If none is found and the SAML response did not have one and the SP requires * the assertions to be signed, the validation fails. * * @param signature the signature * @param context the context * @param engine the engine */ protected final void validateAssertionSignature(final Signature signature, final SAML2MessageContext context, final SignatureTrustEngine engine) { final SAMLPeerEntityContext peerContext = context.getSAMLPeerEntityContext(); if (signature != null) { final String entityId = peerContext.getEntityId(); validateSignature(signature, entityId, engine); } else { if (!peerContext.isAuthenticated()) { throw new SAMLException("Assertion or response must be signed"); } } } /** * Validate the given digital signature by checking its profile and value. * * @param signature the signature * @param idpEntityId the idp entity id * @param trustEngine the trust engine */ protected final void validateSignature(final Signature signature, final String idpEntityId, final SignatureTrustEngine trustEngine) { final SAMLSignatureProfileValidator validator = new SAMLSignatureProfileValidator(); try { validator.validate(signature); } catch (final SignatureException e) { throw new SAMLException("SAMLSignatureProfileValidator failed to validate signature", e); } final CriteriaSet criteriaSet = new CriteriaSet(); criteriaSet.add(new UsageCriterion(UsageType.SIGNING)); criteriaSet.add(new EntityRoleCriterion(IDPSSODescriptor.DEFAULT_ELEMENT_NAME)); criteriaSet.add(new ProtocolCriterion(SAMLConstants.SAML20P_NS)); criteriaSet.add(new EntityIdCriterion(idpEntityId)); final boolean valid; try { valid = trustEngine.validate(signature, criteriaSet); } catch (final SecurityException e) { throw new SAMLException("An error occurred during signature validation", e); } if (!valid) { throw new SAMLException("Signature is not trusted"); } } private boolean isDateValid(final DateTime issueInstant, final int interval) { final DateTime before = DateTime.now().plusSeconds(acceptedSkew); final DateTime after = DateTime.now().minusSeconds(acceptedSkew + interval); boolean isDateValid = issueInstant.isBefore(before) && issueInstant.isAfter(after); if (!isDateValid) { logger.trace("interval={},before={},after={},issueInstant={}", interval, before.toDateTime(issueInstant.getZone()), after.toDateTime(issueInstant.getZone()), issueInstant); } return isDateValid; } private boolean isIssueInstantValid(final DateTime issueInstant) { return isDateValid(issueInstant, 0); } @Override public final void setAcceptedSkew(final int acceptedSkew) { this.acceptedSkew = acceptedSkew; } @Override public final void setMaximumAuthenticationLifetime(final int maximumAuthenticationLifetime) { } }