# 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
</xs:appinfo>
</xs:annotation>
</xs:element>
+ <xs:element name="requireSSO" type="xs:boolean" minOccurs="0" maxOccurs="1">
+ <xs:annotation>
+ <xs:documentation>
+ If true, login through an SSO identity provider is required.
+ </xs:documentation>
+ <xs:appinfo>
+ <hj:basic>
+ <orm:column name="require_sso" />
+ </hj:basic>
+ </xs:appinfo>
+ </xs:annotation>
+ </xs:element>
<xs:element name="tenants" type="account_tenant" minOccurs="1" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
</xs:element>
<xs:element name="personRefName" type="xs:string" minOccurs="1" />
<xs:element name="email" type="xs:string" minOccurs="1" />
+ <xs:element name="requireSSO" type="xs:boolean" minOccurs="0" />
<xs:element name="status" type="status" minOccurs="1" />
<!-- uri to retrive collection object details -->
<xs:element name="uri" type="xs:anyURI" minOccurs="1" />
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,
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,
return Response.status(Response.Status.NOT_FOUND).entity(msg).type("text/plain").build();
}
+ ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig();
+
+ if (ConfigUtils.isSsoAvailable(serviceConfig) && accountListItem.isRequireSSO() != null && accountListItem.isRequireSSO()) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("The account requires single sign-on.").type("text/plain").build();
+ }
+
// If no tenant ID was supplied, use the account's first associated tenant ID for purposes
// of password reset. This is the same way that a tenant is selected for the account when
// logging in. In practice, accounts are only associated with one tenant anyway.
setTenant(account);
+ if (account.getPassword() == null) {
+ // The password is allowed to be null when the user is created with requireSSO == true.
+ // Generate a random password to ensure that it won't be blank if the requireSSO flag
+ // is changed.
+
+ RandomStringGenerator generator = new RandomStringGenerator.Builder()
+ .withinRange(34, 126)
+ .build();
+
+ String randomPassword = generator.generate(24);
+
+ account.setPassword(randomPassword.getBytes());
+ }
+
if (account.getStatus() == null) {
account.setStatus(Status.ACTIVE);
}
if (from.getPersonRefName() != null) {
to.setPersonRefName(from.getPersonRefName());
}
+ if (from.isRequireSSO() != null) {
+ to.setRequireSSO(from.isRequireSSO());
+ }
// Note that we do not allow update of locks
//fixme update for tenant association
accListItem.setTenants(account.getTenants());
accListItem.setEmail(account.getEmail());
+ accListItem.setRequireSSO(account.isRequireSSO());
accListItem.setStatus(account.getStatus());
String id = account.getCsid();
accListItem.setUri(getServiceContextPath() + id);
invalid = true;
msgBldr.append("\nuserId : missing");
}
- if (account.getPassword() == null || account.getPassword().length == 0) {
+ if (
+ (account.isRequireSSO() == null || !account.isRequireSSO())
+ && (account.getPassword() == null || account.getPassword().length == 0)
+ ) {
invalid = true;
msgBldr.append("\npassword : missing");
}
<version>${spring.security.authorization.server.version}</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.springframework.security</groupId>
+ <artifactId>spring-security-saml2-service-provider</artifactId>
+ <version>${spring.security.version}</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
private Set<CSpaceTenant> tenants;
private CSpaceTenant primaryTenant;
+ private boolean requireSSO;
private String salt;
/**
* @param authorities the authorities that have been granted to the user
*/
public CSpaceUser(String username, String password, String salt,
+ boolean requireSSO,
Set<CSpaceTenant> tenants,
Set<? extends GrantedAuthority> authorities) {
authorities);
this.tenants = tenants;
+ this.requireSSO = requireSSO;
this.salt = salt;
if (!tenants.isEmpty()) {
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;
+ }
}
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();
--- /dev/null
+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<Saml2AuthenticatedCSpaceUser> {
+ private static final TypeReference<Set<SimpleGrantedAuthority>> SIMPLE_GRANTED_AUTHORITY_SET = new TypeReference<Set<SimpleGrantedAuthority>>() {
+ };
+
+ private static final TypeReference<Set<CSpaceTenant>> CSPACE_TENANT_SET = new TypeReference<Set<CSpaceTenant>>() {
+ };
+
+ @Override
+ public Saml2AuthenticatedCSpaceUser deserialize(JsonParser parser, DeserializationContext context) throws IOException, JsonProcessingException {
+ ObjectMapper mapper = (ObjectMapper) parser.getCodec();
+ JsonNode jsonNode = mapper.readTree(parser);
+
+ Set<? extends GrantedAuthority> authorities = mapper.convertValue(jsonNode.get("authorities"), SIMPLE_GRANTED_AUTHORITY_SET);
+ Set<CSpaceTenant> 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();
+ }
+}
* Interface for the CollectionSpace realm.
*/
public interface CSpaceRealm {
-
+
/**
* Retrieves the "salt" used to encrypt the user's password
* @param username
/**
* Retrieves the hashed password used to authenticate a user.
- *
+ *
* @param username
* @return the password
* @throws AccountNotFoundException if the user is not found
/**
* Retrieves the roles for a user.
- *
+ *
* @param username
* @return a collection of roles
* @throws AccountException if the roles could not be retrieved
/**
* Retrieves the enabled tenants associated with a user.
- *
+ *
* @param username
* @return a collection of tenants
* @throws AccountException if the tenants could not be retrieved
/**
* 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<CSpaceTenant> 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;
}
/**
* 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;
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<String, ?> options) {
Object optionsObj = options.get(MAX_RETRY_SECONDS_STR);
if (optionsObj != null) {
}
}
}
-
+
protected long getMaxRetrySeconds() {
return this.maxRetrySeconds;
}
-
+
protected void setDelayBetweenAttemptsMillis(Map<String, ?> options) {
Object optionsObj = options.get(DELAY_BETWEEN_ATTEMPTS_MILLISECONDS_STR);
if (optionsObj != null) {
}
}
}
-
+
protected long getDelayBetweenAttemptsMillis() {
return this.delayBetweenAttemptsMillis;
}
-
+
public CSpaceDbRealm() {
datasourceName = DEFAULT_DATASOURCE_NAME;
}
-
+
/**
* CSpace Database Realm
* @param datasourceName datasource name
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();
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);
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");
public Set<CSpaceTenant> 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;
}
return accountIsTenantManager;
}
-
+
/**
* Execute the tenantsQuery against the datasourceName to obtain the tenants for
* the authenticated user.
public Set<CSpaceTenant> getTenants(String username, boolean includeDisabledTenants) throws AccountException {
String tenantsQuery = getTenantQuery(includeDisabledTenants);
-
+
if (logger.isDebugEnabled()) {
logger.debug("getTenants using tenantsQuery: " + tenantsQuery + ", username: " + username);
}
Set<CSpaceTenant> tenants = new LinkedHashSet<CSpaceTenant>();
-
+
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
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()) {
// empty Tenants set.
// FIXME should this be allowed?
}
-
+
return tenants;
}
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());
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.
// 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.
*/
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);
this.tenantsQueryNoDisabled = tenantQuery;
}
*/
-
+
/*
* This method crawls the exception chain looking for network related exceptions and
* returns 'true' if it finds one.
result = true;
break;
}
-
+
cause = cause.getCause();
}
return result;
}
-
+
/*
* Return 'true' if the exception is in the "java.net" package.
*/
}
}
}
-
+
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;
+ }
}
--- /dev/null
+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;
+ }
+}
* 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);
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);
--- /dev/null
+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<? extends GrantedAuthority> 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<? extends GrantedAuthority> authorities
+ ) {
+ super(principal, saml2Response, authorities);
+
+ this.user = user;
+
+ this.setAuthenticated(true);
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return user;
+ }
+
+ @Override
+ public Collection<GrantedAuthority> getAuthorities() {
+ return user.getAuthorities();
+ }
+}
--- /dev/null
+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);
+ }
+}
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String password = null;
String salt = null;
+ Boolean requireSSO = null;
Set<CSpaceTenant> tenants = null;
Set<GrantedAuthority> grantedAuthorities = null;
-
+
try {
password = realm.getPassword(username);
salt = realm.getSalt(username);
+ requireSSO = realm.isRequireSSO(username);
tenants = getTenants(username);
grantedAuthorities = getAuthorities(username);
}
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<GrantedAuthority> getAuthorities(String username) throws AccountException {
Set<String> roles = realm.getRoles(username);
Set<GrantedAuthority> authorities = new LinkedHashSet<GrantedAuthority>(roles.size());
-
+
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
-
+
return authorities;
}
-
+
protected Set<CSpaceTenant> getTenants(String username) throws AccountException {
Set<CSpaceTenant> tenants = realm.getTenants(username);
-
+
return tenants;
}
}
--- /dev/null
+package org.collectionspace.authentication.spring;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class SSORequiredException extends AuthenticationException {
+
+ public SSORequiredException(String msg) {
+ super(msg);
+ }
+}
--- /dev/null
+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<GrantedAuthority>) user.getAuthorities()
+ );
+ }
+
+ public Saml2AuthenticatedCSpaceUser(
+ Saml2AuthenticatedPrincipal principal,
+ String username,
+ String password,
+ String salt,
+ boolean requireSSO,
+ Set<CSpaceTenant> tenants,
+ Set<? extends GrantedAuthority> authorities
+ ) {
+ super(username, password, salt, requireSSO, tenants, authorities);
+
+ this.principal = principal;
+ }
+
+ @Override
+ public String getName() {
+ return principal.getName();
+ }
+
+ @Override
+ public <A> A getFirstAttribute(String name) {
+ return principal.getFirstAttribute(name);
+ }
+
+ @Override
+ public <A> List<A> getAttribute(String name) {
+ return principal.getAttribute(name);
+ }
+
+ @Override
+ public Map<String, List<Object>> getAttributes() {
+ return principal.getAttributes();
+ }
+
+ @Override
+ public String getRelyingPartyRegistrationId() {
+ return principal.getRelyingPartyRegistrationId();
+ }
+
+ @Override
+ public List<String> getSessionIndexes() {
+ return principal.getSessionIndexes();
+ }
+}
HashSet<CSpaceTenant> tenantSet = new HashSet<CSpaceTenant>();
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);
<version>${spring.security.version}</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.springframework.security</groupId>
+ <artifactId>spring-security-saml2-service-provider</artifactId>
+ <version>${spring.security.version}</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
</client>
</client-registrations>
</oauth>
+
+ <!--
+ Example SSO config.
+ -->
+ <!--
+ <sso>
+ <saml>
+ <single-logout />
+
+ <relying-party-registrations>
+ <relying-party id="mocksaml">
+ <name>Mock SAML</name>
+ <icon location="https://mocksaml.com/favicon.ico" />
+ <metadata location="https://mocksaml.com/api/saml/metadata" />
+
+ <signing-x509-credentials>
+ <x509-credential>
+ <private-key location="file:///home/collectionspace/tomcat/cspace/services/credentials/private.key" />
+ <x509-certificate location="file:///home/collectionspace/tomcat/cspace/services/credentials/certificate.crt" />
+ </x509-credential>
+ </signing-x509-credentials>
+ </relying-party>
+ </relying-party-registrations>
+ </saml>
+ </sso>
+ -->
</security>
</svc:service-config>
"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 =
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;
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;
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;
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;
final AuthenticationManager authenticationManager,
final UserDetailsService userDetailsService,
final RegisteredClientRepository registeredClientRepository,
- final ApplicationEventPublisher appEventPublisher
+ final ApplicationEventPublisher appEventPublisher,
+ final Optional<RelyingPartyRegistrationRepository> optionalRelyingPartyRegistrationRepository
) throws Exception {
+ ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig();
+ SAMLType saml = ConfigUtils.getSAML(serviceConfig);
+
this.initializeCorsConfigurations();
http
// 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<ResponseToken, CSpaceSaml2Authentication>() {
+ @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<Saml2LoginConfigurer<HttpSecurity>>() {
+ @Override
+ public void customize(Saml2LoginConfigurer<HttpSecurity> 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<Saml2LogoutConfigurer<HttpSecurity>>() {
+ @Override
+ public void customize(Saml2LogoutConfigurer<HttpSecurity> configurer) {
+ configurer.logoutRequest(new Customizer<Saml2LogoutConfigurer<HttpSecurity>.LogoutRequestConfigurer>() {
+ @Override
+ public void customize(Saml2LogoutConfigurer<HttpSecurity>.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;
}
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
+ @Bean
+ public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
+ List<RelyingPartyRegistration> registrations = new ArrayList<RelyingPartyRegistration>();
+ ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig();
+ List<SAMLRelyingPartyType> 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<AssertingPartyDetails.Builder>() {
+ @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<List<String>>() {
+ @Override
+ public void accept(List<String> 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<Collection<Saml2X509Credential>>() {
+ @Override
+ public void accept(Collection<Saml2X509Credential> 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<Collection<Saml2X509Credential>>() {
+ @Override
+ public void accept(Collection<Saml2X509Credential> 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<Collection<Saml2X509Credential>>() {
+ @Override
+ public void accept(Collection<Saml2X509Credential> 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<String, Object> options = new HashMap<String, Object>();
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");
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);
+ }
+ }
}
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;
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<SAMLRelyingPartyType> 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<SAMLRelyingPartyType> samlRegistrations = getSAMLRelyingPartyRegistrations(serviceConfig);
+
+ return (samlRegistrations != null && samlRegistrations.size() > 0);
+ }
+
public static String getUILoginSuccessUrl(TenantBindingType tenantBinding) throws MalformedURLException {
UIConfig uiConfig = tenantBinding.getUiConfig();
<xs:sequence>
<xs:element name="cors" type="CORSType" minOccurs="0" maxOccurs="1" />
<xs:element name="oauth" type="OAuthType" minOccurs="0" maxOccurs="1" />
+ <xs:element name="sso" type="SSOType" minOccurs="0" maxOccurs="1" />
</xs:sequence>
</xs:complexType>
<xs:element name="access-token-time-to-live" type="xs:string" minOccurs="0" maxOccurs="1" />
</xs:sequence>
</xs:complexType>
+
+ <xs:complexType name="SSOType">
+ <xs:annotation>
+ <xs:documentation>Configures single sign-on.</xs:documentation>
+ </xs:annotation>
+ <xs:sequence>
+ <xs:element name="saml" type="SAMLType" minOccurs="0" maxOccurs="1" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="SAMLType">
+ <xs:annotation>
+ <xs:documentation>Configures SAML single sign-on.</xs:documentation>
+ </xs:annotation>
+ <xs:sequence>
+ <xs:element name="single-logout" type="SAMLSingleLogoutType" minOccurs="0" maxOccurs="1" />
+ <xs:element name="relying-party-registrations" type="SAMLRelyingPartyRegistrationsType" minOccurs="0" maxOccurs="1" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="SAMLSingleLogoutType">
+ <xs:annotation>
+ <xs:documentation>Configures SAML single logout. Single logout is enabled if this element is present.</xs:documentation>
+ </xs:annotation>
+ </xs:complexType>
+
+ <xs:complexType name="SAMLRelyingPartyRegistrationsType">
+ <xs:annotation>
+ <xs:documentation>Configures connections to SAML identity providers.</xs:documentation>
+ </xs:annotation>
+ <xs:sequence>
+ <xs:element name="relying-party" type="SAMLRelyingPartyType" minOccurs="0" maxOccurs="unbounded" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="SAMLRelyingPartyType">
+ <xs:annotation>
+ <xs:documentation>Configures a connection to a SAML identity provider.</xs:documentation>
+ </xs:annotation>
+
+ <xs:sequence>
+ <xs:element name="name" type="xs:string" minOccurs="0" maxOccurs="1">
+ <xs:annotation>
+ <xs:documentation>
+ 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.
+ </xs:documentation>
+ </xs:annotation>
+ </xs:element>
+
+ <xs:element name="icon" type="IconType" minOccurs="0" maxOccurs="1" >
+ <xs:annotation>
+ <xs:documentation>
+ An icon for the IdP, used in the login UI. If no icon is supplied, a
+ default icon is used.
+ </xs:documentation>
+ </xs:annotation>
+ </xs:element>
+
+ <xs:choice minOccurs="1" maxOccurs="1">
+ <xs:annotation>
+ <xs:documentation>
+ Configures the details of the IdP. Provide either metadata for automatic
+ configuration, or asserting-party-details to manually specify the settings.
+ </xs:documentation>
+ </xs:annotation>
+
+ <xs:element name="metadata" type="SAMLMetadataType" />
+ <xs:element name="asserting-party-details" type="AssertingPartyDetailsType" />
+ </xs:choice>
+
+ <xs:element name="signing-x509-credentials" type="X509CredentialsType" minOccurs="0" maxOccurs="1">
+ <xs:annotation>
+ <xs:documentation>
+ 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).
+ </xs:documentation>
+ </xs:annotation>
+ </xs:element>
+ </xs:sequence>
+
+ <xs:attribute name="id" type="xs:string" use="required">
+ <xs:annotation>
+ <xs:documentation>
+ 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.
+ </xs:documentation>
+ </xs:annotation>
+ </xs:attribute>
+ </xs:complexType>
+
+ <xs:complexType name="IconType">
+ <xs:annotation>
+ <xs:documentation>
+ Configures an icon.
+ </xs:documentation>
+ </xs:annotation>
+
+ <xs:attribute name="location" type="xs:string" use="required">
+ <xs:annotation>
+ <xs:documentation>
+ The URL from which to retrieve the icon. This may be a file:// URL if the icon
+ is stored in a local file.
+ </xs:documentation>
+ </xs:annotation>
+ </xs:attribute>
+ </xs:complexType>
+
+ <xs:complexType name="SAMLMetadataType">
+ <xs:annotation>
+ <xs:documentation>
+ Configures metadata retrieval for a SAML relying party.
+ </xs:documentation>
+ </xs:annotation>
+
+ <xs:attribute name="location" type="xs:string" use="required">
+ <xs:annotation>
+ <xs:documentation>
+ The URL from which to retrieve the metadata. This may be a file:// URL if the
+ metadata is stored in a local file.
+ </xs:documentation>
+ </xs:annotation>
+ </xs:attribute>
+ </xs:complexType>
+
+ <xs:complexType name="X509CredentialsType">
+ <xs:sequence>
+ <xs:element name="x509-credential" type="X509CredentialType" minOccurs="1" maxOccurs="unbounded" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="X509CredentialType">
+ <xs:sequence>
+ <xs:element name="private-key" type="PrivateKeyType" minOccurs="0" maxOccurs="1" />
+ <xs:element name="x509-certificate" type="X509CertificateType" minOccurs="1" maxOccurs="1" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="PrivateKeyType">
+ <xs:simpleContent>
+ <xs:extension base="xs:string">
+ <xs:attribute name="location" type="xs:string" />
+ </xs:extension>
+ </xs:simpleContent>
+ </xs:complexType>
+
+ <xs:complexType name="X509CertificateType">
+ <xs:simpleContent>
+ <xs:extension base="xs:string">
+ <xs:attribute name="location" type="xs:string" />
+ </xs:extension>
+ </xs:simpleContent>
+ </xs:complexType>
+
+ <xs:simpleType name="MessageBindingEnum">
+ <xs:restriction base="xs:string">
+ <xs:enumeration value="post"/>
+ <xs:enumeration value="redirect"/>
+ </xs:restriction>
+ </xs:simpleType>
+
+ <xs:complexType name="AssertingPartyDetailsType">
+ <xs:sequence>
+ <xs:element name="entity-id" type="xs:string" minOccurs="1" maxOccurs="1" />
+ <xs:element name="want-authn-requests-signed" type="xs:boolean" minOccurs="0" maxOccurs="1" />
+ <xs:element name="signing-algorithms" type="SigningAlgorithmsType" minOccurs="0" maxOccurs="1" />
+
+ <xs:element name="single-sign-on-service-binding" type="MessageBindingEnum" minOccurs="0" maxOccurs="1" />
+ <xs:element name="single-sign-on-service-location" type="xs:string" minOccurs="0" maxOccurs="1" />
+
+ <xs:element name="single-logout-service-binding" type="MessageBindingEnum" minOccurs="0" maxOccurs="1" />
+ <xs:element name="single-logout-service-location" type="xs:string" minOccurs="0" maxOccurs="1" />
+ <xs:element name="single-logout-service-response-location" type="xs:string" minOccurs="0" maxOccurs="1" />
+
+ <xs:element name="encryption-x509-credentials" type="X509CredentialsType" minOccurs="0" maxOccurs="1" />
+ <xs:element name="verification-x509-credentials" type="X509CredentialsType" minOccurs="0" maxOccurs="1" />
+ </xs:sequence>
+ </xs:complexType>
+
+ <xs:complexType name="SigningAlgorithmsType">
+ <!-- https://litsec.github.io/opensaml-javadoc-mirror/org/opensaml/opensaml-xmlsec-api/3.4.2/constant-values.html#org.opensaml.xmlsec.signature.support.SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS -->
+ <xs:sequence>
+ <xs:element name="signing-algorithm" type="xs:string" minOccurs="1" maxOccurs="unbounded" />
+ </xs:sequence>
+ </xs:complexType>
</xs:schema>
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;
@Produces(MediaType.TEXT_HTML)
public String getHtml(@Context HttpServletRequest request) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException {
ServiceConfig serviceConfig = ServiceMain.getInstance().getServiceConfig();
+ List<SAMLRelyingPartyType> samlRegistrations = ConfigUtils.getSAMLRelyingPartyRegistrations(serviceConfig);
Map<String, Object> uiConfig = new HashMap<>();
+ Map<String, Object> ssoConfig = new HashMap<>();
+
+ if (samlRegistrations != null) {
+ for (SAMLRelyingPartyType samlRegistration : samlRegistrations) {
+ Map<String, String> 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) {