From c355df00dd85dcba4e7c885a2f37c96b630eacaf Mon Sep 17 00:00:00 2001 From: Ray Lee Date: Mon, 6 Nov 2023 10:50:31 -0500 Subject: [PATCH] Allow configuring probe of SAML assertion for CSpace username. Also fix single logout error when the CSpace username comes from an attribute in the SAML assertion instead of the NameID. --- .../spring/CSpaceSaml2Authentication.java | 6 + .../common/security/SecurityConfig.java | 57 +++++++-- .../common/security/SecurityUtils.java | 120 +++++++++++------- .../services/common/config/ConfigUtils.java | 14 ++ .../src/main/resources/service-config.xsd | 53 ++++++++ 5 files changed, 197 insertions(+), 53 deletions(-) diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java index 60488ac20..2adb0174c 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true) public class CSpaceSaml2Authentication extends Saml2Authentication { private final CSpaceUser user; + private final AuthenticatedPrincipal principal; public CSpaceSaml2Authentication(CSpaceUser user, Saml2Authentication authentication) { this( @@ -60,6 +61,7 @@ public class CSpaceSaml2Authentication extends Saml2Authentication { super(principal, saml2Response, authorities); this.user = user; + this.principal = principal; this.setAuthenticated(true); } @@ -73,4 +75,8 @@ public class CSpaceSaml2Authentication extends Saml2Authentication { public Collection getAuthorities() { return user.getAuthorities(); } + + public Saml2Authentication getWrappedAuthentication() { + return new Saml2Authentication(this.principal, getSaml2Response(), getAuthorities()); + } } diff --git a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityConfig.java b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityConfig.java index fc7403106..46ac92944 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityConfig.java +++ b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityConfig.java @@ -33,6 +33,7 @@ import javax.servlet.http.HttpServletRequest; import javax.sql.DataSource; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.collectionspace.authentication.CSpaceUser; import org.collectionspace.authentication.spring.CSpaceDaoAuthenticationProvider; import org.collectionspace.authentication.spring.CSpaceJwtAuthenticationToken; @@ -47,6 +48,7 @@ import org.collectionspace.services.common.ServiceMain; import org.collectionspace.services.common.config.ConfigUtils; import org.collectionspace.services.common.config.TenantBindingConfigReaderImpl; import org.collectionspace.services.config.AssertingPartyDetailsType; +import org.collectionspace.services.config.AssertionProbesType; import org.collectionspace.services.config.OAuthAuthorizationGrantTypeEnum; import org.collectionspace.services.config.OAuthClientAuthenticationMethodEnum; import org.collectionspace.services.config.OAuthClientSettingsType; @@ -94,6 +96,7 @@ import org.springframework.security.config.annotation.web.configurers.LogoutConf import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -112,6 +115,7 @@ import org.springframework.security.oauth2.server.authorization.settings.TokenSe import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationProvider; import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; import org.springframework.security.saml2.provider.service.authentication.OpenSamlAuthenticationProvider.ResponseToken; import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver; import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; @@ -124,6 +128,8 @@ import org.springframework.security.saml2.provider.service.web.DefaultRelyingPar import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter; import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter; +import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml3LogoutRequestResolver; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestResolver; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.logout.LogoutFilter; @@ -529,7 +535,7 @@ public class SecurityConfig { .addFilterBefore(new CSpaceUserAttributeFilter(), LogoutFilter.class); if (relyingPartyRegistrationRepository != null) { - RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = + final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository); // TODO: Use OpenSaml4AuthenticationProvider (requires Java 11) instead of deprecated OpenSamlAuthenticationProvider. @@ -542,19 +548,32 @@ public class SecurityConfig { .createDefaultResponseAuthenticationConverter() .convert(responseToken); + String registrationId = responseToken.getToken().getRelyingPartyRegistration().getRegistrationId(); + ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig(); + SAMLRelyingPartyType registration = ConfigUtils.getSAMLRelyingPartyRegistration(serviceConfig, registrationId); + + AssertionProbesType assertionProbes = ( + registration != null + ? registration.getAssertionUsernameProbes() + : null + ); + Assertion assertion = responseToken.getResponse().getAssertions().get(0); - String username = SecurityUtils.getSamlAssertionUsername(assertion, EMAIL_ATTR_NAMES); + List candidateUsernames = SecurityUtils.findSamlAssertionCandidateUsernames(assertion, assertionProbes); - try { - CSpaceUser user = (CSpaceUser) userDetailsService.loadUserByUsername(username); + for (String candidateUsername : candidateUsernames) { + try { + CSpaceUser user = (CSpaceUser) userDetailsService.loadUserByUsername(candidateUsername); - return new CSpaceSaml2Authentication(user, authentication); + return new CSpaceSaml2Authentication(user, authentication); + } + catch(UsernameNotFoundException e) { + } } - catch(UsernameNotFoundException e) { - String errorMessage = "No CollectionSpace account was found for " + username + "."; - throw(new UsernameNotFoundException(errorMessage, e)); - } + String errorMessage = "No CollectionSpace account was found for " + StringUtils.join(candidateUsernames, " / ") + "."; + + throw(new UsernameNotFoundException(errorMessage)); } }); @@ -589,7 +608,25 @@ public class SecurityConfig { configurer.logoutRequest(new Customizer.LogoutRequestConfigurer>() { @Override public void customize(Saml2LogoutConfigurer.LogoutRequestConfigurer configurer) { - configurer.logoutRequestRepository(new CSpaceSaml2LogoutRequestRepository()); + configurer + .logoutRequestRepository(new CSpaceSaml2LogoutRequestRepository()) + .logoutRequestResolver(new Saml2LogoutRequestResolver() { + @Override + public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication authentication) { + // TODO: Use OpenSaml4LogoutRequestResolver (requires Java 11). + Saml2LogoutRequestResolver resolver = new OpenSaml3LogoutRequestResolver(relyingPartyRegistrationResolver); + + // The name of the authenticated principal in our CSpaceSaml2Authentication + // may have come from an attribute of the SAML assertion instead of the + // NameID, but the logout request needs to send the NameID. + // CSpaceSaml2Authentication.getWrappedAuthentication will get the + // authentication whose principal is the NameID of the assertion. + + Saml2Authentication wrappedAuthentication = ((CSpaceSaml2Authentication) authentication).getWrappedAuthentication(); + + return resolver.resolve(request, wrappedAuthentication); + } + }); } }); } diff --git a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityUtils.java b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityUtils.java index 79d1ddb4c..0c5530947 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityUtils.java +++ b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityUtils.java @@ -22,10 +22,8 @@ */ package org.collectionspace.services.common.security; -import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; -import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.StringTokenizer; @@ -36,6 +34,9 @@ import org.collectionspace.services.client.CollectionSpaceClient; import org.collectionspace.services.client.index.IndexClient; import org.collectionspace.services.client.workflow.WorkflowClient; import org.collectionspace.services.common.api.Tools; +import org.collectionspace.services.config.AssertionAttributeProbeType; +import org.collectionspace.services.config.AssertionNameIDProbeType; +import org.collectionspace.services.config.AssertionProbesType; import org.collectionspace.services.config.service.ServiceBindingType; import org.collectionspace.authentication.AuthN; import org.collectionspace.authentication.spring.CSpacePasswordEncoderFactory; @@ -47,10 +48,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.crypto.password.PasswordEncoder; -import org.jboss.crypto.digest.DigestCallback; import org.jboss.resteasy.spi.HttpRequest; -import org.jboss.security.Base64Encoder; -import org.jboss.security.Base64Utils; import org.opensaml.core.xml.XMLObject; import org.opensaml.core.xml.schema.XSString; import org.opensaml.saml.saml2.core.Assertion; @@ -74,6 +72,25 @@ public class SecurityUtils { public static final String RFC2617_ENCODING = "RFC2617"; private static char MD5_HEX[] = "0123456789abcdef".toCharArray(); + private static final List DEFAULT_SAML_ASSERTION_USERNAME_PROBES = new ArrayList<>(); + + static { + DEFAULT_SAML_ASSERTION_USERNAME_PROBES.add(new AssertionNameIDProbeType()); + + String[] attributeNames = new String[]{ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "email", + "mail" + }; + + for (String attributeName : attributeNames) { + AssertionAttributeProbeType attributeProbe = new AssertionAttributeProbeType(); + attributeProbe.setName(attributeName); + + DEFAULT_SAML_ASSERTION_USERNAME_PROBES.add(attributeProbe); + } + } + /** * createPasswordHash creates password has using configured digest algorithm * and encoding @@ -325,50 +342,67 @@ public class SecurityUtils { return result; } - /* - * Retrieve the CSpace username from a SAML assertion. If the assertion's subject nameID is an - * email address, it is returned. Otherwise, the first value of the given attribute name is - * returned. - */ - public static String getSamlAssertionUsername(Assertion assertion, List attributeNames) { - String subjectNameID = assertion.getSubject().getNameID().getValue(); - - if (subjectNameID.contains("@")) { - return subjectNameID; - } - - for (String attributeName : attributeNames) { - String value = findSamlAssertionAttribute(assertion, attributeName); + /* + * Retrieve the possible CSpace usernames from a SAML assertion. + */ + public static List findSamlAssertionCandidateUsernames(Assertion assertion, AssertionProbesType assertionProbes) { + List candidateUsernames = new ArrayList<>(); + List probes = null; - if (value != null) { - return value; - } - } + if (assertionProbes != null) { + probes = assertionProbes.getNameIdOrAttribute(); + } - return null; - } + if (probes == null || probes.size() == 0) { + probes = DEFAULT_SAML_ASSERTION_USERNAME_PROBES; + } - private static String findSamlAssertionAttribute(Assertion assertion, String attributeName) { - for (AttributeStatement statement : assertion.getAttributeStatements()) { - for (Attribute attribute : statement.getAttributes()) { - String name = attribute.getName(); + for (Object probe : probes) { + if (probe instanceof AssertionNameIDProbeType) { + String subjectNameID = assertion.getSubject().getNameID().getValue(); - if (name.equals(attributeName)) { - List attributeValues = attribute.getAttributeValues(); + if (subjectNameID != null && subjectNameID.length() > 0) { + candidateUsernames.add(subjectNameID); + } + } else if (probe instanceof AssertionAttributeProbeType) { + String attributeName = ((AssertionAttributeProbeType) probe).getName(); + List values = getSamlAssertionAttributeValues(assertion, attributeName); - if (attributeValues != null && attributeValues.size() > 0) { - XMLObject value = attributeValues.get(0); + if (values != null) { + candidateUsernames.addAll(values); + } + } + } - if (value instanceof XSString) { - XSString stringValue = (XSString) value; + return candidateUsernames; + } - return stringValue.getValue(); - } - } - } - } - } + private static List getSamlAssertionAttributeValues(Assertion assertion, String attributeName) { + List values = new ArrayList<>(); + + for (AttributeStatement statement : assertion.getAttributeStatements()) { + for (Attribute attribute : statement.getAttributes()) { + String name = attribute.getName(); + + if (name.equals(attributeName)) { + List attributeValues = attribute.getAttributeValues(); + + if (attributeValues != null) { + for (XMLObject value : attributeValues) { + if (value instanceof XSString) { + XSString stringValue = (XSString) value; + String candidateValue = stringValue.getValue(); + + if (candidateValue != null && candidateValue.length() > 0) { + values.add(candidateValue); + } + } + } + } + } + } + } - return null; - } + return values; + } } diff --git a/services/config/src/main/java/org/collectionspace/services/common/config/ConfigUtils.java b/services/config/src/main/java/org/collectionspace/services/common/config/ConfigUtils.java index 8e4c848bb..b3b253b00 100644 --- a/services/config/src/main/java/org/collectionspace/services/common/config/ConfigUtils.java +++ b/services/config/src/main/java/org/collectionspace/services/common/config/ConfigUtils.java @@ -211,6 +211,20 @@ public class ConfigUtils { return null; } + public static SAMLRelyingPartyType getSAMLRelyingPartyRegistration(ServiceConfig serviceConfig, String registrationId) { + List registrations = getSAMLRelyingPartyRegistrations(serviceConfig); + + if (registrations != null) { + for (SAMLRelyingPartyType registration : registrations) { + if (registration.getId().equals(registrationId)) { + return registration; + } + } + } + + return null; + } + public static boolean isSsoAvailable(ServiceConfig serviceConfig) { List samlRegistrations = getSAMLRelyingPartyRegistrations(serviceConfig); diff --git a/services/config/src/main/resources/service-config.xsd b/services/config/src/main/resources/service-config.xsd index 051f37d42..9598ba0fd 100644 --- a/services/config/src/main/resources/service-config.xsd +++ b/services/config/src/main/resources/service-config.xsd @@ -260,6 +260,22 @@ + + + + + + + + + ]]> + + + @@ -272,6 +288,43 @@ + + + + Configures probes in a SAML assertion. + + + + + + + + + + + + + + + Configures probing of an attribute in a SAML assertion. + + + + + + + The name of the SAML attribute to probe. + + + + + + + + Configures probing of the NameID in a SAML assertion. + + + -- 2.47.3