Add SAML login filter.

This commit is contained in:
Ray Lee
2024-04-08 16:42:12 -04:00
parent 5efd11c25b
commit 551eed8a5a
10 changed files with 1683 additions and 1 deletions

View File

@@ -0,0 +1,750 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.authenticate;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.authenticate.factory.AuthenticateServiceFactory;
import org.dspace.authorize.AuthorizeException;
import org.dspace.content.MetadataField;
import org.dspace.content.MetadataSchema;
import org.dspace.content.MetadataSchemaEnum;
import org.dspace.content.NonUniqueMetadataException;
import org.dspace.content.factory.ContentServiceFactory;
import org.dspace.content.service.MetadataFieldService;
import org.dspace.content.service.MetadataSchemaService;
import org.dspace.core.Context;
import org.dspace.eperson.EPerson;
import org.dspace.eperson.Group;
import org.dspace.eperson.factory.EPersonServiceFactory;
import org.dspace.eperson.service.EPersonService;
import org.dspace.eperson.service.GroupService;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;
/**
* SAML authentication for DSpace.
*
* @author Ray Lee
*/
public class SamlAuthentication implements AuthenticationMethod {
private static final Logger log = LogManager.getLogger(SamlAuthentication.class);
// Additional metadata mappings.
protected Map<String, String> metadataHeaderMap = null;
// Maximum length for ePerson fields.
protected final int NAME_MAX_SIZE = 64;
protected final int PHONE_MAX_SIZE = 32;
// Maximum length for ePerson additional metadata fields.
protected final int METADATA_MAX_SIZE = 1024;
protected EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService();
protected GroupService groupService = EPersonServiceFactory.getInstance().getGroupService();
protected MetadataFieldService metadataFieldService = ContentServiceFactory.getInstance().getMetadataFieldService();
protected MetadataSchemaService metadataSchemaService =
ContentServiceFactory.getInstance().getMetadataSchemaService();
protected ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
/**
* Authenticate the given or implicit credentials. This is the heart of the
* authentication method: test the credentials for authenticity, and if
* accepted, attempt to match (or optionally, create) an
* <code>EPerson</code>. If an <code>EPerson</code> is found it is set in
* the <code>Context</code> that was passed.
*
* DSpace supports authentication using NetID or email address. A user's NetID
* is a unique identifier from the IdP that identifies a particular user. The
* NetID can be of almost any form, such as a unique integer or string. In
* SAML, this is referred to as a Name ID.
*
* There are two ways to supply identity information to DSpace:
*
* 1) Name ID from SAML attribute (best)
*
* The Name ID-based method is superior because users may change their email
* address with the identity provider. When this happens DSpace will not be
* able to associate their new address with their old account.
*
* 2) Email address from SAML attribute (okay)
*
* In the case where a Name ID header is not available or not found DSpace
* will fall back to identifying a user based upon their email address.
*
* Identity Scheme Migration Strategies:
*
* If you are currently using Email based authentication (either 1 or 2) and
* want to upgrade to NetID based authentication then there is an easy path.
* Coordinate with the IdP to provide a Name ID in the SAML assertion. When a
* user attempts to log in, DSpace will first look for an EPerson with the
* passed Name ID. When this fails, DSpace will fall back to email based
* authentication. Then DSpace will update the user's EPerson account record
* to set their NetID, so all future authentications for this user will be based
* upon NetID.
*
* DSpace will prevent an account from switching NetIDs. If an account already
* has a NetID set, and a user tries to authenticate with the same email but
* a different NetID, the authentication will fail.
*
* @param context DSpace context, will be modified (EPerson set) upon success.
* @param username Not used by SAML-based authentication.
* @param password Not used by SAML-based authentication.
* @param realm Not used by SAML-based authentication.
* @param request The HTTP request that started this operation.
* @return one of: SUCCESS, NO_SUCH_USER, BAD_ARGS
* @throws SQLException if a database error occurs.
*/
@Override
public int authenticate(Context context, String username, String password,
String realm, HttpServletRequest request) throws SQLException {
if (request == null) {
log.warn("Unable to authenticate using SAML because the request object is null.");
return BAD_ARGS;
}
// Initialize additional EPerson metadata mappings.
initialize(context);
String nameId = findSingleAttribute(request, getNameIdAttributeName());
if (log.isDebugEnabled()) {
log.debug("Starting SAML Authentication");
log.debug("Received name ID: " + nameId);
}
// Should we auto register new users?
boolean autoRegister = configurationService.getBooleanProperty("authentication-saml.autoregister", true);
// Four steps to authenticate a user:
try {
// Step 1: Identify user
EPerson eperson = findEPerson(context, request);
// Step 2: Register new user, if necessary
if (eperson == null && autoRegister) {
eperson = registerNewEPerson(context, request);
}
if (eperson == null) {
return AuthenticationMethod.NO_SUCH_USER;
}
if (!eperson.canLogIn()) {
return AuthenticationMethod.BAD_ARGS;
}
// Step 3: Update user's metadata
updateEPerson(context, request, eperson);
// Step 4: Log the user in
context.setCurrentUser(eperson);
request.setAttribute("saml.authenticated", true);
AuthenticateServiceFactory.getInstance().getAuthenticationService().initEPerson(context, request, eperson);
log.info(eperson.getEmail() + " has been authenticated via SAML.");
return AuthenticationMethod.SUCCESS;
} catch (Throwable t) {
// Log the error, and undo the authentication before returning a failure.
log.error("Unable to successfully authenticate using SAML for user because of an exception.", t);
context.setCurrentUser(null);
return AuthenticationMethod.NO_SUCH_USER;
}
}
@Override
public List<Group> getSpecialGroups(Context context, HttpServletRequest request) throws SQLException {
return List.of();
}
@Override
public boolean allowSetPassword(Context context, HttpServletRequest request, String email) throws SQLException {
// SAML authentication doesn't use a password.
return false;
}
@Override
public boolean isImplicit() {
return false;
}
@Override
public boolean canSelfRegister(Context context, HttpServletRequest request,
String username) throws SQLException {
// SAML will auto create accounts if configured to do so, but that is not
// the same as self register. Self register means that the user can sign up for
// an account from the web. This is not supported with SAML.
return false;
}
@Override
public void initEPerson(Context context, HttpServletRequest request,
EPerson eperson) throws SQLException {
// We don't do anything because all our work is done in authenticate.
}
/**
* Returns the URL in the SAML relying party service that initiates a login with the IdP,
* as configured.
*
* @see AuthenticationMethod#loginPageURL(Context, HttpServletRequest, HttpServletResponse)
*/
@Override
public String loginPageURL(Context context, HttpServletRequest request, HttpServletResponse response) {
String samlLoginUrl = configurationService.getProperty("authentication-saml.authenticate-endpoint");
return response.encodeRedirectURL(samlLoginUrl);
}
@Override
public String getName() {
return "saml";
}
/**
* Check if the SAML plugin is enabled.
*
* @return true if enabled, false otherwise
*/
public static boolean isEnabled() {
final String samlPluginName = new SamlAuthentication().getName();
boolean samlEnabled = false;
// Loop through all enabled authentication plugins to see if SAML is one of them.
Iterator<AuthenticationMethod> authenticationMethodIterator =
AuthenticateServiceFactory.getInstance().getAuthenticationService().authenticationMethodIterator();
while (authenticationMethodIterator.hasNext()) {
if (samlPluginName.equals(authenticationMethodIterator.next().getName())) {
samlEnabled = true;
break;
}
}
return samlEnabled;
}
/**
* Identify an existing EPerson based upon the SAML attributes provided on
* the request object.
*
* 1) Name ID from SAML attribute (best)
* The Name ID-based method is superior because users may change their email
* address with the identity provider. When this happens DSpace will not be
* able to associate their new address with their old account.
*
* 2) Email address from SAML attribute (okay)
* In the case where a Name ID header is not available or not found DSpace
* will fall back to identifying a user based upon their email address.
*
* If successful then the identified EPerson will be returned, otherwise null.
*
* @param context The DSpace database context
* @param request The current HTTP Request
* @return The EPerson identified or null.
* @throws SQLException if database error
* @throws AuthorizeException if authorization error
*/
protected EPerson findEPerson(Context context, HttpServletRequest request) throws SQLException, AuthorizeException {
String nameId = findSingleAttribute(request, getNameIdAttributeName());
if (nameId != null) {
EPerson ePerson = ePersonService.findByNetid(context, nameId);
if (ePerson == null) {
log.info("Unable to identify EPerson by netid (SAML name ID): " + nameId);
} else {
log.info("Identified EPerson by netid (SAML name ID): " + nameId);
return ePerson;
}
}
String emailAttributeName = getEmailAttributeName();
String email = findSingleAttribute(request, emailAttributeName);
if (email != null) {
email = email.toLowerCase();
EPerson ePerson = ePersonService.findByEmail(context, email);
if (ePerson == null) {
log.info("Unable to identify EPerson by email: " + emailAttributeName + "=" + email);
} else {
log.info("Identified EPerson by email: " + emailAttributeName + "=" + email);
if (ePerson.getNetid() == null) {
return ePerson;
}
// The user has a netid that differs from the received SAML name ID.
log.error("SAML authentication identified EPerson by email: " + emailAttributeName + "=" + email);
log.error("Received SAML name ID: " + nameId);
log.error("EPerson has netid: " + ePerson.getNetid());
log.error(
"The SAML name ID is expected to be the same as the EPerson netid. " +
"This might be a hacking attempt to steal another user's credentials. If the " +
"user's netid has changed you will need to manually change it to the correct " +
"value or unset it in the database.");
}
}
if (nameId == null && email == null) {
log.error(
"SAML authentication did not find a name ID or email in the request from which to indentify a user");
}
return null;
}
/**
* Register a new EPerson. This method is called when no existing user was
* found for the NetID or email and autoregister is enabled. When these conditions
* are met this method will create a new EPerson object.
*
* In order to create a new EPerson object there is a minimal set of metadata
* required: email, first name, and last name. If we don't have access to these
* three pieces of information then we will be unable to create a new EPerson.
*
* Note that this method only adds the minimal metadata. Any additional metadata
* will need to be added by the updateEPerson method.
*
* @param context The current DSpace database context
* @param request The current HTTP Request
* @return A new EPerson object or null if unable to create a new EPerson.
* @throws SQLException if database error
* @throws AuthorizeException if authorization error
*/
protected EPerson registerNewEPerson(Context context, HttpServletRequest request)
throws SQLException, AuthorizeException {
String nameId = findSingleAttribute(request, getNameIdAttributeName());
String emailAttributeName = getEmailAttributeName();
String firstNameAttributeName = getFirstNameAttributeName();
String lastNameAttributeName = getLastNameAttributeName();
String email = findSingleAttribute(request, emailAttributeName);
String firstName = findSingleAttribute(request, firstNameAttributeName);
String lastName = findSingleAttribute(request, lastNameAttributeName);
if (email == null || firstName == null || lastName == null) {
// We require that there be an email, first name, and last name.
String message = "Unable to register new eperson because we are unable to find an email address, " +
"first name, and last name for the user.\n";
message += " name ID: " + nameId + "\n";
message += " email: " + emailAttributeName + "=" + email + "\n";
message += " first name: " + firstNameAttributeName + "=" + firstName + "\n";
message += " last name: " + lastNameAttributeName + "=" + lastName;
log.error(message);
return null;
}
// Truncate values of parameters that are too big.
if (firstName.length() > NAME_MAX_SIZE) {
log.warn(
"Truncating eperson's first name because it is longer than " + NAME_MAX_SIZE + ": " + firstName);
firstName = firstName.substring(0, NAME_MAX_SIZE);
}
if (lastName.length() > NAME_MAX_SIZE) {
log.warn("Truncating eperson's last name because it is longer than " + NAME_MAX_SIZE + ": " + lastName);
lastName = lastName.substring(0, NAME_MAX_SIZE);
}
// Turn off authorizations to create a new user
context.turnOffAuthorisationSystem();
EPerson ePerson = ePersonService.create(context);
// Set the minimum attributes for the new eperson
if (nameId != null) {
ePerson.setNetid(nameId);
}
ePerson.setEmail(email.toLowerCase());
ePerson.setFirstName(context, firstName);
ePerson.setLastName(context, lastName);
ePerson.setCanLogIn(true);
ePerson.setSelfRegistered(true);
// Commit the new eperson
AuthenticateServiceFactory.getInstance().getAuthenticationService().initEPerson(context, request, ePerson);
ePersonService.update(context, ePerson);
context.dispatchEvents();
// Turn authorizations back on
context.restoreAuthSystemState();
if (log.isInfoEnabled()) {
String message = "Auto registered new eperson using SAML attributes:\n";
message += " netid: " + ePerson.getNetid() + "\n";
message += " email: " + ePerson.getEmail() + "\n";
message += " firstName: " + ePerson.getFirstName() + "\n";
message += " lastName: " + ePerson.getLastName();
log.info(message);
}
return ePerson;
}
/**
* After we successfully authenticated a user, this method will update the user's attributes. The
* user's email, name, or other attribute may have been changed since the last time they
* logged into DSpace. This method will update the database with their most recent information.
*
* This method handles the basic DSpace metadata (email, first name, last name) along with
* additional metadata set using the setMetadata() methods on the EPerson object. The
* additional metadata mappings are defined in configuration.
*
* @param context The current DSpace database context
* @param request The current HTTP Request
* @param eperson The eperson object to update.
* @throws SQLException if database error
* @throws AuthorizeException if authorization error
*/
protected void updateEPerson(Context context, HttpServletRequest request, EPerson eperson)
throws SQLException, AuthorizeException {
String nameId = findSingleAttribute(request, getNameIdAttributeName());
String emailAttributeName = getEmailAttributeName();
String firstNameAttributeName = getFirstNameAttributeName();
String lastNameAttributeName = getLastNameAttributeName();
String email = findSingleAttribute(request, emailAttributeName);
String firstName = findSingleAttribute(request, firstNameAttributeName);
String lastName = findSingleAttribute(request, lastNameAttributeName);
// Truncate values of parameters that are too big.
if (firstName != null && firstName.length() > NAME_MAX_SIZE) {
log.warn(
"Truncating eperson's first name because it is longer than " + NAME_MAX_SIZE + ": " + firstName);
firstName = firstName.substring(0, NAME_MAX_SIZE);
}
if (lastName != null && lastName.length() > NAME_MAX_SIZE) {
log.warn("Truncating eperson's last name because it is longer than " + NAME_MAX_SIZE + ": " + lastName);
lastName = lastName.substring(0, NAME_MAX_SIZE);
}
context.turnOffAuthorisationSystem();
// 1) Update the minimum metadata
// Only update the netid if none has been previously set. This can occur when a repo switches
// to netid based authentication. The current users do not have netids and fall back to email-based
// identification but once they login we update their record and lock the account to a particular netid.
if (nameId != null && eperson.getNetid() == null) {
eperson.setNetid(nameId);
}
// The email could have changed if using netid based lookup.
if (email != null) {
eperson.setEmail(email.toLowerCase());
}
if (firstName != null) {
eperson.setFirstName(context, firstName);
}
if (lastName != null) {
eperson.setLastName(context, lastName);
}
if (log.isDebugEnabled()) {
String message = "Updated the eperson's minimal metadata: \n";
message += " Email: " + emailAttributeName + "=" + email + "' \n";
message += " First name: " + firstNameAttributeName + "=" + firstName + "\n";
message += " Last name: " + lastNameAttributeName + "=" + lastName;
log.debug(message);
}
// 2) Update additional eperson metadata
for (String attributeName : metadataHeaderMap.keySet()) {
String metadataFieldName = metadataHeaderMap.get(attributeName);
String value = findSingleAttribute(request, attributeName);
// Truncate values
if (value == null) {
log.warn("Unable to update the eperson's '{}' metadata"
+ " because the attribute '{}' does not exist.", metadataFieldName, attributeName);
continue;
} else if ("phone".equals(metadataFieldName) && value.length() > PHONE_MAX_SIZE) {
log.warn("Truncating eperson phone metadata because it is longer than {}: {}",
PHONE_MAX_SIZE, value);
value = value.substring(0, PHONE_MAX_SIZE);
} else if (value.length() > METADATA_MAX_SIZE) {
log.warn("Truncating eperson {} metadata because it is longer than {}: {}",
metadataFieldName, METADATA_MAX_SIZE, value);
value = value.substring(0, METADATA_MAX_SIZE);
}
ePersonService.setMetadataSingleValue(context, eperson,
MetadataSchemaEnum.EPERSON.getName(), metadataFieldName, null, null, value);
log.debug("Updated the eperson's {} metadata using attribute: {}={}",
metadataFieldName, attributeName, value);
}
ePersonService.update(context, eperson);
context.dispatchEvents();
context.restoreAuthSystemState();
}
/**
* Initialize SAML Authentication.
*
* During initalization the mapping of additional EPerson metadata will be loaded from the configuration
* and cached. While loading the metadata mapping this method will check the EPerson object to see
* if it supports the metadata field. If the field is not supported and autocreate is turned on then
* the field will be automatically created.
*
* It is safe to call this method multiple times.
*
* @param context context
* @throws SQLException if database error
*/
protected synchronized void initialize(Context context) throws SQLException {
if (metadataHeaderMap != null) {
return;
}
HashMap<String, String> map = new HashMap<>();
String[] mappingString = configurationService.getArrayProperty("authentication-saml.eperson.metadata");
boolean autoCreate = configurationService
.getBooleanProperty("authentication-saml.eperson.metadata.autocreate", true);
// Bail out if not set, returning an empty map.
if (mappingString == null || mappingString.length == 0) {
log.debug("No additional eperson metadata mapping found: authentication-saml.eperson.metadata");
metadataHeaderMap = map;
return;
}
log.debug("Loading additional eperson metadata from: authentication-saml.eperson.metadata="
+ StringUtils.join(mappingString, ","));
for (String metadataString : mappingString) {
metadataString = metadataString.trim();
String[] metadataParts = metadataString.split("=>");
if (metadataParts.length != 2) {
log.error("Unable to parse metadata mapping string: '" + metadataString + "'");
continue;
}
String attributeName = metadataParts[0].trim();
String metadataFieldName = metadataParts[1].trim().toLowerCase();
boolean valid = checkIfEpersonMetadataFieldExists(context, metadataFieldName);
if (!valid && autoCreate) {
valid = autoCreateEPersonMetadataField(context, metadataFieldName);
}
if (valid) {
// The eperson field is fine, we can use it.
log.debug("Loading additional eperson metadata mapping for: {}={}",
attributeName, metadataFieldName);
map.put(attributeName, metadataFieldName);
} else {
// The field doesn't exist, and we can't use it.
log.error("Skipping the additional eperson metadata mapping for: {}={}"
+ " because the field is not supported by the current configuration.",
attributeName, metadataFieldName);
}
}
metadataHeaderMap = map;
}
/**
* Check if a metadata field for an EPerson is available.
*
* @param metadataName The name of the metadata field.
* @param context context
* @return True if a valid metadata field, otherwise false.
* @throws SQLException if database error
*/
protected synchronized boolean checkIfEpersonMetadataFieldExists(Context context, String metadataName)
throws SQLException {
if (metadataName == null) {
return false;
}
MetadataField metadataField = metadataFieldService.findByElement(
context, MetadataSchemaEnum.EPERSON.getName(), metadataName, null);
return metadataField != null;
}
/**
* Validate Postgres Column Names
*/
protected final String COLUMN_NAME_REGEX = "^[_A-Za-z0-9]+$";
/**
* Automatically create a new metadata field for an EPerson
*
* @param context context
* @param metadataName The name of the new metadata field.
* @return True if successful, otherwise false.
* @throws SQLException if database error
*/
protected synchronized boolean autoCreateEPersonMetadataField(Context context, String metadataName)
throws SQLException {
if (metadataName == null) {
return false;
}
// The phone is a predefined field
if ("phone".equals(metadataName)) {
return true;
}
if (!metadataName.matches(COLUMN_NAME_REGEX)) {
return false;
}
MetadataSchema epersonSchema = metadataSchemaService.find(context, "eperson");
MetadataField metadataField = null;
try {
context.turnOffAuthorisationSystem();
metadataField = metadataFieldService.create(context, epersonSchema, metadataName, null, null);
} catch (AuthorizeException | NonUniqueMetadataException e) {
log.error(e.getMessage(), e);
return false;
} finally {
context.restoreAuthSystemState();
}
return metadataField != null;
}
@Override
public boolean isUsed(final Context context, final HttpServletRequest request) {
if (request != null &&
context.getCurrentUser() != null &&
request.getAttribute("saml.authenticated") != null
) {
return true;
}
return false;
}
@Override
public boolean canChangePassword(Context context, EPerson ePerson, String currentPassword) {
return false;
}
private String findSingleAttribute(HttpServletRequest request, String name) {
if (StringUtils.isBlank(name)) {
return null;
}
Object value = request.getAttribute(name);
if (value instanceof List) {
List<?> list = (List<?>) value;
if (list.size() == 0) {
value = null;
} else {
value = list.get(0);
}
}
return (value == null ? null : value.toString());
}
private String getNameIdAttributeName() {
return configurationService.getProperty("authentication-saml.attribute.name-id");
}
private String getEmailAttributeName() {
return configurationService.getProperty("authentication-saml.attribute.email");
}
private String getFirstNameAttributeName() {
return configurationService.getProperty("authentication-saml.attribute.first-name");
}
private String getLastNameAttributeName() {
return configurationService.getProperty("authentication-saml.attribute.last-name");
}
}

