From 120aaf248a9e60e23a87934fb6cf2212666e72be Mon Sep 17 00:00:00 2001 From: Ray Lee Date: Thu, 21 Sep 2023 20:48:31 -0400 Subject: [PATCH] DRYD-1243: Add SAML support. (#366) --- build.properties | 2 +- .../src/main/resources/accounts_common.xsd | 12 + .../main/resources/accounts_common_list.xsd | 1 + .../main/resources/db/postgresql/account.sql | 5 + .../services/account/AccountResource.java | 6 + .../storage/AccountDocumentHandler.java | 18 + .../storage/AccountValidatorHandler.java | 5 +- services/authentication/service/pom.xml | 6 + .../authentication/CSpaceUser.java | 11 + .../jackson2/CSpaceUserDeserializer.java | 3 +- ...l2AuthenticatedCSpaceUserDeserializer.java | 55 +++ .../authentication/realm/CSpaceRealm.java | 19 +- .../realm/db/CSpaceDbRealm.java | 154 ++++++-- .../CSpaceDaoAuthenticationProvider.java | 37 ++ .../spring/CSpaceLogoutSuccessHandler.java | 7 + .../spring/CSpaceSaml2Authentication.java | 76 ++++ .../CSpaceSaml2LogoutRequestRepository.java | 42 +++ .../spring/CSpaceUserDetailsService.java | 21 +- .../spring/SSORequiredException.java | 10 + .../spring/Saml2AuthenticatedCSpaceUser.java | 89 +++++ .../services/authorization/AuthZ.java | 2 +- services/common/pom.xml | 6 + .../services/service-config-security.xml | 26 ++ .../AuthorizationCommon.java | 4 +- .../common/security/SecurityConfig.java | 330 +++++++++++++++++- .../services/common/config/ConfigUtils.java | 44 +++ .../src/main/resources/service-config.xsd | 189 ++++++++++ .../services/login/LoginResource.java | 29 ++ 28 files changed, 1149 insertions(+), 60 deletions(-) create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/Saml2AuthenticatedCSpaceUserDeserializer.java create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceDaoAuthenticationProvider.java create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2LogoutRequestRepository.java create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SSORequiredException.java create mode 100644 services/authentication/service/src/main/java/org/collectionspace/authentication/spring/Saml2AuthenticatedCSpaceUser.java diff --git a/build.properties b/build.properties index 5d1206ce2..27cbdd1d7 100644 --- a/build.properties +++ b/build.properties @@ -22,7 +22,7 @@ domain.nuxeo=nuxeo-server # UI settings cspace.ui.package.name=cspace-ui cspace.ui.library.name=cspaceUI -cspace.ui.version=9.0.0-dev.1 +cspace.ui.version=9.0.0-dev.2 cspace.ui.build.branch=master cspace.ui.build.node.ver=14 service.ui.library.name=${cspace.ui.library.name}-service diff --git a/services/account/jaxb/src/main/resources/accounts_common.xsd b/services/account/jaxb/src/main/resources/accounts_common.xsd index 3ac4545b9..7caba18ec 100644 --- a/services/account/jaxb/src/main/resources/accounts_common.xsd +++ b/services/account/jaxb/src/main/resources/accounts_common.xsd @@ -120,6 +120,18 @@ + + + + If true, login through an SSO identity provider is required. + + + + + + + + diff --git a/services/account/jaxb/src/main/resources/accounts_common_list.xsd b/services/account/jaxb/src/main/resources/accounts_common_list.xsd index 6c88ecdc7..02131abe1 100644 --- a/services/account/jaxb/src/main/resources/accounts_common_list.xsd +++ b/services/account/jaxb/src/main/resources/accounts_common_list.xsd @@ -67,6 +67,7 @@ + diff --git a/services/account/pstore/src/main/resources/db/postgresql/account.sql b/services/account/pstore/src/main/resources/db/postgresql/account.sql index 6289f74ed..64fc15c34 100644 --- a/services/account/pstore/src/main/resources/db/postgresql/account.sql +++ b/services/account/pstore/src/main/resources/db/postgresql/account.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS accounts_common ( mobile VARCHAR(255), person_ref_name VARCHAR(255), phone VARCHAR(255), + require_sso BOOLEAN, screen_name VARCHAR(128) NOT NULL, status VARCHAR(15) NOT NULL, updated_at TIMESTAMP, @@ -13,6 +14,10 @@ CREATE TABLE IF NOT EXISTS accounts_common ( roles_protection VARCHAR(255) ); +-- Upgrade older accounts_common tables to 8.0 + +ALTER TABLE accounts_common ADD COLUMN IF NOT EXISTS require_sso BOOLEAN; + CREATE TABLE IF NOT EXISTS accounts_tenants ( hjid INT8 NOT NULL PRIMARY KEY, tenant_id VARCHAR(128) NOT NULL, diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java b/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java index cc21837f7..2bcf39e01 100644 --- a/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java +++ b/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java @@ -556,6 +556,12 @@ public class AccountResource extends SecurityResourceBase${spring.security.authorization.server.version} provided + + org.springframework.security + spring-security-saml2-service-provider + ${spring.security.version} + provided + org.postgresql postgresql diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/CSpaceUser.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/CSpaceUser.java index 7f3ff714d..43b9c2def 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/CSpaceUser.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/CSpaceUser.java @@ -34,6 +34,7 @@ public class CSpaceUser extends User { private Set tenants; private CSpaceTenant primaryTenant; + private boolean requireSSO; private String salt; /** @@ -46,6 +47,7 @@ public class CSpaceUser extends User { * @param authorities the authorities that have been granted to the user */ public CSpaceUser(String username, String password, String salt, + boolean requireSSO, Set tenants, Set authorities) { @@ -57,6 +59,7 @@ public class CSpaceUser extends User { authorities); this.tenants = tenants; + this.requireSSO = requireSSO; this.salt = salt; if (!tenants.isEmpty()) { @@ -89,4 +92,12 @@ public class CSpaceUser extends User { public String getSalt() { return salt != null ? salt : ""; } + + /** + * Determines if the user is required to log in using single sign-on. + * @return true if SSO is required, false otherwise + */ + public boolean isRequireSSO() { + return requireSSO; + } } diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/CSpaceUserDeserializer.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/CSpaceUserDeserializer.java index acaa97b82..70061cc39 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/CSpaceUserDeserializer.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/CSpaceUserDeserializer.java @@ -35,9 +35,10 @@ public class CSpaceUserDeserializer extends JsonDeserializer { JsonNode passwordNode = readJsonNode(jsonNode, "password"); String username = readJsonNode(jsonNode, "username").asText(); String password = passwordNode.asText(""); + boolean requireSSO = readJsonNode(jsonNode, "requireSSO").asBoolean(); String salt = readJsonNode(jsonNode, "salt").asText(); - CSpaceUser result = new CSpaceUser(username, password, salt, tenants, authorities); + CSpaceUser result = new CSpaceUser(username, password, salt, requireSSO, tenants, authorities); if (passwordNode.asText(null) == null) { result.eraseCredentials(); diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/Saml2AuthenticatedCSpaceUserDeserializer.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/Saml2AuthenticatedCSpaceUserDeserializer.java new file mode 100644 index 000000000..473838b8f --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/jackson2/Saml2AuthenticatedCSpaceUserDeserializer.java @@ -0,0 +1,55 @@ +package org.collectionspace.authentication.jackson2; + +import java.io.IOException; +import java.util.Set; + +import org.collectionspace.authentication.CSpaceTenant; +import org.collectionspace.authentication.spring.Saml2AuthenticatedCSpaceUser; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.MissingNode; + +public class Saml2AuthenticatedCSpaceUserDeserializer extends JsonDeserializer { + private static final TypeReference> SIMPLE_GRANTED_AUTHORITY_SET = new TypeReference>() { + }; + + private static final TypeReference> CSPACE_TENANT_SET = new TypeReference>() { + }; + + @Override + public Saml2AuthenticatedCSpaceUser deserialize(JsonParser parser, DeserializationContext context) throws IOException, JsonProcessingException { + ObjectMapper mapper = (ObjectMapper) parser.getCodec(); + JsonNode jsonNode = mapper.readTree(parser); + + Set authorities = mapper.convertValue(jsonNode.get("authorities"), SIMPLE_GRANTED_AUTHORITY_SET); + Set tenants = mapper.convertValue(jsonNode.get("tenants"), CSPACE_TENANT_SET); + + Saml2AuthenticatedPrincipal principal = mapper.convertValue(readJsonNode(jsonNode, "principal"), Saml2AuthenticatedPrincipal.class); + JsonNode passwordNode = readJsonNode(jsonNode, "password"); + String username = readJsonNode(jsonNode, "username").asText(); + String password = passwordNode.asText(""); + boolean requireSSO = readJsonNode(jsonNode, "requireSSO").asBoolean(); + String salt = readJsonNode(jsonNode, "salt").asText(); + + Saml2AuthenticatedCSpaceUser result = new Saml2AuthenticatedCSpaceUser(principal, username, password, salt, requireSSO, tenants, authorities); + + if (passwordNode.asText(null) == null) { + result.eraseCredentials(); + } + + return result; + } + + private JsonNode readJsonNode(JsonNode jsonNode, String field) { + return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance(); + } +} diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/CSpaceRealm.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/CSpaceRealm.java index bfbd20755..36078ad18 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/CSpaceRealm.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/CSpaceRealm.java @@ -38,7 +38,7 @@ import org.collectionspace.authentication.CSpaceTenant; * Interface for the CollectionSpace realm. */ public interface CSpaceRealm { - + /** * Retrieves the "salt" used to encrypt the user's password * @param username @@ -49,7 +49,7 @@ public interface CSpaceRealm { /** * Retrieves the hashed password used to authenticate a user. - * + * * @param username * @return the password * @throws AccountNotFoundException if the user is not found @@ -59,7 +59,7 @@ public interface CSpaceRealm { /** * Retrieves the roles for a user. - * + * * @param username * @return a collection of roles * @throws AccountException if the roles could not be retrieved @@ -68,7 +68,7 @@ public interface CSpaceRealm { /** * Retrieves the enabled tenants associated with a user. - * + * * @param username * @return a collection of tenants * @throws AccountException if the tenants could not be retrieved @@ -77,11 +77,20 @@ public interface CSpaceRealm { /** * Retrieves the tenants associated with a user, optionally including disabled tenants. - * + * * @param username * @param includeDisabledTenants if true, include disabled tenants * @return a collection of tenants * @throws AccountException if the tenants could not be retrieved */ public Set getTenants(String username, boolean includeDisabledTenants) throws AccountException; + + /** + * Determines if the user is required to login using single sign-on. + * + * @param username + * @return true if SSO is required, false otherwise + * @throws AccountException + */ + public boolean isRequireSSO(String username) throws AccountException; } diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/db/CSpaceDbRealm.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/db/CSpaceDbRealm.java index 09942bd30..2fef70989 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/db/CSpaceDbRealm.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/realm/db/CSpaceDbRealm.java @@ -74,16 +74,17 @@ import org.slf4j.LoggerFactory; /** * CSpaceDbRealm provides access to user, password, role, tenant database - * @author + * @author */ public class CSpaceDbRealm implements CSpaceRealm { public static String DEFAULT_DATASOURCE_NAME = "CspaceDS"; - + private Logger logger = LoggerFactory.getLogger(CSpaceDbRealm.class); - + private String datasourceName; private String principalsQuery; private String saltQuery; + private String requireSSOQuery; private String rolesQuery; private String tenantsQueryNoDisabled; private String tenantsQueryWithDisabled; @@ -96,7 +97,7 @@ public class CSpaceDbRealm implements CSpaceRealm { private long delayBetweenAttemptsMillis = DELAY_BETWEEN_ATTEMPTS_MILLISECONDS; private static final String DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR = "delayBetweenAttemptsMillis"; private static final long DELAY_BETWEEN_ATTEMPTS_MILLISECONDS = 200; - + protected void setMaxRetrySeconds(Map options) { Object optionsObj = options.get(MAX_RETRY_SECONDS_STR); if (optionsObj != null) { @@ -109,11 +110,11 @@ public class CSpaceDbRealm implements CSpaceRealm { } } } - + protected long getMaxRetrySeconds() { return this.maxRetrySeconds; } - + protected void setDelayBetweenAttemptsMillis(Map options) { Object optionsObj = options.get(DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR); if (optionsObj != null) { @@ -126,15 +127,15 @@ public class CSpaceDbRealm implements CSpaceRealm { } } } - + protected long getDelayBetweenAttemptsMillis() { return this.delayBetweenAttemptsMillis; } - + public CSpaceDbRealm() { datasourceName = DEFAULT_DATASOURCE_NAME; } - + /** * CSpace Database Realm * @param datasourceName datasource name @@ -152,6 +153,10 @@ public class CSpaceDbRealm implements CSpaceRealm { if (tmp != null) { saltQuery = tmp.toString(); } + tmp = options.get("requireSSOQuery"); + if (tmp != null) { + requireSSOQuery = tmp.toString(); + } tmp = options.get("rolesQuery"); if (tmp != null) { rolesQuery = tmp.toString(); @@ -168,10 +173,10 @@ public class CSpaceDbRealm implements CSpaceRealm { if (tmp != null) { suspendResume = Boolean.valueOf(tmp.toString()).booleanValue(); } - + this.setMaxRetrySeconds(options); this.setDelayBetweenAttemptsMillis(options); - + if (logger.isTraceEnabled()) { logger.trace("DatabaseServerLoginModule, dsJndiName=" + datasourceName); logger.trace("principalsQuery=" + principalsQuery); @@ -270,14 +275,14 @@ public class CSpaceDbRealm implements CSpaceRealm { if (logger.isDebugEnabled()) { logger.debug("No roles found"); } - + return roles; } do { String roleName = rs.getString(1); roles.add(roleName); - + } while (rs.next()); } catch (SQLException ex) { AccountException ae = new AccountException("Query failed"); @@ -316,7 +321,7 @@ public class CSpaceDbRealm implements CSpaceRealm { public Set getTenants(String username) throws AccountException { return getTenants(username, false); } - + private boolean userIsTenantManager(Connection conn, String username) { String acctQuery = "SELECT csid FROM accounts_common WHERE userid=?"; PreparedStatement ps = null; @@ -356,7 +361,7 @@ public class CSpaceDbRealm implements CSpaceRealm { } return accountIsTenantManager; } - + /** * Execute the tenantsQuery against the datasourceName to obtain the tenants for * the authenticated user. @@ -366,13 +371,13 @@ public class CSpaceDbRealm implements CSpaceRealm { public Set getTenants(String username, boolean includeDisabledTenants) throws AccountException { String tenantsQuery = getTenantQuery(includeDisabledTenants); - + if (logger.isDebugEnabled()) { logger.debug("getTenants using tenantsQuery: " + tenantsQuery + ", username: " + username); } Set tenants = new LinkedHashSet(); - + Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; @@ -393,7 +398,7 @@ public class CSpaceDbRealm implements CSpaceRealm { if (logger.isDebugEnabled()) { logger.debug("GetTenants called with tenantManager - synthesizing the pseudo-tenant"); } - + tenants.add(new CSpaceTenant(AuthN.TENANT_MANAGER_ACCT_ID, "PseudoTenant")); } else { if (logger.isDebugEnabled()) { @@ -403,7 +408,7 @@ public class CSpaceDbRealm implements CSpaceRealm { // empty Tenants set. // FIXME should this be allowed? } - + return tenants; } @@ -461,7 +466,7 @@ public class CSpaceDbRealm implements CSpaceRealm { if (requestAttempts > 0) { Thread.sleep(getDelayBetweenAttemptsMillis()); // Wait a little time between reattempts. } - + try { // proceed to the original request by calling doFilter() result = this.getConnection(getDataSourceName()); @@ -482,7 +487,7 @@ public class CSpaceDbRealm implements CSpaceRealm { requestAttempts++; // keep track of how many times we've tried the request } } while (System.currentTimeMillis() < quittingTime); // keep trying until we run out of time - + // // Add a warning to the logs if we encountered *any* failures on our re-attempts. Only add the warning // if we were eventually successful. @@ -498,10 +503,10 @@ public class CSpaceDbRealm implements CSpaceRealm { // If we get here, it means all of our attempts to get a successful call to chain.doFilter() have failed. throw lastException; } - + return result; } - + /* * Don't call this method directly. Instead, use the getConnection() method that take no arguments. */ @@ -509,52 +514,52 @@ public class CSpaceDbRealm implements CSpaceRealm { InitialContext ctx = null; Connection conn = null; DataSource ds = null; - + try { ctx = new InitialContext(); try { ds = (DataSource) ctx.lookup(dataSourceName); } catch (Exception e) {} - + try { Context envCtx = (Context) ctx.lookup("java:comp/env"); ds = (DataSource) envCtx.lookup(dataSourceName); } catch (Exception e) {} - + try { Context envCtx = (Context) ctx.lookup("java:comp"); ds = (DataSource) envCtx.lookup(dataSourceName); } catch (Exception e) {} - + try { Context envCtx = (Context) ctx.lookup("java:"); ds = (DataSource) envCtx.lookup(dataSourceName); } catch (Exception e) {} - + try { Context envCtx = (Context) ctx.lookup("java"); ds = (DataSource) envCtx.lookup(dataSourceName); } catch (Exception e) {} - + try { ds = (DataSource) ctx.lookup("java:/" + dataSourceName); - } catch (Exception e) {} + } catch (Exception e) {} if (ds == null) { ds = AuthN.getDataSource(); } - + if (ds == null) { throw new IllegalArgumentException("datasource not found: " + dataSourceName); } - + conn = ds.getConnection(); if (conn == null) { conn = AuthN.getDataSource().getConnection(); //FIXME:REM - This is the result of some type of JNDI mess. Should try to solve this problem and clean up this code. } - + return conn; - + } catch (NamingException ex) { AccountException ae = new AccountException("Error looking up DataSource from: " + dataSourceName); ae.initCause(ex); @@ -619,7 +624,7 @@ public class CSpaceDbRealm implements CSpaceRealm { this.tenantsQueryNoDisabled = tenantQuery; } */ - + /* * This method crawls the exception chain looking for network related exceptions and * returns 'true' if it finds one. @@ -633,13 +638,13 @@ public class CSpaceDbRealm implements CSpaceRealm { result = true; break; } - + cause = cause.getCause(); } return result; } - + /* * Return 'true' if the exception is in the "java.net" package. */ @@ -713,7 +718,80 @@ public class CSpaceDbRealm implements CSpaceRealm { } } } - + return salt; } + + @Override + public boolean isRequireSSO(String username) throws AccountException { + Boolean requireSSO = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + + try { + conn = getConnection(); + + if (logger.isDebugEnabled()) { + logger.debug("Executing query: " + requireSSOQuery + ", with username: " + username); + } + + ps = conn.prepareStatement(requireSSOQuery); + + ps.setString(1, username); + + rs = ps.executeQuery(); + + if (rs.next() == false) { + if (logger.isDebugEnabled()) { + logger.debug(requireSSOQuery + " returned no matches from db"); + } + + throw new AccountNotFoundException("No matching username found"); + } + + requireSSO = rs.getBoolean(1); + } catch (SQLException ex) { + if (logger.isTraceEnabled() == true) { + logger.error("Could not open database to read AuthN tables.", ex); + } + + AccountException ae = new AccountException("Authentication query failed: " + ex.getLocalizedMessage()); + + ae.initCause(ex); + + throw ae; + } catch (AccountNotFoundException ex) { + throw ex; + } catch (Exception ex) { + AccountException ae = new AccountException("Unknown Exception"); + + ae.initCause(ex); + + throw ae; + } finally { + if (rs != null) { + try { + rs.close(); + } catch (SQLException e) { + } + } + + if (ps != null) { + try { + ps.close(); + } catch (SQLException e) { + } + } + + if (conn != null) { + try { + conn.close(); + } catch (SQLException ex) { + } + } + } + + return requireSSO; + } } diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceDaoAuthenticationProvider.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceDaoAuthenticationProvider.java new file mode 100644 index 000000000..cc60325ab --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceDaoAuthenticationProvider.java @@ -0,0 +1,37 @@ +package org.collectionspace.authentication.spring; + +import org.collectionspace.authentication.CSpaceUser; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * A DaoAuthenticationProvider that checks if the user being authenticated is required to log in + * via single sign-on. + */ +public class CSpaceDaoAuthenticationProvider extends DaoAuthenticationProvider { + private boolean isSsoAvailable = false; + + /** + * Checks if the user is required to log in using SSO. If so, SSORequiredException is thrown. + */ + @Override + protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { + CSpaceUser user = (CSpaceUser) userDetails; + + if (this.isSsoAvailable() && user.isRequireSSO()) { + throw new SSORequiredException("Single sign-on is required for " + user.getUsername() + ". Please sign in through an SSO provider."); + } + + super.additionalAuthenticationChecks(userDetails, authentication); + } + + public boolean isSsoAvailable() { + return this.isSsoAvailable; + } + + public void setSsoAvailable(boolean isSsoAvailable) { + this.isSsoAvailable = isSsoAvailable; + } +} diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceLogoutSuccessHandler.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceLogoutSuccessHandler.java index d0e42afc7..786d4a793 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceLogoutSuccessHandler.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceLogoutSuccessHandler.java @@ -13,6 +13,9 @@ import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuc * A LogoutSuccessHandler that reads the post-logout redirect URL from a parameter in the logout * request. As an anti-phishing security measure, the URL is checked against a list of permitted * redirect URLs (originating from tenant binding configuration or OAuth client configuration). + * + * For SAML logouts, the redirect URL is saved to a request attribute, which is also checked, if + * the redirect parameter is not present. */ public class CSpaceLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { final Logger logger = LoggerFactory.getLogger(CSpaceLogoutSuccessHandler.class); @@ -33,6 +36,10 @@ public class CSpaceLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) { String redirectUrl = request.getParameter(REDIRECT_PARAMETER_NAME); + if (redirectUrl == null) { + redirectUrl = (String) request.getSession().getAttribute(CSpaceSaml2LogoutRequestRepository.REDIRECT_ATTRIBUTE_NAME); + } + if (redirectUrl != null && !isPermitted(redirectUrl)) { logger.warn("Logout redirect url not permitted: {}", redirectUrl); 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 new file mode 100644 index 000000000..60488ac20 --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2Authentication.java @@ -0,0 +1,76 @@ +package org.collectionspace.authentication.spring; + +import java.util.Collection; + +import org.collectionspace.authentication.CSpaceUser; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * A Saml2Authentication whose principal is a CSpaceUser. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE +) +@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true) +public class CSpaceSaml2Authentication extends Saml2Authentication { + private final CSpaceUser user; + + public CSpaceSaml2Authentication(CSpaceUser user, Saml2Authentication authentication) { + this( + user, + (Saml2AuthenticatedPrincipal) authentication.getPrincipal(), + authentication.getSaml2Response(), + authentication.getAuthorities() + ); + } + + public CSpaceSaml2Authentication( + CSpaceUser user, + AuthenticatedPrincipal principal, + java.lang.String saml2Response, + java.util.Collection authorities + ) { + this( + new Saml2AuthenticatedCSpaceUser((Saml2AuthenticatedPrincipal) principal, user), + principal, + saml2Response, + authorities + ); + } + + @JsonCreator + public CSpaceSaml2Authentication( + @JsonProperty("user") Saml2AuthenticatedCSpaceUser user, + @JsonProperty("principal") AuthenticatedPrincipal principal, + @JsonProperty("saml2Response") java.lang.String saml2Response, + @JsonProperty("authorities") java.util.Collection authorities + ) { + super(principal, saml2Response, authorities); + + this.user = user; + + this.setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return user; + } + + @Override + public Collection getAuthorities() { + return user.getAuthorities(); + } +} diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2LogoutRequestRepository.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2LogoutRequestRepository.java new file mode 100644 index 000000000..703fadc77 --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceSaml2LogoutRequestRepository.java @@ -0,0 +1,42 @@ +package org.collectionspace.authentication.spring; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; +import org.springframework.security.saml2.provider.service.web.authentication.logout.HttpSessionLogoutRequestRepository; +import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestRepository; + +/** + * A Saml2LogoutRequestRepository that saves the redirect paramaeter from the logout request to a + * request attribute. This allows CSpaceLogoutSuccessHandler to have access to the parameter value + * following the logout request to the IdP. + */ +public class CSpaceSaml2LogoutRequestRepository implements Saml2LogoutRequestRepository { + public static final String REDIRECT_ATTRIBUTE_NAME = "org.collectionspace.authentication.logout.redirect"; + + private HttpSessionLogoutRequestRepository repository = new HttpSessionLogoutRequestRepository(); + + @Override + public Saml2LogoutRequest loadLogoutRequest(HttpServletRequest request) { + return repository.loadLogoutRequest(request); + } + + @Override + public void saveLogoutRequest( + Saml2LogoutRequest logoutRequest, + HttpServletRequest request, + HttpServletResponse response) + { + repository.saveLogoutRequest(logoutRequest, request, response); + + String redirect = request.getParameter("redirect"); + + request.getSession().setAttribute(REDIRECT_ATTRIBUTE_NAME, redirect); + } + + @Override + public Saml2LogoutRequest removeLogoutRequest(HttpServletRequest request, HttpServletResponse response) { + return repository.removeLogoutRequest(request, response); + } +} diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceUserDetailsService.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceUserDetailsService.java index 74901a35e..754c588f8 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceUserDetailsService.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/CSpaceUserDetailsService.java @@ -75,12 +75,14 @@ public class CSpaceUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { String password = null; String salt = null; + Boolean requireSSO = null; Set tenants = null; Set grantedAuthorities = null; - + try { password = realm.getPassword(username); salt = realm.getSalt(username); + requireSSO = realm.isRequireSSO(username); tenants = getTenants(username); grantedAuthorities = getAuthorities(username); } @@ -90,32 +92,33 @@ public class CSpaceUserDetailsService implements UserDetailsService { catch (AccountException e) { throw new AuthenticationServiceException(e.getMessage(), e); } - - CSpaceUser cspaceUser = + + CSpaceUser cspaceUser = new CSpaceUser( username, password, salt, + requireSSO, tenants, grantedAuthorities); - + return cspaceUser; } - + protected Set getAuthorities(String username) throws AccountException { Set roles = realm.getRoles(username); Set authorities = new LinkedHashSet(roles.size()); - + for (String role : roles) { authorities.add(new SimpleGrantedAuthority(role)); } - + return authorities; } - + protected Set getTenants(String username) throws AccountException { Set tenants = realm.getTenants(username); - + return tenants; } } diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SSORequiredException.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SSORequiredException.java new file mode 100644 index 000000000..5dbcd4ae1 --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SSORequiredException.java @@ -0,0 +1,10 @@ +package org.collectionspace.authentication.spring; + +import org.springframework.security.core.AuthenticationException; + +public class SSORequiredException extends AuthenticationException { + + public SSORequiredException(String msg) { + super(msg); + } +} diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/Saml2AuthenticatedCSpaceUser.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/Saml2AuthenticatedCSpaceUser.java new file mode 100644 index 000000000..bfe263508 --- /dev/null +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/Saml2AuthenticatedCSpaceUser.java @@ -0,0 +1,89 @@ +package org.collectionspace.authentication.spring; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.collectionspace.authentication.CSpaceTenant; +import org.collectionspace.authentication.CSpaceUser; +import org.collectionspace.authentication.jackson2.Saml2AuthenticatedCSpaceUserDeserializer; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * A CSpaceUser that is also a Saml2AuthenticatedPrincipal. This is needed because various parts of + * Spring Security use instanceof Saml2AuthenticatedPrincipal to determine if the currently + * authenticated user logged in via SAML. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonDeserialize(using = Saml2AuthenticatedCSpaceUserDeserializer.class) +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE +) +@JsonIgnoreProperties(ignoreUnknown = true) +public class Saml2AuthenticatedCSpaceUser extends CSpaceUser implements Saml2AuthenticatedPrincipal { + private Saml2AuthenticatedPrincipal principal; + + public Saml2AuthenticatedCSpaceUser(Saml2AuthenticatedPrincipal principal, CSpaceUser user) { + this( + principal, + user.getUsername(), + user.getPassword(), + user.getSalt(), + user.isRequireSSO(), + user.getTenants(), + (Set) user.getAuthorities() + ); + } + + public Saml2AuthenticatedCSpaceUser( + Saml2AuthenticatedPrincipal principal, + String username, + String password, + String salt, + boolean requireSSO, + Set tenants, + Set authorities + ) { + super(username, password, salt, requireSSO, tenants, authorities); + + this.principal = principal; + } + + @Override + public String getName() { + return principal.getName(); + } + + @Override + public A getFirstAttribute(String name) { + return principal.getFirstAttribute(name); + } + + @Override + public List getAttribute(String name) { + return principal.getAttribute(name); + } + + @Override + public Map> getAttributes() { + return principal.getAttributes(); + } + + @Override + public String getRelyingPartyRegistrationId() { + return principal.getRelyingPartyRegistrationId(); + } + + @Override + public List getSessionIndexes() { + return principal.getSessionIndexes(); + } +} diff --git a/services/authorization/service/src/main/java/org/collectionspace/services/authorization/AuthZ.java b/services/authorization/service/src/main/java/org/collectionspace/services/authorization/AuthZ.java index 662d07494..4e15399f5 100644 --- a/services/authorization/service/src/main/java/org/collectionspace/services/authorization/AuthZ.java +++ b/services/authorization/service/src/main/java/org/collectionspace/services/authorization/AuthZ.java @@ -289,7 +289,7 @@ public class AuthZ { HashSet tenantSet = new HashSet(); tenantSet.add(tenant); - CSpaceUser principal = new CSpaceUser(user, password, null, tenantSet, grantedAuthorities); + CSpaceUser principal = new CSpaceUser(user, password, null, false, tenantSet, grantedAuthorities); Authentication authRequest = new UsernamePasswordAuthenticationToken(principal, password, grantedAuthorities); SecurityContextHolder.getContext().setAuthentication(authRequest); diff --git a/services/common/pom.xml b/services/common/pom.xml index e0ac45539..5e46126d1 100644 --- a/services/common/pom.xml +++ b/services/common/pom.xml @@ -370,6 +370,12 @@ ${spring.security.version} provided + + org.springframework.security + spring-security-saml2-service-provider + ${spring.security.version} + provided + com.fasterxml.jackson.core diff --git a/services/common/src/main/cspace/config/services/service-config-security.xml b/services/common/src/main/cspace/config/services/service-config-security.xml index 34ef8c46a..198324719 100644 --- a/services/common/src/main/cspace/config/services/service-config-security.xml +++ b/services/common/src/main/cspace/config/services/service-config-security.xml @@ -61,5 +61,31 @@ + + + diff --git a/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java b/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java index 2dd9ce10b..09545a4dd 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java +++ b/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java @@ -148,8 +148,8 @@ public class AuthorizationCommon { "INSERT INTO users (username,passwd,created_at) VALUES (?,?,now())"; final private static String INSERT_ACCOUNT_SQL = "INSERT INTO accounts_common " - + "(csid, email, userid, status, screen_name, metadata_protection, roles_protection, created_at) " - + "VALUES (?,?,?,'ACTIVE',?, 'immutable', 'immutable', now())"; + + "(csid, email, userid, require_sso, status, screen_name, metadata_protection, roles_protection, created_at) " + + "VALUES (?,?,?,false,'ACTIVE',?, 'immutable', 'immutable', now())"; // TENANT MANAGER specific SQL final private static String QUERY_TENANT_MGR_USER_SQL = 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 8e13db12c..9056f5140 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 @@ -1,32 +1,50 @@ package org.collectionspace.services.common.security; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; import java.net.MalformedURLException; +import java.security.cert.X509Certificate; +import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.Consumer; import javax.servlet.http.HttpServletRequest; import javax.sql.DataSource; +import org.apache.commons.io.IOUtils; import org.collectionspace.authentication.CSpaceUser; +import org.collectionspace.authentication.spring.CSpaceDaoAuthenticationProvider; import org.collectionspace.authentication.spring.CSpaceJwtAuthenticationToken; import org.collectionspace.authentication.spring.CSpaceLogoutSuccessHandler; import org.collectionspace.authentication.spring.CSpacePasswordEncoderFactory; +import org.collectionspace.authentication.spring.CSpaceSaml2Authentication; +import org.collectionspace.authentication.spring.CSpaceSaml2LogoutRequestRepository; import org.collectionspace.authentication.spring.CSpaceUserAttributeFilter; import org.collectionspace.authentication.spring.CSpaceUserDetailsService; import org.collectionspace.services.client.AccountClient; 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.OAuthAuthorizationGrantTypeEnum; import org.collectionspace.services.config.OAuthClientAuthenticationMethodEnum; import org.collectionspace.services.config.OAuthClientSettingsType; @@ -34,22 +52,31 @@ import org.collectionspace.services.config.OAuthClientType; import org.collectionspace.services.config.OAuthScopeEnum; import org.collectionspace.services.config.OAuthTokenSettingsType; import org.collectionspace.services.config.OAuthType; +import org.collectionspace.services.config.SAMLRelyingPartyType; +import org.collectionspace.services.config.SAMLType; import org.collectionspace.services.config.ServiceConfig; +import org.collectionspace.services.config.X509CertificateType; +import org.collectionspace.services.config.X509CredentialType; import org.collectionspace.services.config.tenant.TenantBindingType; import org.collectionspace.authentication.realm.db.CSpaceDbRealm; +import org.opensaml.saml.saml2.core.Assertion; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.convert.converter.Converter; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.http.HttpMethod; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.lang.Nullable; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; @@ -64,6 +91,8 @@ import org.springframework.security.config.annotation.web.configurers.FormLoginC import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; 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.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -79,14 +108,31 @@ import org.springframework.security.oauth2.server.authorization.config.annotatio import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +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.OpenSamlAuthenticationProvider.ResponseToken; +import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration.AssertingPartyDetails; +import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; +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.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.context.SecurityContextPersistenceFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; +import com.google.common.io.CharStreams; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; @@ -219,9 +265,13 @@ public class SecurityConfig { final AuthenticationManager authenticationManager, final UserDetailsService userDetailsService, final RegisteredClientRepository registeredClientRepository, - final ApplicationEventPublisher appEventPublisher + final ApplicationEventPublisher appEventPublisher, + final Optional optionalRelyingPartyRegistrationRepository ) throws Exception { + ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig(); + SAMLType saml = ConfigUtils.getSAML(serviceConfig); + this.initializeCorsConfigurations(); http @@ -353,15 +403,88 @@ public class SecurityConfig { // Insert the username from the security context into a request attribute for logging. .addFilterBefore(new CSpaceUserAttributeFilter(), LogoutFilter.class); + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository = optionalRelyingPartyRegistrationRepository.orElse(null); + + if (relyingPartyRegistrationRepository != null) { + RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = + new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository); + + // TODO: Use OpenSaml4AuthenticationProvider (requires Java 11) instead of deprecated OpenSamlAuthenticationProvider. + final OpenSamlAuthenticationProvider samlAuthenticationProvider = new OpenSamlAuthenticationProvider(); + + samlAuthenticationProvider.setResponseAuthenticationConverter(new Converter() { + @Override + public CSpaceSaml2Authentication convert(ResponseToken responseToken) { + Saml2Authentication authentication = OpenSamlAuthenticationProvider + .createDefaultResponseAuthenticationConverter() + .convert(responseToken); + + Assertion assertion = responseToken.getResponse().getAssertions().get(0); + String username = assertion.getSubject().getNameID().getValue(); + + try { + CSpaceUser user = (CSpaceUser) userDetailsService.loadUserByUsername(username); + + return new CSpaceSaml2Authentication(user, authentication); + } + catch(UsernameNotFoundException e) { + String errorMessage = "No CollectionSpace account was found for " + username + "."; + + throw(new UsernameNotFoundException(errorMessage, e)); + } + } + }); + + http + .saml2Login(new Customizer>() { + @Override + public void customize(Saml2LoginConfigurer configurer) { + ProviderManager providerManager = new ProviderManager(samlAuthenticationProvider); + + providerManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher(appEventPublisher)); + + configurer + .authenticationManager(providerManager) + .loginPage(LOGIN_FORM_URL) + .defaultSuccessUrl(DEFAULT_LOGIN_SUCCESS_URL); + } + }) + // Produce relying party metadata @ /cspace-services/saml2/service-provider-metadata/{id}. + .addFilterBefore( + new Saml2MetadataFilter( + relyingPartyRegistrationResolver, + new OpenSamlMetadataResolver() + ), + Saml2WebSsoAuthenticationFilter.class + ); + + if (saml != null && saml.getSingleLogout() != null) { + http + .saml2Logout(new Customizer>() { + @Override + public void customize(Saml2LogoutConfigurer configurer) { + configurer.logoutRequest(new Customizer.LogoutRequestConfigurer>() { + @Override + public void customize(Saml2LogoutConfigurer.LogoutRequestConfigurer configurer) { + configurer.logoutRequestRepository(new CSpaceSaml2LogoutRequestRepository()); + } + }); + } + }); + } + } + return http.build(); } @Bean public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService) { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig(); + CSpaceDaoAuthenticationProvider provider = new CSpaceDaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(CSpacePasswordEncoderFactory.createDefaultPasswordEncoder()); + provider.setSsoAvailable(ConfigUtils.isSsoAvailable(serviceConfig)); return provider; } @@ -525,6 +648,125 @@ public class SecurityConfig { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } + @Bean + public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() { + List registrations = new ArrayList(); + ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig(); + List relyingPartiesConfig = ConfigUtils.getSAMLRelyingPartyRegistrations(serviceConfig); + + if (relyingPartiesConfig != null) { + for (final SAMLRelyingPartyType relyingPartyConfig : relyingPartiesConfig) { + RelyingPartyRegistration.Builder registrationBuilder; + + if (relyingPartyConfig.getMetadata() != null) { + registrationBuilder = RelyingPartyRegistrations + .fromMetadataLocation(relyingPartyConfig.getMetadata().getLocation()) + .registrationId(relyingPartyConfig.getId()); + } else { + final AssertingPartyDetailsType assertingPartyDetails = relyingPartyConfig.getAssertingPartyDetails(); + + registrationBuilder = RelyingPartyRegistration + .withRegistrationId(relyingPartyConfig.getId()) + .assertingPartyDetails(new Consumer() { + @Override + public void accept(AssertingPartyDetails.Builder builder) { + builder.entityId(assertingPartyDetails.getEntityId()); + + if (assertingPartyDetails.isWantAuthnRequestsSigned() != null) { + builder.wantAuthnRequestsSigned(assertingPartyDetails.isWantAuthnRequestsSigned()); + } + + if (assertingPartyDetails.getSigningAlgorithms() != null) { + builder.signingAlgorithms(new Consumer>() { + @Override + public void accept(List algorithms) { + algorithms.addAll(assertingPartyDetails.getSigningAlgorithms().getSigningAlgorithm()); + } + }); + } + + if (assertingPartyDetails.getSingleSignOnServiceBinding() != null) { + builder.singleSignOnServiceBinding(Saml2MessageBinding.valueOf(assertingPartyDetails.getSingleSignOnServiceBinding().value())); + } + + if (assertingPartyDetails.getSingleSignOnServiceLocation() != null) { + builder.singleSignOnServiceLocation(assertingPartyDetails.getSingleSignOnServiceLocation()); + } + + if (assertingPartyDetails.getSingleLogoutServiceBinding() != null) { + builder.singleLogoutServiceBinding(Saml2MessageBinding.valueOf(assertingPartyDetails.getSingleLogoutServiceBinding().value())); + } + + if (assertingPartyDetails.getSingleLogoutServiceLocation() != null) { + builder.singleLogoutServiceLocation(assertingPartyDetails.getSingleLogoutServiceLocation()); + } + + if (assertingPartyDetails.getSingleLogoutServiceResponseLocation() != null) { + builder.singleLogoutServiceResponseLocation(assertingPartyDetails.getSingleLogoutServiceResponseLocation()); + } + + if (assertingPartyDetails.getEncryptionX509Credentials() != null) { + builder.encryptionX509Credentials(new Consumer>() { + @Override + public void accept(Collection credentials) { + for (X509CredentialType credentialConfig : assertingPartyDetails.getEncryptionX509Credentials().getX509Credential()) { + X509Certificate certificate = certificateFromConfig(credentialConfig.getX509Certificate()); + + if (certificate != null) { + credentials.add(Saml2X509Credential.encryption(certificate)); + } + } + } + }); + } + + if (assertingPartyDetails.getVerificationX509Credentials() != null) { + builder.verificationX509Credentials(new Consumer>() { + @Override + public void accept(Collection credentials) { + for (X509CredentialType credentialConfig : assertingPartyDetails.getVerificationX509Credentials().getX509Credential()) { + X509Certificate certificate = certificateFromConfig(credentialConfig.getX509Certificate()); + + if (certificate != null) { + credentials.add(Saml2X509Credential.verification(certificate)); + } + } + } + }); + } + } + }); + } + + if (relyingPartyConfig.getSigningX509Credentials() != null) { + registrationBuilder.singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo"); + + registrationBuilder.signingX509Credentials(new Consumer>() { + @Override + public void accept(Collection credentials) { + for (X509CredentialType credentialConfig : relyingPartyConfig.getSigningX509Credentials().getX509Credential()) { + PrivateKey privateKey = privateKeyFromUrl(credentialConfig.getPrivateKey().getLocation()); + X509Certificate certificate = certificateFromConfig(credentialConfig.getX509Certificate()); + + if (certificate != null) { + credentials.add(Saml2X509Credential.signing(privateKey, certificate)); + } + } + } + }); + } + + registrations.add(registrationBuilder.build()); + } + } + + if (registrations.size() > 0) { + return new InMemoryRelyingPartyRegistrationRepository(registrations); + } + + return null; + } + @Bean public UserDetailsService userDetailsService() { Map options = new HashMap(); @@ -532,6 +774,7 @@ public class SecurityConfig { options.put("dsJndiName", "CspaceDS"); options.put("principalsQuery", "select passwd from users where username=?"); options.put("saltQuery", "select salt from users where username=?"); + options.put("requireSSOQuery", "select require_sso from accounts_common where userid=?"); options.put("rolesQuery", "select r.rolename from roles as r, accounts_roles as ar where ar.user_id=? and ar.role_id=r.csid"); options.put("tenantsQueryWithDisabled", "select t.id, t.name from accounts_common as a, accounts_tenants as at, tenants as t where a.userid=? and a.csid = at.TENANTS_ACCOUNTS_COMMON_CSID and at.tenant_id = t.id order by t.id"); options.put("tenantsQueryNoDisabled", "select t.id, t.name from accounts_common as a, accounts_tenants as at, tenants as t where a.userid=? and a.csid = at.TENANTS_ACCOUNTS_COMMON_CSID and at.tenant_id = t.id and NOT t.disabled order by t.id"); @@ -540,4 +783,87 @@ public class SecurityConfig { return new CSpaceUserDetailsService(new CSpaceDbRealm(options)); } + + public PrivateKey privateKeyFromUrl(String url) { + Resource resource; + + try { + resource = new UrlResource(url); + } catch (MalformedURLException ex) { + throw new UnsupportedOperationException(ex); + } + + if (!resource.exists()) { + return null; + } + + try (Reader reader = new InputStreamReader(resource.getInputStream())) { + String key = CharStreams.toString(reader); + + String privateKeyPEM = key + .replace("-----BEGIN PRIVATE KEY-----", "") + .replaceAll(System.lineSeparator(), "") + .replace("-----END PRIVATE KEY-----", ""); + + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } + catch (Exception ex) { + throw new UnsupportedOperationException(ex); + } + } + + private X509Certificate certificateFromConfig(X509CertificateType certificate) { + String value = certificate.getValue(); + + if (value != null && value.length() > 0) { + if (!value.startsWith("-----BEGIN CERTIFICATE-----")) { + value = "-----BEGIN CERTIFICATE-----\n" + value + "-----END CERTIFICATE-----\n"; + } + + return certificateFromString(value); + } + + String location = certificate.getLocation(); + + if (location != null) { + return certificateFromUrl(location); + } + + return null; + } + + private X509Certificate certificateFromUrl(String url) { + Resource resource; + + try { + resource = new UrlResource(url); + } catch (MalformedURLException ex) { + throw new UnsupportedOperationException(ex); + } + + if (!resource.exists()) { + return null; + } + + try (InputStream is = resource.getInputStream()) { + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(is); + } + catch (Exception ex) { + throw new UnsupportedOperationException(ex); + } + } + + private X509Certificate certificateFromString(String source) { + try (InputStream is = IOUtils.toInputStream(source, "utf-8")) { + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(is); + } + catch (Exception ex) { + throw new UnsupportedOperationException(ex); + } + } } 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 ab9ad75d4..e3ec282b4 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 @@ -9,6 +9,10 @@ import org.collectionspace.services.config.CORSType; import org.collectionspace.services.config.OAuthClientRegistrationsType; import org.collectionspace.services.config.OAuthClientType; import org.collectionspace.services.config.OAuthType; +import org.collectionspace.services.config.SAMLRelyingPartyRegistrationsType; +import org.collectionspace.services.config.SAMLRelyingPartyType; +import org.collectionspace.services.config.SAMLType; +import org.collectionspace.services.config.SSOType; import org.collectionspace.services.config.SecurityType; import org.collectionspace.services.config.ServiceConfig; import org.collectionspace.services.config.tenant.RepositoryDomainType; @@ -163,6 +167,46 @@ public class ConfigUtils { return null; } + public static SSOType getSSO(ServiceConfig serviceConfig) { + SecurityType security = serviceConfig.getSecurity(); + + if (security != null) { + return security.getSso(); + } + + return null; + } + + public static SAMLType getSAML(ServiceConfig serviceConfig) { + SSOType sso = getSSO(serviceConfig); + + if (sso != null) { + return sso.getSaml(); + } + + return null; + } + + public static List getSAMLRelyingPartyRegistrations(ServiceConfig serviceConfig) { + SAMLType saml = getSAML(serviceConfig); + + if (saml != null) { + SAMLRelyingPartyRegistrationsType registrations = saml.getRelyingPartyRegistrations(); + + if (registrations != null) { + return registrations.getRelyingParty(); + } + } + + return null; + } + + public static boolean isSsoAvailable(ServiceConfig serviceConfig) { + List samlRegistrations = getSAMLRelyingPartyRegistrations(serviceConfig); + + return (samlRegistrations != null && samlRegistrations.size() > 0); + } + public static String getUILoginSuccessUrl(TenantBindingType tenantBinding) throws MalformedURLException { UIConfig uiConfig = tenantBinding.getUiConfig(); diff --git a/services/config/src/main/resources/service-config.xsd b/services/config/src/main/resources/service-config.xsd index c3e57baf4..8bdf0b6b3 100644 --- a/services/config/src/main/resources/service-config.xsd +++ b/services/config/src/main/resources/service-config.xsd @@ -87,6 +87,7 @@ + @@ -177,4 +178,192 @@ + + + + Configures single sign-on. + + + + + + + + + Configures SAML single sign-on. + + + + + + + + + + Configures SAML single logout. Single logout is enabled if this element is present. + + + + + + Configures connections to SAML identity providers. + + + + + + + + + Configures a connection to a SAML identity provider. + + + + + + + A user-facing name for the IdP. This appears in the login UI, so it should + be human-readable, using the terminology/branding that users of the IdP + recognize. If no name is supplied, the registration ID is used in the user + interface. + + + + + + + + An icon for the IdP, used in the login UI. If no icon is supplied, a + default icon is used. + + + + + + + + Configures the details of the IdP. Provide either metadata for automatic + configuration, or asserting-party-details to manually specify the settings. + + + + + + + + + + + The credentials used to sign requests to the IdP. Required if the IdP + wants login requests to be signed (some do, some don't), or if single + logout is enabled (since logout requests must always be signed). + + + + + + + + + A registration ID that must be unique among all SAML IdPs. This ID appears in + URLs, so it's preferable to use only URL-friendly characters. + + + + + + + + + Configures an icon. + + + + + + + The URL from which to retrieve the icon. This may be a file:// URL if the icon + is stored in a local file. + + + + + + + + + Configures metadata retrieval for a SAML relying party. + + + + + + + The URL from which to retrieve the metadata. This may be a file:// URL if the + metadata is stored in a local file. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/login/service/src/main/java/org/collectionspace/services/login/LoginResource.java b/services/login/service/src/main/java/org/collectionspace/services/login/LoginResource.java index cdb2d22a9..d60b00501 100644 --- a/services/login/service/src/main/java/org/collectionspace/services/login/LoginResource.java +++ b/services/login/service/src/main/java/org/collectionspace/services/login/LoginResource.java @@ -24,6 +24,7 @@ import org.collectionspace.authentication.AuthN; 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.SAMLRelyingPartyType; import org.collectionspace.services.config.ServiceConfig; import org.collectionspace.services.config.tenant.TenantBindingType; import org.slf4j.Logger; @@ -63,8 +64,36 @@ public class LoginResource { @Produces(MediaType.TEXT_HTML) public String getHtml(@Context HttpServletRequest request) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException { ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig(); + List samlRegistrations = ConfigUtils.getSAMLRelyingPartyRegistrations(serviceConfig); Map uiConfig = new HashMap<>(); + Map ssoConfig = new HashMap<>(); + + if (samlRegistrations != null) { + for (SAMLRelyingPartyType samlRegistration : samlRegistrations) { + Map registrationConfig = new HashMap<>(); + String name = samlRegistration.getName(); + + if (name == null || name.length() == 0) { + name = samlRegistration.getId(); + } + + registrationConfig.put("name", name); + + if (samlRegistration.getIcon() != null) { + registrationConfig.put("icon", samlRegistration.getIcon().getLocation()); + } + + String url = "/cspace-services/saml2/authenticate/" + samlRegistration.getId(); + + ssoConfig.put(url, registrationConfig); + } + } + + if (!ssoConfig.isEmpty()) { + uiConfig.put("sso", ssoConfig); + } + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); if (csrfToken != null) { -- 2.47.3