Skip to content

Commit

Permalink
feat: modification réponse SAML assertions encryptées pour conformité…
Browse files Browse the repository at this point in the history
… à la spec

- 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)
  • Loading branch information
nathancailbourdin committed Feb 11, 2025
1 parent dded885 commit dabb1a0
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 10 deletions.
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
3 changes: 2 additions & 1 deletion docs/CUSTOMISATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| IDToken custom acr | OidcIdTokenGeneratorService |
| Meilleur compatibilité clients SAML | SamlProfileSaml2ResponseBuilder |
7 changes: 7 additions & 0 deletions docs/SAML.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<xenc11:MGF>` dans la partie <EncryptionMethod> 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é).
Original file line number Diff line number Diff line change
@@ -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<Response> {
@Serial
private static final long serialVersionUID = 1488837627964481272L;

public SamlProfileSaml2ResponseBuilder(final SamlProfileSamlResponseBuilderConfigurationContext configurationContext) {
super(configurationContext);
}

@Override
public Response buildResponse(final Optional<Assertion> 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> 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);
}
}
}
}
1 change: 1 addition & 0 deletions update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit dabb1a0

Please sign in to comment.