View File

@@ -0,0 +1,589 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.authenticate;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import org.dspace.AbstractUnitTest;
import org.dspace.builder.AbstractBuilder;
import org.dspace.builder.EPersonBuilder;
import org.dspace.content.MetadataValue;
import org.dspace.eperson.EPerson;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletRequest;
public class SamlAuthenticationTest extends AbstractUnitTest {
private static ConfigurationService configurationService;
private HttpServletRequest request;
private SamlAuthentication samlAuth;
private EPerson testUser;
@BeforeClass
public static void beforeAll() {
configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
AbstractBuilder.init(); // AbstractUnitTest doesn't do this for us.
}
@Before
public void beforeEach() throws Exception {
configurationService.setProperty("authentication-saml.autoregister", true);
configurationService.setProperty("authentication-saml.eperson.metadata.autocreate", true);
request = new MockHttpServletRequest();
samlAuth = new SamlAuthentication();
testUser = null;
}
@After
public void afterEach() throws Exception {
if (testUser != null) {
EPersonBuilder.deleteEPerson(testUser.getID());
}
}
@AfterClass
public static void afterAll() {
AbstractBuilder.destroy(); // AbstractUnitTest doesn't do this for us.
}
@Test
public void testAuthenticateExistingUserByEmail() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.EMAIL", List.of("alyssa@dspace.org"));
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertNull(user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
}
@Test
public void testAuthenticateExistingUserByNetId() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
}
@Test
public void testAuthenticateExistingUserByEmailWithUnexpectedNetId() throws Exception {
EPerson originalUser = context.getCurrentUser();
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("ben@dspace.org")
.withNetId("002")
.withNameInMetadata("Ben", "Bitdiddle")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org"));
request.setAttribute("org.dspace.saml.NAME_ID", "oh-no-its-different-than-the-stored-netid");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.NO_SUCH_USER, result);
assertEquals(originalUser, context.getCurrentUser());
}
@Test
public void testAuthenticateExistingUserByEmailUpdatesNullNetId() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("carrie@dspace.org")
.withNameInMetadata("Carrie", "Pragma")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.EMAIL", List.of("carrie@dspace.org"));
request.setAttribute("org.dspace.saml.NAME_ID", "netid-from-idp");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("carrie@dspace.org", user.getEmail());
assertEquals("netid-from-idp", user.getNetid());
assertEquals("Carrie", user.getFirstName());
assertEquals("Pragma", user.getLastName());
}
@Test
public void testAuthenticateExistingUserByNetIdUpdatesEmail() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.EMAIL", List.of("aphacker@dspace.org"));
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("aphacker@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
}
@Test
public void testAuthenticateExistingUserUpdatesName() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.GIVEN_NAME", "Liz");
request.setAttribute("org.dspace.saml.SURNAME", "Hacker-Bitdiddle");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Liz", user.getFirstName());
assertEquals("Hacker-Bitdiddle", user.getLastName());
}
@Test
public void testUpdatedNamesAreTruncated() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.GIVEN_NAME",
"This name is really very long and in fact its much too long so it must be trucated yes I said truncated");
request.setAttribute("org.dspace.saml.SURNAME",
"What is going on with these long names it's frankly out of control and I can't take it any more");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("This name is really very long and in fact its much too long so i", user.getFirstName());
assertEquals("What is going on with these long names it's frankly out of contr", user.getLastName());
}
@Test
public void testAuthenticateExistingUserAdditionalMetadata() throws Exception {
configurationService.setProperty("authentication-saml.eperson.metadata",
"org.dspace.saml.PHONE => phone," +
"org.dspace.saml.NICKNAME => nickname");
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.PHONE", "123-456-7890");
request.setAttribute("org.dspace.saml.NICKNAME", "Liz");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
List<MetadataValue> metadata = user.getMetadata();
assertEquals(4, metadata.size());
assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString());
assertEquals("123-456-7890", metadata.get(2).getValue());
assertEquals("eperson_nickname", metadata.get(3).getMetadataField().toString());
assertEquals("Liz", metadata.get(3).getValue());
}
@Test
public void testUpdatedAdditionalMetadataAreTruncated() throws Exception {
configurationService.setProperty("authentication-saml.eperson.metadata",
"org.dspace.saml.PHONE => phone," +
"org.dspace.saml.NICKNAME => nickname");
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.PHONE", "1234567890-1234567890-1234567890-1234567890");
request.setAttribute("org.dspace.saml.NICKNAME", "this is a long nickname.".repeat(100));
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
List<MetadataValue> metadata = user.getMetadata();
assertEquals(4, metadata.size());
assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString());
assertEquals("1234567890-1234567890-1234567890", metadata.get(2).getValue());
assertEquals("eperson_nickname", metadata.get(3).getMetadataField().toString());
assertEquals("this is a long nickname.".repeat(42) + "this is a long n", metadata.get(3).getValue());
}
@Test
public void testInvalidAdditionalMetadataMappingsAreIgnored() throws Exception {
configurationService.setProperty("authentication-saml.eperson.metadata",
"oops this is bad," +
"org.dspace.saml.NICKNAME => nickname");
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.PHONE", "123-456-7890");
request.setAttribute("org.dspace.saml.NICKNAME", "Liz");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
List<MetadataValue> metadata = user.getMetadata();
assertEquals(3, metadata.size());
assertEquals("eperson_nickname", metadata.get(2).getMetadataField().toString());
assertEquals("Liz", metadata.get(2).getValue());
}
@Test
public void testAuthenticateExistingUserAdditionalMetadataAutocreateDisabled() throws Exception {
configurationService.setProperty("authentication-saml.eperson.metadata.autocreate", false);
configurationService.setProperty("authentication-saml.eperson.metadata",
"org.dspace.saml.PHONE => phone," +
"org.dspace.saml.DEPARTMENT => department");
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.PHONE", "123-456-7890");
request.setAttribute("org.dspace.saml.DEPARTMENT", "Library");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
List<MetadataValue> metadata = user.getMetadata();
assertEquals(3, metadata.size());
assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString());
assertEquals("123-456-7890", metadata.get(2).getValue());
}
@Test
public void testAdditionalMetadataWithInvalidNameNotAutocreated() throws Exception {
configurationService.setProperty("authentication-saml.eperson.metadata",
"org.dspace.saml.PHONE => phone," +
"org.dspace.saml.DEPARTMENT => (department)"); // parens not allowed
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNetId("001")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.NAME_ID", "001");
request.setAttribute("org.dspace.saml.PHONE", "123-456-7890");
request.setAttribute("org.dspace.saml.DEPARTMENT", "Library");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("alyssa@dspace.org", user.getEmail());
assertEquals("001", user.getNetid());
assertEquals("Alyssa", user.getFirstName());
assertEquals("Hacker", user.getLastName());
List<MetadataValue> metadata = user.getMetadata();
assertEquals(3, metadata.size());
assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString());
assertEquals("123-456-7890", metadata.get(2).getValue());
}
@Test
public void testExistingUserLoginDisabled() throws Exception {
EPerson originalUser = context.getCurrentUser();
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(false)
.build();
context.restoreAuthSystemState();
request.setAttribute("org.dspace.saml.EMAIL", List.of("alyssa@dspace.org"));
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.BAD_ARGS, result);
assertEquals(originalUser, context.getCurrentUser());
}
@Test
public void testNonExistentUserWithoutEmail() throws Exception {
EPerson originalUser = context.getCurrentUser();
request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.NO_SUCH_USER, result);
assertEquals(originalUser, context.getCurrentUser());
}
@Test
public void testNonExistentUserWithEmailAutoregisterEnabled() throws Exception {
context.setCurrentUser(null);
request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid");
request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org"));
request.setAttribute("org.dspace.saml.GIVEN_NAME", "Ben");
request.setAttribute("org.dspace.saml.SURNAME", "Bitdiddle");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.SUCCESS, result);
EPerson user = context.getCurrentUser();
assertNotNull(user);
assertEquals("ben@dspace.org", user.getEmail());
assertEquals("non-existent-netid", user.getNetid());
assertEquals("Ben", user.getFirstName());
assertEquals("Bitdiddle", user.getLastName());
assertTrue(user.canLogIn());
assertTrue(user.getSelfRegistered());
testUser = user; // Make sure the autoregistered user gets deleted.
}
@Test
public void testNonExistentUserWithEmailAutoregisterDisabled() throws Exception {
configurationService.setProperty("authentication-saml.autoregister", false);
EPerson originalUser = context.getCurrentUser();
request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid");
request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org"));
request.setAttribute("org.dspace.saml.GIVEN_NAME", "Ben");
request.setAttribute("org.dspace.saml.SURNAME", "Bitdiddle");
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.NO_SUCH_USER, result);
assertEquals(originalUser, context.getCurrentUser());
}
@Test
public void testNoEmailOrNameIdInRequest() throws Exception {
context.setCurrentUser(null);
context.turnOffAuthorisationSystem();
testUser = EPersonBuilder.createEPerson(context)
.withEmail("alyssa@dspace.org")
.withNameInMetadata("Alyssa", "Hacker")
.withCanLogin(true)
.build();
context.restoreAuthSystemState();
int result = samlAuth.authenticate(context, null, null, null, request);
assertEquals(AuthenticationMethod.NO_SUCH_USER, result);
}
@Test
public void testRequestIsNull() throws Exception {
EPerson originalUser = context.getCurrentUser();
int result = samlAuth.authenticate(context, null, null, null, null);
assertEquals(AuthenticationMethod.BAD_ARGS, result);
assertEquals(originalUser, context.getCurrentUser());
}
}

