From dabb1a0f9bdaaaf5e61e7fedf98b22d90cfe9ffe Mon Sep 17 00:00:00 2001 From: cailbourdin Date: Tue, 11 Feb 2025 14:06:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20modification=20r=C3=A9ponse=20SAML=20as?= =?UTF-8?q?sertions=20encrypt=C3=A9es=20pour=20conformit=C3=A9=20=C3=A0=20?= =?UTF-8?q?la=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppression du noeud xenc11:MGF dans la balise EncryptionMethod car cela bloque certains clients au moment de la validation du schéma (voir https://www.w3.org/TR/xmlenc-core1/#sec-Alg-KeyTransport) --- README.md | 20 +- build.gradle | 5 +- docs/CUSTOMISATIONS.md | 3 +- docs/SAML.md | 7 + .../SamlProfileSaml2ResponseBuilder.java | 197 ++++++++++++++++++ update.sh | 1 + 6 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/apereo/cas/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java diff --git a/README.md b/README.md index 0d98e9c..4e2061b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ And has a number of custom enhancements : - Custom parameter in url for delegation depending on service - Custom SAML attribute generation (pairwise-id and eduPersonTargetedId) - Change subject in SLO request based on usernameAttributeProvider per service +- Better compatibility with SAML clients (see `SamlProfileSaml2ResponseBuilder.java`) Current CAS Base version : **7.1.4** @@ -96,14 +97,17 @@ All the important parts of the project are listed below: │ │ │ └── services │ │ │ ├── PairwiseIdSamlRegisteredServiceAttributeReleasePolicy.java │ │ │ └── TargetedIdSamlRegisteredServiceAttributeReleasePolicy.java -│ │ └── web/flow -│ │ ├── actions -│ │ │ └── DelegatedClientAuthenticationRedirectAction.java -│ │ ├── error -│ │ │ └── DefaultDelegatedClientAuthenticationFailureEvaluator.java -│ │ ├── resolver/impl -│ │ │ └── DefaultCasDelegatingWebflowEventResolver.java -│ │ └── BaseServiceAuthorizationCheckAction.java +│ │ └── web +│ │ ├── flow +│ │ │ ├── actions +│ │ │ │ └── DelegatedClientAuthenticationRedirectAction.java +│ │ │ ├── error +│ │ │ │ └── DefaultDelegatedClientAuthenticationFailureEvaluator.java +│ │ │ ├── resolver/impl +│ │ │ │ └── DefaultCasDelegatingWebflowEventResolver.java +│ │ │ └── BaseServiceAuthorizationCheckAction.java +│ │ └── idp/profile/builders/response +│ │ └── SamlProfileSaml2ResponseBuilder.java | | | └── resources | ├── META-INF diff --git a/build.gradle b/build.gradle index 4f40ea1..6a945fa 100644 --- a/build.gradle +++ b/build.gradle @@ -382,5 +382,8 @@ dependencies { compileOnly "org.apereo.cas:cas-server-support-saml-idp-core" // Fix SLO custom principal compileOnly "org.apereo.cas:cas-server-core-logout-api" - + // Fix SAML schema validation + compileOnly "org.apereo.cas:cas-server-support-saml-idp-web" + compileOnly "org.apereo.cas:cas-server-support-saml-idp-ticket" + compileOnly "org.apereo.cas:cas-server-core-cookie-api" } diff --git a/docs/CUSTOMISATIONS.md b/docs/CUSTOMISATIONS.md index bd3d199..172a472 100644 --- a/docs/CUSTOMISATIONS.md +++ b/docs/CUSTOMISATIONS.md @@ -13,4 +13,5 @@ | Remontée d'erreur flot délégation | DefaultDelegatedClientAuthenticationFailureEvaluator + DefaultCasDelegatingWebflowEventResolver | | Pairwise-id/eduPersonTargetedId | PairwiseIdSamlRegisteredServiceAttributeReleasePolicy + TargetedIdSamlRegisteredServiceAttributeReleasePolicy | | SLO par principal | DefaultSingleLogoutMessageCreator + OidcSingleLogoutMessageCreator + CasCoreLogoutAutoConfiguration | -| IDToken custom acr | OidcIdTokenGeneratorService | \ No newline at end of file +| IDToken custom acr | OidcIdTokenGeneratorService | +| Meilleur compatibilité clients SAML | SamlProfileSaml2ResponseBuilder | \ No newline at end of file diff --git a/docs/SAML.md b/docs/SAML.md index 1a07459..59340d5 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -328,6 +328,13 @@ Avec : - `salt` la valeur du salt - `separator` la valeur du séparateur +### 10. Personnalisations + +**Amélioration de la compatibilité envers les clients SAML** + +Une modification a été faite dans `SamlProfileSaml2ResponseBuilder` afin de supprimer dans la SAMLResponse la balise `` dans la partie de la clé, car certains client SAML qui ont un validateur de schéma trop strict rejettent cette requête. + + ## Service Provider Pour l'instant le serveur CAS n'agit pas en tant que SP via le protocole SAML. Agir en tant que SP signifierait que le serveur CAS déléguerait son authentification à un IDP SAML. C'est par exemple le cas pour EduConnect (mais pas encore implémenté). diff --git a/src/main/java/org/apereo/cas/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java b/src/main/java/org/apereo/cas/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java new file mode 100644 index 0000000..4a6eb07 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java @@ -0,0 +1,197 @@ +package org.apereo.cas.support.saml.web.idp.profile.builders.response; + +import org.apereo.cas.support.saml.SamlIdPConstants; +import org.apereo.cas.support.saml.SamlIdPUtils; +import org.apereo.cas.support.saml.idp.metadata.locator.SamlIdPSamlRegisteredServiceCriterion; +import org.apereo.cas.support.saml.services.idp.metadata.MetadataEntityAttributeQuery; +import org.apereo.cas.support.saml.web.idp.profile.builders.SamlProfileBuilderContext; +import org.apereo.cas.support.saml.web.idp.profile.builders.enc.encoder.sso.SamlResponseArtifactEncoder; +import org.apereo.cas.support.saml.web.idp.profile.builders.enc.encoder.sso.SamlResponsePostEncoder; +import org.apereo.cas.support.saml.web.idp.profile.builders.enc.encoder.sso.SamlResponsePostSimpleSignEncoder; +import org.apereo.cas.ticket.query.SamlAttributeQueryTicket; +import org.apereo.cas.ticket.query.SamlAttributeQueryTicketFactory; +import org.apereo.cas.util.RandomUtils; +import org.apereo.cas.util.function.FunctionUtils; +import org.apereo.cas.web.support.CookieUtils; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import net.shibboleth.shared.resolver.CriteriaSet; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.jooq.lambda.Unchecked; +import org.opensaml.saml.common.SAMLVersion; +import org.opensaml.saml.common.xml.SAMLConstants; +import org.opensaml.saml.metadata.criteria.entity.impl.EvaluableEntityRoleEntityDescriptorCriterion; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Attribute; +import org.opensaml.saml.saml2.core.AttributeQuery; +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.EncryptedAssertion; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Status; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; + +import java.io.Serial; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * This is {@link SamlProfileSaml2ResponseBuilder}. + * + * @author Misagh Moayyed + * @since 5.1.0 + */ +@Slf4j +public class SamlProfileSaml2ResponseBuilder extends BaseSamlProfileSamlResponseBuilder { + @Serial + private static final long serialVersionUID = 1488837627964481272L; + + public SamlProfileSaml2ResponseBuilder(final SamlProfileSamlResponseBuilderConfigurationContext configurationContext) { + super(configurationContext); + } + + @Override + public Response buildResponse(final Optional assertion, + final SamlProfileBuilderContext context) throws Exception { + val id = '_' + String.valueOf(RandomUtils.nextLong()); + + val entityId = getConfigurationContext().getCasProperties().getAuthn().getSamlIdp().getCore().getEntityId(); + val recipient = getInResponseTo(context.getSamlRequest(), entityId, context.getRegisteredService().isSkipGeneratingResponseInResponseTo()); + val samlResponse = newResponse(id, ZonedDateTime.now(ZoneOffset.UTC), recipient, null); + samlResponse.setVersion(SAMLVersion.VERSION_20); + + val issuerId = FunctionUtils.doIf(StringUtils.isNotBlank(context.getRegisteredService().getIssuerEntityId()), + context.getRegisteredService()::getIssuerEntityId, + Unchecked.supplier(() -> { + val criteriaSet = new CriteriaSet( + new EvaluableEntityRoleEntityDescriptorCriterion(IDPSSODescriptor.DEFAULT_ELEMENT_NAME), + new SamlIdPSamlRegisteredServiceCriterion(context.getRegisteredService())); + LOGGER.trace("Resolving entity id from SAML2 IdP metadata to determine issuer for [{}]", context.getRegisteredService().getName()); + val entityDescriptor = Objects.requireNonNull(getConfigurationContext().getSamlIdPMetadataResolver().resolveSingle(criteriaSet)); + return entityDescriptor.getEntityID(); + })) + .get(); + + samlResponse.setIssuer(buildSamlResponseIssuer(issuerId)); + val acs = SamlIdPUtils.determineEndpointForRequest(Pair.of(context.getSamlRequest(), context.getMessageContext()), + context.getAdaptor(), context.getBinding()); + val location = StringUtils.isBlank(acs.getResponseLocation()) ? acs.getLocation() : acs.getResponseLocation(); + samlResponse.setDestination(location); + + if (getConfigurationContext().getCasProperties() + .getAuthn().getSamlIdp().getCore().isAttributeQueryProfileEnabled()) { + storeAttributeQueryTicketInRegistry(assertion, context); + } + + val finalAssertion = encryptAssertion(assertion, context); + if (finalAssertion.isPresent()) { + val result = finalAssertion.get(); + if (result instanceof final EncryptedAssertion encrypted) { + LOGGER.trace("Built assertion is encrypted, so the response will add it to the encrypted assertions collection"); + // Customization : remove xenc11:MGF node from EncryptionMethod to fix error when validating xml schema + // See https://www.w3.org/TR/xmlenc-core1/#sec-RSA-OAEP as mgf1sha1 is already the default method used + val encryptionMethod = encrypted.getEncryptedKeys().getFirst().getEncryptionMethod(); + if(encryptionMethod.getAlgorithm().equals("http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p")){ + if(encryptionMethod.getDOM().getLastChild().getNodeName().equals("xenc11:MGF")){ + encryptionMethod.getDOM().removeChild(encryptionMethod.getDOM().getLastChild()); + LOGGER.debug("Removed xenc11:MGF node from EncryptionMethod to ensure xml schema validation from all SAML clients"); + } + } + samlResponse.getEncryptedAssertions().add(encrypted); + } else if (result instanceof final Assertion nonEncryptedAssertion) { + LOGGER.trace("Built assertion is not encrypted, so the response will add it to the assertions collection"); + samlResponse.getAssertions().add(nonEncryptedAssertion); + } + } + + samlResponse.setStatus(determineResponseStatus(context)); + + val customizers = configurationContext.getApplicationContext() + .getBeansOfType(SamlIdPResponseCustomizer.class).values(); + customizers.stream() + .sorted(AnnotationAwareOrderComparator.INSTANCE) + .forEach(customizer -> customizer.customizeResponse(context, this, samlResponse)); + + openSamlConfigBean.logObject(samlResponse); + + if (signSamlResponseFor(context)) { + LOGGER.debug("SAML entity id [{}] indicates that SAML responses should be signed", context.getAdaptor().getEntityId()); + val samlResponseSigned = getConfigurationContext().getSamlObjectSigner().encode(samlResponse, + context.getRegisteredService(), context.getAdaptor(), context.getHttpResponse(), context.getHttpRequest(), + context.getBinding(), context.getSamlRequest(), context.getMessageContext()); + openSamlConfigBean.logObject(samlResponseSigned); + return samlResponseSigned; + } + + return samlResponse; + } + + protected boolean signSamlResponseFor(final SamlProfileBuilderContext context) { + return context.getRegisteredService().getSignResponses().isTrue() + || SamlIdPUtils.doesEntityDescriptorMatchEntityAttribute(context.getAdaptor().getEntityDescriptor(), + List.of(MetadataEntityAttributeQuery.of(SamlIdPConstants.KnownEntityAttributes.SHIBBOLETH_SIGN_RESPONSES.getName(), + Attribute.URI_REFERENCE, List.of(Boolean.TRUE.toString())))); + } + + protected Status determineResponseStatus(final SamlProfileBuilderContext context) { + if (context.getAuthenticatedAssertion().isEmpty()) { + if (context.getSamlRequest() instanceof final AuthnRequest authnRequest && authnRequest.isPassive()) { + val message = """ + SAML2 authentication request from %s indicated a passive authentication request, \ + but CAS is unable to satisfy and support this requirement, likely because \ + no existing single sign-on session is available yet to build the SAML2 response. + """.formatted(context.getAdaptor().getEntityId()).stripIndent().trim(); + return newStatus(StatusCode.NO_PASSIVE, message); + } + return newStatus(StatusCode.AUTHN_FAILED, null); + } + return newStatus(StatusCode.SUCCESS, null); + } + + @Override + protected Response encode(final SamlProfileBuilderContext context, + final Response samlResponse, + final String relayState) throws Exception { + LOGGER.trace("Constructing encoder based on binding [{}] for [{}]", context.getBinding(), context.getAdaptor().getEntityId()); + if (context.getBinding().equalsIgnoreCase(SAMLConstants.SAML2_ARTIFACT_BINDING_URI)) { + val encoder = new SamlResponseArtifactEncoder( + getConfigurationContext().getVelocityEngineFactory(), + context.getAdaptor(), context.getHttpRequest(), context.getHttpResponse(), + getConfigurationContext().getSamlArtifactMap()); + return encoder.encode(context.getSamlRequest(), samlResponse, relayState, context.getMessageContext()); + } + + if (context.getBinding().equalsIgnoreCase(SAMLConstants.SAML2_POST_SIMPLE_SIGN_BINDING_URI)) { + val encoder = new SamlResponsePostSimpleSignEncoder(getConfigurationContext().getVelocityEngineFactory(), + context.getAdaptor(), context.getHttpResponse(), context.getHttpRequest()); + return encoder.encode(context.getSamlRequest(), samlResponse, relayState, context.getMessageContext()); + } + + val encoder = new SamlResponsePostEncoder(getConfigurationContext().getVelocityEngineFactory(), context.getAdaptor(), context.getHttpResponse(), context.getHttpRequest()); + return encoder.encode(context.getSamlRequest(), samlResponse, relayState, context.getMessageContext()); + } + + private void storeAttributeQueryTicketInRegistry(final Optional assertion, final SamlProfileBuilderContext context) + throws Exception { + val existingQuery = context.getHttpRequest().getAttribute(AttributeQuery.class.getSimpleName()); + if (existingQuery == null && assertion.isPresent()) { + val nameId = (String) context.getHttpRequest().getAttribute(NameID.class.getName()); + val ticketGrantingTicket = CookieUtils.getTicketGrantingTicketFromRequest( + getConfigurationContext().getTicketGrantingTicketCookieGenerator(), + getConfigurationContext().getTicketRegistry(), context.getHttpRequest()); + + if (ticketGrantingTicket != null) { + val samlAttributeQueryTicketFactory = (SamlAttributeQueryTicketFactory) getConfigurationContext().getTicketFactory().get(SamlAttributeQueryTicket.class); + val ticket = samlAttributeQueryTicketFactory.create(nameId, assertion.get(), context.getAdaptor().getEntityId(), ticketGrantingTicket); + getConfigurationContext().getTicketRegistry().addTicket(ticket); + context.getHttpRequest().setAttribute(SamlAttributeQueryTicket.class.getName(), ticket); + } + } + } +} diff --git a/update.sh b/update.sh index 56a880e..e9eba95 100644 --- a/update.sh +++ b/update.sh @@ -20,6 +20,7 @@ FILES=( "src/main/java/org/apereo/cas/web/flow/actions/DelegatedClientAuthenticationRedirectAction.java support/cas-server-support-pac4j-webflow/src/main/java/org/apereo/cas/web/flow/actions/DelegatedClientAuthenticationRedirectAction.java" "src/main/java/org/apereo/cas/config/CasCoreLogoutAutoConfiguration.java core/cas-server-core-logout/src/main/java/org/apereo/cas/config/CasCoreLogoutAutoConfiguration.java" "src/main/java/org/apereo/cas/logout/DefaultSingleLogoutMessageCreator.java core/cas-server-core-logout-api/src/main/java/org/apereo/cas/logout/DefaultSingleLogoutMessageCreator.java" + "src/main/java/org/apereo/cas/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java support/cas-server-support-saml-idp-web/src/main/java/org/apereo/cas/support/saml/web/idp/profile/builders/response/SamlProfileSaml2ResponseBuilder.java" ) # Créer un dossier diff dans lequel on va copier les fichier locaux et nouveaux