View File

@@ -0,0 +1,119 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.security;
import java.io.IOException;
import java.util.Arrays;
import java.util.stream.Stream;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.authenticate.SamlAuthentication;
import org.dspace.core.Utils;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderNotFoundException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
/**
* A filter that examines requests to see if the user has been authenticated via SAML.
* <p>
* The overall SAML login process is as follows:
* </p>
* <ol>
* <li>When SAML authentication is enabled, the client/UI receives the URL to the active SAML
* relying party's authentication endpoint in the WWW-Authenticate header.
* See {@link org.dspace.authenticate.SamlAuthentication#loginPageURL(org.dspace.core.Context, HttpServletRequest, HttpServletResponse)}.</li>
* <li>The client sends the user to that URL when they select SAML authentication.</li>
* <li>The active SAML relying party sends the client to the login page at the asserting party
* (aka identity provider, or IdP).</li>
* <li>The user logs in to the asserting party.</li>
* <li>If successful, the asserting party sends the client back to the relying party's assertion
* consumer endpoint, along with the SAML assertion.</li>
* <li>The relying party receives the SAML assertion, extracts attributes from the assertion,
* maps them into request attributes, and forwards the request to the path where this filter
* is listening.</li>
* <li>This filter intercepts the request in order to check for a valid SAML login (see
* {@link org.dspace.authenticate.SamlAuthentication#authenticate(org.dspace.core.Context, String, String, String, HttpServletRequest)})
* and stores that user info in a JWT. It also saves that JWT in a <em>temporary</em>
* authentication cookie.</li>
* <li>This filter redirects the user back to the UI (after verifying it's at a trusted URL).</li>
* <li>The client reads the JWT from the cookie, and sends it back in a request to
* /api/authn/login, which triggers the server-side to destroy the cookie and move the JWT
* into a header.</li>
* </ol>
*
* @author Ray Lee
*/
public class SamlLoginFilter extends StatelessLoginFilter {
private static final Logger logger = LogManager.getLogger(SamlLoginFilter.class);
private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
public SamlLoginFilter(String url, String httpMethod, AuthenticationManager authenticationManager,
RestAuthenticationService restAuthenticationService) {
super(url, httpMethod, authenticationManager, restAuthenticationService);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (!SamlAuthentication.isEnabled()) {
throw new ProviderNotFoundException("SAML is disabled.");
}
// Because this authentication is implicit, we pass in an empty DSpaceAuthentication.
return authenticationManager.authenticate(new DSpaceAuthentication());
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication auth) throws IOException, ServletException {
restAuthenticationService.addAuthenticationDataForUser(request, response, (DSpaceAuthentication) auth, true);
redirectAfterSuccess(request, response);
}
/**
* After successful login, redirect to the configured UI URL. If that URL is not allowed for
* this DSpace site, return a 400 error.
*
* @param request
* @param response
* @throws IOException
*/
private void redirectAfterSuccess(HttpServletRequest request, HttpServletResponse response) throws IOException {
String redirectUrl = configurationService.getProperty("dspace.ui.url");
String redirectHostName = Utils.getHostName(redirectUrl);
String serverUrl = configurationService.getProperty("dspace.server.url");
boolean isRedirectAllowed = Stream.concat(
Stream.of(serverUrl),
Arrays.stream(configurationService.getArrayProperty("rest.cors.allowed-origins")))
.map(url -> Utils.getHostName(url))
.anyMatch(hostName -> hostName.equalsIgnoreCase(redirectHostName));
if (isRedirectAllowed) {
logger.debug("SAML redirecting to " + redirectUrl);
response.sendRedirect(redirectUrl);
} else {
logger.error("SAML redirect URL {} is not allowed" + redirectUrl);
response.sendError(HttpServletResponse.SC_BAD_REQUEST,"SAML redirect URL not allowed");
}
}
}

View File

@@ -161,6 +161,12 @@ public class WebSecurityConfiguration {
.addFilterBefore(new OidcLoginFilter("/api/authn/oidc", HttpMethod.GET.name(),
authenticationManager, restAuthenticationService),
LogoutFilter.class)
// Add a filter before our SAML endpoints to do the authentication based on the data in the HTTP request.
// This endpoint only responds to GET as the actual authentication is performed by SAML, which then
// forwards to this endpoint to pass the authentication data to DSpace.
.addFilterBefore(new SamlLoginFilter("/api/authn/saml", HttpMethod.GET.name(),
authenticationManager, restAuthenticationService),
LogoutFilter.class)
// Add a custom Token based authentication filter based on the token previously given to the client
// before each URL
.addFilterBefore(new StatelessAuthenticationFilter(authenticationManager, restAuthenticationService,

View File

@@ -245,7 +245,11 @@ public class JWTTokenRestAuthenticationServiceImpl implements RestAuthentication
// which destroys this temporary auth cookie. So, the auth cookie only exists a few seconds.
if (addCookie) {
ResponseCookie cookie = ResponseCookie.from(AUTHORIZATION_COOKIE, token)
.httpOnly(true).secure(true).sameSite("None").build();
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/server/api/authn")
.build();
// Write the cookie to the Set-Cookie header in order to send it
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

View File

@@ -0,0 +1,163 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.security;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.dspace.AbstractDSpaceTest;
import org.dspace.servicemanager.config.DSpaceConfigurationService;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderNotFoundException;
import org.springframework.security.core.Authentication;
public class SamlLoginFilterTest extends AbstractDSpaceTest {
private static ConfigurationService configurationService;
private AuthenticationManager authManager;
private HttpServletRequest request;
private HttpServletResponse response;
private RestAuthenticationService restAuthService;
private FilterChain filterChain;
private SamlLoginFilter filter;
@BeforeClass
public static void beforeAll() {
configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
}
@Before
public void beforeEach() throws Exception {
resetConfigurationService();
authManager = createAuthenticationManager();
restAuthService = createRestAuthenticationService();
filterChain = Mockito.mock(FilterChain.class);
filter = new SamlLoginFilter("/api/authn/saml", HttpMethod.GET.name(), authManager, restAuthService);
request = createRequest("/api/authn/saml");
response = createResponse();
}
@Test
public void testRedirectAfterSuccess() throws Exception {
configurationService.setProperty("plugin.sequence.org.dspace.authenticate.AuthenticationMethod",
"org.dspace.authenticate.SamlAuthentication");
configurationService.setProperty("dspace.ui.url","http://dspace.example.org");
configurationService.setProperty("dspace.server.url","http://dspace.example.org/server");
filter.doFilter(request, response, filterChain);
verify(response).sendRedirect("http://dspace.example.org");
}
@Test
public void testRedirectToRemoteHostNotAllowed() throws Exception {
configurationService.setProperty("plugin.sequence.org.dspace.authenticate.AuthenticationMethod",
"org.dspace.authenticate.SamlAuthentication");
configurationService.setProperty("dspace.ui.url","http://different.host.bad");
configurationService.setProperty("dspace.server.url","http://dspace.example.org/server");
filter.doFilter(request, response, filterChain);
verify(response).sendError(eq(400), anyString());
}
@Test
public void testRedirectToRemoteHostCorsAllowed() throws Exception {
configurationService.setProperty("plugin.sequence.org.dspace.authenticate.AuthenticationMethod",
"org.dspace.authenticate.SamlAuthentication");
configurationService.setProperty("rest.cors.allowed-origins", "http://different.host.ok");
configurationService.setProperty("dspace.ui.url","http://different.host.ok");
configurationService.setProperty("dspace.server.url","http://dspace.example.org/server");
filter.doFilter(request, response, filterChain);
verify(response).sendRedirect("http://different.host.ok");
}
@Test
public void testAuthCookieSaved() throws Exception {
configurationService.setProperty("plugin.sequence.org.dspace.authenticate.AuthenticationMethod",
"org.dspace.authenticate.SamlAuthentication");
configurationService.setProperty("dspace.ui.url","http://dspace.example.org");
configurationService.setProperty("dspace.server.url","http://dspace.example.org/server");
filter.doFilter(request, response, filterChain);
verify(restAuthService).addAuthenticationDataForUser(
eq(request), eq(response), any(DSpaceAuthentication.class), eq(true));
}
@Test
public void testSamlAuthenticationNotEnabled() throws Exception {
assertThrows(ProviderNotFoundException.class, () -> filter.attemptAuthentication(request, response));
}
private void resetConfigurationService() {
((DSpaceConfigurationService) configurationService).clear();
configurationService.reloadConfig();
}
private AuthenticationManager createAuthenticationManager() {
AuthenticationManager mockAuthManager = Mockito.mock(AuthenticationManager.class);
when(mockAuthManager.authenticate(any(Authentication.class)))
.thenReturn(Mockito.mock(DSpaceAuthentication.class));
return mockAuthManager;
}
private RestAuthenticationService createRestAuthenticationService() throws Exception {
RestAuthenticationService mockRestAuthService = Mockito.mock(RestAuthenticationService.class);
doNothing().when(mockRestAuthService).addAuthenticationDataForUser(
isA(HttpServletRequest.class), isA(HttpServletResponse.class),
isA(DSpaceAuthentication.class), isA(Boolean.class));
return mockRestAuthService;
}
private HttpServletRequest createRequest(String path) {
MockHttpServletRequest mockRequest = new MockHttpServletRequest(HttpMethod.GET.name(), path);
mockRequest.setPathInfo(path);
return mockRequest;
}
private HttpServletResponse createResponse() throws Exception {
HttpServletResponse mockResponse = Mockito.mock(HttpServletResponse.class);
doNothing().when(mockResponse).sendRedirect(isA(String.class));
doNothing().when(mockResponse).sendError(isA(Integer.class), isA(String.class));
return mockResponse;
}
}

View File

@@ -1647,6 +1647,7 @@ include = ${module_dir}/authentication-ip.cfg
include = ${module_dir}/authentication-ldap.cfg
include = ${module_dir}/authentication-oidc.cfg
include = ${module_dir}/authentication-password.cfg
include = ${module_dir}/authentication-saml.cfg
include = ${module_dir}/authentication-shibboleth.cfg
include = ${module_dir}/authentication-x509.cfg
include = ${module_dir}/authority.cfg

View File

@@ -198,6 +198,9 @@ db.password = dspace
# X.509 certificate authentication. See authentication-x509.cfg for default configuration.
#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.X509Authentication
# SAML authentication/authorization. See authenication-saml.cfg for default configuration.
#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.SamlAuthentication
# Authentication by Password (encrypted in DSpace's database). See authentication-password.cfg for default configuration.
# Enabled by default in authentication.cfg
#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.PasswordAuthentication

View File

@@ -0,0 +1,41 @@
#---------------------------------------------------------------#
#---------------SAML AUTHENTICATION CONFIGURATIONS--------------#
#---------------------------------------------------------------#
# Configuration properties used by the SAML #
# Authentication plugin, when it is enabled. #
#---------------------------------------------------------------#
# The ID of the SAML relying party we should use for authentication
# authentication-saml.relying-party-id = auth0
# The base URL of all SAML relying party endpoints
authentication-saml.relying-party-url = ${dspace.server.url}/saml2
# The URL of the authenticate endpoint for the SAML relying party
authentication-saml.authenticate-endpoint = ${authentication-saml.relying-party-url}/authenticate/${authentication-saml.relying-party-id}
# Should we allow new users to be registered automatically?
authentication-saml.autoregister = true
# The request attribute that contains the ID of the SAML relying party that authenticated the user
authentication-saml.attribute.relying-party-id = org.dspace.saml.RELYING_PARTY_ID
# The request attribute that contains the user's SAML name ID
authentication-saml.attribute.name-id = org.dspace.saml.NAME_ID
# The request attribute that contains the user's email
authentication-saml.attribute.email = org.dspace.saml.EMAIL
# The request attribute that contains the user's first name
authentication-saml.attribute.first-name = org.dspace.saml.GIVEN_NAME
# The request attribute that contains the user's last name
authentication-saml.attribute.last-name = org.dspace.saml.SURNAME
# Additional attribute mappings. Multiple attributes may be stored for each user. The left side is
# the request attribute, and the right side is the ePerson metadata field to map the attribute to.
# authentication-saml.eperson.metadata = \
# org.dspace.saml.PHONE => phone
# If the ePerson metadata field is not found, should it be created automatically?
authentication-saml.eperson.metadata.autocreate = true

View File

@@ -30,6 +30,9 @@
# * OIDC Authentication
# Plugin class: org.dspace.authenticate.OidcAuthentication
# Configuration file: authentication-oidc.cfg
# * SAML Authentication
# Plugin class: org.dspace.authenticate.SamlAuthentication
# Configuration file: authentication-saml.cfg
#
# One or more of the above plugins can be enabled by listing its plugin class in
@@ -58,6 +61,9 @@
# OIDC authentication. See authentication-oidc.cfg for default configuration.
#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.OidcAuthentication
# SAML authentication. See authentication-saml.cfg for default configuration.
#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.SamlAuthentication
# Authentication by Password (encrypted in DSpace's database). See authentication-password.cfg for default configuration.
# Enabled by default (to disable, either comment out, or define a new list of AuthenticationMethod plugins in your local.cfg)
plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.PasswordAuthentication