diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java index 9ff516b1e3..5a39ff887b 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java @@ -10,6 +10,8 @@ package org.dspace.access.status; import java.sql.SQLException; import java.time.LocalDate; +import org.dspace.content.AccessStatus; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; @@ -21,22 +23,37 @@ public interface AccessStatusHelper { * Calculate the access status for the item. * * @param context the DSpace context - * @param item the item + * @param item the item * @param threshold the embargo threshold date - * @return an access status value + * @param type the type of calculation + * @return the access status * @throws SQLException An exception that provides information on a database access error or other errors. */ - public String getAccessStatusFromItem(Context context, Item item, LocalDate threshold) - throws SQLException; + public AccessStatus getAccessStatusFromItem(Context context, + Item item, LocalDate threshold, String type) throws SQLException; /** - * Retrieve embargo information for the item + * Calculate the anonymous access status for the item. * * @param context the DSpace context * @param item the item to check for embargo information * @param threshold the embargo threshold date - * @return an embargo date + * @return the access status * @throws SQLException An exception that provides information on a database access error or other errors. */ - public String getEmbargoFromItem(Context context, Item item, LocalDate threshold) throws SQLException; + public AccessStatus getAnonymousAccessStatusFromItem(Context context, + Item item, LocalDate threshold) throws SQLException; + + /** + * Calculate the access status for the bitstream. + * + * @param context the DSpace context + * @param bitstream the bitstream + * @param threshold the embargo threshold date + * @param type the type of calculation + * @return the access status + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public AccessStatus getAccessStatusFromBitstream(Context context, + Bitstream bitstream, LocalDate threshold, String type) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java index 0ca01159e2..60c5bc1365 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java @@ -11,7 +11,12 @@ import java.sql.SQLException; import java.time.LocalDate; import java.time.ZoneId; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.access.status.service.AccessStatusService; +import org.dspace.content.AccessStatus; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.core.service.PluginService; @@ -22,11 +27,16 @@ import org.springframework.beans.factory.annotation.Autowired; * Implementation for the access status calculation service. */ public class AccessStatusServiceImpl implements AccessStatusService { + private static final Logger log = LogManager.getLogger(AccessStatusServiceImpl.class); + // Plugin implementation, set from the DSpace configuration by init(). protected AccessStatusHelper helper = null; protected LocalDate forever_date = null; + protected String itemCalculationType = null; + protected String bitstreamCalculationType = null; + @Autowired(required = true) protected ConfigurationService configurationService; @@ -59,16 +69,35 @@ public class AccessStatusServiceImpl implements AccessStatusService { .atStartOfDay() .atZone(ZoneId.systemDefault()) .toLocalDate(); + + itemCalculationType = getAccessStatusCalculationType("access.status.for-user.item"); + bitstreamCalculationType = getAccessStatusCalculationType("access.status.for-user.bitstream"); } } @Override - public String getAccessStatus(Context context, Item item) throws SQLException { - return helper.getAccessStatusFromItem(context, item, forever_date); + public AccessStatus getAccessStatus(Context context, Item item) throws SQLException { + return helper.getAccessStatusFromItem(context, item, forever_date, itemCalculationType); } @Override - public String getEmbargoFromItem(Context context, Item item) throws SQLException { - return helper.getEmbargoFromItem(context, item, forever_date); + public AccessStatus getAnonymousAccessStatus(Context context, Item item) throws SQLException { + return helper.getAnonymousAccessStatusFromItem(context, item, forever_date); + } + + @Override + public AccessStatus getAccessStatus(Context context, Bitstream bitstream) throws SQLException { + return helper.getAccessStatusFromBitstream(context, bitstream, forever_date, bitstreamCalculationType); + } + + private String getAccessStatusCalculationType(String key) { + String value = configurationService.getProperty(key, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS); + if (!StringUtils.equalsIgnoreCase(value, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS) && + !StringUtils.equalsIgnoreCase(value, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER)) { + log.warn("The configuration parameter \"" + key + + "\" contains an invalid value. Valid values include: 'anonymous' and 'current'."); + value = DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS; + } + return value; } } diff --git a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java index 86735d2bbc..9e1c40d85a 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java @@ -9,14 +9,17 @@ package org.dspace.access.status; import java.sql.SQLException; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.factory.AuthorizeServiceFactory; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; +import org.dspace.content.AccessStatus; import org.dspace.content.Bitstream; import org.dspace.content.Bundle; import org.dspace.content.DSpaceObject; @@ -25,21 +28,23 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.ItemService; import org.dspace.core.Constants; 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.GroupService; /** * Default plugin implementation of the access status helper. - * The getAccessStatusFromItem method provides a simple logic to - * calculate the access status of an item based on the policies of - * the primary or the first bitstream in the original bundle. - * Users can override this method for enhanced functionality. - * - * The getEmbargoInformationFromItem method provides a simple logic to - * * retrieve embargo information of bitstreams from an item based on the policies of - * * the primary or the first bitstream in the original bundle. - * * Users can override this method for enhanced functionality. + * + * The methods provides a simple logic to calculate the access status + * of an item based on the policies of the primary or the first bitstream + * in the original bundle. Users can override those methods for + * enhanced functionality. */ public class DefaultAccessStatusHelper implements AccessStatusHelper { + public static final String STATUS_FOR_CURRENT_USER = "current"; + public static final String STATUS_FOR_ANONYMOUS = "anonymous"; + public static final String EMBARGO = "embargo"; public static final String METADATA_ONLY = "metadata.only"; public static final String OPEN_ACCESS = "open.access"; @@ -52,13 +57,15 @@ public class DefaultAccessStatusHelper implements AccessStatusHelper { AuthorizeServiceFactory.getInstance().getResourcePolicyService(); protected AuthorizeService authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService(); + protected GroupService groupService = + EPersonServiceFactory.getInstance().getGroupService(); public DefaultAccessStatusHelper() { super(); } /** - * Look at the item's policies to determine an access status value. + * Look at the item's primary or first bitstream policies to determine an access status value. * It is also considering a date threshold for embargoes and restrictions. * * If the item is null, simply returns the "unknown" value. @@ -66,14 +73,70 @@ public class DefaultAccessStatusHelper implements AccessStatusHelper { * @param context the DSpace context * @param item the item to check for embargoes * @param threshold the embargo threshold date - * @return an access status value + * @param type the type of calculation + * @return the access status */ @Override - public String getAccessStatusFromItem(Context context, Item item, LocalDate threshold) + public AccessStatus getAccessStatusFromItem(Context context, Item item, LocalDate threshold, String type) throws SQLException { if (item == null) { - return UNKNOWN; + return new AccessStatus(UNKNOWN, null); } + Bitstream bitstream = getPrimaryOrFirstBitstreamInOriginalBundle(item); + if (bitstream == null) { + return new AccessStatus(METADATA_ONLY, null); + } + return getAccessStatusFromBitstream(context, bitstream, threshold, type); + } + + /** + * Look at the bitstream policies to determine an access status value. + * It is also considering a date threshold for embargoes and restrictions. + * + * If the bitstream is null, simply returns the "unknown" value. + * + * @param context the DSpace context + * @param bitstream the bitstream to check for embargoes + * @param threshold the embargo threshold date + * @param type the type of calculation + * @return the access status + */ + @Override + public AccessStatus getAccessStatusFromBitstream(Context context, + Bitstream bitstream, LocalDate threshold, String type) throws SQLException { + if (bitstream == null) { + return new AccessStatus(UNKNOWN, null); + } + List policies = getReadPolicies(context, bitstream, type); + LocalDate availabilityDate = findAvailabilityDate(policies, threshold); + // Get the access status based on the availability date + String accessStatus = getAccessStatusFromAvailabilityDate(availabilityDate, threshold); + return new AccessStatus(accessStatus, availabilityDate); + } + + /** + * Look at the anonymous policies of the primary (or first) + * bitstream of the item to retrieve its embargo. + * + * @param context the DSpace context + * @param item the item + * @param threshold the embargo threshold date + * @return the access status + */ + @Override + public AccessStatus getAnonymousAccessStatusFromItem(Context context, Item item, LocalDate threshold) + throws SQLException { + return getAccessStatusFromItem(context, item, threshold, STATUS_FOR_ANONYMOUS); + } + + /** + * Look in the item's original bundle. First, try to get the primary bitstream. + * If the bitstream is null, simply returns the first one. + * + * @param item the DSpace item + * @return the bitstream + */ + private Bitstream getPrimaryOrFirstBitstreamInOriginalBundle(Item item) { // Consider only the original bundles. List bundles = item.getBundles(Constants.DEFAULT_BUNDLE_NAME); // Check for primary bitstreams first. @@ -91,157 +154,159 @@ public class DefaultAccessStatusHelper implements AccessStatusHelper { .findFirst() .orElse(null); } - return calculateAccessStatusForDso(context, bitstream, threshold); + return bitstream; } /** - * Look at the DSpace object's policies to determine an access status value. + * Retrieves the anonymous read policies for a DSpace object + * + * @param context the DSpace context + * @param dso the DSpace object + * @return a list of policies + */ + private List getAnonymousReadPolicies(Context context, DSpaceObject dso) + throws SQLException { + // Only consider read policies. Use the find without a group + // as it's not returning all expected values + List readPolicies = resourcePolicyService.find(context, dso, Constants.READ); + // Filter the policies with the anonymous group + List filteredPolicies = readPolicies.stream() + .filter(p -> StringUtils.equals(p.getGroup().getName(), Group.ANONYMOUS)) + .collect(Collectors.toList()); + return filteredPolicies; + } + + /** + * Retrieves the current user read policies for a DSpace object + * + * @param context the DSpace context + * @param dso the DSpace object + * @return a list of policies + */ + private List getCurrentUserReadPolicies(Context context, DSpaceObject dso) + throws SQLException { + // First, look if the current user can read the object + boolean canRead = authorizeService.authorizeActionBoolean(context, dso, Constants.READ); + // If it's true, it can't be an embargo or a restriction, shortcircuit the process + // and return a null value (indicating an open access) + if (canRead) { + return null; + } + // Only consider read policies + List policies = resourcePolicyService.find(context, dso, Constants.READ); + // Only calculate the embargo date for the current user + EPerson currentUser = context.getCurrentUser(); + List readPolicies = new ArrayList(); + for (ResourcePolicy policy : policies) { + EPerson eperson = policy.getEPerson(); + if (eperson != null && currentUser != null && eperson.getID() == currentUser.getID()) { + readPolicies.add(policy); + continue; + } + Group group = policy.getGroup(); + if (group != null && groupService.isMember(context, currentUser, group)) { + readPolicies.add(policy); + } + } + return readPolicies; + } + + /** + * Retrieves the read policies for a DSpace object based on the type + * + * If the type is current, consider the current logged in user + * If the type is anonymous, only consider the anonymous group + * + * @param context the DSpace context + * @param dso the DSpace object + * @param type the type of calculation + * @return a list of policies + */ + private List getReadPolicies(Context context, DSpaceObject dso, String type) + throws SQLException { + if (StringUtils.equalsIgnoreCase(type, STATUS_FOR_CURRENT_USER)) { + return getCurrentUserReadPolicies(context, dso); + } else { + // Only calculate the status for the anonymous group read policies + return getAnonymousReadPolicies(context, dso); + } + } + + /** + * Look at the read policies to retrieve the access status availability date. + * + * @param readPolicies the read policies + * @param threshold the embargo threshold date + * @return an availability date + */ + private LocalDate findAvailabilityDate(List readPolicies, LocalDate threshold) { + // If the list is null, the object is readable + if (readPolicies == null) { + return null; + } + // If there's no policies, return the threshold date (restriction) + if (readPolicies.size() == 0) { + return threshold; + } + LocalDate availabilityDate = null; + LocalDate currentDate = LocalDate.now(); + boolean takeMostRecentDate = true; + // Looks at all read policies + for (ResourcePolicy policy : readPolicies) { + boolean isValid = resourcePolicyService.isDateValid(policy); + // If any policy is valid, the object is accessible + if (isValid) { + return null; + } + // There may be an active embargo + LocalDate startDate = policy.getStartDate(); + // Ignore policy with no start date or which is expired + if (startDate == null || startDate.isBefore(currentDate)) { + continue; + } + // Policy with a start date over the threshold (restriction) + // overrides the embargos + if (!startDate.isBefore(threshold)) { + takeMostRecentDate = false; + } + // Take the most recent embargo date if there is no restriction, otherwise + // take the highest date (account for rare cases where more than one resource + // policy exists) + if (availabilityDate == null) { + availabilityDate = startDate; + } else if (takeMostRecentDate) { + availabilityDate = startDate.isBefore(availabilityDate) ? startDate : availabilityDate; + } else { + availabilityDate = startDate.isAfter(availabilityDate) ? startDate : availabilityDate; + } + } + return availabilityDate; + } + + /** + * Look at the DSpace object availability date to determine an access status value. * * If the object is null, returns the "metadata.only" value. - * If any policy attached to the object is valid for the anonymous group, - * returns the "open.access" value. - * Otherwise, if the policy start date is before the embargo threshold date, - * returns the "embargo" value. - * Every other cases return the "restricted" value. + * If there's no availability date, returns the "open.access" value. + * If the availability date is after or equal to the embargo + * threshold date, returns the "restricted" value. + * Every other cases return the "embargo" value. * - * @param context the DSpace context - * @param dso the DSpace object - * @param threshold the embargo threshold date + * @param availabilityDate the DSpace object availability date + * @param threshold the embargo threshold date * @return an access status value */ - private String calculateAccessStatusForDso(Context context, DSpaceObject dso, LocalDate threshold) - throws SQLException { - if (dso == null) { - return METADATA_ONLY; - } - // Only consider read policies. - List policies = authorizeService - .getPoliciesActionFilter(context, dso, Constants.READ); - int openAccessCount = 0; - int embargoCount = 0; - int restrictedCount = 0; - int unknownCount = 0; - // Looks at all read policies. - for (ResourcePolicy policy : policies) { - boolean isValid = resourcePolicyService.isDateValid(policy); - Group group = policy.getGroup(); - // The group must not be null here. However, - // if it is, consider this as an unexpected case. - if (group == null) { - unknownCount++; - } else if (StringUtils.equals(group.getName(), Group.ANONYMOUS)) { - // Only calculate the status for the anonymous group. - if (isValid) { - // If the policy is valid, the anonymous group have access - // to the bitstream. - openAccessCount++; - } else { - LocalDate startDate = policy.getStartDate(); - if (startDate != null && !startDate.isBefore(threshold)) { - // If the policy start date have a value and if this value - // is equal or superior to the configured forever date, the - // access status is also restricted. - restrictedCount++; - } else { - // If the current date is not between the policy start date - // and end date, the access status is embargo. - embargoCount++; - } - } - } - } - if (openAccessCount > 0) { + private String getAccessStatusFromAvailabilityDate(LocalDate availabilityDate, LocalDate threshold) { + // If there is no availability date, it's an open access. + if (availabilityDate == null) { return OPEN_ACCESS; } - if (embargoCount > 0 && restrictedCount == 0) { - return EMBARGO; + // If the policy start date have a value and if this value + // is equal or superior to the configured forever date, the + // access status is also restricted. + if (!availabilityDate.isBefore(threshold)) { + return RESTRICTED; } - if (unknownCount > 0) { - return UNKNOWN; - } - return RESTRICTED; - } - - /** - * Look at the policies of the primary (or first) bitstream of the item to retrieve its embargo. - * - * If the item is null, simply returns an empty map with no embargo information. - * - * @param context the DSpace context - * @param item the item to embargo - * @return an access status value - */ - @Override - public String getEmbargoFromItem(Context context, Item item, LocalDate threshold) - throws SQLException { - LocalDate embargoDate; - - // If Item status is not "embargo" then return a null embargo date. - String accessStatus = getAccessStatusFromItem(context, item, threshold); - - if (item == null || !accessStatus.equals(EMBARGO)) { - return null; - } - // Consider only the original bundles. - List bundles = item.getBundles(Constants.DEFAULT_BUNDLE_NAME); - // Check for primary bitstreams first. - Bitstream bitstream = bundles.stream() - .map(bundle -> bundle.getPrimaryBitstream()) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - if (bitstream == null) { - // If there is no primary bitstream, - // take the first bitstream in the bundles. - bitstream = bundles.stream() - .map(bundle -> bundle.getBitstreams()) - .flatMap(List::stream) - .findFirst() - .orElse(null); - } - - if (bitstream == null) { - return null; - } - - embargoDate = this.retrieveShortestEmbargo(context, bitstream); - - return embargoDate != null ? embargoDate.toString() : null; - } - - /** - * - */ - private LocalDate retrieveShortestEmbargo(Context context, Bitstream bitstream) throws SQLException { - LocalDate embargoDate = null; - // Only consider read policies. - List policies = authorizeService - .getPoliciesActionFilter(context, bitstream, Constants.READ); - - // Looks at all read policies. - for (ResourcePolicy policy : policies) { - boolean isValid = resourcePolicyService.isDateValid(policy); - Group group = policy.getGroup(); - - if (group != null && StringUtils.equals(group.getName(), Group.ANONYMOUS)) { - // Only calculate the status for the anonymous group. - if (!isValid) { - // If the policy is not valid there is an active embargo - LocalDate startDate = policy.getStartDate(); - - if (startDate != null && !startDate.isBefore(LocalDate.now())) { - // There is an active embargo: aim to take the shortest embargo (account for rare cases where - // more than one resource policy exists) - if (embargoDate == null) { - embargoDate = startDate; - } else { - embargoDate = startDate.isBefore(embargoDate) ? startDate : embargoDate; - } - } - } - } - } - - return embargoDate; + return EMBARGO; } } diff --git a/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java index e86c5a69f4..a5c341d98a 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java +++ b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java @@ -9,6 +9,8 @@ package org.dspace.access.status.service; import java.sql.SQLException; +import org.dspace.content.AccessStatus; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; @@ -39,19 +41,29 @@ public interface AccessStatusService { * Calculate the access status for an Item while considering the forever embargo date threshold. * * @param context the DSpace context - * @param item the item - * @return an access status value + * @param item the item + * @return the access status * @throws SQLException An exception that provides information on a database access error or other errors. */ - public String getAccessStatus(Context context, Item item) throws SQLException; + public AccessStatus getAccessStatus(Context context, Item item) throws SQLException; /** - * Retrieve embargo information for the item + * Calculate the anonymous access status for an Item while considering the forever embargo date threshold. * * @param context the DSpace context * @param item the item to check for embargo information - * @return an embargo date + * @return the access status * @throws SQLException An exception that provides information on a database access error or other errors. */ - public String getEmbargoFromItem(Context context, Item item) throws SQLException; + public AccessStatus getAnonymousAccessStatus(Context context, Item item) throws SQLException; + + /** + * Calculate the access status for a bitstream while considering the forever embargo date threshold. + * + * @param context the DSpace context + * @param bitstream the bitstream + * @return the access status + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public AccessStatus getAccessStatus(Context context, Bitstream bitstream) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java b/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java index ecd6a24287..eeb698379f 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java @@ -19,6 +19,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -457,7 +458,7 @@ public class DSpaceCSV implements Serializable { List collections = i.getCollections(); for (Collection c : collections) { // Only add if it is not the owning collection - if (!c.getHandle().equals(owningCollectionHandle)) { + if (!Objects.equals(c.getHandle(), owningCollectionHandle)) { line.add("collection", c.getHandle()); } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItem.java b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItem.java index 9cd06d2010..e7d04c0e84 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItem.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItem.java @@ -72,6 +72,12 @@ public class RequestItem implements ReloadableEntity { @Column(name = "accept_request") private boolean accept_request; + @Column(name = "access_token", unique = true, length = 48) + private String access_token = null; + + @Column(name = "access_expiry") + private Instant access_expiry = null; + /** * Protected constructor, create object using: * {@link org.dspace.app.requestitem.service.RequestItemService#createRequest( @@ -85,7 +91,7 @@ public class RequestItem implements ReloadableEntity { return requestitem_id; } - void setAllfiles(boolean allfiles) { + public void setAllfiles(boolean allfiles) { this.allfiles = allfiles; } @@ -134,7 +140,8 @@ public class RequestItem implements ReloadableEntity { } /** - * @return a unique request identifier which can be emailed. + * @return a unique request identifier which can be emailed to the *approver* of the request. + * This is not the same as the access token, which is used by the requester to access the item after approval. */ public String getToken() { return token; @@ -187,4 +194,38 @@ public class RequestItem implements ReloadableEntity { void setRequest_date(Instant request_date) { this.request_date = request_date; } + + /** + * @return A unique token to be used by the requester when granted access to the resource, which + * can be emailed upon approval + */ + public String getAccess_token() { + return access_token; + } + + public void setAccess_token(String access_token) { + this.access_token = access_token; + } + + /** + * @return The date and time when the access token expires. + */ + public Instant getAccess_expiry() { + return access_expiry; + } + public void setAccess_expiry(Instant access_expiry) { + this.access_expiry = access_expiry; + } + + /** + * Sanitize personal information and the approval token, to be used when returning a RequestItem + * to Angular, especially for users clicking on the secure link + */ + public void sanitizePersonalData() { + setReqEmail("sanitized"); + setReqName("sanitized"); + setReqMessage("sanitized"); + // Even though [approval] token is not a name, it can be used to access the original object + setToken("sanitized"); + } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemEmailNotifier.java b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemEmailNotifier.java index c489fb4b3f..d210752597 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemEmailNotifier.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemEmailNotifier.java @@ -10,6 +10,8 @@ package org.dspace.app.requestitem; import java.io.IOException; import java.sql.SQLException; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.List; import jakarta.annotation.ManagedBean; @@ -28,6 +30,7 @@ import org.dspace.core.Context; import org.dspace.core.Email; import org.dspace.core.I18nUtil; import org.dspace.core.LogHelper; +import org.dspace.core.Utils; import org.dspace.eperson.EPerson; import org.dspace.handle.service.HandleService; import org.dspace.services.ConfigurationService; @@ -174,9 +177,23 @@ public class RequestItemEmailNotifier { grantorAddress = grantor.getEmail(); } + // Set date format for access expiry date + String accessExpiryFormat = configurationService.getProperty("request.item.grant.link.dateformat", + "yyyy-MM-dd"); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(accessExpiryFormat) + .withZone(ZoneId.of("UTC")); + + Email email; + // If this item has a secure access token, send the template with that link instead of attaching files + if (ri.isAccept_request() && ri.getAccess_token() != null) { + email = Email.getEmail(I18nUtil.getEmailFilename(context.getCurrentLocale(), + "request_item.granted_token")); + } else { + email = Email.getEmail(I18nUtil.getEmailFilename(context.getCurrentLocale(), + ri.isAccept_request() ? "request_item.granted" : "request_item.rejected")); + } + // Build an email back to the requester. - Email email = Email.getEmail(I18nUtil.getEmailFilename(context.getCurrentLocale(), - ri.isAccept_request() ? "request_item.granted" : "request_item.rejected")); email.addArgument(ri.getReqName()); // {0} requestor's name email.addArgument(handleService.getCanonicalForm(ri.getItem().getHandle())); // {1} URL of the requested Item email.addArgument(ri.getItem().getName()); // {2} title of the requested Item @@ -188,34 +205,47 @@ public class RequestItemEmailNotifier { // Attach bitstreams. try { if (ri.isAccept_request()) { - if (ri.isAllfiles()) { - Item item = ri.getItem(); - List bundles = item.getBundles("ORIGINAL"); - for (Bundle bundle : bundles) { - List bitstreams = bundle.getBitstreams(); - for (Bitstream bitstream : bitstreams) { - if (!bitstream.getFormat(context).isInternal() && - requestItemService.isRestricted(context, - bitstream)) { - // #8636 Anyone receiving the email can respond to the - // request without authenticating into DSpace - context.turnOffAuthorisationSystem(); - email.addAttachment( - bitstreamService.retrieve(context, bitstream), - bitstream.getName(), - bitstream.getFormat(context).getMIMEType()); - context.restoreAuthSystemState(); - } - } + if (ri.getAccess_token() != null) { + // {6} secure access link + email.addArgument(configurationService.getProperty("dspace.ui.url") + + "/items/" + ri.getItem().getID() + + "?accessToken=" + ri.getAccess_token()); + // {7} access end date, but only add formatted date string if it is set and not "forever" + if (ri.getAccess_expiry() != null && !ri.getAccess_expiry().equals(Utils.getMaxTimestamp())) { + email.addArgument(dateTimeFormatter.format(ri.getAccess_expiry())); + } else { + email.addArgument(null); } } else { - Bitstream bitstream = ri.getBitstream(); - // #8636 Anyone receiving the email can respond to the request without authenticating into DSpace - context.turnOffAuthorisationSystem(); - email.addAttachment(bitstreamService.retrieve(context, bitstream), - bitstream.getName(), - bitstream.getFormat(context).getMIMEType()); - context.restoreAuthSystemState(); + if (ri.isAllfiles()) { + Item item = ri.getItem(); + List bundles = item.getBundles("ORIGINAL"); + for (Bundle bundle : bundles) { + List bitstreams = bundle.getBitstreams(); + for (Bitstream bitstream : bitstreams) { + if (!bitstream.getFormat(context).isInternal() && + requestItemService.isRestricted(context, + bitstream)) { + // #8636 Anyone receiving the email can respond to the + // request without authenticating into DSpace + context.turnOffAuthorisationSystem(); + email.addAttachment( + bitstreamService.retrieve(context, bitstream), + bitstream.getName(), + bitstream.getFormat(context).getMIMEType()); + context.restoreAuthSystemState(); + } + } + } + } else { + Bitstream bitstream = ri.getBitstream(); + //#8636 Anyone receiving the email can respond to the request without authenticating into DSpace + context.turnOffAuthorisationSystem(); + email.addAttachment(bitstreamService.retrieve(context, bitstream), + bitstream.getName(), + bitstream.getFormat(context).getMIMEType()); + context.restoreAuthSystemState(); + } } email.send(); } else { diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java index e23b30ba03..7a61ae9bd0 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java @@ -7,25 +7,40 @@ */ package org.dspace.app.requestitem; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.sql.SQLException; +import java.text.ParseException; +import java.time.DateTimeException; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Iterator; import java.util.List; +import java.util.TimeZone; +import org.apache.http.client.utils.URIBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.app.requestitem.dao.RequestItemDAO; import org.dspace.app.requestitem.service.RequestItemService; +import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; import org.dspace.content.DSpaceObject; import org.dspace.content.Item; import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.core.LogHelper; import org.dspace.core.Utils; +import org.dspace.services.ConfigurationService; +import org.dspace.util.DateMathParser; +import org.dspace.util.MultiFormatDateParser; import org.springframework.beans.factory.annotation.Autowired; /** @@ -35,6 +50,7 @@ import org.springframework.beans.factory.annotation.Autowired; * This class should never be accessed directly. * * @author kevinvandevelde at atmire.com + * @author Kim Shepherd */ public class RequestItemServiceImpl implements RequestItemService { @@ -49,16 +65,43 @@ public class RequestItemServiceImpl implements RequestItemService { @Autowired(required = true) protected ResourcePolicyService resourcePolicyService; + @Autowired + protected ConfigurationService configurationService; + + /** + * Always set UTC for dateMathParser for consistent database date handling + */ + static DateMathParser dateMathParser = new DateMathParser(TimeZone.getTimeZone("UTC")); + + private static final int DEFAULT_MINIMUM_FILE_SIZE = 20; + protected RequestItemServiceImpl() { } + /** + * Create a new request-a-copy item request. + * + * @param context The relevant DSpace Context. + * @param bitstream The requested bitstream + * @param item The requested item + * @param allFiles true indicates that all bitstreams of this item are requested + * @param reqEmail email + * Requester email + * @param reqName Requester name + * @param reqMessage Request message text + * @return token to be used to approver for grant/deny + * @throws SQLException + */ @Override public String createRequest(Context context, Bitstream bitstream, Item item, boolean allFiles, String reqEmail, String reqName, String reqMessage) throws SQLException { + + // Create an empty request item RequestItem requestItem = requestItemDAO.create(context, new RequestItem()); + // Set values of the request item based on supplied parameters requestItem.setToken(Utils.generateHexKey()); requestItem.setBitstream(bitstream); requestItem.setItem(item); @@ -68,10 +111,56 @@ public class RequestItemServiceImpl implements RequestItemService { requestItem.setReqMessage(reqMessage); requestItem.setRequest_date(Instant.now()); + // If the 'link' feature is enabled and the filesize threshold is met, pre-generate access token now + // so it can be previewed by approver and so Angular and REST services can use the existence of this token + // as an indication of which delivery method to use. + // Access period will be created upon actual approval. + if (configurationService.getBooleanProperty("request.item.grant.link", false)) { + // The 'send link' feature is enabled, is the file(s) requested over the size threshold (megabytes as int)? + // Default is 20MB minimum. For inspection purposes we convert to bytes. + long minimumSize = configurationService.getLongProperty( + "request.item.grant.link.filesize", DEFAULT_MINIMUM_FILE_SIZE) * 1024 * 1024; + // If we have a single bitstream, we will initialise the "minimum threshold reached" correctly + boolean minimumSizeThresholdReached = (null != bitstream && bitstream.getSizeBytes() >= minimumSize); + // If all files (and presumably no min reached since bitstream should be null), we look for ANY >= min size + if (!minimumSizeThresholdReached && allFiles) { + // Iterate bitstream and inspect file sizes. At each loop iteration we will break out if the min + // was already reached. + String[] bundleNames = configurationService.getArrayProperty("request.item.grant.link.bundles", + new String[]{"ORIGINAL"}); + for (String bundleName : bundleNames) { + if (!minimumSizeThresholdReached) { + for (Bundle bundle : item.getBundles(bundleName)) { + if (null != bundle && !minimumSizeThresholdReached) { + for (Bitstream bitstreamToCheck : bundle.getBitstreams()) { + if (bitstreamToCheck.getSizeBytes() >= minimumSize) { + minimumSizeThresholdReached = true; + break; + } + } + + } + } + } + } + } + + // Now, only generate and set an access token if the minimum file size threshold was reached. + // Otherwise, an email attachment will still be used. + // From now on, the existence of an access token in the RequestItem indicates that a web link should be + // sent instead of attaching file(s) as an attachment. + if (minimumSizeThresholdReached) { + requestItem.setAccess_token(Utils.generateHexKey()); + } + } + + // Save the request item requestItemDAO.save(context, requestItem); - log.debug("Created RequestItem with ID {} and token {}", - requestItem::getID, requestItem::getToken); + log.debug("Created RequestItem with ID {}, approval token {}, access token {}, access expiry {}", + requestItem::getID, requestItem::getToken, requestItem::getAccess_token, requestItem::getAccess_expiry); + + // Return the approver token return requestItem.getToken(); } @@ -128,4 +217,186 @@ public class RequestItemServiceImpl implements RequestItemService { } return true; } + + /** + * Find a request item by its access token. This is the token that a requester would use + * to authenticate themselves as a granted requester. + * It is up to the RequestItemRepository to check validity of the item, access granted, data sanitization, etc. + * + * @param context current DSpace session. + * @param accessToken the token identifying the request to be temporarily accessed + * @return request item data + */ + @Override + public RequestItem findByAccessToken(Context context, String accessToken) { + try { + return requestItemDAO.findByAccessToken(context, accessToken); + } catch (SQLException e) { + log.error(e.getMessage()); + return null; + } + } + + /** + * Set the access expiry date for the request item. + * @param requestItem the request item to update + * @param accessExpiry the expiry date to set + */ + @Override + public void setAccessExpiry(RequestItem requestItem, Instant accessExpiry) { + requestItem.setAccess_expiry(accessExpiry); + } + + /** + * Take a string either as a formatted date, or in the "math" format expected by + * the DateMathParser, e.g. +7DAYS or +10MONTHS, and set the access expiry date accordingly. + * There are no special checks here to check that the date is in the future, or after the + * 'decision date', as there may be legitimate reasons to set past dates. + * If past dates are not allowed by some interface, then the caller should check this. + * + * @param requestItem the request item to update + * @param dateOrDelta the delta as a string in format expected by the DateMathParser + */ + @Override + public void setAccessExpiry(RequestItem requestItem, String dateOrDelta) { + try { + setAccessExpiry(requestItem, parseDateOrDelta(dateOrDelta, requestItem.getDecision_date())); + } catch (ParseException e) { + log.error("Error parsing access expiry or duration: {}", e.getMessage()); + } + } + + /** + * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, + * either return cleanly or throw an AuthorizeException + * + * @param context the DSpace context + * @param requestItem the request item containing request and approval data + * @param bitstream the bitstream to which access is requested + * @param accessToken the access token supplied by the user (e.g. to REST controller) + * @throws AuthorizeException + */ + @Override + public void authorizeAccessByAccessToken(Context context, RequestItem requestItem, Bitstream bitstream, + String accessToken) throws AuthorizeException { + if (requestItem == null || bitstream == null || context == null || accessToken == null) { + throw new AuthorizeException("Null resources provided, not authorized"); + } + // 1. Request is accepted + if (requestItem.isAccept_request() + // 2. Request access token is not null and matches supplied string + && (requestItem.getAccess_token() != null && requestItem.getAccess_token().equals(accessToken)) + // 3. Request is 'allfiles' or for this bitstream ID + && (requestItem.isAllfiles() || bitstream.equals(requestItem.getBitstream())) + // 4. access expiry timestamp is null (forever), or is *after* the current time + && (requestItem.getAccess_expiry() == null || requestItem.getAccess_expiry().isAfter(Instant.now())) + ) { + log.info("Authorizing access to bitstream {} by access token", bitstream.getID()); + return; + } + // Default, throw authorize exception + throw new AuthorizeException("Unauthorized access to bitstream by access token for bitstream ID " + + bitstream.getID()); + } + + /** + * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, + * either return cleanly or throw an AuthorizeException + * + * @param context the DSpace context + * @param bitstream the bitstream to which access is requested + * @param accessToken the access token supplied by the user (e.g. to REST controller) + * @throws AuthorizeException + */ + @Override + public void authorizeAccessByAccessToken(Context context, Bitstream bitstream, String accessToken) + throws AuthorizeException { + if (bitstream == null || context == null || accessToken == null) { + throw new AuthorizeException("Null resources provided, not authorized"); + } + // get request item from access token + RequestItem requestItem = findByAccessToken(context, accessToken); + if (requestItem == null) { + throw new AuthorizeException("Null item request provided, not authorized"); + } + // Continue with authorization check + authorizeAccessByAccessToken(context, requestItem, bitstream, accessToken); + } + + /** + * Generate a link back to DSpace, to act on a request. + * + * @param token identifies the request. + * @return URL to the item request API, with the token as request parameter + * "token". + * @throws URISyntaxException passed through. + * @throws MalformedURLException passed through. + */ + @Override + public String getLinkTokenEmail(String token) + throws URISyntaxException, MalformedURLException { + final String base = configurationService.getProperty("dspace.ui.url"); + URIBuilder uriBuilder = new URIBuilder(base); + String currentPath = uriBuilder.getPath(); + String newPath = (currentPath == null || currentPath.isEmpty() || currentPath.equals("/")) + ? "/request-a-copy/" + token + : currentPath + "/request-a-copy/" + token; + URI uri = uriBuilder.setPath(newPath).build(); + return uri.toURL().toExternalForm(); + } + + /** + * Sanitize a RequestItem. The following values in the referenced RequestItem + * are nullified: + * - approver token (aka token) + * - requester name + * - requester email + * - requester message + * + * These properties contain personal information, or can be used to access personal information + * and are not needed except for sending the original request and grant/deny emails + * + * @param requestItem + */ + @Override + public void sanitizeRequestItem(Context context, RequestItem requestItem) { + if (null == requestItem) { + log.error("Null request item passed for sanitization, skipping"); + return; + } + + // Sanitized referenced data (strips requester name, email, message, and the approver token) + requestItem.sanitizePersonalData(); + } + + /** + * Parse a date or delta string into an Instant. Kept here as a static method for use in unit tests + * and other areas that might not have access to the full spring service + * + * @param dateOrDelta + * @param decisionDate + * @return parsed date as instant + * @throws ParseException + */ + public static Instant parseDateOrDelta(String dateOrDelta, Instant decisionDate) + throws ParseException, DateTimeException { + // First, if dateOrDelta is a null string or "FOREVER", we will set the expiry + // date to a very distant date in the future. + if (dateOrDelta == null || dateOrDelta.equals("FOREVER")) { + return Utils.getMaxTimestamp(); + } + // Next, try parsing as a straight date using the multiple format parser + ZonedDateTime parsedExpiryDate = MultiFormatDateParser.parse(dateOrDelta); + + if (parsedExpiryDate == null) { + // That did not work, so try parsing as a delta + // Set the 'now' date to the decision date of the request item + dateMathParser.setNow(LocalDateTime.ofInstant(decisionDate, ZoneOffset.UTC)); + // Parse the delta (e.g. +7DAYS) and set the new access expiry date + return dateMathParser.parseMath(dateOrDelta).toInstant(ZoneOffset.UTC); + } else { + // The expiry date was a valid formatted date string, so set the access expiry date + return parsedExpiryDate.toInstant(); + } + } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java index b36ae58e0c..9a7419bc9c 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java @@ -26,7 +26,7 @@ import org.dspace.core.GenericDAO; */ public interface RequestItemDAO extends GenericDAO { /** - * Fetch a request named by its unique token (passed in emails). + * Fetch a request named by its unique approval token (passed in emails). * * @param context the current DSpace context. * @param token uniquely identifies the request. @@ -35,5 +35,18 @@ public interface RequestItemDAO extends GenericDAO { */ public RequestItem findByToken(Context context, String token) throws SQLException; + /** + * Fetch a request named by its unique access token (passed in emails). + * Note this is the token used by the requester to access an approved resource, not the token + * used by the item submitter or helpdesk to grant the access. + * + * @param context the current DSpace context. + * @param accessToken uniquely identifies the request + * @return the found request or {@code null} + * @throws SQLException passed through. + */ + public RequestItem findByAccessToken(Context context, String accessToken) throws SQLException; + public Iterator findByItem(Context context, Item item) throws SQLException; + } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java index c76bd50d19..000559b14d 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java @@ -42,6 +42,17 @@ public class RequestItemDAOImpl extends AbstractHibernateDAO implem criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.token), token)); return uniqueResult(context, criteriaQuery, false, RequestItem.class); } + + @Override + public RequestItem findByAccessToken(Context context, String accessToken) throws SQLException { + CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); + CriteriaQuery criteriaQuery = getCriteriaQuery(criteriaBuilder, RequestItem.class); + Root requestItemRoot = criteriaQuery.from(RequestItem.class); + criteriaQuery.select(requestItemRoot); + criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.access_token), accessToken)); + return uniqueResult(context, criteriaQuery, true, RequestItem.class); + } + @Override public Iterator findByItem(Context context, Item item) throws SQLException { CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/package-info.java b/dspace-api/src/main/java/org/dspace/app/requestitem/package-info.java index fa7c15b230..a973718efc 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/package-info.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/package-info.java @@ -8,13 +8,19 @@ /** * Feature for conveying a request that materials forbidden to the requester - * by resource policy be made available by other means. The request will be - * e-mailed to a responsible party for consideration and action. Find details - * in the user documentation under the rubric "Request a Copy". + * by resource policy be made available by other means. + * + * There are several methods of making the resource(s) available to the requester: + * 1. The request will be e-mailed to a responsible party for consideration and action. + * Find details in the user documentation under the rubric "Request a Copy". * *

Mailing is handled by {@link RequestItemEmailNotifier}. Responsible * parties are represented by {@link RequestItemAuthor} * + * 2. A unique 48-char token will be generated and included in a special weblink emailed to the requester. + * This link will provide access to the requester as though they had READ policy access while the access period + * has not expired, or forever if the access period is null. + * *

This package includes several "strategy" classes which discover * responsible parties in various ways. See * {@link RequestItemSubmitterStrategy} and the classes which extend it, and diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java index efac3b18bc..e50a961f9c 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java @@ -7,11 +7,15 @@ */ package org.dspace.app.requestitem.service; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.sql.SQLException; +import java.time.Instant; import java.util.Iterator; import java.util.List; import org.dspace.app.requestitem.RequestItem; +import org.dspace.authorize.AuthorizeException; import org.dspace.content.Bitstream; import org.dspace.content.DSpaceObject; import org.dspace.content.Item; @@ -23,6 +27,7 @@ import org.dspace.core.Context; * for the RequestItem object and is autowired by Spring. * * @author kevinvandevelde at atmire.com + * @author Kim Shepherd */ public interface RequestItemService { @@ -40,7 +45,7 @@ public interface RequestItemService { * @return the token of the request item * @throws SQLException if database error */ - public String createRequest(Context context, Bitstream bitstream, Item item, + String createRequest(Context context, Bitstream bitstream, Item item, boolean allFiles, String reqEmail, String reqName, String reqMessage) throws SQLException; @@ -49,35 +54,46 @@ public interface RequestItemService { * * @param context current DSpace session. * @return all item requests. - * @throws java.sql.SQLException passed through. + * @throws SQLException passed through. */ - public List findAll(Context context) + List findAll(Context context) throws SQLException; /** - * Retrieve a request by its token. + * Retrieve a request by its approver token. * * @param context current DSpace session. - * @param token the token identifying the request. + * @param token the token identifying the request to be approved. * @return the matching request, or null if not found. */ - public RequestItem findByToken(Context context, String token); + RequestItem findByToken(Context context, String token); + /** + * Retrieve a request by its access token, for use by the requester + * + * @param context current DSpace session. + * @param token the token identifying the request to be temporarily accessed + * @return the matching request, or null if not found. + */ + RequestItem findByAccessToken(Context context, String token); /** * Retrieve a request based on the item. * @param context current DSpace session. * @param item the item to find requests for. * @return the matching requests, or null if not found. */ - public Iterator findByItem(Context context, Item item) throws SQLException; + Iterator findByItem(Context context, Item item) throws SQLException; /** - * Save updates to the record. Only accept_request, and decision_date are set-able. + * Save updates to the record. Only accept_request, decision_date, access_period are settable. + * + * Note: the "is settable" rules mentioned here are enforced in RequestItemRest with annotations meaning that + * these JSON properties are considered READ-ONLY by the core DSpaceRestRepository methods * * @param context The relevant DSpace Context. * @param requestItem requested item */ - public void update(Context context, RequestItem requestItem); + void update(Context context, RequestItem requestItem); /** * Remove the record from the database. @@ -85,7 +101,7 @@ public interface RequestItemService { * @param context current DSpace context. * @param request record to be removed. */ - public void delete(Context context, RequestItem request); + void delete(Context context, RequestItem request); /** * Is there at least one valid READ resource policy for this object? @@ -94,6 +110,77 @@ public interface RequestItemService { * @return true if a READ policy applies. * @throws SQLException passed through. */ - public boolean isRestricted(Context context, DSpaceObject o) + boolean isRestricted(Context context, DSpaceObject o) throws SQLException; + + /** + * Set the access expiry timestamp for a request item. After this date, the + * bitstream(s) will no longer be available for download even with a token. + * @param requestItem the request item + * @param accessExpiry the expiry timestamp + */ + void setAccessExpiry(RequestItem requestItem, Instant accessExpiry); + + /** + * Set the access expiry timestamp for a request item by delta string. + * After this date, the bitstream(s) will no longer be available for download + * even with a token. + * @param requestItem the request item + * @param delta the delta to calculate the expiry timestamp, from the decision date + */ + void setAccessExpiry(RequestItem requestItem, String delta); + + /** + * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, + * either return cleanly or throw an AuthorizeException + * + * @param context the DSpace context + * @param requestItem the request item containing request and approval data + * @param bitstream the bitstream to which access is requested + * @param accessToken the access token supplied by the user (e.g. to REST controller) + * @throws AuthorizeException + */ + void authorizeAccessByAccessToken(Context context, RequestItem requestItem, Bitstream bitstream, + String accessToken) + throws AuthorizeException; + + /** + * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, + * either return cleanly or throw an AuthorizeException + * + * @param context the DSpace context + * @param bitstream the bitstream to which access is requested + * @param accessToken the access token supplied by the user (e.g. to REST controller) + * @throws AuthorizeException + */ + void authorizeAccessByAccessToken(Context context, Bitstream bitstream, String accessToken) + throws AuthorizeException; + + /** + * Generate a link back to DSpace, to act on a request. + * + * @param token identifies the request. + * @return URL to the item request API, with the token as request parameter + * "token". + * @throws URISyntaxException passed through. + * @throws MalformedURLException passed through. + */ + String getLinkTokenEmail(String token) + throws URISyntaxException, MalformedURLException; + + /** + * Sanitize a RequestItem depending on the current session user. If the current user is not + * the approver, an administrator or other privileged group, the following values in the return object + * are nullified: + * - approver token (aka token) + * - requester name + * - requester email + * - requester message + * + * These properties contain personal information, or can be used to access personal information + * and are not needed except for sending the original request and grant/deny emails + * + * @param requestItem + */ + void sanitizeRequestItem(Context context, RequestItem requestItem); } diff --git a/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java b/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java index ee30338a8f..858eafc928 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java @@ -29,7 +29,10 @@ import org.dspace.authorize.AuthorizeException; import org.dspace.core.Context; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.RegistrationDataService; import org.dspace.orcid.OrcidToken; import org.dspace.orcid.client.OrcidClient; import org.dspace.orcid.client.OrcidConfiguration; @@ -47,11 +50,15 @@ import org.springframework.beans.factory.annotation.Autowired; * ORCID authentication for DSpace. * * @author Luca Giamminonni (luca.giamminonni at 4science.it) - * */ public class OrcidAuthenticationBean implements AuthenticationMethod { + + public static final String ORCID_DEFAULT_FIRSTNAME = "Unnamed"; + public static final String ORCID_DEFAULT_LASTNAME = ORCID_DEFAULT_FIRSTNAME; public static final String ORCID_AUTH_ATTRIBUTE = "orcid-authentication"; + public static final String ORCID_REGISTRATION_TOKEN = "orcid-registration-token"; + public static final String ORCID_DEFAULT_REGISTRATION_URL = "/external-login/{0}"; private final static Logger LOGGER = LogManager.getLogger(); @@ -78,6 +85,9 @@ public class OrcidAuthenticationBean implements AuthenticationMethod { @Autowired private OrcidTokenService orcidTokenService; + @Autowired + private RegistrationDataService registrationDataService; + @Override public int authenticate(Context context, String username, String password, String realm, HttpServletRequest request) throws SQLException { @@ -183,7 +193,7 @@ public class OrcidAuthenticationBean implements AuthenticationMethod { return ePerson.canLogIn() ? logInEPerson(context, token, ePerson) : BAD_ARGS; } - return canSelfRegister() ? registerNewEPerson(context, person, token) : NO_SUCH_USER; + return canSelfRegister() ? createRegistrationData(context, request, person, token) : NO_SUCH_USER; } @@ -211,48 +221,59 @@ public class OrcidAuthenticationBean implements AuthenticationMethod { } } - private int registerNewEPerson(Context context, Person person, OrcidTokenResponseDTO token) throws SQLException { + private int createRegistrationData( + Context context, HttpServletRequest request, Person person, OrcidTokenResponseDTO token + ) throws SQLException { try { context.turnOffAuthorisationSystem(); - String email = getEmail(person) - .orElseThrow(() -> new IllegalStateException("The email is configured private on orcid")); + RegistrationData registrationData = + this.registrationDataService.create(context, token.getOrcid(), RegistrationTypeEnum.ORCID); - String orcid = token.getOrcid(); + registrationData.setEmail(getEmail(person).orElse(null)); + setOrcidMetadataOnRegistration(context, registrationData, person, token); - EPerson eperson = ePersonService.create(context); + registrationDataService.update(context, registrationData); - eperson.setNetid(orcid); - - eperson.setEmail(email); - - Optional firstName = getFirstName(person); - if (firstName.isPresent()) { - eperson.setFirstName(context, firstName.get()); - } - - Optional lastName = getLastName(person); - if (lastName.isPresent()) { - eperson.setLastName(context, lastName.get()); - } - eperson.setCanLogIn(true); - eperson.setSelfRegistered(true); - - setOrcidMetadataOnEPerson(context, eperson, token); - - ePersonService.update(context, eperson); - context.setCurrentUser(eperson); + request.setAttribute(ORCID_REGISTRATION_TOKEN, registrationData.getToken()); + context.commit(); context.dispatchEvents(); - return SUCCESS; - } catch (Exception ex) { LOGGER.error("An error occurs registering a new EPerson from ORCID", ex); context.rollback(); - return NO_SUCH_USER; } finally { context.restoreAuthSystemState(); + return NO_SUCH_USER; + } + } + + private void setOrcidMetadataOnRegistration( + Context context, RegistrationData registration, Person person, OrcidTokenResponseDTO token + ) throws SQLException, AuthorizeException { + String orcid = token.getOrcid(); + + setRegistrationMetadata(context, registration, "eperson.firstname", getFirstName(person)); + setRegistrationMetadata(context, registration, "eperson.lastname", getLastName(person)); + registrationDataService.setRegistrationMetadataValue(context, registration, "eperson", "orcid", null, orcid); + + for (String scope : token.getScopeAsArray()) { + registrationDataService.addMetadata(context, registration, "eperson", "orcid", "scope", scope); + } + } + + private void setRegistrationMetadata( + Context context, RegistrationData registration, String metadataString, String value) { + String[] split = metadataString.split("\\."); + String qualifier = split.length > 2 ? split[2] : null; + try { + registrationDataService.setRegistrationMetadataValue( + context, registration, split[0], split[1], qualifier, value + ); + } catch (SQLException | AuthorizeException ex) { + LOGGER.error("An error occurs setting metadata", ex); + throw new RuntimeException(ex); } } @@ -296,16 +317,20 @@ public class OrcidAuthenticationBean implements AuthenticationMethod { return Optional.ofNullable(emails.get(0).getEmail()); } - private Optional getFirstName(Person person) { + private String getFirstName(Person person) { return Optional.ofNullable(person.getName()) - .map(name -> name.getGivenNames()) - .map(givenNames -> givenNames.getContent()); + .map(name -> name.getGivenNames()) + .map(givenNames -> givenNames.getContent()) + .filter(StringUtils::isNotBlank) + .orElse(ORCID_DEFAULT_FIRSTNAME); } - private Optional getLastName(Person person) { + private String getLastName(Person person) { return Optional.ofNullable(person.getName()) - .map(name -> name.getFamilyName()) - .map(givenNames -> givenNames.getContent()); + .map(name -> name.getFamilyName()) + .map(givenNames -> givenNames.getContent()) + .filter(StringUtils::isNotBlank) + .orElse(ORCID_DEFAULT_LASTNAME); } private boolean canSelfRegister() { diff --git a/dspace-api/src/main/java/org/dspace/content/AccessStatus.java b/dspace-api/src/main/java/org/dspace/content/AccessStatus.java new file mode 100644 index 0000000000..83924b5770 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/content/AccessStatus.java @@ -0,0 +1,64 @@ +/** + * 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.content; + +import java.time.LocalDate; + +/** + * Utility class for access status + */ +public class AccessStatus { + /** + * the status value + */ + private String status; + + /** + * the availability date if required + */ + private LocalDate availabilityDate; + + /** + * Construct a new access status + * + * @param status the status value + * @param availabilityDate the availability date + */ + public AccessStatus(String status, LocalDate availabilityDate) { + this.status = status; + this.availabilityDate = availabilityDate; + } + + /** + * @return Returns the status value. + */ + public String getStatus() { + return status; + } + + /** + * @param status The status value. + */ + public void setStatus(String status) { + this.status = status; + } + + /** + * @return Returns the availability date. + */ + public LocalDate getAvailabilityDate() { + return availabilityDate; + } + + /** + * @param availabilityDate The availability date. + */ + public void setAvailabilityDate(LocalDate availabilityDate) { + this.availabilityDate = availabilityDate; + } +} diff --git a/dspace-api/src/main/java/org/dspace/core/Utils.java b/dspace-api/src/main/java/org/dspace/core/Utils.java index 352081efeb..7c90309619 100644 --- a/dspace-api/src/main/java/org/dspace/core/Utils.java +++ b/dspace-api/src/main/java/org/dspace/core/Utils.java @@ -24,6 +24,8 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.ParseException; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; @@ -478,4 +480,24 @@ public final class Utils { ConfigurationService config = DSpaceServicesFactory.getInstance().getConfigurationService(); return StringSubstitutor.replace(string, config.getProperties()); } + + /** + * Get the maximum timestamp that can be stored in a PostgreSQL database with hibernate, + * for our "distant future" access expiry date. + * @return the maximum timestamp that can be stored with Postgres + Hibernate + */ + public static Instant getMaxTimestamp() { + return LocalDateTime.of(294276, 12, 31, 23, 59, 59) + .toInstant(ZoneOffset.UTC); + } + + /** + * Get the minimum timestamp that can be stored in a PostgreSQL database, for date validation or any other + * purpose to ensure we don't try to store a date before the epoch. + * @return the minimum timestamp that can be stored with Postgres + Hibernate + */ + public static Instant getMinTimestamp() { + return LocalDateTime.of(-4713, 11, 12, 0, 0, 0) + .toInstant(ZoneOffset.UTC); + } } diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceIndexAccessStatusPlugin.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceIndexAccessStatusPlugin.java index f086eeadf5..f32c3261d3 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceIndexAccessStatusPlugin.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceIndexAccessStatusPlugin.java @@ -13,6 +13,7 @@ import org.apache.solr.common.SolrInputDocument; import org.dspace.access.status.DefaultAccessStatusHelper; import org.dspace.access.status.factory.AccessStatusServiceFactory; import org.dspace.access.status.service.AccessStatusService; +import org.dspace.content.AccessStatus; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.discovery.indexobject.IndexableItem; @@ -61,6 +62,7 @@ public class SolrServiceIndexAccessStatusPlugin implements SolrServiceIndexPlugi UNKNOWN = "unknown" */ private String retrieveItemAccessStatus(Context context, Item item) throws SQLException { - return accessStatusService.getAccessStatus(context, item); + AccessStatus accessStatus = accessStatusService.getAccessStatus(context, item); + return accessStatus.getStatus(); } } diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/LDNMessageEntityIndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/LDNMessageEntityIndexFactoryImpl.java index c10272dcca..a3905d662a 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/LDNMessageEntityIndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/LDNMessageEntityIndexFactoryImpl.java @@ -25,6 +25,7 @@ import org.dspace.app.ldn.service.LDNMessageService; import org.dspace.content.Item; import org.dspace.content.service.ItemService; import org.dspace.core.Context; +import org.dspace.util.SolrUtils; import org.springframework.beans.factory.annotation.Autowired; /** @@ -146,7 +147,7 @@ public class LDNMessageEntityIndexFactoryImpl extends IndexFactoryImpl> allowedMergeArguments = + Map.of( + "email", + (RegistrationData registrationData, EPerson eperson) -> eperson.setEmail(registrationData.getEmail()) + ); + @Autowired(required = true) protected EPersonService ePersonService; + @Autowired(required = true) protected RegistrationDataService registrationDataService; @Autowired private ConfigurationService configurationService; + @Autowired + private GroupService groupService; + @Autowired private AuthenticationService authenticationService; + @Autowired + private MetadataValueService metadataValueService; + protected AccountServiceImpl() { } @@ -86,7 +114,7 @@ public class AccountServiceImpl implements AccountService { if (!authenticationService.canSelfRegister(context, null, email)) { throw new IllegalStateException("self registration is not allowed with this email address"); } - sendInfo(context, email, true, true); + sendInfo(context, email, RegistrationTypeEnum.REGISTER, true); } /** @@ -110,9 +138,27 @@ public class AccountServiceImpl implements AccountService { */ @Override public void sendForgotPasswordInfo(Context context, String email) - throws SQLException, IOException, MessagingException, - AuthorizeException { - sendInfo(context, email, false, true); + throws SQLException, IOException, MessagingException, AuthorizeException { + sendInfo(context, email, RegistrationTypeEnum.FORGOT, true); + } + + /** + * Checks if exists an account related to the token provided + * + * @param context DSpace context + * @param token Account token + * @return true if exists, false otherwise + * @throws SQLException + * @throws AuthorizeException + */ + @Override + public boolean existsAccountFor(Context context, String token) throws SQLException, AuthorizeException { + return getEPerson(context, token) != null; + } + + @Override + public boolean existsAccountWithEmail(Context context, String email) throws SQLException { + return ePersonService.findByEmail(context, email) != null; } /** @@ -179,6 +225,271 @@ public class AccountServiceImpl implements AccountService { registrationDataService.deleteByToken(context, token); } + @Override + public EPerson mergeRegistration(Context context, UUID personId, String token, List overrides) + throws AuthorizeException, SQLException { + + RegistrationData registrationData = getRegistrationData(context, token); + EPerson eperson = null; + if (personId != null) { + eperson = ePersonService.findByIdOrLegacyId(context, personId.toString()); + } + + if (!canCreateUserBy(context, registrationData.getRegistrationType())) { + throw new AuthorizeException("Token type invalid for the current user."); + } + + if (hasLoggedEPerson(context) && !isSameContextEPerson(context, eperson)) { + throw new AuthorizeException("Only the user with id: " + personId + " can make this action."); + } + + context.turnOffAuthorisationSystem(); + + eperson = Optional.ofNullable(eperson).orElseGet(() -> createEPerson(context, registrationData)); + updateValuesFromRegistration(context, eperson, registrationData, overrides); + deleteToken(context, token); + ePersonService.update(context, eperson); + + context.commit(); + context.restoreAuthSystemState(); + + return eperson; + } + + private EPerson createEPerson(Context context, RegistrationData registrationData) { + EPerson eperson; + try { + eperson = ePersonService.create(context); + + eperson.setNetid(registrationData.getNetId()); + eperson.setEmail(registrationData.getEmail()); + + RegistrationDataMetadata firstName = + registrationDataService.getMetadataByMetadataString( + registrationData, + "eperson.firstname" + ); + if (firstName != null) { + eperson.setFirstName(context, firstName.getValue()); + } + + RegistrationDataMetadata lastName = + registrationDataService.getMetadataByMetadataString( + registrationData, + "eperson.lastname" + ); + if (lastName != null) { + eperson.setLastName(context, lastName.getValue()); + } + eperson.setCanLogIn(true); + eperson.setSelfRegistered(true); + } catch (SQLException | AuthorizeException e) { + throw new RuntimeException( + "Cannote create the eperson linked to the token: " + registrationData.getToken(), + e + ); + } + return eperson; + } + + private boolean hasLoggedEPerson(Context context) { + return context.getCurrentUser() != null; + } + + private boolean isSameContextEPerson(Context context, EPerson eperson) { + return context.getCurrentUser().equals(eperson); + } + + @Override + public RegistrationData renewRegistrationForEmail( + Context context, RegistrationDataPatch registrationDataPatch + ) throws AuthorizeException { + try { + RegistrationData newRegistration = registrationDataService.clone(context, registrationDataPatch); + registrationDataService.delete(context, registrationDataPatch.getOldRegistration()); + sendRegistationLinkByEmail(context, newRegistration); + return newRegistration; + } catch (SQLException | MessagingException | IOException e) { + log.error(e); + throw new RuntimeException(e); + } + } + + private boolean isEmailConfirmed(RegistrationData oldRegistration, String email) { + return email.equals(oldRegistration.getEmail()); + } + + @Override + public boolean isTokenValidForCreation(RegistrationData registrationData) { + return ( + isExternalRegistrationToken(registrationData.getRegistrationType()) || + isValidationToken(registrationData.getRegistrationType()) + ) && + StringUtils.isNotBlank(registrationData.getNetId()); + } + + private boolean canCreateUserBy(Context context, RegistrationTypeEnum registrationTypeEnum) { + return isValidationToken(registrationTypeEnum) || + canCreateUserFromExternalRegistrationToken(context, registrationTypeEnum); + } + + private static boolean canCreateUserFromExternalRegistrationToken( + Context context, RegistrationTypeEnum registrationTypeEnum + ) { + return context.getCurrentUser() != null && isExternalRegistrationToken(registrationTypeEnum); + } + + private static boolean isExternalRegistrationToken(RegistrationTypeEnum registrationTypeEnum) { + return RegistrationTypeEnum.ORCID.equals(registrationTypeEnum); + } + + private static boolean isValidationToken(RegistrationTypeEnum registrationTypeEnum) { + return RegistrationTypeEnum.VALIDATION_ORCID.equals(registrationTypeEnum); + } + + + /** + * Updates Eperson using the provided {@link RegistrationData}.
+ * Tries to replace {@code metadata} already set inside the {@link EPerson} with the ones + * listed inside the {@code overrides} field by taking the value from the {@link RegistrationData}.
+ * Updates the empty values inside the {@link EPerson} by taking them directly from the {@link RegistrationData}, + * according to the method {@link AccountServiceImpl#getUpdateActions(Context, EPerson, RegistrationData)} + * + * @param context The DSpace context + * @param eperson The EPerson that should be updated + * @param registrationData The RegistrationData related to that EPerson + * @param overrides List of metadata that will be overwritten inside the EPerson + */ + protected void updateValuesFromRegistration( + Context context, EPerson eperson, RegistrationData registrationData, List overrides + ) { + Stream.concat( + getMergeActions(registrationData, overrides), + getUpdateActions(context, eperson, registrationData) + ).forEach(c -> c.accept(eperson)); + } + + private Stream> getMergeActions(RegistrationData registrationData, List overrides) { + if (overrides == null || overrides.isEmpty()) { + return Stream.empty(); + } + return overrides.stream().map(f -> mergeField(f, registrationData)); + } + + /** + * This methods tries to fullfill missing values inside the {@link EPerson} by taking them directly from the + * {@link RegistrationData}.
+ * Returns a {@link Stream} of consumers that will be evaluated on an {@link EPerson}, this stream contains + * the following actions: + *

    + *
  • Copies {@code netId} and {@code email} to the {@link EPerson}
  • + *
  • Copies any {@link RegistrationData#metadata} inside {@link EPerson#metadata} if isn't already set.
  • + *
+ * + * @param context DSpace context + * @param eperson EPerson that will be evaluated + * @param registrationData RegistrationData used as a base to copy value from. + * @return a stream of consumers to be evaluated on EPerson. + */ + protected Stream> getUpdateActions( + Context context, EPerson eperson, RegistrationData registrationData + ) { + Stream.Builder> actions = Stream.builder(); + if (eperson.getNetid() == null) { + actions.add(p -> p.setNetid(registrationData.getNetId())); + } + if (eperson.getEmail() == null) { + actions.add(p -> p.setEmail(registrationData.getEmail())); + } + for (RegistrationDataMetadata metadatum : registrationData.getMetadata()) { + Optional> epersonMetadata = + Optional.ofNullable( + ePersonService.getMetadataByMetadataString( + eperson, metadatum.getMetadataField().toString('.') + ) + ).filter(l -> !l.isEmpty()); + if (epersonMetadata.isEmpty()) { + actions.add(p -> addMetadataValue(context, metadatum, p)); + } + } + return actions.build(); + } + + private List addMetadataValue(Context context, RegistrationDataMetadata metadatum, EPerson p) { + try { + return ePersonService.addMetadata( + context, p, metadatum.getMetadataField(), Item.ANY, List.of(metadatum.getValue()) + ); + } catch (SQLException e) { + throw new RuntimeException( + "Could not add metadata" + metadatum.getMetadataField() + " to eperson with uuid: " + p.getID(), e); + } + } + + /** + * This method returns a Consumer that will override a given {@link MetadataValue} of the {@link EPerson} by taking + * that directly from the {@link RegistrationData}. + * + * @param field The metadatafield + * @param registrationData The RegistrationData where the metadata wil be taken + * @return a Consumer of the person that will replace that field + */ + protected Consumer mergeField(String field, RegistrationData registrationData) { + return person -> + allowedMergeArguments.getOrDefault( + field, + mergeRegistrationMetadata(field) + ).accept(registrationData, person); + } + + /** + * This method returns a {@link BiConsumer} that can be evaluated on any {@link RegistrationData} and + * {@link EPerson} in order to replace the value of the metadata {@code field} placed on the {@link EPerson} + * by taking the value directly from the {@link RegistrationData}. + * + * @param field The metadata that will be overwritten inside the {@link EPerson} + * @return a BiConsumer + */ + protected BiConsumer mergeRegistrationMetadata(String field) { + return (registrationData, person) -> { + RegistrationDataMetadata registrationMetadata = getMetadataOrThrow(registrationData, field); + MetadataValue metadata = getMetadataOrThrow(person, field); + metadata.setValue(registrationMetadata.getValue()); + ePersonService.setMetadataModified(person); + }; + } + + private RegistrationDataMetadata getMetadataOrThrow(RegistrationData registrationData, String field) { + return registrationDataService.getMetadataByMetadataString(registrationData, field); + } + + private MetadataValue getMetadataOrThrow(EPerson eperson, String field) { + return ePersonService.getMetadataByMetadataString(eperson, field).stream().findFirst() + .orElseThrow( + () -> new IllegalArgumentException( + "Could not find the metadata field: " + field + " for eperson: " + eperson.getID()) + ); + } + + private RegistrationData getRegistrationData(Context context, String token) + throws SQLException, AuthorizeException { + return Optional.ofNullable(registrationDataService.findByToken(context, token)) + .filter(rd -> + isValid(rd) || + !isValidationToken(rd.getRegistrationType()) + ) + .orElseThrow( + () -> new AuthorizeException( + "The registration token: " + token + " is not valid!" + ) + ); + } + + private boolean isValid(RegistrationData rd) { + return registrationDataService.isValid(rd); + } + + /** * THIS IS AN INTERNAL METHOD. THE SEND PARAMETER ALLOWS IT TO BE USED FOR * TESTING PURPOSES. @@ -191,8 +502,7 @@ public class AccountServiceImpl implements AccountService { * * @param context DSpace context * @param email Email address to send the forgot-password email to - * @param isRegister If true, this is for registration; otherwise, it is - * for forgot-password + * @param type Type of registration {@link RegistrationTypeEnum} * @param send If true, send email; otherwise do not send any email * @return null if no EPerson with that email found * @throws SQLException Cannot create registration data in database @@ -200,16 +510,17 @@ public class AccountServiceImpl implements AccountService { * @throws IOException Error reading email template * @throws AuthorizeException Authorization error */ - protected RegistrationData sendInfo(Context context, String email, - boolean isRegister, boolean send) throws SQLException, IOException, - MessagingException, AuthorizeException { + protected RegistrationData sendInfo( + Context context, String email, RegistrationTypeEnum type, boolean send + ) throws SQLException, IOException, MessagingException, AuthorizeException { // See if a registration token already exists for this user - RegistrationData rd = registrationDataService.findByEmail(context, email); - + RegistrationData rd = registrationDataService.findBy(context, email, type); + boolean isRegister = RegistrationTypeEnum.REGISTER.equals(type); // If it already exists, just re-issue it if (rd == null) { rd = registrationDataService.create(context); + rd.setRegistrationType(type); rd.setToken(Utils.generateHexKey()); // don't set expiration date any more @@ -229,7 +540,7 @@ public class AccountServiceImpl implements AccountService { } if (send) { - sendEmail(context, email, isRegister, rd); + fillAndSendEmail(context, email, isRegister, rd); } return rd; @@ -250,7 +561,7 @@ public class AccountServiceImpl implements AccountService { * @throws IOException A general class of exceptions produced by failed or interrupted I/O operations. * @throws SQLException An exception that provides information on a database access error or other errors. */ - protected void sendEmail(Context context, String email, boolean isRegister, RegistrationData rd) + protected void fillAndSendEmail(Context context, String email, boolean isRegister, RegistrationData rd) throws MessagingException, IOException, SQLException { String base = configurationService.getProperty("dspace.ui.url"); @@ -261,11 +572,9 @@ public class AccountServiceImpl implements AccountService { .append(rd.getToken()) .toString(); Locale locale = context.getCurrentLocale(); - Email bean = Email.getEmail(I18nUtil.getEmailFilename(locale, isRegister ? "register" - : "change_password")); - bean.addRecipient(email); - bean.addArgument(specialLink); - bean.send(); + String emailFilename = I18nUtil.getEmailFilename(locale, isRegister ? "register" : "change_password"); + + fillAndSendEmail(email, emailFilename, specialLink); // Breadcrumbs if (log.isInfoEnabled()) { @@ -273,4 +582,64 @@ public class AccountServiceImpl implements AccountService { + " information to " + email); } } + + /** + * This method returns a link that will point to the Angular UI that will be used by the user to complete the + * registration process. + * + * @param base is the UI url of DSpace + * @param rd is the RegistrationData related to the user + * @param subPath is the specific page that will be loaded on the FE + * @return String that represents that link + */ + private static String getSpecialLink(String base, RegistrationData rd, String subPath) { + return new StringBuffer(base) + .append(base.endsWith("/") ? "" : "/") + .append(subPath) + .append("/") + .append(rd.getToken()) + .toString(); + } + + /** + * Fills out a given email template obtained starting from the {@link RegistrationTypeEnum}. + * + * @param context The DSpace Context + * @param rd The RegistrationData that will be used as a registration. + * @throws MessagingException + * @throws IOException + */ + protected void sendRegistationLinkByEmail( + Context context, RegistrationData rd + ) throws MessagingException, IOException { + String base = configurationService.getProperty("dspace.ui.url"); + + // Note change from "key=" to "token=" + String specialLink = getSpecialLink(base, rd, rd.getRegistrationType().getLink()); + + String emailFilename = I18nUtil.getEmailFilename( + context.getCurrentLocale(), rd.getRegistrationType().toString().toLowerCase() + ); + + fillAndSendEmail(rd.getEmail(), emailFilename, specialLink); + + log.info(LogMessage.of(() -> "Sent " + rd.getRegistrationType().getLink() + " link to " + rd.getEmail())); + } + + /** + * This method fills out the given email with all the fields and sends out the email. + * + * @param email - The recipient + * @param emailFilename The name of the email + * @param specialLink - The link that will be set inside the email + * @throws IOException + * @throws MessagingException + */ + protected void fillAndSendEmail(String email, String emailFilename, String specialLink) + throws IOException, MessagingException { + Email bean = Email.getEmail(emailFilename); + bean.addRecipient(email); + bean.addArgument(specialLink); + bean.send(); + } } diff --git a/dspace-api/src/main/java/org/dspace/eperson/AltchaCaptchaServiceImpl.java b/dspace-api/src/main/java/org/dspace/eperson/AltchaCaptchaServiceImpl.java new file mode 100644 index 0000000000..ee8d7743a0 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/AltchaCaptchaServiceImpl.java @@ -0,0 +1,113 @@ +/** + * 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.eperson; + +import java.nio.charset.StandardCharsets; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.digest.HmacUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.eperson.service.CaptchaService; +import org.dspace.services.ConfigurationService; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Basic services implementation for a Proof of Work Captcha like Altcha. + * Unlike Google ReCaptcha, there is no remote API or service to rely on, we simply + * compare the client-side crypto puzzle challenge to our own work and pass or fail the validation that way + * See https://altcha.org/docs/server-integration for implementation pseudocode. + * + * @see AltchaCaptchaController for REST impl + * + * @author Kim Shepherd + */ +public class AltchaCaptchaServiceImpl implements CaptchaService { + + private static final Logger log = LogManager.getLogger(AltchaCaptchaServiceImpl.class); + + @Autowired + private ConfigurationService configurationService; + + @PostConstruct + public void init() { + } + + /** + * Process a response string and validate as per interface + * + * @param captchaPayloadHeader reCaptcha token to be validated + * @param action action of reCaptcha + * @throws InvalidReCaptchaException + */ + @Override + public void processResponse(String captchaPayloadHeader, String action) throws InvalidReCaptchaException { + if (!validateAltchaCaptcha(captchaPayloadHeader)) { + throw new InvalidReCaptchaException("ALTCHA captcha validation failed"); + } + } + + /** + * Validate captcha payload by reproducing the work + * @param captchaPayloadHeader header conforming to altcha specs + * See: https://altcha.org/docs/server-integration + * @return + */ + private boolean validateAltchaCaptcha(String captchaPayloadHeader) throws InvalidReCaptchaException { + // Decode base64 string for parsing to json + String captchaPayloadJson = + new String(Base64.decodeBase64(captchaPayloadHeader.getBytes(StandardCharsets.UTF_8))); + // Parse as JSON + JSONObject captchaPayload = new JSONObject(captchaPayloadJson); + // Extract data and validate work + try { + // Make sure the required fields are present + if (captchaPayload.has("challenge") && captchaPayload.has("salt") + && captchaPayload.has("number") && captchaPayload.has("signature") + && captchaPayload.has("algorithm")) { + String algorithm = captchaPayload.getString("algorithm"); + if (!"SHA-256".equals(algorithm)) { + throw new InvalidReCaptchaException("ALTCHA algorithm must be SHA-256, check config and payload"); + } + // Get fields for code readability and debugging + String challenge = captchaPayload.getString("challenge"); + String salt = captchaPayload.getString("salt"); + String number = String.valueOf(captchaPayload.getNumber("number")); + String signature = captchaPayload.getString("signature"); + // Calculate hash + String hash = CaptchaService.calculateHash(salt + number, captchaPayload.getString("algorithm")); + // Get hmacKey + String hmacKey = configurationService.getProperty("altcha.hmac.key"); + if (hmacKey == null) { + log.error("hmac key not found, see: altcha.hmac.key in altcha.cfg"); + throw new InvalidReCaptchaException("hmac key not found"); + } + // HMAC signature, using configured HMAC key and the generated challenge string + String hmac = new HmacUtils("HmacSHA256", hmacKey).hmacHex(challenge); + if (org.apache.commons.lang3.StringUtils.isBlank(hmac)) { + log.error("Error generating HMAC signature"); + // Default, return no content + throw new InvalidReCaptchaException("error generating hmac signature"); + } + // Compare received and expected values + boolean challengeVerified = challenge.equals(hash); + boolean signatureVerified = signature.equals(hmac); + return challengeVerified && signatureVerified; + } + } catch (Exception e) { + // If *any* error is enountered, throw InvalidReCaptchaException + throw new InvalidReCaptchaException("Failed to validate ALTCHA captcha: " + e.getMessage()); + } + + // By default, fail the validation + return false; + } + +} \ No newline at end of file diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationData.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationData.java index 5a2e8c0125..5874e2ebdd 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/RegistrationData.java +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationData.java @@ -8,16 +8,24 @@ package org.dspace.eperson; import java.time.Instant; +import java.util.SortedSet; +import java.util.TreeSet; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.SequenceGenerator; import jakarta.persistence.Table; import org.dspace.core.Context; import org.dspace.core.ReloadableEntity; +import org.hibernate.annotations.SortNatural; /** * Database entity representation of the registrationdata table @@ -34,21 +42,65 @@ public class RegistrationData implements ReloadableEntity { @SequenceGenerator(name = "registrationdata_seq", sequenceName = "registrationdata_seq", allocationSize = 1) private Integer id; - @Column(name = "email", unique = true, length = 64) + /** + * Contains the email used to register the user. + */ + @Column(name = "email", length = 64) private String email; + /** + * Contains the unique id generated fot the user. + */ @Column(name = "token", length = 48) private String token; + /** + * Expiration date of this registration data. + */ @Column(name = "expires") private Instant expires; + /** + * Metadata linked to this registration data + */ + @SortNatural + @OneToMany( + fetch = FetchType.LAZY, + mappedBy = "registrationData", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private SortedSet metadata = new TreeSet<>(); + + /** + * External service used to register the user. + * Allowed values are inside {@link RegistrationTypeEnum} + */ + @Column(name = "registration_type") + @Enumerated(EnumType.STRING) + private RegistrationTypeEnum registrationType; + + /** + * Contains the external id provided by the external service + * accordingly to the registration type. + */ + @Column(name = "net_id", length = 64) + private final String netId; + /** * Protected constructor, create object using: * {@link org.dspace.eperson.service.RegistrationDataService#create(Context)} */ protected RegistrationData() { + this(null); + } + /** + * Protected constructor, create object using: + * {@link org.dspace.eperson.service.RegistrationDataService#create(Context, String)} + */ + protected RegistrationData(String netId) { + this.netId = netId; } public Integer getID() { @@ -59,7 +111,7 @@ public class RegistrationData implements ReloadableEntity { return email; } - void setEmail(String email) { + public void setEmail(String email) { this.email = email; } @@ -78,4 +130,24 @@ public class RegistrationData implements ReloadableEntity { void setExpires(Instant expires) { this.expires = expires; } + + public RegistrationTypeEnum getRegistrationType() { + return registrationType; + } + + public void setRegistrationType(RegistrationTypeEnum registrationType) { + this.registrationType = registrationType; + } + + public SortedSet getMetadata() { + return metadata; + } + + public void setMetadata(SortedSet metadata) { + this.metadata = metadata; + } + + public String getNetId() { + return netId; + } } diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataExpirationConfiguration.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataExpirationConfiguration.java new file mode 100644 index 0000000000..4a9c45dc6b --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataExpirationConfiguration.java @@ -0,0 +1,97 @@ +/** + * 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.eperson; + +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; + +/** + * Singleton that encapsulates the configuration of each different token {@link RegistrationTypeEnum} duration.
+ * Contains also utility methods to compute the expiration date of the registered token. + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationDataExpirationConfiguration { + + private static final String EXPIRATION_PROP = "eperson.registration-data.token.{0}.expiration"; + private static final String DURATION_FORMAT = "PT{0}"; + + public static final RegistrationDataExpirationConfiguration INSTANCE = + new RegistrationDataExpirationConfiguration(); + + public static RegistrationDataExpirationConfiguration getInstance() { + return INSTANCE; + } + + private final Map expirationMap; + + private RegistrationDataExpirationConfiguration() { + this.expirationMap = + Stream.of(RegistrationTypeEnum.values()) + .map(type -> Optional.ofNullable(getDurationOf(type)) + .map(duration -> Map.entry(type, duration)) + .orElse(null) + ) + .filter(Objects::nonNull) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Duration getDurationOf(RegistrationTypeEnum type) { + String format = MessageFormat.format(EXPIRATION_PROP, type.toString().toLowerCase()); + ConfigurationService config = DSpaceServicesFactory.getInstance().getConfigurationService(); + String typeValue = config.getProperty(format); + + if (StringUtils.isBlank(typeValue)) { + return null; + } + + return Duration.parse(MessageFormat.format(DURATION_FORMAT, typeValue)); + } + + /** + * Retrieves the {@link Duration} configuration of a given {@link RegistrationTypeEnum}. + * + * @param type is the type of the given registration token + * @return the {@link Duration} of that specific token. + */ + public Duration getExpiration(RegistrationTypeEnum type) { + return expirationMap.get(type); + } + + /** + * Retrieves the expiration date of the given {@link RegistrationTypeEnum}. + * + * @param type is the RegistrationTypeEnum of the token + * @return a Date that represents the expiration date. + */ + public Instant computeExpirationDate(RegistrationTypeEnum type) { + + if (type == null) { + return null; + } + + Duration duration = this.expirationMap.get(type); + + if (duration == null) { + return null; + } + + return Instant.now().plus(duration); + } + +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadata.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadata.java new file mode 100644 index 0000000000..8a35d3b6ce --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadata.java @@ -0,0 +1,105 @@ +/** + * 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.eperson; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import org.dspace.content.MetadataField; +import org.dspace.core.ReloadableEntity; +import org.hibernate.Length; + +/** + * Metadata related to a registration data {@link RegistrationData} + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +@Entity +@Table(name = "registrationdata_metadata") +public class RegistrationDataMetadata implements ReloadableEntity, Comparable { + + @Id + @Column(name = "registrationdata_metadata_id") + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "registrationdata_metadatavalue_seq") + @SequenceGenerator( + name = "registrationdata_metadatavalue_seq", + sequenceName = "registrationdata_metadatavalue_seq", + allocationSize = 1 + ) + private final Integer id; + + /** + * {@link RegistrationData} linked to this metadata value + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "registrationdata_id") + private RegistrationData registrationData = null; + + /** + * The linked {@link MetadataField} instance + */ + @ManyToOne + @JoinColumn(name = "metadata_field_id") + private MetadataField metadataField = null; + + /** + * Value represented by this {@link RegistrationDataMetadata} instance + * related to the metadataField {@link MetadataField} + */ + @Column(name = "text_value", length = Length.LONG32) + private String value = null; + + /** + * Protected constructor + */ + protected RegistrationDataMetadata() { + id = 0; + } + + + @Override + public Integer getID() { + return id; + } + + public MetadataField getMetadataField() { + return metadataField; + } + + void setMetadataField(MetadataField metadataField) { + this.metadataField = metadataField; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public int compareTo(RegistrationDataMetadata o) { + return Integer.compare(this.id, o.id); + } + + void setRegistrationData(RegistrationData registrationData) { + this.registrationData = registrationData; + } + + public RegistrationData getRegistrationData() { + return registrationData; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadataServiceImpl.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadataServiceImpl.java new file mode 100644 index 0000000000..8d12bafc00 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataMetadataServiceImpl.java @@ -0,0 +1,90 @@ +/** + * 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.eperson; + +import java.sql.SQLException; +import java.util.List; + +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.MetadataField; +import org.dspace.content.service.MetadataFieldService; +import org.dspace.core.Context; +import org.dspace.eperson.dao.RegistrationDataMetadataDAO; +import org.dspace.eperson.service.RegistrationDataMetadataService; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationDataMetadataServiceImpl implements RegistrationDataMetadataService { + + @Autowired + private RegistrationDataMetadataDAO registrationDataMetadataDAO; + + @Autowired + private MetadataFieldService metadataFieldService; + + @Override + public RegistrationDataMetadata create(Context context, RegistrationData registrationData, String schema, + String element, String qualifier, String value) throws SQLException { + return create( + context, registrationData, + metadataFieldService.findByElement(context, schema, element, qualifier), + value + ); + } + + @Override + public RegistrationDataMetadata create(Context context, RegistrationData registrationData, + MetadataField metadataField) throws SQLException { + RegistrationDataMetadata metadata = new RegistrationDataMetadata(); + metadata.setRegistrationData(registrationData); + metadata.setMetadataField(metadataField); + return registrationDataMetadataDAO.create(context, metadata); + } + + @Override + public RegistrationDataMetadata create( + Context context, RegistrationData registrationData, MetadataField metadataField, String value + ) throws SQLException { + RegistrationDataMetadata metadata = new RegistrationDataMetadata(); + metadata.setRegistrationData(registrationData); + metadata.setMetadataField(metadataField); + metadata.setValue(value); + return registrationDataMetadataDAO.create(context, metadata); + } + + @Override + public RegistrationDataMetadata create(Context context) throws SQLException, AuthorizeException { + return registrationDataMetadataDAO.create(context, new RegistrationDataMetadata()); + } + + @Override + public RegistrationDataMetadata find(Context context, int id) throws SQLException { + return registrationDataMetadataDAO.findByID(context, RegistrationDataMetadata.class, id); + } + + @Override + public void update(Context context, RegistrationDataMetadata registrationDataMetadata) + throws SQLException, AuthorizeException { + registrationDataMetadataDAO.save(context, registrationDataMetadata); + } + + @Override + public void update(Context context, List t) throws SQLException, AuthorizeException { + for (RegistrationDataMetadata registrationDataMetadata : t) { + update(context, registrationDataMetadata); + } + } + + @Override + public void delete(Context context, RegistrationDataMetadata registrationDataMetadata) + throws SQLException, AuthorizeException { + registrationDataMetadataDAO.delete(context, registrationDataMetadata); + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataServiceImpl.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataServiceImpl.java index b272751685..76b2a40b7d 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationDataServiceImpl.java @@ -8,13 +8,26 @@ package org.dspace.eperson; import java.sql.SQLException; +import java.time.Instant; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.dspace.authorize.AuthorizeException; +import org.dspace.content.MetadataField; +import org.dspace.content.MetadataValue; +import org.dspace.content.service.MetadataFieldService; import org.dspace.core.Context; +import org.dspace.core.Utils; import org.dspace.eperson.dao.RegistrationDataDAO; +import org.dspace.eperson.dto.RegistrationDataChanges; +import org.dspace.eperson.dto.RegistrationDataPatch; +import org.dspace.eperson.service.RegistrationDataMetadataService; import org.dspace.eperson.service.RegistrationDataService; import org.springframework.beans.factory.annotation.Autowired; @@ -26,19 +39,67 @@ import org.springframework.beans.factory.annotation.Autowired; * @author kevinvandevelde at atmire.com */ public class RegistrationDataServiceImpl implements RegistrationDataService { - @Autowired(required = true) + @Autowired() protected RegistrationDataDAO registrationDataDAO; + @Autowired() + protected RegistrationDataMetadataService registrationDataMetadataService; + + @Autowired() + protected MetadataFieldService metadataFieldService; + + protected RegistrationDataExpirationConfiguration expirationConfiguration = + RegistrationDataExpirationConfiguration.getInstance(); + protected RegistrationDataServiceImpl() { } @Override public RegistrationData create(Context context) throws SQLException, AuthorizeException { - return registrationDataDAO.create(context, new RegistrationData()); + return create(context, null, null); } + @Override + public RegistrationData create(Context context, String netId) throws SQLException, AuthorizeException { + return this.create(context, netId, null); + } + + @Override + public RegistrationData create(Context context, String netId, RegistrationTypeEnum type) + throws SQLException, AuthorizeException { + return registrationDataDAO.create(context, newInstance(netId, type, null)); + } + + private RegistrationData newInstance(String netId, RegistrationTypeEnum type, String email) { + RegistrationData rd = new RegistrationData(netId); + rd.setToken(Utils.generateHexKey()); + rd.setRegistrationType(type); + rd.setExpires(expirationConfiguration.computeExpirationDate(type)); + rd.setEmail(email); + return rd; + } + + @Override + public RegistrationData clone( + Context context, RegistrationDataPatch registrationDataPatch + ) throws SQLException, AuthorizeException { + RegistrationData old = registrationDataPatch.getOldRegistration(); + RegistrationDataChanges changes = registrationDataPatch.getChanges(); + RegistrationData rd = newInstance(old.getNetId(), changes.getRegistrationType(), changes.getEmail()); + + for (RegistrationDataMetadata metadata : old.getMetadata()) { + addMetadata(context, rd, metadata.getMetadataField(), metadata.getValue()); + } + + return registrationDataDAO.create(context, rd); + } + + private boolean isEmailConfirmed(RegistrationData old, String newEmail) { + return newEmail.equals(old.getEmail()); + } + @Override public RegistrationData findByToken(Context context, String token) throws SQLException { return registrationDataDAO.findByToken(context, token); @@ -49,12 +110,124 @@ public class RegistrationDataServiceImpl implements RegistrationDataService { return registrationDataDAO.findByEmail(context, email); } + @Override + public RegistrationData findBy(Context context, String email, RegistrationTypeEnum type) throws SQLException { + return registrationDataDAO.findBy(context, email, type); + } + @Override public void deleteByToken(Context context, String token) throws SQLException { registrationDataDAO.deleteByToken(context, token); } + @Override + public Stream>> groupEpersonMetadataByRegistrationData( + EPerson ePerson, RegistrationData registrationData + ) + throws SQLException { + Map> epersonMeta = + ePerson.getMetadata() + .stream() + .collect( + Collectors.groupingBy( + MetadataValue::getMetadataField + ) + ); + return registrationData.getMetadata() + .stream() + .map(meta -> + Map.entry( + meta, + Optional.ofNullable(epersonMeta.get(meta.getMetadataField())) + .filter(list -> list.size() == 1) + .map(values -> values.get(0)) + ) + ); + } + + @Override + public void setRegistrationMetadataValue( + Context context, RegistrationData registration, String schema, String element, String qualifier, String value + ) throws SQLException, AuthorizeException { + + List metadata = + registration.getMetadata() + .stream() + .filter(m -> areEquals(m, schema, element, qualifier)) + .collect(Collectors.toList()); + + if (metadata.size() > 1) { + throw new IllegalStateException("Find more than one registration metadata to update!"); + } + + RegistrationDataMetadata registrationDataMetadata; + if (metadata.isEmpty()) { + registrationDataMetadata = + createMetadata(context, registration, schema, element, qualifier, value); + } else { + registrationDataMetadata = metadata.get(0); + registrationDataMetadata.setValue(value); + } + registrationDataMetadataService.update(context, registrationDataMetadata); + } + + @Override + public void addMetadata( + Context context, RegistrationData registration, MetadataField mf, String value + ) throws SQLException, AuthorizeException { + registration.getMetadata().add( + registrationDataMetadataService.create(context, registration, mf, value) + ); + this.update(context, registration); + } + + @Override + public void addMetadata( + Context context, RegistrationData registration, String schema, String element, String qualifier, String value + ) throws SQLException, AuthorizeException { + MetadataField mf = metadataFieldService.findByElement(context, schema, element, qualifier); + registration.getMetadata().add( + registrationDataMetadataService.create(context, registration, mf, value) + ); + this.update(context, registration); + } + + @Override + public RegistrationDataMetadata getMetadataByMetadataString(RegistrationData registrationData, String field) { + return registrationData.getMetadata().stream() + .filter(m -> field.equals(m.getMetadataField().toString('.'))) + .findFirst().orElse(null); + } + + private boolean areEquals(RegistrationDataMetadata m, String schema, String element, String qualifier) { + return m.getMetadataField().getMetadataSchema().getName().equals(schema) + && m.getMetadataField().getElement().equals(element) + && StringUtils.equals(m.getMetadataField().getQualifier(), qualifier); + } + + private RegistrationDataMetadata createMetadata( + Context context, RegistrationData registration, + String schema, String element, String qualifier, + String value + ) { + try { + return registrationDataMetadataService.create( + context, registration, schema, element, qualifier, value + ); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private RegistrationDataMetadata createMetadata(Context context, RegistrationData registration, MetadataField mf) { + try { + return registrationDataMetadataService.create(context, registration, mf); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + @Override public RegistrationData find(Context context, int id) throws SQLException { return registrationDataDAO.findByID(context, RegistrationData.class, id); @@ -75,8 +248,25 @@ public class RegistrationDataServiceImpl implements RegistrationDataService { } } + @Override + public void markAsExpired(Context context, RegistrationData registrationData) throws SQLException { + registrationData.setExpires(Instant.now()); + registrationDataDAO.save(context, registrationData); + } + @Override public void delete(Context context, RegistrationData registrationData) throws SQLException, AuthorizeException { registrationDataDAO.delete(context, registrationData); } + + @Override + public void deleteExpiredRegistrations(Context context) throws SQLException { + registrationDataDAO.deleteExpiredBy(context, Instant.now()); + } + + @Override + public boolean isValid(RegistrationData rd) { + return rd.getExpires() == null || rd.getExpires().isAfter(Instant.now()); + } + } diff --git a/dspace-api/src/main/java/org/dspace/eperson/RegistrationTypeEnum.java b/dspace-api/src/main/java/org/dspace/eperson/RegistrationTypeEnum.java new file mode 100644 index 0000000000..28a594742f --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/RegistrationTypeEnum.java @@ -0,0 +1,33 @@ +/** + * 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.eperson; + +/** + * External provider allowed to register e-persons stored with {@link RegistrationData} + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public enum RegistrationTypeEnum { + + ORCID("external-login"), + VALIDATION_ORCID("review-account"), + FORGOT("forgot"), + REGISTER("register"), + INVITATION("invitation"), + CHANGE_PASSWORD("change-password"); + + private final String link; + + RegistrationTypeEnum(String link) { + this.link = link; + } + + public String getLink() { + return link; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataDAO.java b/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataDAO.java index 5650c5e5b2..d5cb7e4b76 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataDAO.java +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataDAO.java @@ -8,10 +8,12 @@ package org.dspace.eperson.dao; import java.sql.SQLException; +import java.time.Instant; import org.dspace.core.Context; import org.dspace.core.GenericDAO; import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; /** * Database Access Object interface class for the RegistrationData object. @@ -23,9 +25,52 @@ import org.dspace.eperson.RegistrationData; */ public interface RegistrationDataDAO extends GenericDAO { + /** + * Finds {@link RegistrationData} by email. + * + * @param context Context for the current request + * @param email The email + * @return + * @throws SQLException + */ public RegistrationData findByEmail(Context context, String email) throws SQLException; + /** + * Finds {@link RegistrationData} by email and type. + * + * @param context Context for the current request + * @param email The email + * @param type The type of the {@link RegistrationData} + * @return + * @throws SQLException + */ + public RegistrationData findBy(Context context, String email, RegistrationTypeEnum type) throws SQLException; + + /** + * Finds {@link RegistrationData} by token. + * + * @param context the context + * @param token The token related to the {@link RegistrationData}. + * @return + * @throws SQLException + */ public RegistrationData findByToken(Context context, String token) throws SQLException; + /** + * Deletes {@link RegistrationData} by token. + * + * @param context Context for the current request + * @param token The token to delete registrations for + * @throws SQLException + */ public void deleteByToken(Context context, String token) throws SQLException; + + /** + * Deletes expired {@link RegistrationData}. + * + * @param context Context for the current request + * @param instant The date to delete expired registrations for + * @throws SQLException + */ + void deleteExpiredBy(Context context, Instant instant) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataMetadataDAO.java b/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataMetadataDAO.java new file mode 100644 index 0000000000..84ef2989cc --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/RegistrationDataMetadataDAO.java @@ -0,0 +1,22 @@ +/** + * 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.eperson.dao; + +import org.dspace.core.GenericDAO; +import org.dspace.eperson.RegistrationDataMetadata; + +/** + * Database Access Object interface class for the {@link org.dspace.eperson.RegistrationDataMetadata} object. + * The implementation of this class is responsible for all database calls for the RegistrationData object and is + * autowired by spring + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public interface RegistrationDataMetadataDAO extends GenericDAO { + +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataDAOImpl.java b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataDAOImpl.java index 63e87400ce..5914c5319b 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataDAOImpl.java @@ -8,15 +8,18 @@ package org.dspace.eperson.dao.impl; import java.sql.SQLException; +import java.time.Instant; import jakarta.persistence.Query; import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; import org.dspace.core.AbstractHibernateDAO; import org.dspace.core.Context; import org.dspace.eperson.RegistrationData; import org.dspace.eperson.RegistrationData_; +import org.dspace.eperson.RegistrationTypeEnum; import org.dspace.eperson.dao.RegistrationDataDAO; /** @@ -42,6 +45,21 @@ public class RegistrationDataDAOImpl extends AbstractHibernateDAO registrationDataRoot = criteriaQuery.from(RegistrationData.class); + criteriaQuery.select(registrationDataRoot); + criteriaQuery.where( + criteriaBuilder.and( + criteriaBuilder.equal(registrationDataRoot.get(RegistrationData_.email), email), + criteriaBuilder.equal(registrationDataRoot.get(RegistrationData_.registrationType), type) + ) + ); + return uniqueResult(context, criteriaQuery, false, RegistrationData.class); + } + @Override public RegistrationData findByToken(Context context, String token) throws SQLException { CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); @@ -59,4 +77,15 @@ public class RegistrationDataDAOImpl extends AbstractHibernateDAO deleteQuery = criteriaBuilder.createCriteriaDelete(RegistrationData.class); + Root deleteRoot = deleteQuery.from(RegistrationData.class); + deleteQuery.where( + criteriaBuilder.lessThanOrEqualTo(deleteRoot.get(RegistrationData_.expires), instant) + ); + getHibernateSession(context).createQuery(deleteQuery).executeUpdate(); + } } diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataMetadataDAOImpl.java b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataMetadataDAOImpl.java new file mode 100644 index 0000000000..713032b05b --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/RegistrationDataMetadataDAOImpl.java @@ -0,0 +1,19 @@ +/** + * 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.eperson.dao.impl; + +import org.dspace.core.AbstractHibernateDAO; +import org.dspace.eperson.RegistrationDataMetadata; +import org.dspace.eperson.dao.RegistrationDataMetadataDAO; + +/** + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationDataMetadataDAOImpl extends AbstractHibernateDAO + implements RegistrationDataMetadataDAO { +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataChanges.java b/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataChanges.java new file mode 100644 index 0000000000..365d152a8d --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataChanges.java @@ -0,0 +1,64 @@ +/** + * 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.eperson.dto; + +import org.dspace.eperson.RegistrationTypeEnum; + +/** + * Class that embeds a change done for the {@link org.dspace.eperson.RegistrationData} + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationDataChanges { + + @SuppressWarnings("checkstyle:LineLength") + private static final String EMAIL_PATTERN = "^[a-zA-Z0-9.!#$%&'*+\\\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"; + + private final String email; + private final RegistrationTypeEnum registrationType; + + public RegistrationDataChanges(String email, RegistrationTypeEnum type) { + if (email == null || email.trim().isBlank()) { + throw new IllegalArgumentException("Cannot update with an empty email address"); + } + if (type == null) { + throw new IllegalArgumentException("Cannot update with a null registration type"); + } + this.email = email; + if (!isValidEmail()) { + throw new IllegalArgumentException("Invalid email address provided!"); + } + this.registrationType = type; + } + + /** + * Checks if the email is valid using the EMAIL_PATTERN. + * @return true if valid, false otherwise + */ + public boolean isValidEmail() { + return email.matches(EMAIL_PATTERN); + } + + /** + * Returns the email of change. + * + * @return the email of the change + */ + public String getEmail() { + return email; + } + + /** + * Returns the {@link RegistrationTypeEnum} of the registration. + * + * @return the type of the change + */ + public RegistrationTypeEnum getRegistrationType() { + return registrationType; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataPatch.java b/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataPatch.java new file mode 100644 index 0000000000..c259204d48 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dto/RegistrationDataPatch.java @@ -0,0 +1,44 @@ +/** + * 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.eperson.dto; + +import org.dspace.eperson.RegistrationData; + +/** + * This POJO encapsulates the details of the PATCH request that updates the {@link RegistrationData}. + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationDataPatch { + + private final RegistrationData oldRegistration; + private final RegistrationDataChanges changes; + + public RegistrationDataPatch(RegistrationData oldRegistration, RegistrationDataChanges changes) { + this.oldRegistration = oldRegistration; + this.changes = changes; + } + + /** + * Returns the value of the previous registration + * + * @return RegistrationData + */ + public RegistrationData getOldRegistration() { + return oldRegistration; + } + + /** + * Returns the changes related to the registration + * + * @return RegistrationDataChanges + */ + public RegistrationDataChanges getChanges() { + return changes; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactory.java b/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactory.java new file mode 100644 index 0000000000..ac6f5b61d6 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactory.java @@ -0,0 +1,21 @@ +/** + * 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.eperson.factory; + +import org.dspace.eperson.service.CaptchaService; +import org.dspace.services.factory.DSpaceServicesFactory; + +public abstract class CaptchaServiceFactory { + + public abstract CaptchaService getCaptchaService(); + + public static CaptchaServiceFactory getInstance() { + return DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName("captchaServiceFactory", CaptchaServiceFactory.class); + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactoryImpl.java b/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactoryImpl.java new file mode 100644 index 0000000000..61196f2419 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/factory/CaptchaServiceFactoryImpl.java @@ -0,0 +1,38 @@ +/** + * 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.eperson.factory; + +import org.dspace.eperson.service.CaptchaService; +import org.dspace.services.ConfigurationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; + +public class CaptchaServiceFactoryImpl extends CaptchaServiceFactory { + + @Autowired + @Qualifier("googleCaptchaService") + private CaptchaService googleCaptchaService; + + @Autowired + @Qualifier("altchaCaptchaService") + private CaptchaService altchaCaptchaService; + + @Autowired + private ConfigurationService configurationService; + + @Override + public CaptchaService getCaptchaService() { + String provider = configurationService.getProperty("captcha.provider"); + + if ("altcha".equalsIgnoreCase(provider)) { + return altchaCaptchaService; + } + + return googleCaptchaService; // default to Google ReCaptcha + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactory.java b/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactory.java index b80c37f13f..e81f15ac2c 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactory.java +++ b/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactory.java @@ -10,6 +10,7 @@ package org.dspace.eperson.factory; import org.dspace.eperson.service.AccountService; import org.dspace.eperson.service.EPersonService; import org.dspace.eperson.service.GroupService; +import org.dspace.eperson.service.RegistrationDataMetadataService; import org.dspace.eperson.service.RegistrationDataService; import org.dspace.eperson.service.SubscribeService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -28,6 +29,8 @@ public abstract class EPersonServiceFactory { public abstract RegistrationDataService getRegistrationDataService(); + public abstract RegistrationDataMetadataService getRegistrationDAtaDataMetadataService(); + public abstract AccountService getAccountService(); public abstract SubscribeService getSubscribeService(); diff --git a/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactoryImpl.java b/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactoryImpl.java index c4a6cbe996..30fd09d929 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/factory/EPersonServiceFactoryImpl.java @@ -10,6 +10,7 @@ package org.dspace.eperson.factory; import org.dspace.eperson.service.AccountService; import org.dspace.eperson.service.EPersonService; import org.dspace.eperson.service.GroupService; +import org.dspace.eperson.service.RegistrationDataMetadataService; import org.dspace.eperson.service.RegistrationDataService; import org.dspace.eperson.service.SubscribeService; import org.springframework.beans.factory.annotation.Autowired; @@ -29,6 +30,8 @@ public class EPersonServiceFactoryImpl extends EPersonServiceFactory { @Autowired(required = true) private RegistrationDataService registrationDataService; @Autowired(required = true) + private RegistrationDataMetadataService registrationDataMetadataService; + @Autowired(required = true) private AccountService accountService; @Autowired(required = true) private SubscribeService subscribeService; @@ -58,4 +61,8 @@ public class EPersonServiceFactoryImpl extends EPersonServiceFactory { return subscribeService; } + @Override + public RegistrationDataMetadataService getRegistrationDAtaDataMetadataService() { + return registrationDataMetadataService; + } } diff --git a/dspace-api/src/main/java/org/dspace/eperson/service/AccountService.java b/dspace-api/src/main/java/org/dspace/eperson/service/AccountService.java index 637b81c41d..7025afddf4 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/service/AccountService.java +++ b/dspace-api/src/main/java/org/dspace/eperson/service/AccountService.java @@ -9,11 +9,15 @@ package org.dspace.eperson.service; import java.io.IOException; import java.sql.SQLException; +import java.util.List; +import java.util.UUID; import jakarta.mail.MessagingException; import org.dspace.authorize.AuthorizeException; import org.dspace.core.Context; import org.dspace.eperson.EPerson; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.dto.RegistrationDataPatch; /** * Methods for handling registration by email and forgotten passwords. When @@ -30,20 +34,79 @@ import org.dspace.eperson.EPerson; * @version $Revision$ */ public interface AccountService { - public void sendRegistrationInfo(Context context, String email) throws SQLException, IOException, MessagingException, AuthorizeException; public void sendForgotPasswordInfo(Context context, String email) throws SQLException, IOException, MessagingException, AuthorizeException; + /** + * Checks if exists an account related to the token provided + * + * @param context DSpace context + * @param token Account token + * @return true if exists, false otherwise + * @throws SQLException + * @throws AuthorizeException + */ + boolean existsAccountFor(Context context, String token) + throws SQLException, AuthorizeException; + + /** + * Checks if exists an account related to the email provided + * + * @param context DSpace context + * @param email String email to search for + * @return true if exists, false otherwise + * @throws SQLException + */ + boolean existsAccountWithEmail(Context context, String email) + throws SQLException; + public EPerson getEPerson(Context context, String token) throws SQLException, AuthorizeException; + public String getEmail(Context context, String token) throws SQLException; - public String getEmail(Context context, String token) - throws SQLException; + public void deleteToken(Context context, String token) throws SQLException; - public void deleteToken(Context context, String token) - throws SQLException; + /** + * Merge registration data with an existing EPerson or create a new one. + * + * @param context DSpace context + * @param userId The ID of the EPerson to merge with or create + * @param token The token to use for registration data + * @param overrides List of fields to override in the EPerson + * @return The merged or created EPerson + * @throws AuthorizeException If the user is not authorized to perform the action + * @throws SQLException If a database error occurs + */ + EPerson mergeRegistration( + Context context, + UUID userId, + String token, + List overrides + ) throws AuthorizeException, SQLException; + + /** + * This method creates a fresh new {@link RegistrationData} based on the {@link RegistrationDataPatch} requested + * by a given user. + * + * @param context - The DSapce Context + * @param registrationDataPatch - Details of the patch request coming from the Controller + * @return a newly created {@link RegistrationData} + * @throws AuthorizeException + */ + RegistrationData renewRegistrationForEmail( + Context context, + RegistrationDataPatch registrationDataPatch + ) throws AuthorizeException; + + /** + * Checks if the {@link RegistrationData#token} is valid. + * + * @param registrationData that will be checked + * @return true if valid, false otherwise + */ + boolean isTokenValidForCreation(RegistrationData registrationData); } diff --git a/dspace-api/src/main/java/org/dspace/eperson/service/CaptchaService.java b/dspace-api/src/main/java/org/dspace/eperson/service/CaptchaService.java index da417facc6..99d34bf8cf 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/service/CaptchaService.java +++ b/dspace-api/src/main/java/org/dspace/eperson/service/CaptchaService.java @@ -7,6 +7,9 @@ */ package org.dspace.eperson.service; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + import org.dspace.eperson.InvalidReCaptchaException; /** @@ -27,4 +30,30 @@ public interface CaptchaService { */ public void processResponse(String response, String action) throws InvalidReCaptchaException; + /** + * Encode bytes to hex string + * @param bytes bytes to encode + * @return hex string + */ + public static String bytesToHex(byte[] bytes) { + StringBuilder stringBuilder = new StringBuilder(); + for (byte b : bytes) { + stringBuilder.append(String.format("%02x", b)); + } + return stringBuilder.toString(); + } + + /** + * Calculate a hex string from a digest, given an input string + * @param input input string + * @param algorithm algorithm key, eg. SHA-256 + * @return + * @throws NoSuchAlgorithmException + */ + public static String calculateHash(String input, String algorithm) throws NoSuchAlgorithmException { + MessageDigest sha256 = MessageDigest.getInstance(algorithm); + byte[] hashBytes = sha256.digest(input.getBytes()); + return bytesToHex(hashBytes); + } + } \ No newline at end of file diff --git a/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataMetadataService.java b/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataMetadataService.java new file mode 100644 index 0000000000..d2531a0ac2 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataMetadataService.java @@ -0,0 +1,66 @@ +/** + * 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.eperson.service; + +import java.sql.SQLException; + +import org.dspace.content.MetadataField; +import org.dspace.core.Context; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationDataMetadata; +import org.dspace.service.DSpaceCRUDService; + +/** + * This class contains business-logic to handle {@link RegistrationDataMetadata}. + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public interface RegistrationDataMetadataService extends DSpaceCRUDService { + + /** + * Creates a new {@link RegistrationDataMetadata} that will be stored starting from the parameters of the method. + * + * @param context - the DSpace Context + * @param registrationData - the Registration data that will contain the metadata + * @param schema - the schema of the metadata field + * @param element - the element of the metadata field + * @param qualifier - the qualifier of the metadata field + * @param value - the value of that metadata + * @return the newly created RegistrationDataMetadata + * @throws SQLException + */ + RegistrationDataMetadata create(Context context, RegistrationData registrationData, String schema, + String element, String qualifier, String value) throws SQLException; + + /** + * Creates a new {@link RegistrationDataMetadata} + * + * @param context - the DSpace Context + * @param registrationData - the RegistrationData that will contain that metadata + * @param metadataField - the metadataField + * @return the newly created RegistrationDataMetadata + * @throws SQLException + */ + RegistrationDataMetadata create( + Context context, RegistrationData registrationData, MetadataField metadataField + ) throws SQLException; + + /** + * Creates a new {@link RegistrationDataMetadata} + * + * @param context - the DSpace Context + * @param registrationData - the RegistrationData that will contain that metadata + * @param metadataField - the metadataField that will be stored + * @param value - the value that will be placed inside the RegistrationDataMetadata + * @return the newly created {@link RegistrationDataMetadata} + * @throws SQLException + */ + RegistrationDataMetadata create( + Context context, RegistrationData registrationData, MetadataField metadataField, String value + ) throws SQLException; +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataService.java b/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataService.java index d1e78fa2bc..f10da961ca 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataService.java +++ b/dspace-api/src/main/java/org/dspace/eperson/service/RegistrationDataService.java @@ -8,13 +8,23 @@ package org.dspace.eperson.service; import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.MetadataField; +import org.dspace.content.MetadataValue; import org.dspace.core.Context; +import org.dspace.eperson.EPerson; import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationDataMetadata; +import org.dspace.eperson.RegistrationTypeEnum; +import org.dspace.eperson.dto.RegistrationDataPatch; import org.dspace.service.DSpaceCRUDService; /** - * Service interface class for the RegistrationData object. + * Service interface class for the {@link RegistrationData} object. * The implementation of this class is responsible for all business logic calls for the RegistrationData object and * is autowired by spring * @@ -22,10 +32,45 @@ import org.dspace.service.DSpaceCRUDService; */ public interface RegistrationDataService extends DSpaceCRUDService { + RegistrationData create(Context context) throws SQLException, AuthorizeException; + + RegistrationData create(Context context, String netId) throws SQLException, AuthorizeException; + + RegistrationData create(Context context, String netId, RegistrationTypeEnum type) + throws SQLException, AuthorizeException; + + RegistrationData clone( + Context context, RegistrationDataPatch registrationDataPatch + ) throws SQLException, AuthorizeException; + public RegistrationData findByToken(Context context, String token) throws SQLException; public RegistrationData findByEmail(Context context, String email) throws SQLException; + RegistrationData findBy(Context context, String email, RegistrationTypeEnum type) throws SQLException; + public void deleteByToken(Context context, String token) throws SQLException; + Stream>> groupEpersonMetadataByRegistrationData( + EPerson ePerson, RegistrationData registrationData + ) throws SQLException; + + void setRegistrationMetadataValue( + Context context, RegistrationData registration, String schema, String element, String qualifier, String value + ) throws SQLException, AuthorizeException; + + void addMetadata( + Context context, RegistrationData registration, String schema, String element, String qualifier, String value + ) throws SQLException, AuthorizeException; + + RegistrationDataMetadata getMetadataByMetadataString(RegistrationData registrationData, String field); + + void addMetadata(Context context, RegistrationData rd, MetadataField metadataField, String value) + throws SQLException, AuthorizeException; + + void markAsExpired(Context context, RegistrationData registrationData) throws SQLException, AuthorizeException; + + void deleteExpiredRegistrations(Context context) throws SQLException; + + boolean isValid(RegistrationData rd); } diff --git a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java index f976864d07..554abc5404 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java @@ -36,10 +36,12 @@ import org.dspace.discovery.SearchServiceException; import org.dspace.discovery.indexobject.IndexableItem; import org.dspace.eperson.EPerson; import org.dspace.eperson.service.EPersonService; +import org.dspace.orcid.OrcidQueue; import org.dspace.orcid.OrcidToken; import org.dspace.orcid.client.OrcidClient; import org.dspace.orcid.model.OrcidEntityType; import org.dspace.orcid.model.OrcidTokenResponseDTO; +import org.dspace.orcid.service.OrcidQueueService; import org.dspace.orcid.service.OrcidSynchronizationService; import org.dspace.orcid.service.OrcidTokenService; import org.dspace.profile.OrcidEntitySyncPreference; @@ -61,9 +63,13 @@ import org.springframework.beans.factory.annotation.Autowired; public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationService { private static final Logger log = LoggerFactory.getLogger(OrcidSynchronizationServiceImpl.class); + @Autowired private ItemService itemService; + @Autowired + private OrcidQueueService orcidQueueService; + @Autowired private ConfigurationService configurationService; @@ -120,7 +126,6 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ @Override public void unlinkProfile(Context context, Item profile) throws SQLException { - clearOrcidProfileMetadata(context, profile); clearSynchronizationSettings(context, profile); @@ -129,6 +134,11 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ updateItem(context, profile); + List queueRecords = orcidQueueService.findByProfileItemId(context, profile.getID()); + for (OrcidQueue queueRecord : queueRecords) { + orcidQueueService.delete(context, queueRecord); + } + } private void clearOrcidToken(Context context, Item profile) { diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql new file mode 100644 index 0000000000..09b52ef74b --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql @@ -0,0 +1,13 @@ +-- +-- 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/ +-- + +-- Add new access_token column to hold a secure access token for the requestor to use for weblink-based access +ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_token VARCHAR(48); +-- Add new access_expiry DATESTAMP column to hold the expiry date of the access token +-- (note this is separate from the existing 'expires' column which was intended as the expiry date of the request itself) +ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_expiry TIMESTAMP; diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.03.12__registration_data.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.03.12__registration_data.sql new file mode 100644 index 0000000000..f568b4731a --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.0_2025.03.12__registration_data.sql @@ -0,0 +1,46 @@ +-- +-- 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/ +-- + +----------------------------------------------------------------------------------- +-- ALTER table registrationdata +----------------------------------------------------------------------------------- + +EXECUTE IMMEDIATE 'ALTER TABLE registrationdata DROP CONSTRAINT ' || + QUOTE_IDENT((SELECT CONSTRAINT_NAME + FROM information_schema.key_column_usage + WHERE TABLE_SCHEMA = 'PUBLIC' AND TABLE_NAME = 'REGISTRATIONDATA' AND COLUMN_NAME = 'EMAIL')); + +ALTER TABLE registrationdata +ADD COLUMN registration_type VARCHAR2(255); + +ALTER TABLE registrationdata +ADD COLUMN net_id VARCHAR2(64); + +CREATE SEQUENCE IF NOT EXISTS registrationdata_metadatavalue_seq START WITH 1 INCREMENT BY 1; + +----------------------------------------------------------------------------------- +-- Creates table registrationdata_metadata +----------------------------------------------------------------------------------- + +CREATE TABLE registrationdata_metadata ( + registrationdata_metadata_id INTEGER NOT NULL, + registrationdata_id INTEGER, + metadata_field_id INTEGER, + text_value CLOB, + CONSTRAINT pk_registrationdata_metadata PRIMARY KEY (registrationdata_metadata_id) +); + +ALTER TABLE registrationdata_metadata +ADD CONSTRAINT FK_REGISTRATIONDATA_METADATA_ON_METADATA_FIELD + FOREIGN KEY (metadata_field_id) + REFERENCES metadatafieldregistry (metadata_field_id) ON DELETE CASCADE; + +ALTER TABLE registrationdata_metadata +ADD CONSTRAINT FK_REGISTRATIONDATA_METADATA_ON_REGISTRATIONDATA + FOREIGN KEY (registrationdata_id) + REFERENCES registrationdata (registrationdata_id) ON DELETE CASCADE; diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql new file mode 100644 index 0000000000..1082b25a49 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.02.13__add_access_colums_to_requestitem_table.sql @@ -0,0 +1,13 @@ +-- +-- 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/ +-- + +-- Add new access_token column to hold a secure access token for the requestor to use for weblink-based access +ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_token VARCHAR(48); +-- Add new access_expiry DATESTAMP column to hold the expiry date of the access token +-- (note this is separate from the existing 'expires' column which was intended as the expiry date of the request itself) +ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_expiry TIMESTAMP; \ No newline at end of file diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.03.12__registration_data.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.03.12__registration_data.sql new file mode 100644 index 0000000000..6ada0f35fd --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.0_2025.03.12__registration_data.sql @@ -0,0 +1,52 @@ +-- +-- 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/ +-- + +----------------------------------------------------------------------------------- +-- ALTER table registrationdata +----------------------------------------------------------------------------------- + +DO $$ + BEGIN + EXECUTE 'ALTER TABLE registrationdata DROP CONSTRAINT IF EXISTS ' || + QUOTE_IDENT(( + SELECT CONSTRAINT_NAME + FROM information_schema.key_column_usage + WHERE TABLE_NAME = 'registrationdata' AND COLUMN_NAME = 'email' + )); + end +$$; + +ALTER TABLE registrationdata +ADD COLUMN registration_type VARCHAR(255); + +ALTER TABLE registrationdata +ADD COLUMN net_id VARCHAR(64); + +CREATE SEQUENCE IF NOT EXISTS registrationdata_metadatavalue_seq START WITH 1 INCREMENT BY 1; + +----------------------------------------------------------------------------------- +-- Creates table registrationdata_metadata +----------------------------------------------------------------------------------- + +CREATE TABLE registrationdata_metadata ( + registrationdata_metadata_id INTEGER NOT NULL, + registrationdata_id INTEGER, + metadata_field_id INTEGER, + text_value TEXT, + CONSTRAINT pk_registrationdata_metadata PRIMARY KEY (registrationdata_metadata_id) +); + +ALTER TABLE registrationdata_metadata +ADD CONSTRAINT FK_REGISTRATIONDATA_METADATA_ON_METADATA_FIELD + FOREIGN KEY (metadata_field_id) + REFERENCES metadatafieldregistry (metadata_field_id) ON DELETE CASCADE; + +ALTER TABLE registrationdata_metadata +ADD CONSTRAINT FK_REGISTRATIONDATA_METADATA_ON_REGISTRATIONDATA + FOREIGN KEY (registrationdata_id) + REFERENCES registrationdata (registrationdata_id) ON DELETE CASCADE; diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/captcha.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/captcha.xml new file mode 100644 index 0000000000..124282025b --- /dev/null +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/captcha.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java index 87127f9cf8..2dbffbf5e5 100644 --- a/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java +++ b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java @@ -8,24 +8,35 @@ package org.dspace.access.status; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.time.LocalDate; import org.apache.logging.log4j.Logger; import org.dspace.AbstractUnitTest; import org.dspace.access.status.factory.AccessStatusServiceFactory; import org.dspace.access.status.service.AccessStatusService; import org.dspace.authorize.AuthorizeException; +import org.dspace.content.AccessStatus; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.Item; import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.BitstreamService; +import org.dspace.content.service.BundleService; import org.dspace.content.service.CollectionService; import org.dspace.content.service.CommunityService; import org.dspace.content.service.InstallItemService; import org.dspace.content.service.ItemService; import org.dspace.content.service.WorkspaceItemService; +import org.dspace.core.Constants; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -40,6 +51,8 @@ public class AccessStatusServiceTest extends AbstractUnitTest { private Collection collection; private Community owningCommunity; private Item item; + private Bundle bundle; + private Bitstream bitstream; protected CommunityService communityService = ContentServiceFactory.getInstance().getCommunityService(); @@ -47,6 +60,10 @@ public class AccessStatusServiceTest extends AbstractUnitTest { ContentServiceFactory.getInstance().getCollectionService(); protected ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + protected BundleService bundleService = + ContentServiceFactory.getInstance().getBundleService(); + protected BitstreamService bitstreamService = + ContentServiceFactory.getInstance().getBitstreamService(); protected WorkspaceItemService workspaceItemService = ContentServiceFactory.getInstance().getWorkspaceItemService(); protected InstallItemService installItemService = @@ -71,6 +88,10 @@ public class AccessStatusServiceTest extends AbstractUnitTest { collection = collectionService.create(context, owningCommunity); item = installItemService.installItem(context, workspaceItemService.create(context, collection, true)); + bundle = bundleService.create(context, item, Constants.CONTENT_BUNDLE_NAME); + bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); context.restoreAuthSystemState(); } catch (AuthorizeException ex) { log.error("Authorization Error in init", ex); @@ -78,6 +99,9 @@ public class AccessStatusServiceTest extends AbstractUnitTest { } catch (SQLException ex) { log.error("SQL Error in init", ex); fail("SQL Error in init: " + ex.getMessage()); + } catch (IOException ex) { + log.error("IO Error in init", ex); + fail("IO Error in init: " + ex.getMessage()); } } @@ -92,6 +116,16 @@ public class AccessStatusServiceTest extends AbstractUnitTest { @Override public void destroy() { context.turnOffAuthorisationSystem(); + try { + bitstreamService.delete(context, bitstream); + } catch (Exception e) { + // ignore + } + try { + bundleService.delete(context, bundle); + } catch (Exception e) { + // ignore + } try { itemService.delete(context, item); } catch (Exception e) { @@ -108,6 +142,8 @@ public class AccessStatusServiceTest extends AbstractUnitTest { // ignore } context.restoreAuthSystemState(); + bitstream = null; + bundle = null; item = null; collection = null; owningCommunity = null; @@ -119,8 +155,29 @@ public class AccessStatusServiceTest extends AbstractUnitTest { } @Test - public void testGetAccessStatus() throws Exception { - String status = accessStatusService.getAccessStatus(context, item); - assertNotEquals("testGetAccessStatus 0", status, DefaultAccessStatusHelper.UNKNOWN); + public void testGetAccessStatusItem() throws Exception { + AccessStatus accessStatus = accessStatusService.getAccessStatus(context, item); + String status = accessStatus.getStatus(); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNotEquals("testGetAccessStatusItem 0", status, DefaultAccessStatusHelper.UNKNOWN); + assertNull("testGetAccessStatusItem 1", availabilityDate); + } + + @Test + public void testGetAnonymousAccessStatusItem() throws Exception { + AccessStatus accessStatus = accessStatusService.getAnonymousAccessStatus(context, item); + String status = accessStatus.getStatus(); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNotEquals("testGetAnonymousAccessStatusItem 0", status, DefaultAccessStatusHelper.UNKNOWN); + assertNull("testGetAnonymousAccessStatusItem 1", availabilityDate); + } + + @Test + public void testGetAccessStatusBitstream() throws Exception { + AccessStatus accessStatus = accessStatusService.getAccessStatus(context, bitstream); + String status = accessStatus.getStatus(); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNotEquals("testGetAccessStatusBitstream 0", status, DefaultAccessStatusHelper.UNKNOWN); + assertNull("testGetAccessStatusBitstream 1", availabilityDate); } } diff --git a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java index 1dae57941c..4ad0f5c986 100644 --- a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java +++ b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java @@ -9,6 +9,7 @@ package org.dspace.access.status; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; import java.io.ByteArrayInputStream; @@ -25,6 +26,7 @@ import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.factory.AuthorizeServiceFactory; import org.dspace.authorize.service.ResourcePolicyService; +import org.dspace.content.AccessStatus; import org.dspace.content.Bitstream; import org.dspace.content.Bundle; import org.dspace.content.Collection; @@ -39,8 +41,10 @@ import org.dspace.content.service.InstallItemService; import org.dspace.content.service.ItemService; import org.dspace.content.service.WorkspaceItemService; import org.dspace.core.Constants; +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.junit.After; import org.junit.Before; @@ -83,6 +87,8 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { AuthorizeServiceFactory.getInstance().getResourcePolicyService(); protected GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); + protected EPersonService ePersonService = + EPersonServiceFactory.getInstance().getEPersonService(); /** * This method will be run before every test as per @Before. It will @@ -203,8 +209,13 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { */ @Test public void testWithNullItem() throws Exception { - String status = helper.getAccessStatusFromItem(context, null, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + null, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithNullItem 0", status, equalTo(DefaultAccessStatusHelper.UNKNOWN)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithNullItem 1", availabilityDate); } /** @@ -213,8 +224,13 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { */ @Test public void testWithoutBundle() throws Exception { - String status = helper.getAccessStatusFromItem(context, itemWithoutBundle, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithoutBundle, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithoutBundle 0", status, equalTo(DefaultAccessStatusHelper.METADATA_ONLY)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithoutBundle 1", availabilityDate); } /** @@ -226,8 +242,20 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { context.turnOffAuthorisationSystem(); bundleService.create(context, itemWithoutBitstream, Constants.CONTENT_BUNDLE_NAME); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithoutBitstream, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithoutBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithoutBitstream 0", status, equalTo(DefaultAccessStatusHelper.METADATA_ONLY)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithoutBitstream 1", availabilityDate); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + null, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithoutBitstream 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.UNKNOWN)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertNull("testWithoutBitstream 4", bitstreamAvailabilityDate); } /** @@ -243,8 +271,20 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { bitstream.setName(context, "primary"); bundle.setPrimaryBitstreamID(bitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithBitstream, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithBitstream 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithBitstream 1", availabilityDate); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithBitstream 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertNull("testWithBitstream 4", bitstreamAvailabilityDate); } /** @@ -264,15 +304,26 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { ResourcePolicy policy = resourcePolicyService.create(context, null, group); policy.setRpName("Embargo"); policy.setAction(Constants.READ); - policy.setStartDate(LocalDate.of(9999, 12, 31)); + LocalDate startDate = LocalDate.of(9999, 12, 31); + policy.setStartDate(startDate); policies.add(policy); authorizeService.removeAllPolicies(context, bitstream); authorizeService.addPolicies(context, policies, bitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithEmbargo, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithEmbargo, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithEmbargo 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); - String embargoDate = helper.getEmbargoFromItem(context, itemWithEmbargo, threshold); - assertThat("testWithEmbargo 1", embargoDate, equalTo(policy.getStartDate().toString())); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithEmbargo 1", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithEmbargo 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.EMBARGO)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithEmbargo 4", bitstreamAvailabilityDate, equalTo(startDate)); } /** @@ -292,13 +343,26 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { ResourcePolicy policy = resourcePolicyService.create(context, null, group); policy.setRpName("Restriction"); policy.setAction(Constants.READ); - policy.setStartDate(LocalDate.of(10000, 1, 1)); + LocalDate startDate = LocalDate.of(10000, 1, 1); + policy.setStartDate(startDate); policies.add(policy); authorizeService.removeAllPolicies(context, bitstream); authorizeService.addPolicies(context, policies, bitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithDateRestriction, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithDateRestriction, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithDateRestriction 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithDateRestriction 1", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithDateRestriction 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate bistreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithDateRestriction 4", bistreamAvailabilityDate, equalTo(startDate)); } /** @@ -322,8 +386,20 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { authorizeService.removeAllPolicies(context, bitstream); authorizeService.addPolicies(context, policies, bitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithGroupRestriction, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithGroupRestriction, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithGroupRestriction 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithGroupRestriction 1", availabilityDate, equalTo(threshold)); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithGroupRestriction 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithGroupRestriction 4", bitstreamAvailabilityDate, equalTo(threshold)); } /** @@ -340,8 +416,20 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { bundle.setPrimaryBitstreamID(bitstream); authorizeService.removeAllPolicies(context, bitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithoutPolicy, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithoutPolicy, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithoutPolicy 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithoutPolicy 1", availabilityDate, equalTo(threshold)); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithoutPolicy 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithoutPolicy 4", bitstreamAvailabilityDate, equalTo(threshold)); } /** @@ -356,8 +444,20 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); bitstream.setName(context, "first"); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithoutPrimaryBitstream, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithoutPrimaryBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithoutPrimaryBitstream 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithoutPrimaryBitstream 1", availabilityDate); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithoutPrimaryBitstream 3", bitstreamStatus, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertNull("testWithoutPrimaryBitstream 4", bitstreamAvailabilityDate); } /** @@ -370,7 +470,7 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { context.turnOffAuthorisationSystem(); Bundle bundle = bundleService.create(context, itemWithPrimaryAndMultipleBitstreams, Constants.CONTENT_BUNDLE_NAME); - bitstreamService.create(context, bundle, + Bitstream otherBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); Bitstream primaryBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); @@ -380,15 +480,35 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { ResourcePolicy policy = resourcePolicyService.create(context, null, group); policy.setRpName("Embargo"); policy.setAction(Constants.READ); - policy.setStartDate(LocalDate.of(9999, 12, 31)); + LocalDate startDate = LocalDate.of(9999, 12, 31); + policy.setStartDate(startDate); policies.add(policy); authorizeService.removeAllPolicies(context, primaryBitstream); authorizeService.addPolicies(context, policies, primaryBitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithPrimaryAndMultipleBitstreams, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); assertThat("testWithPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); - String embargoDate = helper.getEmbargoFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); - assertThat("testWithPrimaryAndMultipleBitstreams 1", embargoDate, equalTo(policy.getStartDate().toString())); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithPrimaryAndMultipleBitstreams 1", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream -> primary + AccessStatus accessStatusPrimaryBitstream = helper.getAccessStatusFromBitstream(context, + primaryBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String primaryBitstreamStatus = accessStatusPrimaryBitstream.getStatus(); + assertThat("testWithPrimaryAndMultipleBitstreams 3", primaryBitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + LocalDate primaryAvailabilityDate = accessStatusPrimaryBitstream.getAvailabilityDate(); + assertThat("testWithPrimaryAndMultipleBitstreams 4", primaryAvailabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream -> other + AccessStatus accessStatusOtherBitstream = helper.getAccessStatusFromBitstream(context, + otherBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String otherBitstreamStatus = accessStatusOtherBitstream.getStatus(); + assertThat("testWithPrimaryAndMultipleBitstreams 5", otherBitstreamStatus, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate otherAvailabilityDate = accessStatusOtherBitstream.getAvailabilityDate(); + assertNull("testWithPrimaryAndMultipleBitstreams 6", otherAvailabilityDate); } /** @@ -401,7 +521,7 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { context.turnOffAuthorisationSystem(); Bundle bundle = bundleService.create(context, itemWithoutPrimaryAndMultipleBitstreams, Constants.CONTENT_BUNDLE_NAME); - bitstreamService.create(context, bundle, + Bitstream firstBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); Bitstream anotherBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); @@ -410,14 +530,167 @@ public class DefaultAccessStatusHelperTest extends AbstractUnitTest { ResourcePolicy policy = resourcePolicyService.create(context, null, group); policy.setRpName("Embargo"); policy.setAction(Constants.READ); - policy.setStartDate(LocalDate.of(9999, 12, 31)); + LocalDate startDate = LocalDate.of(9999, 12, 31); + policy.setStartDate(startDate); policies.add(policy); authorizeService.removeAllPolicies(context, anotherBitstream); authorizeService.addPolicies(context, policies, anotherBitstream); context.restoreAuthSystemState(); - String status = helper.getAccessStatusFromItem(context, itemWithoutPrimaryAndMultipleBitstreams, threshold); - assertThat("testWithNoPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); - String embargoDate = helper.getEmbargoFromItem(context, itemWithEmbargo, threshold); - assertThat("testWithNoPrimaryAndMultipleBitstreams 1", embargoDate, equalTo(null)); + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithoutPrimaryAndMultipleBitstreams, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); + assertThat("testWithNoPrimaryAndMultipleBitstreams 0", status, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithNoPrimaryAndMultipleBitstreams 1", availabilityDate); + // getAccessStatusFromBitstream -> first + AccessStatus accessStatusFirstBitstream = helper.getAccessStatusFromBitstream(context, + firstBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String firstBitstreamStatus = accessStatusFirstBitstream.getStatus(); + assertThat("testWithNoPrimaryAndMultipleBitstreams 3", firstBitstreamStatus, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate firstAvailabilityDate = accessStatusFirstBitstream.getAvailabilityDate(); + assertNull("testWithNoPrimaryAndMultipleBitstreams 4", firstAvailabilityDate); + // getAccessStatusFromBitstream -> other + AccessStatus accessStatusOtherBitstream = helper.getAccessStatusFromBitstream(context, + anotherBitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String otherBitstreamStatus = accessStatusOtherBitstream.getStatus(); + assertThat("testWithNoPrimaryAndMultipleBitstreams 5", otherBitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + LocalDate otherAvailabilityDate = accessStatusOtherBitstream.getAvailabilityDate(); + assertThat("testWithNoPrimaryAndMultipleBitstreams 6", otherAvailabilityDate, equalTo(startDate)); + } + + /** + * Test for an item with an embargo for both configurations (current, anonymous) and as a guest + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithEmbargoForCurrentOrAnonymousAsGuest() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithEmbargo, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + List policies = new ArrayList<>(); + Group group = groupService.findByName(context, Group.ANONYMOUS); + ResourcePolicy policy = resourcePolicyService.create(context, null, group); + policy.setRpName("Embargo"); + policy.setAction(Constants.READ); + LocalDate startDate = LocalDate.of(9999, 12, 31); + policy.setStartDate(startDate); + policies.add(policy); + EPerson admin = ePersonService.create(context); + Group adminGroup = groupService.findByName(context, Group.ADMIN); + ResourcePolicy adminPolicy = resourcePolicyService.create(context, admin, adminGroup); + adminPolicy.setRpName("Open Access For Admin"); + adminPolicy.setAction(Constants.READ); + policies.add(adminPolicy); + authorizeService.removeAllPolicies(context, bitstream); + authorizeService.addPolicies(context, policies, bitstream); + context.restoreAuthSystemState(); + // Configuration: current + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithEmbargo, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 1", status, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 2", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 3", bitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + LocalDate bistreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 4", bistreamAvailabilityDate, equalTo(startDate)); + // Configuration: anonymous + // getAccessStatusFromItem + accessStatus = helper.getAccessStatusFromItem(context, + itemWithEmbargo, threshold, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS); + status = accessStatus.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 5", status, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 6", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream + accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS); + bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 7", bitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + bistreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsGuest 8", bistreamAvailabilityDate, equalTo(startDate)); + } + + /** + * Test for an item with an embargo for both configurations (current, anonymous) and as an admin + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithEmbargoForCurrentOrAnonymousAsAdmin() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithEmbargo, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + List policies = new ArrayList<>(); + Group group = groupService.findByName(context, Group.ANONYMOUS); + ResourcePolicy policy = resourcePolicyService.create(context, null, group); + policy.setRpName("Embargo"); + policy.setAction(Constants.READ); + LocalDate startDate = LocalDate.of(9999, 12, 31); + policy.setStartDate(startDate); + policies.add(policy); + EPerson admin = ePersonService.create(context); + Group adminGroup = groupService.findByName(context, Group.ADMIN); + ResourcePolicy adminPolicy = resourcePolicyService.create(context, admin, adminGroup); + adminPolicy.setRpName("Open Access For Admin"); + adminPolicy.setAction(Constants.READ); + policies.add(adminPolicy); + authorizeService.removeAllPolicies(context, bitstream); + authorizeService.addPolicies(context, policies, bitstream); + context.restoreAuthSystemState(); + EPerson currentUser = context.getCurrentUser(); + context.setCurrentUser(admin); + // Configuration: current + // getAccessStatusFromItem + AccessStatus accessStatus = helper.getAccessStatusFromItem(context, + itemWithEmbargo, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String status = accessStatus.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 1", status, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + assertNull("testWithEmbargoForCurrentOrAnonymousAsAdmin 2", availabilityDate); + // getAccessStatusFromBitstream + AccessStatus accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_CURRENT_USER); + String bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 3", bitstreamStatus, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + LocalDate bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertNull("testWithEmbargoForCurrentOrAnonymousAsAdmin 4", bitstreamAvailabilityDate); + // Configuration: anonymous + accessStatus = helper.getAccessStatusFromItem(context, + itemWithEmbargo, threshold, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS); + status = accessStatus.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 5", status, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + availabilityDate = accessStatus.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 6", availabilityDate, equalTo(startDate)); + // getAccessStatusFromBitstream + accessStatusBitstream = helper.getAccessStatusFromBitstream(context, + bitstream, threshold, DefaultAccessStatusHelper.STATUS_FOR_ANONYMOUS); + bitstreamStatus = accessStatusBitstream.getStatus(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 7", bitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + bitstreamAvailabilityDate = accessStatusBitstream.getAvailabilityDate(); + assertThat("testWithEmbargoForCurrentOrAnonymousAsAdmin 8", bitstreamAvailabilityDate, equalTo(startDate)); + context.setCurrentUser(currentUser); } } diff --git a/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemEmailNotifierTest.java b/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemEmailNotifierTest.java index 0868017ed9..0bdebb22f3 100644 --- a/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemEmailNotifierTest.java +++ b/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemEmailNotifierTest.java @@ -10,8 +10,12 @@ package org.dspace.app.requestitem; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; +import java.io.ByteArrayInputStream; +import java.io.InputStream; + import jakarta.mail.Address; import jakarta.mail.Message; import jakarta.mail.Provider; @@ -21,9 +25,12 @@ import org.dspace.AbstractUnitTest; import org.dspace.app.requestitem.factory.RequestItemServiceFactory; import org.dspace.app.requestitem.service.RequestItemService; import org.dspace.builder.AbstractBuilder; +import org.dspace.builder.BitstreamBuilder; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.ItemBuilder; +import org.dspace.builder.RequestItemBuilder; +import org.dspace.content.Bitstream; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.Item; @@ -38,6 +45,7 @@ import org.junit.BeforeClass; import org.junit.Ignore; import org.junit.Test; + /** * Tests for {@link RequestItemEmailNotifier}. * @@ -59,6 +67,7 @@ public class RequestItemEmailNotifierTest private static BitstreamService bitstreamService; private static HandleService handleService; private static RequestItemService requestItemService; + private static RequestItemEmailNotifier requestItemEmailNotifier; public RequestItemEmailNotifierTest() { super(); @@ -76,6 +85,18 @@ public class RequestItemEmailNotifierTest = HandleServiceFactory.getInstance().getHandleService(); requestItemService = RequestItemServiceFactory.getInstance().getRequestItemService(); + + // Instantiate and initialize the unit, using the "help desk" strategy. + requestItemEmailNotifier + = new RequestItemEmailNotifier( + DSpaceServicesFactory.getInstance() + .getServiceManager() + .getServiceByName(RequestItemHelpdeskStrategy.class.getName(), + RequestItemAuthorExtractor.class)); + requestItemEmailNotifier.bitstreamService = bitstreamService; + requestItemEmailNotifier.configurationService = configurationService; + requestItemEmailNotifier.handleService = handleService; + requestItemEmailNotifier.requestItemService = requestItemService; } @AfterClass @@ -87,7 +108,7 @@ public class RequestItemEmailNotifierTest /** * Test of sendRequest method, of class RequestItemEmailNotifier. - * @throws java.lang.Exception passed through. + * @throws Exception passed through. */ @Ignore @Test @@ -96,7 +117,7 @@ public class RequestItemEmailNotifierTest /** * Test of sendResponse method, of class RequestItemEmailNotifier. - * @throws java.lang.Exception passed through. + * @throws Exception passed through. */ @Test public void testSendResponse() throws Exception { @@ -137,18 +158,6 @@ public class RequestItemEmailNotifierTest // Ensure that mail is "sent". configurationService.setProperty("mail.server.disabled", "false"); - // Instantiate and initialize the unit, using the "help desk" strategy. - RequestItemEmailNotifier requestItemEmailNotifier - = new RequestItemEmailNotifier( - DSpaceServicesFactory.getInstance() - .getServiceManager() - .getServiceByName(RequestItemHelpdeskStrategy.class.getName(), - RequestItemAuthorExtractor.class)); - requestItemEmailNotifier.bitstreamService = bitstreamService; - requestItemEmailNotifier.configurationService = configurationService; - requestItemEmailNotifier.handleService = handleService; - requestItemEmailNotifier.requestItemService = requestItemService; - // Test the unit. Template supplies the Subject: value requestItemEmailNotifier.sendResponse(context, ri, null, TEST_MESSAGE); @@ -180,7 +189,7 @@ public class RequestItemEmailNotifierTest /** * Test of sendResponse method -- rejection case. - * @throws java.lang.Exception passed through. + * @throws Exception passed through. */ @Test public void testSendRejection() @@ -222,18 +231,6 @@ public class RequestItemEmailNotifierTest // Ensure that mail is "sent". configurationService.setProperty("mail.server.disabled", "false"); - // Instantiate and initialize the unit, using the "help desk" strategy. - RequestItemEmailNotifier requestItemEmailNotifier - = new RequestItemEmailNotifier( - DSpaceServicesFactory.getInstance() - .getServiceManager() - .getServiceByName(RequestItemHelpdeskStrategy.class.getName(), - RequestItemAuthorExtractor.class)); - requestItemEmailNotifier.bitstreamService = bitstreamService; - requestItemEmailNotifier.configurationService = configurationService; - requestItemEmailNotifier.handleService = handleService; - requestItemEmailNotifier.requestItemService = requestItemService; - // Test the unit. Template supplies the Subject: value requestItemEmailNotifier.sendResponse(context, ri, null, TEST_MESSAGE); @@ -267,9 +264,54 @@ public class RequestItemEmailNotifierTest (String)content, containsString("denied")); } + @Test + public void testEmailGenerationWithLargeFileLink() throws Exception { + // Create some content to send. + context.turnOffAuthorisationSystem(); + Community com = CommunityBuilder.createCommunity(context) + .withName("Top Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, com) + .build(); + Item item = ItemBuilder.createItem(context, col) + .withTitle("Test Item") + .build(); + // Create a large bitstream so that the 20MB threshold is reached for large file link generation. + byte[] bytes = new byte[21 * 1024 * 1024]; + InputStream is = new ByteArrayInputStream(bytes); + Bitstream largeBitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("large.pdf") + .build(); + context.restoreAuthSystemState(); + // Create a request to which we can respond. + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, largeBitstream) + .withAcceptRequest(true) + .build(); + + // Install a fake transport for RFC2822 email addresses. + Session session = DSpaceServicesFactory.getInstance().getEmailService().getSession(); + Provider transportProvider = new Provider(Provider.Type.TRANSPORT, + DUMMY_PROTO, JavaMailTestTransport.class.getCanonicalName(), + "DSpace", "1.0"); + session.addProvider(transportProvider); + session.setProvider(transportProvider); + session.setProtocolForAddress("rfc822", DUMMY_PROTO); + String responseLink = request.getAccess_token(); + requestItemEmailNotifier.sendResponse(context, request, "Subject", "Message"); + + // Check that the email contains the access link and no attachment. + Message myMessage = JavaMailTestTransport.getMessage(); + String content = (String)myMessage.getContent(); + assertThat("Should contain access link", content, containsString(responseLink)); + assertThat("Should not contain attachment marker", content, not(containsString("Attachment"))); + } + + /** * Test of requestOpenAccess method, of class RequestItemEmailNotifier. - * @throws java.lang.Exception passed through. + * @throws Exception passed through. */ @Ignore @Test diff --git a/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemTest.java b/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemTest.java new file mode 100644 index 0000000000..d9c00c8b6c --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/app/requestitem/RequestItemTest.java @@ -0,0 +1,387 @@ +/** + * 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.requestitem; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.sql.SQLException; +import java.text.ParseException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Iterator; + +import org.dspace.AbstractUnitTest; +import org.dspace.app.requestitem.factory.RequestItemServiceFactory; +import org.dspace.app.requestitem.service.RequestItemService; +import org.dspace.authorize.AuthorizeException; +import org.dspace.builder.AbstractBuilder; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.builder.RequestItemBuilder; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.Item; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Context; +import org.dspace.handle.factory.HandleServiceFactory; +import org.dspace.handle.service.HandleService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Unit testing for RequestItem and RequestItemService ("Request-a-copy" feature) + * + * @author Kim Shepherd + */ +public class RequestItemTest extends AbstractUnitTest { + + private static RequestItemService requestItemService; + private static ConfigurationService configurationService; + private static HandleService handleService; + private static BitstreamService bitstreamService; + + private Community parentCommunity; + private Collection collection; + private Item item; + private Bitstream bitstream; + private Context context; + + @BeforeClass + public static void setUpClass() + throws SQLException { + AbstractBuilder.init(); // AbstractUnitTest doesn't do this for us. + Context ctx = new Context(); + ctx.turnOffAuthorisationSystem(); + ctx.restoreAuthSystemState(); + ctx.complete(); + } + + @AfterClass + public static void tearDownClass() throws Exception { + // AbstractUnitTest doesn't do this for us. + AbstractBuilder.cleanupObjects(); + AbstractBuilder.destroy(); + } + + // @Override + @Before + public void setUp() throws Exception { + //super.setUp(); + configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + handleService + = HandleServiceFactory.getInstance().getHandleService(); + requestItemService = RequestItemServiceFactory.getInstance().getRequestItemService(); + bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); + configurationService.setProperty("mail.server.disabled", "false"); + + try { + context = new Context(); + context.turnOffAuthorisationSystem(); + context.setCurrentUser(eperson); + // Create test resources + parentCommunity = CommunityBuilder + .createCommunity(context) + .withName("Community") + .build(); + collection = CollectionBuilder + .createCollection(context, parentCommunity) + .withName("Collection") + .withAdminGroup(eperson) + .build(); + item = ItemBuilder + .createItem(context, collection) + .withTitle("Item") + .build(); + InputStream is = new ByteArrayInputStream(new byte[0]); + bitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("Bitstream") + .build(); + } catch (SQLException | AuthorizeException | IOException e) { + e.printStackTrace(); + } + } + + @Test + public void testAccessTokenGenerationWithLargeFile() throws Exception { + // Create large bitstream over threshold + byte[] bytes = new byte[21 * 1024 * 1024]; // 21MB + InputStream is = new ByteArrayInputStream(bytes); + Bitstream largeBitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("LargeBitstream") + .build(); + + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, largeBitstream) + .build(); + + // Since we are over the threshold, the token should not be null + assertNotNull("Request token should not be null", request.getAccess_token()); + } + + @Test + public void testAccessTokenGenerationWithSmallFile() throws Exception { + // Create small file under the default threshold of 20MB + byte[] bytes = new byte[1 * 1024 * 1024]; // 1MB + InputStream is = new ByteArrayInputStream(bytes); + Bitstream smallBitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("SmallBitstream") + .build(); + + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, smallBitstream) + .build(); + + // Since we are under the threshold, the token should be null and + // the item will be sent via email attachment + assertNull("Request token should be null", request.getAccess_token()); + } + + @Test + public void testAuthorizeWithValidPeriod() throws Exception { + Instant decisionDate = getYesterdayAsInstant(); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withAccessToken("test-token") + .withDecisionDate(decisionDate) // Yesterday + .withAccessExpiry(getExpiryAsInstant("+10DAYS", decisionDate)) // 10 day period + .build(); + + // The access token should be valid so we expect no exceptions + try { + requestItemService.authorizeAccessByAccessToken(context, request, bitstream, request.getAccess_token()); + assertNotNull(request.getAccess_token()); + } catch (AuthorizeException e) { + fail("AuthorizeException should not be thrown for a valid expiry period"); + } + } + + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithExpiredPeriod() throws Exception { + Instant decisionDate = getYesterdayAsInstant(); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withAccessToken("test-token") + .withDecisionDate(decisionDate) // Yesterday + .withAccessExpiry(getExpiryAsInstant("+1DAY", decisionDate)) // 1 day period + .build(); + + // The access token should not be valid so we expect to catch an AuthorizeException + // when we call RequestItemService.authorizeByAccessToken + requestItemService.authorizeAccessByAccessToken(context, request, bitstream, request.getAccess_token()); + } + + + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithNullToken() throws Exception { + requestItemService.authorizeAccessByAccessToken(context, bitstream, null); + } + + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithInvalidToken() throws Exception { + requestItemService.authorizeAccessByAccessToken(context, bitstream, "invalid-token-123"); + } + + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithMismatchedToken() throws Exception { + Instant decisionDate = getYesterdayAsInstant(); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withAccessToken("test-token") + .withDecisionDate(decisionDate) // Yesterday + .withAccessExpiry(getExpiryAsInstant("FOREVER", decisionDate)) // forever + .build(); + + // The access token should NOT valid so we expect to catch an AuthorizeException + // when we call RequestItemService.authorizeByAccessToken + requestItemService.authorizeAccessByAccessToken(context, request, bitstream, "invalid-token-123"); + } + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithMismatchedBitstream() throws Exception { + // Create request for one bitstream + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withAccessToken("test-token") + .build(); + + // Create different bitstream + InputStream is = new ByteArrayInputStream(new byte[0]); + Bitstream otherBitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("OtherBitstream") + .build(); + + // Try to authorize access to different bitstream + requestItemService.authorizeAccessByAccessToken(context, request, otherBitstream, request.getAccess_token()); + } + + @Test(expected = AuthorizeException.class) + public void testAuthorizeWithAllFilesDisabled() throws Exception { + // Create request for specific bitstream + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withAccessToken("test-token") + .withAllFiles(false) + .build(); + + // Create different bitstream + InputStream is = new ByteArrayInputStream(new byte[0]); + Bitstream otherBitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("OtherBitstream") + .build(); + + // Try to access different bitstream when allfiles=false + requestItemService.authorizeAccessByAccessToken(context, request, otherBitstream, request.getAccess_token()); + } + + @Test + public void testGrantRequestWithAccessPeriod() throws Exception { + Instant decisionDate = Instant.now(); + Instant expectedExpiryDate = decisionDate.plus(7, ChronoUnit.DAYS); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .build(); + + request.setAccept_request(true); + request.setDecision_date(decisionDate); + request.setAccess_expiry(getExpiryAsInstant("+7DAYS", decisionDate)); // 7 day access + requestItemService.update(context, request); + + RequestItem found = requestItemService.findByToken(context, request.getToken()); + assertTrue(found.isAccept_request()); + assertEquals(decisionDate, found.getDecision_date()); + assertEquals(expectedExpiryDate, found.getAccess_expiry()); + } + + @Test + public void testDenyRequest() throws Exception { + Instant decisionDate = Instant.now(); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .build(); + + request.setAccept_request(false); + request.setDecision_date(decisionDate); + requestItemService.update(context, request); + + RequestItem found = requestItemService.findByToken(context, request.getToken()); + assertFalse(found.isAccept_request()); + assertNotNull(found.getDecision_date()); + } + + @Test + public void testFindRequestsByItem() throws Exception { + // Create multiple requests + RequestItem request1 = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .build(); + + RequestItem request2 = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .build(); + + Iterator requests = requestItemService.findByItem(context, item); + int count = 0; + while (requests.hasNext()) { + count++; + requests.next(); + } + assertEquals(2, count); + } + + @Test + public void testModifyGrantedRequest() throws Exception { + Instant decisionDate = Instant.now(); + Instant expectedExpiryDate = decisionDate.plus(10, ChronoUnit.DAYS); + RequestItem request = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .withAcceptRequest(true) + .withDecisionDate(decisionDate) + .withAccessExpiry(getExpiryAsInstant("+1DAY", decisionDate)) + .build(); + + // Manually set new expiry date + request.setAccess_expiry(getExpiryAsInstant("+10DAYS", decisionDate)); + request.setAllfiles(true); + requestItemService.update(context, request); + + RequestItem found = requestItemService.findByToken(context, request.getToken()); + assertEquals(expectedExpiryDate, found.getAccess_expiry()); + assertTrue(found.isAccept_request()); + assertTrue(found.isAllfiles()); + } + /** + * Test that generated links include the correct base URL, where the UI URL has a subpath like /subdir + */ + @Test + public void testGetLinkTokenEmailWithSubPath() throws MalformedURLException, URISyntaxException { + String currentDspaceUrl = configurationService.getProperty("dspace.ui.url"); + String newDspaceUrl = currentDspaceUrl + "/subdir"; + // Add a /subdir to the url for this test + configurationService.setProperty("dspace.ui.url", newDspaceUrl); + String expectedUrl = newDspaceUrl + "/request-a-copy/token"; + String generatedLink = requestItemService.getLinkTokenEmail("token"); + // The URLs should match + assertEquals(expectedUrl, generatedLink); + configurationService.reloadConfig(); + } + + /** + * Test that generated links include the correct base URL, with NO subpath elements + */ + @Test + public void testGetLinkTokenEmailWithoutSubPath() throws MalformedURLException, URISyntaxException { + String currentDspaceUrl = configurationService.getProperty("dspace.ui.url"); + String expectedUrl = currentDspaceUrl + "/request-a-copy/token"; + String generatedLink = requestItemService.getLinkTokenEmail("token"); + // The URLs should match + assertEquals(expectedUrl, generatedLink); + configurationService.reloadConfig(); + } + + private Instant getYesterdayAsInstant() { + return Instant.now().minus(Duration.ofDays(1)); + } + + private Instant getExpiryAsInstant(String dateOrDelta, Instant decision) { + try { + return RequestItemServiceImpl.parseDateOrDelta(dateOrDelta, decision); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + +} diff --git a/dspace-api/src/test/java/org/dspace/builder/RequestItemBuilder.java b/dspace-api/src/test/java/org/dspace/builder/RequestItemBuilder.java index aaa5254cf8..64ccddfc41 100644 --- a/dspace-api/src/test/java/org/dspace/builder/RequestItemBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/RequestItemBuilder.java @@ -39,6 +39,9 @@ public class RequestItemBuilder private Bitstream bitstream; private Instant decisionDate; private boolean accepted; + private String accessToken = null; + private Instant accessExpiry = null; + private boolean allFiles; protected RequestItemBuilder(Context context) { super(context); @@ -87,13 +90,29 @@ public class RequestItemBuilder return this; } + public RequestItemBuilder withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public RequestItemBuilder withAccessExpiry(Instant accessExpiry) { + this.accessExpiry = accessExpiry; + return this; + } + + public RequestItemBuilder withAllFiles(boolean allFiles) { + this.allFiles = allFiles; + return this; + } + @Override public RequestItem build() { LOG.atDebug() .withLocation() - .log("Building request with item ID {} and bitstream ID {}", + .log("Building request with item ID {} and bitstream ID {} and allfiles {}", () -> item.getID().toString(), - () -> bitstream.getID().toString()); + () -> (bitstream == null ? "" : bitstream.getID().toString()), + () -> Boolean.toString(allFiles)); String token; try { @@ -106,6 +125,11 @@ public class RequestItemBuilder requestItem = requestItemService.findByToken(context, token); requestItem.setAccept_request(accepted); requestItem.setDecision_date(decisionDate); + if (accessToken != null) { + requestItem.setAccess_token(accessToken); + } + requestItem.setAccess_expiry(accessExpiry); + requestItem.setAllfiles(allFiles); requestItemService.update(context, requestItem); diff --git a/dspace-api/src/test/java/org/dspace/eperson/AccountServiceImplIT.java b/dspace-api/src/test/java/org/dspace/eperson/AccountServiceImplIT.java new file mode 100644 index 0000000000..6a0799e92c --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/eperson/AccountServiceImplIT.java @@ -0,0 +1,263 @@ +/** + * 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.eperson; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.List; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.authorize.AuthorizeException; +import org.dspace.builder.EPersonBuilder; +import org.dspace.builder.MetadataFieldBuilder; +import org.dspace.content.Item; +import org.dspace.content.MetadataField; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.AccountService; +import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.RegistrationDataService; +import org.junit.Before; +import org.junit.Test; + +public class AccountServiceImplIT extends AbstractIntegrationTestWithDatabase { + + public static final String ORCID_NETID = "vins01"; + public static final String ORCID_EMAIL = "vins-01@fake.mail"; + public static final String CUSTOM_METADATA_VALUE = "vins01-customID"; + + AccountService accountService = + EPersonServiceFactory.getInstance().getAccountService(); + + EPersonService ePersonService = + EPersonServiceFactory.getInstance().getEPersonService(); + + RegistrationDataService registrationDataService = + EPersonServiceFactory.getInstance().getRegistrationDataService(); + ; + + EPerson tokenPerson; + RegistrationData orcidToken; + MetadataField metadataField; + + @Before + public void setUp() throws Exception { + super.setUp(); + + context.turnOffAuthorisationSystem(); + + tokenPerson = + EPersonBuilder.createEPerson(context) + .withNameInMetadata("Vincenzo", "Mecca") + .withEmail(null) + .withNetId(null) + .withCanLogin(true) + .build(); + + metadataField = + MetadataFieldBuilder.createMetadataField(context, "identifier", "custom", null) + .build(); + + orcidToken = + registrationDataService.create(context, ORCID_NETID, RegistrationTypeEnum.ORCID); + orcidToken.setEmail(ORCID_EMAIL); + + registrationDataService.addMetadata(context, orcidToken, metadataField, CUSTOM_METADATA_VALUE); + registrationDataService.update(context, orcidToken); + + context.restoreAuthSystemState(); + } + + + @Test + public void testMergedORCIDRegistration() throws SQLException, AuthorizeException { + + // set current logged-in eperson + context.setCurrentUser(tokenPerson); + + // try to update account details with the ORCID token + EPerson updatedEperson = + accountService.mergeRegistration( + context, tokenPerson.getID(), orcidToken.getToken(), + List.of() + ); + + // updates value with the one inside the ORCID token + assertThat(updatedEperson, notNullValue()); + assertThat(updatedEperson.getEmail(), is(ORCID_EMAIL)); + assertThat(updatedEperson.getNetid(), is(ORCID_NETID)); + + String customMetadataFound = + ePersonService.getMetadataFirstValue( + updatedEperson, metadataField.getMetadataSchema().getName(), metadataField.getElement(), + metadataField.getQualifier(), Item.ANY + ); + + // updates the metadata with the one set in the ORCID token + assertThat(customMetadataFound, is(CUSTOM_METADATA_VALUE)); + // deletes the token + assertThat(registrationDataService.findByToken(context, orcidToken.getToken()), nullValue()); + } + + @Test + public void testMergedORCIDRegistrationWithOverwrittenMetadata() throws SQLException, AuthorizeException { + + // set current logged-in eperson + context.setCurrentUser(tokenPerson); + + registrationDataService.addMetadata( + context, orcidToken, "eperson", "firstname", null, "Vins" + ); + registrationDataService.addMetadata( + context, orcidToken, "eperson", "lastname", null, "4Science" + ); + registrationDataService.update(context, orcidToken); + + // try to update account details with the ORCID token + EPerson updatedEperson = + accountService.mergeRegistration(context, tokenPerson.getID(), orcidToken.getToken(), + List.of("eperson.firstname", "eperson.lastname")); + + // updates value with the one inside the ORCID token + assertThat(updatedEperson, notNullValue()); + assertThat(updatedEperson.getEmail(), is(ORCID_EMAIL)); + assertThat(updatedEperson.getNetid(), is(ORCID_NETID)); + // overwrites values with the one from the token + assertThat(updatedEperson.getFirstName(), is("Vins")); + assertThat(updatedEperson.getLastName(), is("4Science")); + + String customMetadataFound = + ePersonService.getMetadataFirstValue( + updatedEperson, metadataField.getMetadataSchema().getName(), metadataField.getElement(), + metadataField.getQualifier(), Item.ANY + ); + + // updates the metadata with the one set in the ORCID token + assertThat(customMetadataFound, is(CUSTOM_METADATA_VALUE)); + // deletes the token + assertThat(registrationDataService.findByToken(context, orcidToken.getToken()), nullValue()); + } + + + @Test + public void testCannotMergedORCIDRegistrationWithDifferentLoggedEperson() { + + // set current logged-in admin + context.setCurrentUser(admin); + + // try to update eperson details with the ORCID token while logged in as admin + assertThrows( + AuthorizeException.class, + () -> accountService.mergeRegistration(context, tokenPerson.getID(), orcidToken.getToken(), List.of()) + ); + } + + @Test + public void testCreateUserWithRegistration() throws SQLException, AuthorizeException, IOException { + + // set current logged-in eperson + context.setCurrentUser(null); + + context.turnOffAuthorisationSystem(); + // create an orcid validation token + RegistrationData orcidRegistration = + registrationDataService.create(context, ORCID_NETID, RegistrationTypeEnum.VALIDATION_ORCID); + registrationDataService.addMetadata( + context, orcidRegistration, "eperson", "firstname", null, "Vincenzo" + ); + registrationDataService.addMetadata( + context, orcidRegistration, "eperson", "lastname", null, "Mecca" + ); + orcidRegistration.setEmail(ORCID_EMAIL); + registrationDataService.update(context, orcidRegistration); + + context.commit(); + context.restoreAuthSystemState(); + + EPerson createdEPerson = null; + try { + + // try to create a new account during orcid registration + createdEPerson = + accountService.mergeRegistration(context, null, orcidRegistration.getToken(), List.of()); + + // updates value with the one inside the validation token + assertThat(createdEPerson, notNullValue()); + assertThat(createdEPerson.getFirstName(), is("Vincenzo")); + assertThat(createdEPerson.getLastName(), is("Mecca")); + assertThat(createdEPerson.getEmail(), is(ORCID_EMAIL)); + assertThat(createdEPerson.getNetid(), is(ORCID_NETID)); + + // deletes the token + assertThat(registrationDataService.findByToken(context, orcidRegistration.getToken()), nullValue()); + } finally { + context.turnOffAuthorisationSystem(); + ePersonService.delete(context, context.reloadEntity(createdEPerson)); + RegistrationData found = context.reloadEntity(orcidRegistration); + if (found != null) { + registrationDataService.delete(context, found); + } + context.restoreAuthSystemState(); + } + + } + + + @Test + public void testInvalidMergeWithoutValidToken() throws SQLException, AuthorizeException { + + // create a register token + RegistrationData anyToken = + registrationDataService.create(context, ORCID_NETID, RegistrationTypeEnum.REGISTER); + + try { + + assertThrows( + AuthorizeException.class, + () -> accountService.mergeRegistration(context, null, anyToken.getToken(), List.of()) + ); + + // sets as forgot token + anyToken.setRegistrationType(RegistrationTypeEnum.FORGOT); + registrationDataService.update(context, anyToken); + + assertThrows( + AuthorizeException.class, + () -> accountService.mergeRegistration(context, null, anyToken.getToken(), List.of()) + ); + + // sets as change_password token + anyToken.setRegistrationType(RegistrationTypeEnum.CHANGE_PASSWORD); + registrationDataService.update(context, anyToken); + + assertThrows( + AuthorizeException.class, + () -> accountService.mergeRegistration(context, null, anyToken.getToken(), List.of()) + ); + + // sets as invitation token + anyToken.setRegistrationType(RegistrationTypeEnum.INVITATION); + registrationDataService.update(context, anyToken); + + assertThrows( + AuthorizeException.class, + () -> accountService.mergeRegistration(context, null, anyToken.getToken(), List.of()) + ); + + } finally { + registrationDataService.delete(context, context.reloadEntity(anyToken)); + } + + } + +} \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/eperson/AltchaCaptchaServiceTest.java b/dspace-api/src/test/java/org/dspace/eperson/AltchaCaptchaServiceTest.java new file mode 100644 index 0000000000..edd9c7601b --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/eperson/AltchaCaptchaServiceTest.java @@ -0,0 +1,77 @@ +/** + * 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.eperson; + +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.dspace.AbstractUnitTest; +import org.dspace.eperson.factory.CaptchaServiceFactory; +import org.dspace.eperson.service.CaptchaService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Basic tests to verity the Altcha captcha service can verify payloads from the client + * + * @author Kim Shepherd + */ +public class AltchaCaptchaServiceTest extends AbstractUnitTest { + + CaptchaService captchaService; + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + @After + public void tearDown() { + configurationService.setProperty("captcha.provider", "google"); + } + @Before + public void setUp() { + configurationService.setProperty("captcha.provider", "altcha"); + configurationService.setProperty("altcha.hmac.key", "onetwothreesecret"); + captchaService = CaptchaServiceFactory.getInstance().getCaptchaService(); + } + + @Test + public void testValidJSONCaptchaPayloadValidation() { + // Create raw JSON first, using previous known-good payload with our test hmac secret of "onetwothreesecret" + JSONObject json = new JSONObject(); + json.put("algorithm", "SHA-256"); + json.put("salt", "dcf5eba26e"); + json.put("number", 4791); + json.put("challenge", "0d8dd34089fdd610bd9a8857ea1fa4a5f9fe4b53f5df0c4e1eff6dc987c4d2bf"); + json.put("signature", "dfe4ec56f3d61e3a021b1c3b3ea4c7d6aea9812ab719ffe130fd386ce0b4158c"); + // Base64 encode it + String payload = Base64.getEncoder().encodeToString(json.toString().getBytes(StandardCharsets.UTF_8)); + + // Now validate + captchaService.processResponse(payload, "validate"); + } + + @Test(expected = InvalidReCaptchaException.class) + public void testInvalidCaptchaPayloadValidation() { + // Create raw JSON first + JSONObject json = new JSONObject(); + json.put("algorithm", "SHA-256"); + json.put("challenge", "abcdefg"); + json.put("salt", "salt123"); + json.put("number", 1); + json.put("signature", "123123123123"); + String payload = Base64.getEncoder().encodeToString(json.toString().getBytes(StandardCharsets.UTF_8)); + // Ask the captcha service to validate the payload + captchaService.processResponse(payload, "validate"); + // If we got here, something is off - an exception should have been thrown + fail("Invalid captcha payload should have failed with IllegalReCaptchaException"); + } +} diff --git a/dspace-api/src/test/java/org/dspace/eperson/RegistrationDataMetadataServiceImplIT.java b/dspace-api/src/test/java/org/dspace/eperson/RegistrationDataMetadataServiceImplIT.java new file mode 100644 index 0000000000..3ca76165aa --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/eperson/RegistrationDataMetadataServiceImplIT.java @@ -0,0 +1,167 @@ +/** + * 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.eperson; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.builder.MetadataFieldBuilder; +import org.dspace.content.MetadataField; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.RegistrationDataMetadataService; +import org.dspace.eperson.service.RegistrationDataService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + */ +public class RegistrationDataMetadataServiceImplIT extends AbstractIntegrationTestWithDatabase { + + RegistrationDataMetadataService registrationDataMetadataService = + EPersonServiceFactory.getInstance().getRegistrationDAtaDataMetadataService(); + + RegistrationDataService registrationDataService = + EPersonServiceFactory.getInstance().getRegistrationDataService(); + + MetadataField metadataField; + RegistrationData registrationData; + RegistrationDataMetadata metadata; + + @Before + @Override + public void setUp() throws Exception { + super.setUp(); + + context.turnOffAuthorisationSystem(); + this.registrationData = + this.registrationDataService.create(context); + + this.metadataField = + MetadataFieldBuilder.createMetadataField(context, "dc", "identifier", "custom") + .build(); + + context.restoreAuthSystemState(); + } + + @After + @Override + public void destroy() throws Exception { + this.registrationDataService.delete(context, registrationData); + super.destroy(); + } + + + @Test + public void testEmptyMetadataCreation() throws Exception { + try { + metadata = registrationDataMetadataService.create(context, registrationData, metadataField); + + assertThat(metadata, notNullValue()); + assertThat(metadata.getValue(), nullValue()); + assertThat(metadata.getRegistrationData().getID(), is(registrationData.getID())); + assertThat(metadata.getMetadataField(), is(metadataField)); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + + @Test + public void testValidMetadataCreation() throws Exception { + try { + metadata = + registrationDataMetadataService.create(context, registrationData, metadataField, "my-identifier"); + + assertThat(metadata, notNullValue()); + assertThat(metadata.getValue(), is("my-identifier")); + assertThat(metadata.getRegistrationData().getID(), is(registrationData.getID())); + assertThat(metadata.getMetadataField(), is(metadataField)); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + + @Test + public void testExistingMetadataFieldMetadataCreation() throws Exception { + try { + metadata = + registrationDataMetadataService.create( + context, registrationData, "dc", "identifier", "other", "my-identifier" + ); + + assertThat(metadata, notNullValue()); + assertThat(metadata.getValue(), is("my-identifier")); + assertThat(metadata.getRegistrationData().getID(), is(registrationData.getID())); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + + + @Test + public void testFindMetadata() throws Exception { + try { + metadata = registrationDataMetadataService.create(context, registrationData, metadataField); + + RegistrationDataMetadata found = + registrationDataMetadataService.find(context, metadata.getID()); + + assertThat(found.getID(), is(metadata.getID())); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + + @Test + public void testUpdateMetadata() throws Exception { + try { + metadata = registrationDataMetadataService.create(context, registrationData, metadataField); + metadata.setValue("custom-value"); + registrationDataMetadataService.update(context, metadata); + + RegistrationDataMetadata found = + registrationDataMetadataService.find(context, metadata.getID()); + + assertThat(found.getID(), is(metadata.getID())); + assertThat(found.getValue(), is("custom-value")); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + + @Test + public void testDeleteMetadata() throws Exception { + try { + metadata = registrationDataMetadataService.create(context, registrationData, metadataField); + + RegistrationDataMetadata found = + registrationDataMetadataService.find(context, metadata.getID()); + + assertThat(found, notNullValue()); + + registrationDataMetadataService.delete(context, metadata); + + found = registrationDataMetadataService.find(context, metadata.getID()); + + assertThat(found, nullValue()); + + } finally { + registrationDataMetadataService.delete(context, metadata); + } + } + +} \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java b/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java index 8c0f7e5b5e..4a868eff66 100644 --- a/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java +++ b/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java @@ -36,21 +36,28 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIdentifierProvi public void setUp() throws Exception { super.setUp(); context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) .withName("Parent Community") .build(); collection = CollectionBuilder.createCollection(context, parentCommunity) .withName("Collection") .build(); + + context.restoreAuthSystemState(); } private void createVersions() throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + itemV1 = ItemBuilder.createItem(context, collection) .withTitle("First version") .build(); firstHandle = itemV1.getHandle(); itemV2 = VersionBuilder.createVersion(context, itemV1, "Second version").build().getItem(); itemV3 = VersionBuilder.createVersion(context, itemV1, "Third version").build().getItem(); + + context.restoreAuthSystemState(); } @Test diff --git a/dspace-oai/src/main/java/org/dspace/xoai/app/plugins/AccessStatusElementItemCompilePlugin.java b/dspace-oai/src/main/java/org/dspace/xoai/app/plugins/AccessStatusElementItemCompilePlugin.java index 3201a02291..1dc9e53d7c 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/app/plugins/AccessStatusElementItemCompilePlugin.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/app/plugins/AccessStatusElementItemCompilePlugin.java @@ -8,13 +8,16 @@ package org.dspace.xoai.app.plugins; import java.sql.SQLException; +import java.time.LocalDate; import java.util.List; import com.lyncode.xoai.dataprovider.xml.xoai.Element; import com.lyncode.xoai.dataprovider.xml.xoai.Metadata; import org.apache.commons.lang3.StringUtils; +import org.dspace.access.status.DefaultAccessStatusHelper; import org.dspace.access.status.factory.AccessStatusServiceFactory; import org.dspace.access.status.service.AccessStatusService; +import org.dspace.content.AccessStatus; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.xoai.app.XOAIExtensionItemCompilePlugin; @@ -51,10 +54,13 @@ public class AccessStatusElementItemCompilePlugin implements XOAIExtensionItemCo AccessStatusService accessStatusService = AccessStatusServiceFactory.getInstance().getAccessStatusService(); try { - String accessStatusType; - accessStatusType = accessStatusService.getAccessStatus(context, item); - - String embargoFromItem = accessStatusService.getEmbargoFromItem(context, item); + AccessStatus accessStatusResult = accessStatusService.getAnonymousAccessStatus(context, item); + String accessStatusType = accessStatusResult.getStatus(); + LocalDate availabilityDate = accessStatusResult.getAvailabilityDate(); + String embargoFromItem = null; + if (accessStatusType == DefaultAccessStatusHelper.EMBARGO && availabilityDate != null) { + embargoFromItem = availabilityDate.toString(); + } Element accessStatus = ItemUtils.create("access-status"); accessStatus.getField().add(ItemUtils.createValue("value", accessStatusType)); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/AltchaCaptchaRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/AltchaCaptchaRestController.java new file mode 100644 index 0000000000..a404d3a460 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/AltchaCaptchaRestController.java @@ -0,0 +1,185 @@ +/** + * 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; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collections; +import java.util.random.RandomGenerator; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.codec.digest.HmacUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tomcat.util.http.FastHttpDateFormat; +import org.dspace.services.ConfigurationService; +import org.json.JSONObject; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.webmvc.ControllerUtils; +import org.springframework.hateoas.Link; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * ALTCHA (Proof of Work, cookie-less) controller to handle challenge requests. + * A salt, challenge hash will be sent and the client will have to calculate the number and send it back + * to implementing controllers (e.g. request-a-copy) for validation. + * The proof-of-work makes spam uneconomic, without requiring annoying puzzle tests or 3rd party services. + * @see org.dspace.app.rest.repository.RequestItemRepository + * @see Altcha docs> + * + * @author Kim Shepherd + */ +@RequestMapping(value = "/api/captcha") +@RestController +public class AltchaCaptchaRestController implements InitializingBean { + @Autowired + ConfigurationService configurationService; + @Autowired + DiscoverableEndpointsService discoverableEndpointsService; + + // Logger + private static Logger log = LogManager.getLogger(); + + // Default response expiry + private static final long DEFAULT_EXPIRE_TIME = 60L * 60L * 1000L; + + /** + * Calculate a challenge for ALTCHA captcha + * + * See https://altcha.org/docs/server-integration for implementation details and examples + * @param request HTTP request + * @param response HTTP response + * @return response entity with JSON challenge for client to begin proof-of-work + */ + @GetMapping("/challenge") + @PreAuthorize("permitAll()") + public ResponseEntity getAltchaChallenge(HttpServletRequest request, HttpServletResponse response) { + // Check if captcha provider is set to altcha + if (!configurationService.getProperty("captcha.provider", "google").equals("altcha")) { + log.error("altcha is not enabled"); + return ControllerUtils.toEmptyResponse(HttpStatus.BAD_REQUEST); + } + // Set algorithm and hmac key + // Algorithm + String algorithm = configurationService.getProperty("altcha.algorithm", "SHA-256"); + String hmacKey = configurationService.getProperty("altcha.hmac.key"); + if (hmacKey == null) { + log.error("hmac key not found, see: altcha.hmac.key in altcha.cfg"); + return ControllerUtils.toEmptyResponse(HttpStatus.BAD_REQUEST); + } + + // Instantiate random generator + RandomGenerator generator = RandomGenerator.getDefault(); + + // Generate a random salt + String randomSalt = bytesToHex(generateSalt()); + + // Generate a random integer and write as string, 0 - 100000 + // This number is kept fairly low to keep proof of work at a decent trade-off for the client + // We want to dissuade spammers, while keeping form functionality smooth for the user + int randomNumber = generator.nextInt(100000); + String randomNumberString = String.valueOf(randomNumber); + + // Generate the challenge as a hex string sha256 hash of concatenated salt and random string + try { + // Challenge = sha256 of salt + secret + String challenge = calculateHash(randomSalt + randomNumberString, algorithm); + if (StringUtils.isBlank(challenge)) { + log.error("Error generating altcha challenge"); + // Default, return no content + return ControllerUtils.toEmptyResponse(HttpStatus.INTERNAL_SERVER_ERROR); + } + // HMAC signature, using configured HMAC key and the generated challenge string + String hmac = new HmacUtils("HmacSHA256", hmacKey).hmacHex(challenge); + if (StringUtils.isBlank(hmac)) { + log.error("Error generating HMAC signature"); + // Default, return no content + return ControllerUtils.toEmptyResponse(HttpStatus.INTERNAL_SERVER_ERROR); + } + + // Set response body and headers + JSONObject jsonObject = new JSONObject(); + jsonObject.put("algorithm", algorithm); + jsonObject.put("challenge", challenge); + jsonObject.put("salt", randomSalt); + jsonObject.put("signature", hmac); + String body = jsonObject.toString(); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.put("Cache-Control", Collections.singletonList("private,no-cache")); + httpHeaders.put("Expires", Collections.singletonList(FastHttpDateFormat.formatDate( + System.currentTimeMillis() + DEFAULT_EXPIRE_TIME))); + httpHeaders.put("Content-Type", Collections.singletonList("application/json")); + httpHeaders.put("Content-Length", Collections.singletonList( + String.valueOf(body.getBytes(StandardCharsets.UTF_8).length))); + return ResponseEntity.ok().headers(httpHeaders).body(body); + + } catch (NoSuchAlgorithmException e) { + log.error("No such algorithm: {}, {}", algorithm, e.getMessage()); + return ControllerUtils.toEmptyResponse(HttpStatus.BAD_REQUEST); + } + + + } + + /** + * Makes a salt like 0c9c5ef19f + * Kept fairly simple as all we want is basic proof-of-work from the client + * @return salt string + */ + private static byte[] generateSalt() { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[5]; + random.nextBytes(salt); + return salt; + } + + /** + * Encode bytes to hex string + * @param bytes bytes to encode + * @return hex string + */ + public static String bytesToHex(byte[] bytes) { + StringBuilder stringBuilder = new StringBuilder(); + for (byte b : bytes) { + stringBuilder.append(String.format("%02x", b)); + } + return stringBuilder.toString(); + } + + /** + * Calculate a hex string from a digest, given an input string + * @param input input string + * @param algorithm algorithm key, eg. SHA-256 + * @return + * @throws NoSuchAlgorithmException + */ + public static String calculateHash(String input, String algorithm) throws NoSuchAlgorithmException { + MessageDigest sha256 = MessageDigest.getInstance(algorithm); + byte[] hashBytes = sha256.digest(input.getBytes()); + return bytesToHex(hashBytes); + } + + @Override + public void afterPropertiesSet() throws Exception { + discoverableEndpointsService + .register(this, Arrays + .asList(Link.of("/api/captcha", "captcha"))); + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java index 11b048e23e..51b6d720e7 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java @@ -23,6 +23,8 @@ import org.apache.catalina.connector.ClientAbortException; import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; +import org.dspace.app.requestitem.RequestItem; +import org.dspace.app.requestitem.service.RequestItemService; import org.dspace.app.rest.converter.ConverterService; import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.model.BitstreamRest; @@ -93,33 +95,71 @@ public class BitstreamRestController { @Autowired private ConfigurationService configurationService; + @Autowired + private RequestItemService requestItemService; + @Autowired ConverterService converter; @Autowired Utils utils; - @PreAuthorize("hasPermission(#uuid, 'BITSTREAM', 'READ')") + /** + * Retrieve bitstream. An access token (created by request a copy for some files, if enabled) can optionally + * be used for authorization instead of current user/group + * + * @param uuid bitstream ID + * @param accessToken request-a-copy access token (optional) + * @param response HTTP response + * @param request HTTP request + * @return response entity with bitstream content + * @throws IOException + * @throws SQLException + * @throws AuthorizeException + */ + @PreAuthorize("#accessToken != null|| hasPermission(#uuid, 'BITSTREAM', 'READ')") @RequestMapping( method = {RequestMethod.GET, RequestMethod.HEAD}, value = "content") - public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse response, - HttpServletRequest request) throws IOException, SQLException, AuthorizeException { - + public ResponseEntity retrieve(@PathVariable UUID uuid, + @Parameter(value = "accessToken", required = false) String accessToken, + HttpServletResponse response, + HttpServletRequest request) throws IOException, SQLException, AuthorizeException { + // Obtain context Context context = ContextUtil.obtainContext(request); - + // Find bitstream Bitstream bit = bitstreamService.find(context, uuid); - EPerson currentUser = context.getCurrentUser(); - if (bit == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } + // Get EPerson + EPerson currentUser = context.getCurrentUser(); + // Get bitstream metadata Long lastModified = bitstreamService.getLastModified(bit); BitstreamFormat format = bit.getFormat(context); String mimetype = format.getMIMEType(); String name = getBitstreamName(bit, format); + // If an access token is found, immediately authenticate it if request a copy is enabled + // Though, if we do further "has to be loggd in requester" checks we'll have to check here anyway + // Even if eperson is not null and has access, we will treat this token as the primary means of + // authorizing bitstream download access + boolean authorizedByAccessToken = false; + // There may be a way of checking enabled in preauth + if (StringUtils.isNotBlank(accessToken) && requestACopyEnabled()) { + RequestItem requestItem = requestItemService.findByAccessToken(context, accessToken); + // Try authorize by token. An AuthorizeException will be thrown if the token is invalid, expired, + // for the wrong bitstream, or does not match (see RequestItemService) + requestItemService.authorizeAccessByAccessToken(context, requestItem, bit, accessToken); + authorizedByAccessToken = true; + log.debug("Authorize access by token={} bitstream={}", accessToken, bit.getID()); + } + // If an authorization error was encountered it will be rethrown by this method even if the eperson + // could technically READ the bitstream normally. This is for consistency and clarify of usage - if we + // want a fallback we will need to reauthenticate as otherwise any eperson could have supplied a non-blank + // access token here + if (StringUtils.isBlank(request.getHeader("Range"))) { //We only log a download request when serving a request without Range header. This is because //a browser always sends a regular request first to check for Range support. @@ -131,42 +171,59 @@ public class BitstreamRestController { bit)); } + // Begin actual bitstream delivery try { + // Check if a citation coverpage is valid for this download long filesize = bit.getSizeBytes(); Boolean citationEnabledForBitstream = citationDocumentService.isCitationEnabledForBitstream(bit, context); - var bitstreamResource = - new org.dspace.app.rest.utils.BitstreamResource(name, uuid, - currentUser != null ? currentUser.getID() : null, - context.getSpecialGroupUuids(), citationEnabledForBitstream); + + // Generate a special bitstream resource stream depending on whether we are accessing by token + // or eperson / group access + org.dspace.app.rest.utils.BitstreamResource bitstreamResource; + if (authorizedByAccessToken) { + // Get input stream using temporary privileged context + bitstreamResource = new org.dspace.app.rest.utils.BitstreamResourceAccessByToken(name, uuid, + currentUser != null ? currentUser.getID() : null, + context.getSpecialGroupUuids(), citationEnabledForBitstream, accessToken); + } else { + // Get input stream using default user/group authorization + bitstreamResource = + new org.dspace.app.rest.utils.BitstreamResource(name, uuid, + currentUser != null ? currentUser.getID() : null, + context.getSpecialGroupUuids(), citationEnabledForBitstream); + } + + // We have all the data we need, close the connection to the database so that it doesn't stay open during + // download/streaming + context.complete(); + + // Set http headers HttpHeadersInitializer httpHeadersInitializer = new HttpHeadersInitializer() - .withBufferSize(BUFFER_SIZE) - .withFileName(name) - .withChecksum(bitstreamResource.getChecksum()) - .withLength(bitstreamResource.contentLength()) - .withMimetype(mimetype) - .with(request) - .with(response); + .withBufferSize(BUFFER_SIZE) + .withFileName(name) + .withChecksum(bitstreamResource.getChecksum()) + .withLength(bitstreamResource.contentLength()) + .withMimetype(mimetype) + .with(request) + .with(response); + // Set last modified in headers if (lastModified != null) { httpHeadersInitializer.withLastModified(lastModified); } - //Determine if we need to send the file as a download or if the browser can open it inline - //The file will be downloaded if its size is larger than the configured threshold, - //or if its mimetype/extension appears in the "webui.content_disposition_format" config + // Determine if we need to send the file as a download or if the browser can open it inline + // The file will be downloaded if its size is larger than the configured threshold, + // or if its mimetype/extension appears in the "webui.content_disposition_format" config long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold"); if ((dispositionThreshold >= 0 && filesize > dispositionThreshold) || checkFormatForContentDisposition(format)) { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); } - //We have all the data we need, close the connection to the database so that it doesn't stay open during - //download/streaming - context.complete(); - - //Send the data + // Send the data if (httpHeadersInitializer.isValid()) { HttpHeaders httpHeaders = httpHeadersInitializer.initialiseHeaders(); @@ -187,6 +244,12 @@ public class BitstreamRestController { return null; } + /** + * Get the name for attachment disposition headers + * @param bit bitstream + * @param format bitstream format + * @return name + */ private String getBitstreamName(Bitstream bit, BitstreamFormat format) { String name = bit.getName(); if (name == null) { @@ -199,6 +262,11 @@ public class BitstreamRestController { return name; } + /** + * Check for a success or other non-error response message + * @param response HTTP resposnse + * @return success or redirection code indication as boolean + */ private boolean isNotAnErrorResponse(HttpServletResponse response) { Response.Status.Family responseCode = Response.Status.Family.familyOf(response.getStatus()); return responseCode.equals(Response.Status.Family.SUCCESSFUL) @@ -250,6 +318,18 @@ public class BitstreamRestController { return download; } + /** + * Quick check to see if request a copy is enabled. If not, for safety, we'll deny any downoads + * @return true or false + */ + private boolean requestACopyEnabled() { + // If the feature is not enabled, throw exception + if (configurationService.getProperty("request.item.type") != null) { + return true; + } + return false; + } + /** * This method will update the bitstream format of the bitstream that corresponds to the provided bitstream uuid. * diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/EPersonRegistrationRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/EPersonRegistrationRestController.java new file mode 100644 index 0000000000..94195e6991 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/EPersonRegistrationRestController.java @@ -0,0 +1,89 @@ +/** + * 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; + +import java.util.List; +import java.util.UUID; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import org.dspace.app.rest.converter.ConverterService; +import org.dspace.app.rest.model.EPersonRest; +import org.dspace.app.rest.model.hateoas.EPersonResource; +import org.dspace.app.rest.repository.EPersonRestRepository; +import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.core.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.webmvc.ControllerUtils; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This controller is responsible to handle {@link org.dspace.eperson.RegistrationData} + * of a given {@link org.dspace.eperson.EPerson} + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +@RestController +@RequestMapping("/api/" + EPersonRest.CATEGORY + "/" + EPersonRest.PLURAL_NAME) +public class EPersonRegistrationRestController { + + @Autowired + private EPersonRestRepository ePersonRestRepository; + + @Autowired + private ConverterService converter; + + /** + * This method will merge the data coming from a {@link org.dspace.eperson.RegistrationData} into the current + * logged-in user. + *
+ * The request must have an empty body, and a token parameter should be provided: + *
+     *  
+     *   curl -X POST http://${dspace.url}/api/eperson/epersons/${id-eperson}?token=${token}&override=${metadata-fields}
+     *        -H "Content-Type: application/json"
+     *        -H "Authorization: Bearer ${bearer-token}"
+     *  
+     * 
+ * @param request httpServletRequest incoming + * @param uuid uuid of the eperson + * @param token registration token + * @param override fields to override inside from the registration data to the eperson + * @return + * @throws Exception + */ + @RequestMapping(method = RequestMethod.POST, value = "/{uuid}") + public ResponseEntity> post( + HttpServletRequest request, + @PathVariable String uuid, + @RequestParam @NotNull String token, + @RequestParam(required = false) List override + ) throws Exception { + Context context = ContextUtil.obtainContext(request); + try { + context.turnOffAuthorisationSystem(); + EPersonRest epersonRest = + ePersonRestRepository.mergeFromRegistrationData(context, UUID.fromString(uuid), token, override); + EPersonResource resource = converter.toResource(epersonRest); + return ControllerUtils.toResponseEntity(HttpStatus.CREATED, new HttpHeaders(), resource); + } catch (Exception e) { + throw e; + } finally { + context.restoreAuthSystemState(); + } + } + +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/WebApplication.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/WebApplication.java index 5494475b52..fe53557d68 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/WebApplication.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/WebApplication.java @@ -183,7 +183,7 @@ public class WebApplication { // Allow list of request preflight headers allowed to be sent to us from the client .allowedHeaders("Accept", "Authorization", "Content-Type", "Origin", "X-On-Behalf-Of", "X-Requested-With", "X-XSRF-TOKEN", "X-CORRELATION-ID", "X-REFERRER", - "x-recaptcha-token") + "x-captcha-payload") // Allow list of response headers allowed to be sent by us (the server) to the client .exposedHeaders("Authorization", "DSPACE-XSRF-TOKEN", "Location", "WWW-Authenticate"); } @@ -195,7 +195,7 @@ public class WebApplication { // Allow list of request preflight headers allowed to be sent to us from the client .allowedHeaders("Accept", "Authorization", "Content-Type", "Origin", "X-On-Behalf-Of", "X-Requested-With", "X-XSRF-TOKEN", "X-CORRELATION-ID", "X-REFERRER", - "x-recaptcha-token") + "x-captcha-payload") // Allow list of response headers allowed to be sent by us (the server) to the client .exposedHeaders("Authorization", "DSPACE-XSRF-TOKEN", "Location", "WWW-Authenticate"); } @@ -207,7 +207,7 @@ public class WebApplication { // Allow list of request preflight headers allowed to be sent to us from the client .allowedHeaders("Accept", "Authorization", "Content-Type", "Origin", "X-On-Behalf-Of", "X-Requested-With", "X-XSRF-TOKEN", "X-CORRELATION-ID", "X-REFERRER", - "x-recaptcha-token", "access-control-allow-headers") + "x-captcha-payload", "access-control-allow-headers") // Allow list of response headers allowed to be sent by us (the server) to the client .exposedHeaders("Authorization", "DSPACE-XSRF-TOKEN", "Location", "WWW-Authenticate"); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/RequestCopyFeature.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/RequestCopyFeature.java index da692c7214..62ff69602f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/RequestCopyFeature.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/RequestCopyFeature.java @@ -62,6 +62,14 @@ public class RequestCopyFeature implements AuthorizationFeature { @Autowired private ConfigurationService configurationService; + /** + * Check if the user is authorized to request a copy of a bitstream belonging to a given item + * @param context + * the DSpace Context + * @param object the item for which we are requesting a copy + * @return + * @throws SQLException + */ @Override public boolean isAuthorized(Context context, BaseObjectRest object) throws SQLException { String requestType = configurationService.getProperty("request.item.type"); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/MetadataConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/MetadataConverter.java index 76aca4be23..da47f3d8b6 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/MetadataConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/MetadataConverter.java @@ -35,7 +35,7 @@ import org.springframework.stereotype.Component; * Converter to translate between lists of domain {@link MetadataValue}s and {@link MetadataRest} representations. */ @Component -public class MetadataConverter implements DSpaceConverter { +public class MetadataConverter implements DSpaceConverter> { @Autowired private ContentServiceFactory contentServiceFactory; @@ -46,7 +46,7 @@ public class MetadataConverter implements DSpaceConverter convert(MetadataValueList metadataValues, Projection projection) { // Convert each value to a DTO while retaining place order in a map of key -> SortedSet Map> mapOfSortedSets = new HashMap<>(); @@ -60,7 +60,7 @@ public class MetadataConverter implements DSpaceConverter metadataRest = new MetadataRest<>(); // Populate MetadataRest's map of key -> List while respecting SortedSet's order Map> mapOfLists = metadataRest.getMap(); @@ -80,14 +80,14 @@ public class MetadataConverter implements DSpaceConverter void setMetadata(Context context, T dso, MetadataRest metadataRest) - throws SQLException, AuthorizeException { + throws SQLException, AuthorizeException { DSpaceObjectService dsoService = contentServiceFactory.getDSpaceObjectService(dso); dsoService.clearMetadata(context, dso, Item.ANY, Item.ANY, Item.ANY, Item.ANY); persistMetadataRest(context, dso, metadataRest, dsoService); @@ -97,14 +97,14 @@ public class MetadataConverter implements DSpaceConverter void addMetadata(Context context, T dso, MetadataRest metadataRest) - throws SQLException, AuthorizeException { + throws SQLException, AuthorizeException { DSpaceObjectService dsoService = contentServiceFactory.getDSpaceObjectService(dso); persistMetadataRest(context, dso, metadataRest, dsoService); } @@ -113,33 +113,34 @@ public class MetadataConverter implements DSpaceConverter void mergeMetadata(Context context, T dso, MetadataRest metadataRest) - throws SQLException, AuthorizeException { + public void mergeMetadata( + Context context, T dso, MetadataRest metadataRest + ) throws SQLException, AuthorizeException { DSpaceObjectService dsoService = contentServiceFactory.getDSpaceObjectService(dso); - for (Map.Entry> entry: metadataRest.getMap().entrySet()) { + for (Map.Entry> entry : metadataRest.getMap().entrySet()) { List metadataByMetadataString = dsoService.getMetadataByMetadataString(dso, entry.getKey()); dsoService.removeMetadataValues(context, dso, metadataByMetadataString); } persistMetadataRest(context, dso, metadataRest, dsoService); } - private void persistMetadataRest(Context context, T dso, MetadataRest metadataRest, - DSpaceObjectService dsoService) - throws SQLException, AuthorizeException { - for (Map.Entry> entry: metadataRest.getMap().entrySet()) { + private void persistMetadataRest( + Context context, T dso, MetadataRest metadataRest, DSpaceObjectService dsoService + ) throws SQLException, AuthorizeException { + for (Map.Entry> entry : metadataRest.getMap().entrySet()) { String[] seq = entry.getKey().split("\\."); String schema = seq[0]; String element = seq[1]; String qualifier = seq.length == 3 ? seq[2] : null; - for (MetadataValueRest mvr: entry.getValue()) { + for (MetadataValueRest mvr : entry.getValue()) { dsoService.addMetadata(context, dso, schema, element, qualifier, mvr.getLanguage(), - mvr.getValue(), mvr.getAuthority(), mvr.getConfidence()); + mvr.getValue(), mvr.getAuthority(), mvr.getConfidence()); } } dsoService.update(context, dso); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RegistrationDataConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RegistrationDataConverter.java new file mode 100644 index 0000000000..539cc4bc7e --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RegistrationDataConverter.java @@ -0,0 +1,129 @@ +/** + * 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.converter; + +import java.sql.SQLException; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; +import org.dspace.app.rest.model.MetadataRest; +import org.dspace.app.rest.model.RegistrationMetadataRest; +import org.dspace.app.rest.model.RegistrationRest; +import org.dspace.app.rest.projection.Projection; +import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.MetadataValue; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.AccountService; +import org.dspace.eperson.service.RegistrationDataService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Converts a given {@link RegistrationRest} DTO into a {@link RegistrationData} entity. + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +@Component +public class RegistrationDataConverter implements DSpaceConverter { + + @Autowired + private HttpServletRequest request; + + @Autowired + private RegistrationDataService registrationDataService; + + @Override + public RegistrationRest convert(RegistrationData registrationData, Projection projection) { + + if (registrationData == null) { + return null; + } + + Context context = ContextUtil.obtainContext(request); + + AccountService accountService = EPersonServiceFactory.getInstance().getAccountService(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setId(registrationData.getID()); + registrationRest.setEmail(registrationData.getEmail()); + registrationRest.setNetId(registrationData.getNetId()); + registrationRest.setRegistrationType( + Optional.ofNullable(registrationData.getRegistrationType()) + .map(RegistrationTypeEnum::toString) + .orElse(null) + ); + + EPerson ePerson = null; + try { + ePerson = accountService.getEPerson(context, registrationData.getToken()); + } catch (SQLException | AuthorizeException e) { + throw new RuntimeException(e); + } + + if (ePerson != null) { + registrationRest.setUser(ePerson.getID()); + try { + MetadataRest metadataRest = getMetadataRest(ePerson, registrationData); + if (registrationData.getEmail() != null) { + metadataRest.put( + "email", + new RegistrationMetadataRest(registrationData.getEmail(), ePerson.getEmail()) + ); + } + registrationRest.setRegistrationMetadata(metadataRest); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } else { + registrationRest.setRegistrationMetadata(getMetadataRest(registrationData)); + } + + return registrationRest; + } + + + private MetadataRest getMetadataRest(EPerson ePerson, RegistrationData registrationData) + throws SQLException { + return registrationDataService.groupEpersonMetadataByRegistrationData(ePerson, registrationData) + .reduce( + new MetadataRest<>(), + (map, entry) -> map.put( + entry.getKey().getMetadataField().toString('.'), + new RegistrationMetadataRest( + entry.getKey().getValue(), + entry.getValue().map(MetadataValue::getValue).orElse(null) + ) + ), + (m1, m2) -> { + m1.getMap().putAll(m2.getMap()); + return m1; + } + ); + } + + private MetadataRest getMetadataRest(RegistrationData registrationData) { + MetadataRest metadataRest = new MetadataRest<>(); + registrationData.getMetadata().forEach( + (m) -> metadataRest.put( + m.getMetadataField().toString('.'), + new RegistrationMetadataRest(m.getValue()) + ) + ); + return metadataRest; + } + + @Override + public Class getModelClass() { + return RegistrationData.class; + } + +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RequestItemConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RequestItemConverter.java index e6d6f4da2a..1523417608 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RequestItemConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RequestItemConverter.java @@ -8,6 +8,8 @@ package org.dspace.app.rest.converter; +import java.time.Instant; + import jakarta.inject.Named; import org.dspace.app.requestitem.RequestItem; import org.dspace.app.rest.model.RequestItemRest; @@ -15,8 +17,8 @@ import org.dspace.app.rest.projection.Projection; import org.dspace.content.Bitstream; /** - * Convert between {@link org.dspace.app.requestitem.RequestItem} and - * {@link org.dspace.app.rest.model.RequestItemRest}. + * Convert between {@link RequestItem} and + * {@link RequestItemRest}. * * @author Mark H. Wood */ @@ -45,6 +47,14 @@ public class RequestItemConverter requestItemRest.setRequestName(requestItem.getReqName()); requestItemRest.setRequestDate(requestItem.getRequest_date()); requestItemRest.setToken(requestItem.getToken()); + requestItemRest.setAccessToken(requestItem.getAccess_token()); + requestItemRest.setAccessExpiry(requestItem.getAccess_expiry()); + if ( requestItem.getAccess_expiry() == null || + requestItem.getAccess_expiry().isBefore(Instant.now())) { + requestItemRest.setAccessExpired(true); + } else { + requestItemRest.setAccessExpired(false); + } return requestItemRest; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java index 85993f9a92..d9deaa2a5d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java @@ -18,6 +18,7 @@ public class AccessStatusRest implements RestModel { public static final String PLURAL_NAME = NAME; String status; + String embargoDate; @Override @JsonProperty(access = Access.READ_ONLY) @@ -35,10 +36,12 @@ public class AccessStatusRest implements RestModel { public AccessStatusRest() { setStatus(null); + setEmbargoDate(null); } public AccessStatusRest(String status) { setStatus(status); + setEmbargoDate(null); } public String getStatus() { @@ -48,4 +51,12 @@ public class AccessStatusRest implements RestModel { public void setStatus(String status) { this.status = status; } + + public String getEmbargoDate() { + return embargoDate; + } + + public void setEmbargoDate(String embargoDate) { + this.embargoDate = embargoDate; + } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java index d456f72223..7a02812165 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonProperty.Access; */ @LinksRest(links = { @LinkRest(name = BitstreamRest.BUNDLE, method = "getBundle"), + @LinkRest(name = BitstreamRest.ACCESS_STATUS, method = "getAccessStatus"), @LinkRest(name = BitstreamRest.FORMAT, method = "getFormat"), @LinkRest(name = BitstreamRest.THUMBNAIL, method = "getThumbnail") }) @@ -26,6 +27,7 @@ public class BitstreamRest extends DSpaceObjectRest { public static final String CATEGORY = RestAddressableModel.CORE; public static final String BUNDLE = "bundle"; + public static final String ACCESS_STATUS = "accessStatus"; public static final String FORMAT = "format"; public static final String THUMBNAIL = "thumbnail"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/DSpaceObjectRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/DSpaceObjectRest.java index 1b71eb8957..e7b43ebe33 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/DSpaceObjectRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/DSpaceObjectRest.java @@ -20,7 +20,7 @@ public abstract class DSpaceObjectRest extends BaseObjectRest { private String name; private String handle; - MetadataRest metadata = new MetadataRest(); + MetadataRest metadata = new MetadataRest<>(); @Override public String getId() { @@ -56,11 +56,11 @@ public abstract class DSpaceObjectRest extends BaseObjectRest { * * @return the metadata. */ - public MetadataRest getMetadata() { + public MetadataRest getMetadata() { return metadata; } - public void setMetadata(MetadataRest metadata) { + public void setMetadata(MetadataRest metadata) { this.metadata = metadata; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/MetadataRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/MetadataRest.java index d1367c8fea..072acbcfd7 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/MetadataRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/MetadataRest.java @@ -19,10 +19,10 @@ import org.apache.commons.lang3.builder.HashCodeBuilder; /** * Rest representation of a map of metadata keys to ordered lists of values. */ -public class MetadataRest { +public class MetadataRest { @JsonAnySetter - private SortedMap> map = new TreeMap(); + private SortedMap> map = new TreeMap(); /** * Gets the map. @@ -30,7 +30,7 @@ public class MetadataRest { * @return the map of keys to ordered values. */ @JsonAnyGetter - public SortedMap> getMap() { + public SortedMap> getMap() { return map; } @@ -44,16 +44,16 @@ public class MetadataRest { * they are passed to this method. * @return this instance, to support chaining calls for easy initialization. */ - public MetadataRest put(String key, MetadataValueRest... values) { + public MetadataRest put(String key, T... values) { // determine highest explicitly ordered value int highest = -1; - for (MetadataValueRest value : values) { + for (T value : values) { if (value.getPlace() > highest) { highest = value.getPlace(); } } // add any non-explicitly ordered values after highest - for (MetadataValueRest value : values) { + for (T value : values) { if (value.getPlace() < 0) { highest++; value.setPlace(highest); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationMetadataRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationMetadataRest.java new file mode 100644 index 0000000000..46671c43d8 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationMetadataRest.java @@ -0,0 +1,40 @@ +/** + * 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.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * This POJO represents a {@link MetadataValueRest} that will be placed inside a given + * {@link org.dspace.eperson.RegistrationData} that is coming directly from the REST controller. + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class RegistrationMetadataRest extends MetadataValueRest { + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String overrides; + + public RegistrationMetadataRest(String value, String overrides) { + super(); + this.value = value; + this.overrides = overrides; + } + + public RegistrationMetadataRest(String value) { + this(value, null); + } + + public String getOverrides() { + return overrides; + } + + public void setOverrides(String overrides) { + this.overrides = overrides; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationRest.java index eb7c58a18c..95c3b5bbe3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RegistrationRest.java @@ -9,6 +9,7 @@ package org.dspace.app.rest.model; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import org.dspace.app.rest.RestResourceController; @@ -24,11 +25,25 @@ public class RegistrationRest extends RestAddressableModel { public static final String PLURAL_NAME = "registrations"; public static final String CATEGORY = EPERSON; + private Integer id; private String email; private UUID user; + private String registrationType; + private String netId; + @JsonInclude(JsonInclude.Include.NON_NULL) + private MetadataRest registrationMetadata; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } /** * Generic getter for the email + * * @return the email value of this RegisterRest */ public String getEmail() { @@ -37,7 +52,8 @@ public class RegistrationRest extends RestAddressableModel { /** * Generic setter for the email - * @param email The email to be set on this RegisterRest + * + * @param email The email to be set on this RegisterRest */ public void setEmail(String email) { this.email = email; @@ -45,6 +61,7 @@ public class RegistrationRest extends RestAddressableModel { /** * Generic getter for the user + * * @return the user value of this RegisterRest */ public UUID getUser() { @@ -53,12 +70,38 @@ public class RegistrationRest extends RestAddressableModel { /** * Generic setter for the user - * @param user The user to be set on this RegisterRest + * + * @param user The user to be set on this RegisterRest */ public void setUser(UUID user) { this.user = user; } + public String getRegistrationType() { + return registrationType; + } + + public void setRegistrationType(String registrationType) { + this.registrationType = registrationType; + } + + public String getNetId() { + return netId; + } + + public void setNetId(String netId) { + this.netId = netId; + } + + public MetadataRest getRegistrationMetadata() { + return registrationMetadata; + } + + public void setRegistrationMetadata( + MetadataRest registrationMetadata) { + this.registrationMetadata = registrationMetadata; + } + @Override public String getCategory() { return CATEGORY; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RequestItemRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RequestItemRest.java index 7ff25792e2..20606475f7 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RequestItemRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RequestItemRest.java @@ -53,6 +53,14 @@ public class RequestItemRest extends BaseObjectRest { protected boolean allfiles; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + protected String accessToken; + + protected Instant accessExpiry; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + protected boolean accessExpired; + /** * @return the bitstream requested. */ @@ -207,6 +215,41 @@ public class RequestItemRest extends BaseObjectRest { this.allfiles = allfiles; } + /** + * @return the unique access token to be used by the requester. This is separate to the approval token ('token') + */ + public String getAccessToken() { + return accessToken; + } + + /** + * @param accessToken the access token to be used by the requester (not settable through JSON update) + */ + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + /** + * @return the date the access token expires. + */ + public Instant getAccessExpiry() { + return this.accessExpiry; + } + + /** + * @param accessExpiry the date the access token expires. + */ + public void setAccessExpiry(Instant accessExpiry) { + this.accessExpiry = accessExpiry; + } + + public boolean isAccessExpired() { + return accessExpired; + } + + public void setAccessExpired(boolean accessExpired) { + this.accessExpired = accessExpired; + } /* * Common REST object methods. */ diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java new file mode 100644 index 0000000000..2f75304f5b --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java @@ -0,0 +1,71 @@ +/** + * 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.repository; + +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.UUID; + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletRequest; +import org.dspace.access.status.DefaultAccessStatusHelper; +import org.dspace.access.status.service.AccessStatusService; +import org.dspace.app.rest.model.AccessStatusRest; +import org.dspace.app.rest.model.BitstreamRest; +import org.dspace.app.rest.projection.Projection; +import org.dspace.content.AccessStatus; +import org.dspace.content.Bitstream; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +/** + * Link repository for calculating the access status of a Bitstream, + * including the embargo date + */ +@Component(BitstreamRest.CATEGORY + "." + BitstreamRest.PLURAL_NAME + "." + BitstreamRest.ACCESS_STATUS) +public class BitstreamAccessStatusLinkRepository extends AbstractDSpaceRestRepository + implements LinkRestRepository { + + @Autowired + BitstreamService bitstreamService; + + @Autowired + AccessStatusService accessStatusService; + + @PreAuthorize("hasPermission(#bitstreamId, 'BITSTREAM', 'METADATA_READ')") + public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, + UUID bitstreamId, + @Nullable Pageable optionalPageable, + Projection projection) { + try { + Context context = obtainContext(); + Bitstream bitstream = bitstreamService.find(context, bitstreamId); + if (bitstream == null) { + throw new ResourceNotFoundException("No such bitstream: " + bitstreamId); + } + AccessStatusRest accessStatusRest = new AccessStatusRest(); + AccessStatus accessStatus = accessStatusService.getAccessStatus(context, bitstream); + String status = accessStatus.getStatus(); + if (status == DefaultAccessStatusHelper.EMBARGO) { + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + String embargoDate = availabilityDate.toString(); + accessStatusRest.setEmbargoDate(embargoDate); + } + accessStatusRest.setStatus(status); + return accessStatusRest; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/EPersonRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/EPersonRestRepository.java index 7091e5a4ec..0ee367dbed 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/EPersonRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/EPersonRestRepository.java @@ -19,6 +19,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.DiscoverableEndpointsService; +import org.dspace.app.rest.EPersonRegistrationRestController; import org.dspace.app.rest.Parameter; import org.dspace.app.rest.SearchRestMethod; import org.dspace.app.rest.exception.DSpaceBadRequestException; @@ -190,7 +191,7 @@ public class EPersonRestRepository extends DSpaceObjectRestRepository metadataRest = epersonRest.getMetadata(); if (metadataRest != null) { List epersonFirstName = metadataRest.getMap().get("eperson.firstname"); List epersonLastName = metadataRest.getMap().get("eperson.lastname"); @@ -213,10 +214,25 @@ public class EPersonRestRepository extends DSpaceObjectRestRepository + * + * @param context - The Dspace Context + * @param uuid - The uuid of the eperson + * @param token - A valid registration token + * @param override - An optional list of metadata fields that will be overwritten + * @return a EPersonRest entity updated with the registration data. + * @throws AuthorizeException + */ + public EPersonRest mergeFromRegistrationData( + Context context, UUID uuid, String token, List override + ) throws AuthorizeException { + try { + + if (uuid == null) { + throw new DSpaceBadRequestException("The uuid of the person cannot be null"); + } + + if (token == null) { + throw new DSpaceBadRequestException("You must provide a token for the eperson"); + } + + return converter.toRest( + accountService.mergeRegistration(context, uuid, token, override), + utils.obtainProjection() + ); + } catch (SQLException e) { + log.error(e); + throw new RuntimeException(e); + } + } + @Override public void afterPropertiesSet() throws Exception { discoverableEndpointsService.register(this, Arrays.asList( diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java index 975171fba3..08af02f9bd 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java @@ -9,14 +9,17 @@ package org.dspace.app.rest.repository; import java.sql.SQLException; +import java.time.LocalDate; import java.util.UUID; import jakarta.annotation.Nullable; import jakarta.servlet.http.HttpServletRequest; +import org.dspace.access.status.DefaultAccessStatusHelper; import org.dspace.access.status.service.AccessStatusService; import org.dspace.app.rest.model.AccessStatusRest; import org.dspace.app.rest.model.ItemRest; import org.dspace.app.rest.projection.Projection; +import org.dspace.content.AccessStatus; import org.dspace.content.Item; import org.dspace.content.service.ItemService; import org.dspace.core.Context; @@ -51,8 +54,14 @@ public class ItemAccessStatusLinkRepository extends AbstractDSpaceRestRepository throw new ResourceNotFoundException("No such item: " + itemId); } AccessStatusRest accessStatusRest = new AccessStatusRest(); - String accessStatus = accessStatusService.getAccessStatus(context, item); - accessStatusRest.setStatus(accessStatus); + AccessStatus accessStatus = accessStatusService.getAccessStatus(context, item); + String status = accessStatus.getStatus(); + if (status == DefaultAccessStatusHelper.EMBARGO) { + LocalDate availabilityDate = accessStatus.getAvailabilityDate(); + String embargoDate = availabilityDate.toString(); + accessStatusRest.setEmbargoDate(embargoDate); + } + accessStatusRest.setStatus(status); return accessStatusRest; } catch (SQLException e) { throw new RuntimeException(e); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RegistrationRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RegistrationRestRepository.java index 628fe42f17..6bea9598a1 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RegistrationRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RegistrationRestRepository.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.mail.MessagingException; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.BadRequestException; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,6 +26,10 @@ import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.exception.RepositoryMethodNotImplementedException; import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.RegistrationRest; +import org.dspace.app.rest.model.patch.Patch; +import org.dspace.app.rest.repository.patch.ResourcePatch; +import org.dspace.app.rest.repository.patch.operation.RegistrationEmailPatchOperation; +import org.dspace.app.rest.utils.Utils; import org.dspace.app.util.AuthorizeUtil; import org.dspace.authenticate.service.AuthenticationService; import org.dspace.authorize.AuthorizeException; @@ -32,6 +37,8 @@ import org.dspace.core.Context; import org.dspace.eperson.EPerson; import org.dspace.eperson.InvalidReCaptchaException; import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; +import org.dspace.eperson.factory.CaptchaServiceFactory; import org.dspace.eperson.service.AccountService; import org.dspace.eperson.service.CaptchaService; import org.dspace.eperson.service.EPersonService; @@ -54,9 +61,10 @@ public class RegistrationRestRepository extends DSpaceRestRepository resourcePatch; + @Autowired private ObjectMapper mapper; @@ -103,7 +116,7 @@ public class RegistrationRestRepository extends DSpaceRestRepository getDomainClass() { - return RegistrationRest.class; - } - /** * This method will find the RegistrationRest object that is associated with the token given + * * @param token The token to be found and for which a RegistrationRest object will be found - * @return A RegistrationRest object for the given token - * @throws SQLException If something goes wrong + * @return A RegistrationRest object for the given token + * @throws SQLException If something goes wrong * @throws AuthorizeException If something goes wrong */ @SearchRestMethod(name = "findByToken") @@ -194,17 +203,62 @@ public class RegistrationRestRepository extends DSpaceRestRepository getDomainClass() { + return RegistrationRest.class; + } + } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RequestItemRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RequestItemRepository.java index 3259239efd..702d021600 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RequestItemRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/RequestItemRepository.java @@ -15,8 +15,6 @@ import java.net.MalformedURLException; import java.net.URISyntaxException; import java.sql.SQLException; import java.time.Instant; -import java.util.LinkedList; -import java.util.List; import java.util.UUID; import com.fasterxml.jackson.databind.JsonNode; @@ -24,12 +22,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.apache.commons.validator.routines.EmailValidator; -import org.apache.http.client.utils.URIBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.app.requestitem.RequestItem; import org.dspace.app.requestitem.RequestItemEmailNotifier; import org.dspace.app.requestitem.service.RequestItemService; +import org.dspace.app.rest.Parameter; +import org.dspace.app.rest.SearchRestMethod; import org.dspace.app.rest.converter.RequestItemConverter; import org.dspace.app.rest.exception.IncompleteItemRequestException; import org.dspace.app.rest.exception.RepositoryMethodNotImplementedException; @@ -37,23 +36,31 @@ import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.RequestItemRest; import org.dspace.app.rest.projection.Projection; import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.content.service.BitstreamService; import org.dspace.content.service.ItemService; import org.dspace.core.Context; import org.dspace.eperson.EPerson; +import org.dspace.eperson.InvalidReCaptchaException; +import org.dspace.eperson.factory.CaptchaServiceFactory; +import org.dspace.eperson.service.CaptchaService; import org.dspace.services.ConfigurationService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; /** - * Component to expose item requests. + * Component to expose item requests and handle operations like create (request), put (grant/deny), and + * email sending. Support for requested item access by a secure token / link is supported as well as the legacy + * "attach files to email" method. See dspace.cfg for configuration. * * @author Mark H. Wood + * @author Kim Shepherd */ @Component(RequestItemRest.CATEGORY + '.' + RequestItemRest.PLURAL_NAME) public class RequestItemRepository @@ -77,6 +84,12 @@ public class RequestItemRepository @Autowired(required = true) protected RequestItemEmailNotifier requestItemEmailNotifier; + @Autowired + protected AuthorizeService authorizeService; + + private CaptchaService captchaService = CaptchaServiceFactory.getInstance().getCaptchaService(); + + private static final Logger log = LogManager.getLogger(); @Autowired private ObjectMapper mapper; @@ -109,6 +122,24 @@ public class RequestItemRepository HttpServletRequest req = getRequestService() .getCurrentRequest() .getHttpServletRequest(); + + // If captcha is configured for this action, perform validation + if (configurationService.getBooleanProperty("request.item.create.captcha", false)) { + // Get captcha payload header, if any + String captchaPayloadHeader = req.getHeader("x-captcha-payload"); + if (StringUtils.isBlank(captchaPayloadHeader)) { + throw new AuthorizeException("Valid captcha payload is required"); + } + // Validate and verify captcha payload token or proof of work + // Rethrow exception as authZ exception if validation fails + try { + captchaService.processResponse(captchaPayloadHeader, "request_item"); + } catch (InvalidReCaptchaException e) { + throw new AuthorizeException(e.getMessage()); + } + } + + ObjectMapper mapper = new ObjectMapper(); RequestItemRest rir; try { rir = mapper.readValue(req.getInputStream(), RequestItemRest.class); @@ -195,7 +226,7 @@ public class RequestItemRepository // Create a link back to DSpace for the approver's response. String responseLink; try { - responseLink = getLinkTokenEmail(ri.getToken()); + responseLink = requestItemService.getLinkTokenEmail(ri.getToken()); } catch (URISyntaxException | MalformedURLException e) { LOG.warn("Impossible URL error while composing email: {}", e::getMessage); @@ -229,15 +260,17 @@ public class RequestItemRepository throw new UnprocessableEntityException("Item request not found"); } - // Do not permit updates after a decision has been given. - Instant decisionDate = ri.getDecision_date(); - if (null != decisionDate) { - throw new UnprocessableEntityException("Request was " - + (ri.isAccept_request() ? "granted" : "denied") - + " on " + decisionDate + " and may not be updated."); + // Previously there was a check here to prevent updates after *any* decision was given. + // This is now updated to allow specific updates to *granted* requests, so that it is possible + // to revoke access tokens or alter access period + // Throw error only if decision date was set but was denied + if (null != ri.getDecision_date() && !ri.isAccept_request()) { + throw new UnprocessableEntityException("Item request was already denied, no further updates are possible"); } // Make the changes + + // Extract and set the 'accept' indicator JsonNode acceptRequestNode = requestBody.findValue("acceptRequest"); if (null == acceptRequestNode) { throw new UnprocessableEntityException("acceptRequest is required"); @@ -245,18 +278,30 @@ public class RequestItemRepository ri.setAccept_request(acceptRequestNode.asBoolean()); } + // Extract and set the response message to include in the email JsonNode responseMessageNode = requestBody.findValue("responseMessage"); String message = null; if (responseMessageNode != null && !responseMessageNode.isNull()) { message = responseMessageNode.asText(); } + // Set the decision date (now)` + ri.setDecision_date(Instant.now()); + + // If the (optional) access expiry period was included, extract it here and set accordingly + // We expect it to be sent either as a timestamp or as a delta math like +7DAYS + JsonNode accessPeriod = requestBody.findValue("accessPeriod"); + if (accessPeriod != null && !accessPeriod.isNull()) { + // The request item service is responsible for parsing and setting the expiry date based + // on a delta like "+7DAYS" or special string like "FOREVER", or a formatted date + requestItemService.setAccessExpiry(ri, accessPeriod.asText()); + } + JsonNode responseSubjectNode = requestBody.findValue("subject"); String subject = null; if (responseSubjectNode != null && !responseSubjectNode.isNull()) { subject = responseSubjectNode.asText(); } - ri.setDecision_date(Instant.now()); requestItemService.update(context, ri); // Send the response email @@ -282,33 +327,39 @@ public class RequestItemRepository return rir; } + /** + * + * @param accessToken + * @return + */ + @PreAuthorize("permitAll()") + @SearchRestMethod(name = "byAccessToken") + public RequestItemRest findByAccessToken(@Parameter(value = "accessToken", required = true) String accessToken) { + + // Send 404 NOT FOUND if access token is null + if (StringUtils.isBlank(accessToken)) { + throw new ResourceNotFoundException("No such accessToken=" + accessToken); + } + + // Get the current context and request item + Context context = obtainContext(); + RequestItem requestItem = requestItemService.findByAccessToken(context, accessToken); + + // Previously, a 404 was thrown if the request item was not found, and a 401 or 403 was thrown depending + // on authorization and validity checks. These checks are still strictly enforced in the BitstreamContoller + // and BitstreamResourceAccessByToken classes for actual downloads, but here we continue to pass a 200 OK + // response so that we can display more meaningful alerts to users in the item page rather than serve hard + // redirects or lose information like expiry dates and access status + + // Sanitize the request item (stripping personal data) for privacy + requestItemService.sanitizeRequestItem(context, requestItem); + // Convert and return the final request item + return requestItemConverter.convert(requestItem, utils.obtainProjection()); + } + @Override public Class getDomainClass() { return RequestItemRest.class; } - /** - * Generate a link back to DSpace, to act on a request. - * - * @param token identifies the request. - * @return URL to the item request API, with /request-a-copy/{token} as the last URL segments - * @throws URISyntaxException passed through. - * @throws MalformedURLException passed through. - */ - public String getLinkTokenEmail(String token) - throws URISyntaxException, MalformedURLException { - final String base = configurationService.getProperty("dspace.ui.url"); - - // Construct the link, making sure to support sub-paths - URIBuilder uriBuilder = new URIBuilder(base); - List segments = new LinkedList<>(); - if (StringUtils.isNotBlank(uriBuilder.getPath())) { - segments.add(StringUtils.strip(uriBuilder.getPath(), "/")); - } - segments.add("request-a-copy"); - segments.add(token); - - // Build and return the URL from segments (or throw exception) - return uriBuilder.setPathSegments(segments).build().toURL().toExternalForm(); - } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/RegistrationEmailPatchOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/RegistrationEmailPatchOperation.java new file mode 100644 index 0000000000..e4bbd45a3f --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/RegistrationEmailPatchOperation.java @@ -0,0 +1,166 @@ +/** + * 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.repository.patch.operation; + +import java.sql.SQLException; +import java.text.MessageFormat; +import java.util.Optional; + +import com.fasterxml.jackson.databind.JsonNode; +import org.dspace.app.rest.exception.DSpaceBadRequestException; +import org.dspace.app.rest.exception.UnprocessableEntityException; +import org.dspace.app.rest.model.patch.JsonValueEvaluator; +import org.dspace.app.rest.model.patch.Operation; +import org.dspace.authorize.AuthorizeException; +import org.dspace.core.Context; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; +import org.dspace.eperson.dto.RegistrationDataChanges; +import org.dspace.eperson.dto.RegistrationDataPatch; +import org.dspace.eperson.service.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Implementation for RegistrationData email patches. + * + * Example: + * curl -X PATCH http://${dspace.server.url}/api/eperson/registration/<:registration-id>?token=<:token> -H " + * Content-Type: application/json" -d '[{ "op": "replace", "path": "/email", "value": "new@email"]' + * + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +@Component +public class RegistrationEmailPatchOperation extends PatchOperation { + + /** + * Path in json body of patch that uses this operation + */ + private static final String OPERATION_PATH_EMAIL = "/email"; + + @Autowired + private AccountService accountService; + + @Override + public R perform(Context context, R object, Operation operation) { + checkOperationValue(operation.getValue()); + + RegistrationDataPatch registrationDataPatch; + try { + String email = getTextValue(operation); + registrationDataPatch = + new RegistrationDataPatch( + object, + new RegistrationDataChanges( + email, + registrationTypeFor(context, object, email) + ) + ); + } catch (IllegalArgumentException e) { + throw new UnprocessableEntityException( + "Cannot perform the patch operation", + e + ); + } catch (SQLException e) { + throw new RuntimeException(e); + } + + if (!supports(object, operation)) { + throw new UnprocessableEntityException( + MessageFormat.format( + "RegistrationEmailReplaceOperation does not support {0} operation", + operation.getOp() + ) + ); + } + + if (!isOperationAllowed(operation, object)) { + throw new UnprocessableEntityException( + MessageFormat.format( + "Attempting to perform {0} operation over {1} value (e-mail).", + operation.getOp(), + object.getEmail() == null ? "null" : "not null" + ) + ); + } + + + try { + return (R) accountService.renewRegistrationForEmail(context, registrationDataPatch); + } catch (AuthorizeException e) { + throw new DSpaceBadRequestException( + MessageFormat.format( + "Cannot perform {0} operation over {1} value (e-mail).", + operation.getOp(), + object.getEmail() == null ? "null" : "not null" + ), + e + ); + } + } + + private static String getTextValue(Operation operation) { + Object value = operation.getValue(); + + if (value instanceof String) { + return ((String) value); + } + + if (value instanceof JsonValueEvaluator) { + return Optional.of((JsonValueEvaluator) value) + .map(JsonValueEvaluator::getValueNode) + .filter(nodes -> !nodes.isEmpty()) + .map(nodes -> nodes.get(0)) + .map(JsonNode::asText) + .orElseThrow(() -> new DSpaceBadRequestException("No value provided for operation")); + } + throw new DSpaceBadRequestException("Invalid patch value for operation!"); + } + + private RegistrationTypeEnum registrationTypeFor( + Context context, R object, String email + ) + throws SQLException { + if (accountService.existsAccountWithEmail(context, email)) { + return RegistrationTypeEnum.VALIDATION_ORCID; + } + return object.getRegistrationType(); + } + + + /** + * Checks whether the email of RegistrationData has an existing value to replace or adds a new value. + * + * @param operation operation to check + * @param registrationData Object on which patch is being done + */ + private boolean isOperationAllowed(Operation operation, RegistrationData registrationData) { + return isReplaceOperationAllowed(operation, registrationData) || + isAddOperationAllowed(operation, registrationData); + } + + private boolean isAddOperationAllowed(Operation operation, RegistrationData registrationData) { + return operation.getOp().trim().equalsIgnoreCase(OPERATION_ADD) && registrationData.getEmail() == null; + } + + private static boolean isReplaceOperationAllowed(Operation operation, RegistrationData registrationData) { + return operation.getOp().trim().equalsIgnoreCase(OPERATION_REPLACE) && registrationData.getEmail() != null; + } + + @Override + public boolean supports(Object objectToMatch, Operation operation) { + return (objectToMatch instanceof RegistrationData && + ( + operation.getOp().trim().equalsIgnoreCase(OPERATION_REPLACE) || + operation.getOp().trim().equalsIgnoreCase(OPERATION_ADD) + ) && + operation.getPath().trim().equalsIgnoreCase(OPERATION_PATH_EMAIL)); + } +} + diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java index 70496b9dba..084a4bb2ab 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java @@ -7,7 +7,12 @@ */ package org.dspace.app.rest.security; +import static org.dspace.authenticate.OrcidAuthenticationBean.ORCID_AUTH_ATTRIBUTE; +import static org.dspace.authenticate.OrcidAuthenticationBean.ORCID_DEFAULT_REGISTRATION_URL; +import static org.dspace.authenticate.OrcidAuthenticationBean.ORCID_REGISTRATION_TOKEN; + import java.io.IOException; +import java.text.MessageFormat; import java.util.ArrayList; import jakarta.servlet.FilterChain; @@ -45,7 +50,8 @@ public class OrcidLoginFilter extends StatelessLoginFilter { private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); private OrcidAuthenticationBean orcidAuthentication = new DSpace().getServiceManager() - .getServiceByName("orcidAuthentication", OrcidAuthenticationBean.class); + .getServiceByName("orcidAuthentication", + OrcidAuthenticationBean.class); public OrcidLoginFilter(String url, String httpMethod, AuthenticationManager authenticationManager, RestAuthenticationService restAuthenticationService) { @@ -66,13 +72,13 @@ public class OrcidLoginFilter extends StatelessLoginFilter { @Override protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, - Authentication auth) throws IOException, ServletException { + Authentication auth) throws IOException, ServletException { DSpaceAuthentication dSpaceAuthentication = (DSpaceAuthentication) auth; log.debug("Orcid authentication successful for EPerson {}. Sending back temporary auth cookie", - dSpaceAuthentication.getName()); + dSpaceAuthentication.getName()); restAuthenticationService.addAuthenticationDataForUser(req, res, dSpaceAuthentication, true); @@ -81,26 +87,41 @@ public class OrcidLoginFilter extends StatelessLoginFilter { @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, - AuthenticationException failed) throws IOException, ServletException { + AuthenticationException failed) throws IOException, ServletException { Context context = ContextUtil.obtainContext(request); - if (orcidAuthentication.isUsed(context, request)) { - String baseRediredirectUrl = configurationService.getProperty("dspace.ui.url"); - String redirectUrl = baseRediredirectUrl + "/error?status=401&code=orcid.generic-error"; - response.sendRedirect(redirectUrl); // lgtm [java/unvalidated-url-redirection] - } else { + if (!orcidAuthentication.isUsed(context, request)) { super.unsuccessfulAuthentication(request, response, failed); + return; } + String baseRediredirectUrl = configurationService.getProperty("dspace.ui.url"); + String redirectUrl = baseRediredirectUrl + "/error?status=401&code=orcid.generic-error"; + Object registrationToken = request.getAttribute(ORCID_REGISTRATION_TOKEN); + if (registrationToken != null) { + final String orcidRegistrationDataUrl = + configurationService.getProperty("orcid.registration-data.url", ORCID_DEFAULT_REGISTRATION_URL); + redirectUrl = baseRediredirectUrl + MessageFormat.format(orcidRegistrationDataUrl, registrationToken); + if (log.isDebugEnabled()) { + log.debug( + "Orcid authentication failed for user with ORCID {}.", + request.getAttribute(ORCID_AUTH_ATTRIBUTE) + ); + log.debug("Redirecting to {} for registration completion.", redirectUrl); + } + } + + response.sendRedirect(redirectUrl); // lgtm [java/unvalidated-url-redirection] } /** * After successful login, redirect to the DSpace URL specified by this Orcid * request (in the "redirectUrl" request parameter). If that 'redirectUrl' is * not valid or trusted for this DSpace site, then return a 400 error. - * @param request - * @param response + * + * @param request + * @param response * @throws IOException */ private void redirectAfterSuccess(HttpServletRequest request, HttpServletResponse response) throws IOException { @@ -128,9 +149,9 @@ public class OrcidLoginFilter extends StatelessLoginFilter { response.sendRedirect(redirectUrl); } else { log.error("Invalid Orcid redirectURL=" + redirectUrl + - ". URL doesn't match hostname of server or UI!"); + ". URL doesn't match hostname of server or UI!"); response.sendError(HttpServletResponse.SC_BAD_REQUEST, - "Invalid redirectURL! Must match server or ui hostname."); + "Invalid redirectURL! Must match server or ui hostname."); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/ApplicationConfig.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/ApplicationConfig.java index ba43fdf956..35be8b4ab1 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/ApplicationConfig.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/ApplicationConfig.java @@ -32,10 +32,13 @@ import org.springframework.context.annotation.Configuration; "org.dspace.app.rest.converter", "org.dspace.app.rest.repository", "org.dspace.app.rest.utils", + "org.dspace.app.rest.link", + "org.dspace.app.rest.converter.factory", "org.dspace.app.configuration", "org.dspace.iiif", "org.dspace.app.iiif", - "org.dspace.app.ldn" + "org.dspace.app.ldn", + "org.dspace.app.scheduler" }) public class ApplicationConfig { // Allowed CORS origins ("Access-Control-Allow-Origin" header) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java index 1ac6a320d9..06ece80513 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java @@ -38,21 +38,21 @@ import org.springframework.util.DigestUtils; */ public class BitstreamResource extends AbstractResource { - private static final Logger LOG = LogManager.getLogger(BitstreamResource.class); + static final Logger LOG = LogManager.getLogger(BitstreamResource.class); - private final String name; - private final UUID uuid; - private final UUID currentUserUUID; - private final boolean shouldGenerateCoverPage; - private final Set currentSpecialGroups; + protected final String name; + protected final UUID uuid; + protected final UUID currentUserUUID; + protected final boolean shouldGenerateCoverPage; + protected final Set currentSpecialGroups; - private final BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); - private final EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); - private final CitationDocumentService citationDocumentService = + protected final BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); + protected final EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); + protected final CitationDocumentService citationDocumentService = new DSpace().getServiceManager() .getServicesByType(CitationDocumentService.class).get(0); - private BitstreamDocument document; + protected BitstreamDocument document; public BitstreamResource(String name, UUID uuid, UUID currentUserUUID, Set currentSpecialGroups, boolean shouldGenerateCoverPage) { @@ -71,7 +71,7 @@ public class BitstreamResource extends AbstractResource { * @param bitstream the pdf for which we want to generate a coverpage * @return a byte array containing the cover page */ - private byte[] getCoverpageByteArray(Context context, Bitstream bitstream) + byte[] getCoverpageByteArray(Context context, Bitstream bitstream) throws IOException, SQLException, AuthorizeException { try { var citedDocument = citationDocumentService.makeCitedDocument(context, bitstream); @@ -101,7 +101,7 @@ public class BitstreamResource extends AbstractResource { } @Override - public long contentLength() { + public long contentLength() throws IOException { fetchDocument(); return document.length(); @@ -113,7 +113,7 @@ public class BitstreamResource extends AbstractResource { return document.etag(); } - private void fetchDocument() { + void fetchDocument() { if (document != null) { return; } @@ -138,7 +138,7 @@ public class BitstreamResource extends AbstractResource { LOG.debug("fetched document {} {}", shouldGenerateCoverPage, document); } - private String etag(Bitstream bitstream) { + String etag(Bitstream bitstream) { /* Ideally we would calculate the md5 checksum based on the document with coverpage. However it looks like the coverpage generation is not stable (e.g. if invoked twice it will return @@ -157,7 +157,7 @@ public class BitstreamResource extends AbstractResource { return builder.toString(); } - private Context initializeContext() throws SQLException { + Context initializeContext() throws SQLException { Context context = new Context(); EPerson currentUser = ePersonService.find(context, currentUserUUID); context.setCurrentUser(currentUser); @@ -165,5 +165,5 @@ public class BitstreamResource extends AbstractResource { return context; } - private record BitstreamDocument(String etag, long length, InputStream inputStream) {} + record BitstreamDocument(String etag, long length, InputStream inputStream) {} } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResourceAccessByToken.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResourceAccessByToken.java new file mode 100644 index 0000000000..99a79a8894 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResourceAccessByToken.java @@ -0,0 +1,105 @@ +/** + * 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.utils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.sql.SQLException; +import java.util.Set; +import java.util.UUID; + +import org.dspace.app.requestitem.factory.RequestItemServiceFactory; +import org.dspace.app.requestitem.service.RequestItemService; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.Bitstream; +import org.dspace.core.Context; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.springframework.core.io.AbstractResource; + +/** + * This class acts as a {@link AbstractResource} used by Spring's framework to send the data in a proper and + * streamlined way inside the {@link org.springframework.http.ResponseEntity} body. + * This class' attributes are being used by Spring's framework in the overridden methods so that the proper + * attributes are given and used in the response. + * + * Unlike the BitstreamResource, this resource expects a valid and authorised access token to use when + * retrieving actual bitstream data. It will either set a special group in the temp context for READ of the + * bitstream, or turn off authorisation for the lifetime of the temp (autocloseable in try-with-resources) context + * + * @author Kim Shepherd + */ +public class BitstreamResourceAccessByToken extends BitstreamResource { + + private String accessToken; + + private RequestItemService requestItemService = RequestItemServiceFactory.getInstance().getRequestItemService(); + + private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + public BitstreamResourceAccessByToken(String name, UUID uuid, UUID currentUserUUID, Set currentSpecialGroups, + boolean shouldGenerateCoverPage, String accessToken) { + super(name, uuid, currentUserUUID, currentSpecialGroups, shouldGenerateCoverPage); + this.accessToken = accessToken; + } + + /** + * Get the bitstream content using the special temporary context if the request-a-copy access request + * is properly authenticated and authorised. After this method is called, the bitstream content is + * updated so the correct input stream can be returned + * + * @throws IOException + */ + @Override + public void fetchDocument() { + // If the feature is not enabled, throw exception + if (configurationService.getProperty("request.item.type") == null) { + throw new RuntimeException("Request a copy is not enabled, download via access token will not be allowed"); + } + + // If document is already set for this BitstreamResource, return + if (document != null) { + return; + } + + try (Context fileRetrievalContext = initializeContext()) { + // Set special privileges for context for this access + // Note - this is a try-with-resources statement. The context has a very limited lifetime. + // However, be very careful using context in this block! It should ONLY perform authorization + // of the access token and retrieval of the bitstream content. + fileRetrievalContext.turnOffAuthorisationSystem(); + // Get bitstream from uuid + Bitstream bitstream = bitstreamService.find(fileRetrievalContext, uuid); + + try { + // Explicitly authenticate the access request acceptance for the bitstream + // even if we have already done it in the REST controller and throw Authorize exception if not valid + requestItemService.authorizeAccessByAccessToken(fileRetrievalContext, bitstream, accessToken); + + } catch (AuthorizeException e) { + throw new AuthorizeException("Authorization to bitstream " + uuid + " by access token FAILED"); + } + if (shouldGenerateCoverPage) { + var coverPage = getCoverpageByteArray(fileRetrievalContext, bitstream); + + this.document = new BitstreamDocument(etag(bitstream), + coverPage.length, + new ByteArrayInputStream(coverPage)); + } else { + this.document = new BitstreamDocument(bitstream.getChecksum(), + bitstream.getSizeBytes(), + bitstreamService.retrieve(fileRetrievalContext, bitstream)); + } + } catch (SQLException | AuthorizeException | IOException e) { + throw new RuntimeException(e); + } + + LOG.debug("fetched document {} {}", shouldGenerateCoverPage, document); + } + +} \ No newline at end of file diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/scheduler/eperson/RegistrationDataScheduler.java b/dspace-server-webapp/src/main/java/org/dspace/app/scheduler/eperson/RegistrationDataScheduler.java new file mode 100644 index 0000000000..49ceeba0dc --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/scheduler/eperson/RegistrationDataScheduler.java @@ -0,0 +1,60 @@ +/** + * 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.scheduler.eperson; + +import java.sql.SQLException; + +import org.dspace.core.Context; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.service.RegistrationDataService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * Contains all the schedulable task related to {@link RegistrationData} entities. + * Can be enabled via the configuration property {@code eperson.registration-data.scheduler.enabled} + * + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +@Service +@ConditionalOnProperty(prefix = "eperson.registration-data.scheduler", name = "enabled", havingValue = "true") +public class RegistrationDataScheduler { + + private static final Logger log = LoggerFactory.getLogger(RegistrationDataScheduler.class); + + @Autowired + private RegistrationDataService registrationDataService; + + /** + * Deletes expired {@link RegistrationData}. + * This task is scheduled to be run by the cron expression defined in the configuration file. + * + */ + @Scheduled(cron = "${eperson.registration-data.scheduler.expired-registration-data.cron:-}") + protected void deleteExpiredRegistrationData() throws SQLException { + Context context = new Context(); + context.turnOffAuthorisationSystem(); + try { + + registrationDataService.deleteExpiredRegistrations(context); + + context.restoreAuthSystemState(); + context.complete(); + } catch (Exception e) { + context.abort(); + log.error("Failed to delete expired registrations", e); + throw e; + } + } + + +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/AltchaCaptchaRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/AltchaCaptchaRestControllerIT.java new file mode 100644 index 0000000000..aee7103a7e --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/AltchaCaptchaRestControllerIT.java @@ -0,0 +1,82 @@ +/** + * 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; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.services.ConfigurationService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Test basic challenge method in AltchaCaptchaRestController + * Actual payload validation tests are done in AltchaCaptchaServiceTest + * + * @author Kim Shepherd + */ +public class AltchaCaptchaRestControllerIT extends AbstractControllerIntegrationTest { + + @Autowired + ConfigurationService configurationService; + + @Before + public void setup() { + configurationService.setProperty("captcha.provider", "altcha"); + configurationService.setProperty("altcha.hmac.key", "onetwothreesecret"); + } + + @After + public void tearDown() { + configurationService.setProperty("captcha.provider", "google"); + } + + @Test + public void testGetAltchaChallengeAuthenticated() throws Exception { + String authToken = getAuthToken(eperson.getEmail(), password); + + getClient(authToken).perform(get("/api/captcha/challenge")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/json;charset=UTF-8")) + .andExpect(header().exists("Cache-Control")) + .andExpect(header().exists("Expires")) + .andExpect(jsonPath("$.algorithm").value("SHA-256")) + .andExpect(jsonPath("$.challenge").isString()) + .andExpect(jsonPath("$.salt").isString()) + .andExpect(jsonPath("$.signature").isString()); + } + + @Test + public void testGetAltchaChallengeUnauthenticated() throws Exception { + getClient().perform(get("/api/captcha/challenge")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/json;charset=UTF-8")) + .andExpect(jsonPath("$.algorithm").value("SHA-256")) + .andExpect(jsonPath("$.challenge").isString()) + .andExpect(jsonPath("$.salt").isString()) + .andExpect(jsonPath("$.signature").isString()); + } + + @Test + public void testGetAltchaChallengeWithMissingHmacKey() throws Exception { + // Temporarily clear the HMAC key config + configurationService.setProperty("altcha.hmac.key", null); + + getClient().perform(get("/api/captcha/challenge")) + .andExpect(status().isBadRequest()); + + // Reset the config + configurationService.setProperty("altcha.hmac.key", "onetwothreesecret"); + } + +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java index 691927c6e4..786b9fe0ee 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java @@ -7,6 +7,7 @@ */ package org.dspace.app.rest; +import static com.jayway.jsonpath.JsonPath.read; import static jakarta.mail.internet.MimeUtility.encodeText; import static java.util.UUID.randomUUID; import static org.apache.commons.codec.CharEncoding.UTF_8; @@ -52,8 +53,11 @@ import java.io.StringWriter; import java.io.Writer; import java.nio.file.Files; import java.time.Period; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.CharEncoding; @@ -61,6 +65,9 @@ import org.apache.commons.lang3.StringUtils; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.solr.client.solrj.SolrServerException; +import org.dspace.app.requestitem.RequestItem; +import org.dspace.app.requestitem.service.RequestItemService; +import org.dspace.app.rest.model.RequestItemRest; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; @@ -70,6 +77,7 @@ import org.dspace.builder.CommunityBuilder; import org.dspace.builder.EPersonBuilder; import org.dspace.builder.GroupBuilder; import org.dspace.builder.ItemBuilder; +import org.dspace.builder.RequestItemBuilder; import org.dspace.content.Bitstream; import org.dspace.content.BitstreamFormat; import org.dspace.content.Collection; @@ -127,6 +135,16 @@ public class BitstreamRestControllerIT extends AbstractControllerIntegrationTest @Autowired private CollectionService collectionService; + @Autowired + private RequestItemService requestItemService; + + @Autowired + private ObjectMapper mapper; + + public static final String requestItemUrl = REST_SERVER_URL + + RequestItemRest.CATEGORY + '/' + + RequestItemRest.PLURAL_NAME; + private Bitstream bitstream; private BitstreamFormat supportedFormat; private BitstreamFormat knownFormat; @@ -1500,4 +1518,103 @@ public class BitstreamRestControllerIT extends AbstractControllerIntegrationTest } } + @Test + public void restrictedBitstreamWithAccessTokenTest() throws Exception { + context.turnOffAuthorisationSystem(); + + EPerson eperson2 = EPersonBuilder.createEPerson(context) + .withEmail("eperson2@mail.com") + .withPassword("qwerty02") + .build(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection 1") + .build(); + + Group restrictedGroup = GroupBuilder.createGroup(context) + .withName("Restricted Group") + .addMember(eperson) + .build(); + + Item item; + + // Create large bitstream over threshold + byte[] bytes = new byte[21 * 1024 * 1024]; // 21MB + try (InputStream is = new ByteArrayInputStream(bytes)) { + + item = ItemBuilder.createItem(context, col1) + .withTitle("item 1") + .withIssueDate("2013-01-17") + .withAuthor("Doe, John") + .build(); + bitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName("Test Embargoed Bitstream") + .withDescription("This bitstream is embargoed") + .withMimeType("text/plain") + .withReaderGroup(restrictedGroup) + .build(); + } + context.restoreAuthSystemState(); + + // Create an item request to approve. + RequestItem itemRequest = RequestItemBuilder + .createRequestItem(context, item, bitstream) + .build(); + + // Create the HTTP request body. + Map parameters = Map.of( + "acceptRequest", "true", + "subject", "subject", + "responseMessage", "Request accepted", + "accessPeriod", "+1DAY", + "suggestOpenAccess", "true"); + String content = mapper + .writer() + .writeValueAsString(parameters); + + // Send the request to approve the request. + String authToken = getAuthToken(eperson.getEmail(), password); + AtomicReference requestTokenRef = new AtomicReference<>(); + getClient(authToken).perform(put(requestItemUrl + '/' + itemRequest.getToken()) + .contentType(contentType) + .content(content)) + .andExpect(status().isOk() + ) + .andDo((var result) -> requestTokenRef.set( + read(result.getResponse().getContentAsString(), "token"))); + RequestItem foundRequest + = requestItemService.findByToken(context, requestTokenRef.get()); + + // download the bitstream - 4 scenarios + + // Someone in the restricted group is allowed access to the item without any access token + getClient(authToken).perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")) + .andExpect(status().isOk()); + + checkNumberOfStatsRecords(bitstream, 1); + + String tokenEPerson2 = getAuthToken(eperson2.getEmail(), "qwerty02"); + getClient(tokenEPerson2).perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")) + .andExpect(status().isForbidden()); + + // Anonymous users CANNOT access/download Bitstreams that are restricted, without the access token + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")) + .andExpect(status().isUnauthorized()); + + // Anonymous users CANNOT access/download Bitstreams that are restricted, with an invalid access token + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content") + .param("accessToken", "invalid_token")).andExpect(status().isUnauthorized()); + + // Anonymous users CAN access/download Bitstreams that are restricted, with a valid access token + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content") + .param("accessToken", foundRequest.getAccess_token())).andExpect(status().isOk()); + + // Cleanup created request + RequestItemBuilder.deleteRequestItem(foundRequest.getToken()); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java index 5803ec8115..8302b2951c 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java @@ -16,6 +16,8 @@ import static org.dspace.core.Constants.WRITE; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -2933,6 +2935,82 @@ public class BitstreamRestRepositoryIT extends AbstractControllerIntegrationTest .andExpect(status().isNoContent()); } + @Test + public void findAccessStatusForBitstreamBadRequestTest() throws Exception { + getClient().perform(get("/api/core/bitstreams/{uuid}/accessStatus", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + public void findAccessStatusForItemNotFoundTest() throws Exception { + UUID fakeUUID = UUID.randomUUID(); + getClient().perform(get("/api/core/items/{uuid}/accessStatus", fakeUUID)) + .andExpect(status().isNotFound()); + } + + @Test + public void findAccessStatusForBitstreamTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection 1") + .build(); + Item publicItem1 = ItemBuilder.createItem(context, col1) + .withTitle("Test item 1") + .build(); + String bitstreamContent = "ThisIsSomeDummyText"; + Bitstream bitstream = null; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, publicItem1, is) + .withName("Bitstream") + .withDescription("Description") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + // Bitstream access status should still be accessible by anonymous request + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/accessStatus")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", HalMatcher.matchNoEmbeds())) + .andExpect(jsonPath("$.status", notNullValue())) + .andExpect(jsonPath("$.embargoDate", nullValue())); + } + + @Test + public void findAccessStatusWithEmbargoDateForBitstreamTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection 1") + .build(); + Item publicItem1 = ItemBuilder.createItem(context, col1) + .withTitle("Test item 1") + .build(); + String bitstreamContent = "ThisIsSomeDummyText"; + Bitstream bitstream = null; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, publicItem1, is) + .withName("Bitstream") + .withDescription("Description") + .withMimeType("text/plain") + .withEmbargoPeriod(Period.ofMonths(6)) + .build(); + } + context.restoreAuthSystemState(); + + // Bitstream access status should still be accessible by anonymous request + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/accessStatus")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", HalMatcher.matchNoEmbeds())) + .andExpect(jsonPath("$.status", notNullValue())) + .andExpect(jsonPath("$.embargoDate", notNullValue())); + } + public boolean bitstreamExists(String token, Bitstream ...bitstreams) throws Exception { for (Bitstream bitstream : bitstreams) { if (getClient(token).perform(get("/api/core/bitstreams/" + bitstream.getID())) diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRegistrationRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRegistrationRestControllerIT.java new file mode 100644 index 0000000000..348527a331 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRegistrationRestControllerIT.java @@ -0,0 +1,404 @@ +/** + * 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; + +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.dspace.app.rest.matcher.MetadataMatcher; +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.builder.EPersonBuilder; +import org.dspace.content.MetadataField; +import org.dspace.content.service.MetadataFieldService; +import org.dspace.core.Email; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; +import org.dspace.eperson.dto.RegistrationDataChanges; +import org.dspace.eperson.dto.RegistrationDataPatch; +import org.dspace.eperson.service.AccountService; +import org.dspace.eperson.service.RegistrationDataService; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) + **/ +public class EPersonRegistrationRestControllerIT extends AbstractControllerIntegrationTest { + + private static MockedStatic emailMockedStatic; + + @Autowired + private AccountService accountService; + @Autowired + private RegistrationDataService registrationDataService; + @Autowired + private MetadataFieldService metadataFieldService; + + private RegistrationData orcidRegistration; + private MetadataField orcidMf; + private MetadataField firstNameMf; + private MetadataField lastNameMf; + private EPerson customEPerson; + private String customPassword; + + + @BeforeClass + public static void init() throws Exception { + emailMockedStatic = Mockito.mockStatic(Email.class); + } + + @AfterClass + public static void tearDownClass() throws Exception { + emailMockedStatic.close(); + } + + @Before + public void setUp() throws Exception { + super.setUp(); + context.turnOffAuthorisationSystem(); + + orcidRegistration = + registrationDataService.create(context, "0000-0000-0000-0000", RegistrationTypeEnum.ORCID); + + orcidMf = + metadataFieldService.findByElement(context, "eperson", "orcid", null); + firstNameMf = + metadataFieldService.findByElement(context, "eperson", "firstname", null); + lastNameMf = + metadataFieldService.findByElement(context, "eperson", "lastname", null); + + registrationDataService.addMetadata( + context, orcidRegistration, orcidMf, "0000-0000-0000-0000" + ); + registrationDataService.addMetadata( + context, orcidRegistration, firstNameMf, "Vincenzo" + ); + registrationDataService.addMetadata( + context, orcidRegistration, lastNameMf, "Mecca" + ); + + registrationDataService.update(context, orcidRegistration); + + customPassword = "vins-01"; + customEPerson = + EPersonBuilder.createEPerson(context) + .withEmail("vins-01@fake.mail") + .withNameInMetadata("Vins", "4Science") + .withPassword(customPassword) + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + } + + @After + public void destroy() throws Exception { + RegistrationData found = context.reloadEntity(orcidRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + super.destroy(); + } + + + @Test + public void givenOrcidToken_whenPostForMerge_thenUnauthorized() throws Exception { + + getClient().perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", orcidRegistration.getToken()) + .param("override", "eperson.firtname,eperson.lastname,eperson.orcid") + ).andExpect(status().isUnauthorized()); + + } + + @Test + public void givenExpiredToken_whenPostForMerge_thenUnauthorized() throws Exception { + + context.turnOffAuthorisationSystem(); + registrationDataService.markAsExpired(context, orcidRegistration); + context.restoreAuthSystemState(); + + getClient().perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", orcidRegistration.getToken()) + .param("override", "eperson.firtname,eperson.lastname,eperson.orcid") + ).andExpect(status().isUnauthorized()); + + } + + @Test + public void givenExpiredToken_whenPostAuthForMerge_thenForbidden() throws Exception { + + context.turnOffAuthorisationSystem(); + registrationDataService.markAsExpired(context, orcidRegistration); + context.restoreAuthSystemState(); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + getClient(tokenAdmin).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", orcidRegistration.getToken()) + .param("override", "eperson.firtname,eperson.lastname,eperson.orcid") + ).andExpect(status().isForbidden()); + + } + + @Test + public void givenValidationRegistration_whenPostAuthDiffersFromIdPathParam_thenForbidden() throws Exception { + + context.turnOffAuthorisationSystem(); + RegistrationData validationRegistration = + registrationDataService.create(context, "0000-0000-0000-0000", RegistrationTypeEnum.VALIDATION_ORCID); + context.restoreAuthSystemState(); + + try { + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + getClient(tokenAdmin).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + ).andExpect(status().isForbidden()); + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + } + + @Test + public void givenValidationRegistration_whenPostWithoutOverride_thenCreated() throws Exception { + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + context.turnOffAuthorisationSystem(); + RegistrationDataChanges changes = + new RegistrationDataChanges("vins-01@fake.mail", RegistrationTypeEnum.VALIDATION_ORCID); + RegistrationData validationRegistration = + this.accountService.renewRegistrationForEmail( + context, new RegistrationDataPatch(orcidRegistration, changes) + ); + context.restoreAuthSystemState(); + + try { + String customToken = getAuthToken(customEPerson.getEmail(), customPassword); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + ).andExpect(status().isCreated()); + + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + } + + @Test + public void givenValidationRegistration_whenPostWithOverride_thenCreated() throws Exception { + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + context.turnOffAuthorisationSystem(); + RegistrationDataChanges changes = + new RegistrationDataChanges("vins-01@fake.mail", RegistrationTypeEnum.VALIDATION_ORCID); + RegistrationData validationRegistration = + this.accountService.renewRegistrationForEmail( + context, new RegistrationDataPatch(orcidRegistration, changes) + ); + context.restoreAuthSystemState(); + + try { + + String customToken = getAuthToken(customEPerson.getEmail(), customPassword); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + .param("override", "eperson.firstname,eperson.lastname") + ).andExpect(status().isCreated()); + + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + } + + @Test + public void givenValidationRegistration_whenPostWithoutOverride_thenOnlyNewMetadataAdded() throws Exception { + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + context.turnOffAuthorisationSystem(); + RegistrationDataChanges changes = + new RegistrationDataChanges("vins-01@fake.mail", RegistrationTypeEnum.VALIDATION_ORCID); + RegistrationData validationRegistration = + this.accountService.renewRegistrationForEmail( + context, new RegistrationDataPatch(orcidRegistration, changes) + ); + context.restoreAuthSystemState(); + + try { + String customToken = getAuthToken(customEPerson.getEmail(), customPassword); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + ).andExpect(status().isCreated()) + .andExpect( + jsonPath("$.netid", equalTo("0000-0000-0000-0000")) + ) + .andExpect( + jsonPath("$.metadata", + Matchers.allOf( + MetadataMatcher.matchMetadata("eperson.firstname", "Vins"), + MetadataMatcher.matchMetadata("eperson.lastname", "4Science"), + MetadataMatcher.matchMetadata("eperson.orcid", "0000-0000-0000-0000") + ) + ) + ); + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + + } + + @Test + public void givenValidationRegistration_whenPostWithOverride_thenMetadataReplaced() throws Exception { + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + context.turnOffAuthorisationSystem(); + RegistrationDataChanges changes = + new RegistrationDataChanges("vins-01@fake.mail", RegistrationTypeEnum.VALIDATION_ORCID); + RegistrationData validationRegistration = + this.accountService.renewRegistrationForEmail( + context, new RegistrationDataPatch(orcidRegistration, changes) + ); + context.restoreAuthSystemState(); + + try { + String customToken = getAuthToken(customEPerson.getEmail(), customPassword); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + .param("override", "eperson.firstname,eperson.lastname") + ) + .andExpect(status().isCreated()) + .andExpect( + jsonPath("$.netid", equalTo("0000-0000-0000-0000")) + ) + .andExpect( + jsonPath("$.metadata", + Matchers.allOf( + MetadataMatcher.matchMetadata("eperson.firstname", "Vincenzo"), + MetadataMatcher.matchMetadata("eperson.lastname", "Mecca"), + MetadataMatcher.matchMetadata("eperson.orcid", "0000-0000-0000-0000") + ) + ) + ); + + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + } + + @Test + public void givenValidationRegistration_whenPostWithOverrideAndMetadataNotFound_thenBadRequest() throws Exception { + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + context.turnOffAuthorisationSystem(); + RegistrationDataChanges changes = + new RegistrationDataChanges("vins-01@fake.mail", RegistrationTypeEnum.VALIDATION_ORCID); + RegistrationData validationRegistration = + this.accountService.renewRegistrationForEmail( + context, new RegistrationDataPatch(orcidRegistration, changes) + ); + + try { + + context.restoreAuthSystemState(); + + String customToken = getAuthToken(customEPerson.getEmail(), customPassword); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + .param("override", "eperson.phone") + ).andExpect(status().isBadRequest()); + + context.turnOffAuthorisationSystem(); + MetadataField phoneMf = + metadataFieldService.findByElement(context, "eperson", "phone", null); + + registrationDataService.addMetadata( + context, validationRegistration, phoneMf, "1234567890" + ); + context.restoreAuthSystemState(); + + getClient(customToken).perform( + post("/api/eperson/epersons/" + customEPerson.getID()) + .param("token", validationRegistration.getToken()) + .param("override", "eperson.phone") + ).andExpect(status().isBadRequest()); + + } finally { + RegistrationData found = context.reloadEntity(validationRegistration); + if (found != null) { + this.registrationDataService.delete(context, found); + } + } + + } + +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRestRepositoryIT.java index c8746cae68..9b4258fc3f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/EPersonRestRepositoryIT.java @@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.sql.SQLException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.HashMap; @@ -65,6 +66,7 @@ import org.dspace.app.rest.model.patch.Operation; import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.app.rest.test.MetadataPatchSuite; +import org.dspace.authorize.AuthorizeException; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.EPersonBuilder; @@ -72,10 +74,14 @@ import org.dspace.builder.GroupBuilder; import org.dspace.builder.WorkflowItemBuilder; import org.dspace.content.Collection; import org.dspace.content.Community; +import org.dspace.content.MetadataField; +import org.dspace.content.service.MetadataFieldService; import org.dspace.core.I18nUtil; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; import org.dspace.eperson.PasswordHash; +import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; import org.dspace.eperson.service.AccountService; import org.dspace.eperson.service.EPersonService; import org.dspace.eperson.service.GroupService; @@ -102,6 +108,9 @@ public class EPersonRestRepositoryIT extends AbstractControllerIntegrationTest { @Autowired private ConfigurationService configurationService; + @Autowired + private MetadataFieldService metadataFieldService; + @Autowired private ObjectMapper mapper; @@ -3176,6 +3185,138 @@ public class EPersonRestRepositoryIT extends AbstractControllerIntegrationTest { } } + + @Test + public void postEpersonFromOrcidRegistrationToken() throws Exception { + + context.turnOffAuthorisationSystem(); + + String registrationEmail = "vins-01@fake.mail"; + RegistrationData orcidRegistration = + createRegistrationData(RegistrationTypeEnum.ORCID, registrationEmail); + + context.restoreAuthSystemState(); + + ObjectMapper mapper = new ObjectMapper(); + EPersonRest ePersonRest = new EPersonRest(); + MetadataRest metadataRest = new MetadataRest(); + ePersonRest.setEmail(registrationEmail); + ePersonRest.setCanLogIn(true); + ePersonRest.setNetid(orcidRegistration.getNetId()); + MetadataValueRest surname = new MetadataValueRest(); + surname.setValue("Doe"); + metadataRest.put("eperson.lastname", surname); + MetadataValueRest firstname = new MetadataValueRest(); + firstname.setValue("John"); + metadataRest.put("eperson.firstname", firstname); + ePersonRest.setMetadata(metadataRest); + + AtomicReference idRef = new AtomicReference(); + + try { + getClient().perform(post("/api/eperson/epersons") + .param("token", orcidRegistration.getToken()) + .content(mapper.writeValueAsBytes(ePersonRest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andDo(result -> idRef + .set(UUID.fromString(read(result.getResponse().getContentAsString(), "$.id")))); + } finally { + EPersonBuilder.deleteEPerson(idRef.get()); + } + } + + + @Test + public void postEPersonFromOrcidValidationRegistrationToken() throws Exception { + + context.turnOffAuthorisationSystem(); + + String registrationEmail = "vins-01@fake.mail"; + RegistrationData orcidRegistration = + createRegistrationData(RegistrationTypeEnum.VALIDATION_ORCID, registrationEmail); + + context.restoreAuthSystemState(); + + ObjectMapper mapper = new ObjectMapper(); + EPersonRest ePersonRest = createEPersonRest(registrationEmail, orcidRegistration.getNetId()); + + AtomicReference idRef = new AtomicReference<>(); + + try { + getClient().perform(post("/api/eperson/epersons") + .param("token", orcidRegistration.getToken()) + .content(mapper.writeValueAsBytes(ePersonRest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", Matchers.allOf( + hasJsonPath("$.uuid", not(empty())), + // is it what you expect? EPerson.getName() returns the email... + //hasJsonPath("$.name", is("Doe John")), + hasJsonPath("$.email", is(registrationEmail)), + hasJsonPath("$.type", is("eperson")), + hasJsonPath("$.netid", is("0000-0000-0000-0000")), + hasJsonPath("$._links.self.href", not(empty())), + hasJsonPath("$.metadata", Matchers.allOf( + matchMetadata("eperson.firstname", "Vincenzo"), + matchMetadata("eperson.lastname", "Mecca"), + matchMetadata("eperson.orcid", "0000-0000-0000-0000") + ))))) + .andDo(result -> idRef + .set(UUID.fromString(read(result.getResponse().getContentAsString(), "$.id")))); + } finally { + EPersonBuilder.deleteEPerson(idRef.get()); + } + } + + @Test + public void postEpersonNetIdWithoutPasswordNotExternalRegistrationToken() throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + + String newRegisterEmail = "new-register@fake-email.com"; + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(newRegisterEmail); + registrationRest.setNetId("0000-0000-0000-0000"); + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsBytes(registrationRest))) + .andExpect(status().isCreated()); + + RegistrationData byEmail = registrationDataService.findByEmail(context, newRegisterEmail); + + String newRegisterToken = byEmail.getToken(); + + EPersonRest ePersonRest = new EPersonRest(); + MetadataRest metadataRest = new MetadataRest(); + ePersonRest.setEmail(newRegisterEmail); + ePersonRest.setCanLogIn(true); + ePersonRest.setNetid("0000-0000-0000-0000"); + MetadataValueRest surname = new MetadataValueRest(); + surname.setValue("Doe"); + metadataRest.put("eperson.lastname", surname); + MetadataValueRest firstname = new MetadataValueRest(); + firstname.setValue("John"); + metadataRest.put("eperson.firstname", firstname); + ePersonRest.setMetadata(metadataRest); + + String token = getAuthToken(admin.getEmail(), password); + + try { + getClient().perform(post("/api/eperson/epersons") + .param("token", newRegisterToken) + .content(mapper.writeValueAsBytes(ePersonRest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } finally { + context.turnOffAuthorisationSystem(); + registrationDataService.delete(context, byEmail); + context.restoreAuthSystemState(); + } + } + + @Test public void findByMetadataByCommAdminAndByColAdminTest() throws Exception { context.turnOffAuthorisationSystem(); @@ -3732,4 +3873,51 @@ public class EPersonRestRepositoryIT extends AbstractControllerIntegrationTest { } + private static EPersonRest createEPersonRest(String registrationEmail, String netId) { + EPersonRest ePersonRest = new EPersonRest(); + MetadataRest metadataRest = new MetadataRest(); + ePersonRest.setEmail(registrationEmail); + ePersonRest.setCanLogIn(true); + ePersonRest.setNetid(netId); + MetadataValueRest surname = new MetadataValueRest(); + surname.setValue("Mecca"); + metadataRest.put("eperson.lastname", surname); + MetadataValueRest firstname = new MetadataValueRest(); + firstname.setValue("Vincenzo"); + metadataRest.put("eperson.firstname", firstname); + MetadataValueRest orcid = new MetadataValueRest(); + orcid.setValue("0000-0000-0000-0000"); + metadataRest.put("eperson.orcid", orcid); + ePersonRest.setMetadata(metadataRest); + return ePersonRest; + } + + private RegistrationData createRegistrationData(RegistrationTypeEnum validationOrcid, String registrationEmail) + throws SQLException, AuthorizeException { + RegistrationData orcidRegistration = + registrationDataService.create(context, "0000-0000-0000-0000", validationOrcid); + orcidRegistration.setEmail(registrationEmail); + + MetadataField orcidMf = + metadataFieldService.findByElement(context, "eperson", "orcid", null); + MetadataField firstNameMf = + metadataFieldService.findByElement(context, "eperson", "firstname", null); + MetadataField lastNameMf = + metadataFieldService.findByElement(context, "eperson", "lastname", null); + + registrationDataService.addMetadata( + context, orcidRegistration, orcidMf, "0000-0000-0000-0000" + ); + registrationDataService.addMetadata( + context, orcidRegistration, firstNameMf, "Vincenzo" + ); + registrationDataService.addMetadata( + context, orcidRegistration, lastNameMf, "Mecca" + ); + + registrationDataService.update(context, orcidRegistration); + return orcidRegistration; + } + + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java index 2010632caf..2d47f42a60 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java @@ -4688,7 +4688,36 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { context.restoreAuthSystemState(); getClient().perform(get("/api/core/items/{uuid}/accessStatus", item.getID())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status", notNullValue())); + .andExpect(jsonPath("$.status", notNullValue())) + .andExpect(jsonPath("$.embargoDate", nullValue())); + } + + @Test + public void findAccessStatusWithEmbargoDateForItemTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection owningCollection = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Owning Collection") + .build(); + Item item = ItemBuilder.createItem(context, owningCollection) + .withTitle("Test item") + .build(); + Bundle originalBundle = BundleBuilder.createBundle(context, item) + .withName(Constants.DEFAULT_BUNDLE_NAME) + .build(); + InputStream is = IOUtils.toInputStream("dummy", "utf-8"); + Bitstream bitstream = BitstreamBuilder.createBitstream(context, originalBundle, is) + .withName("test.pdf") + .withMimeType("application/pdf") + .withEmbargoPeriod(Period.ofMonths(6)) + .build(); + context.restoreAuthSystemState(); + getClient().perform(get("/api/core/items/{uuid}/accessStatus", item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status", notNullValue())) + .andExpect(jsonPath("$.embargoDate", notNullValue())); } @Test diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/OrcidLoginFilterIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/OrcidLoginFilterIT.java index 25ad884cfd..a81afd3ef4 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/OrcidLoginFilterIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/OrcidLoginFilterIT.java @@ -10,9 +10,11 @@ package org.dspace.app.rest; import static java.util.Arrays.asList; import static org.dspace.app.matcher.MetadataValueMatcher.with; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -21,6 +23,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; @@ -29,11 +32,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.sql.SQLException; import java.text.ParseException; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import com.jayway.jsonpath.JsonPath; import com.nimbusds.jose.JOSEException; import com.nimbusds.jwt.SignedJWT; import jakarta.servlet.http.Cookie; +import org.dspace.app.rest.matcher.MetadataMatcher; import org.dspace.app.rest.model.AuthnRest; import org.dspace.app.rest.security.OrcidLoginFilter; import org.dspace.app.rest.security.jwt.EPersonClaimProvider; @@ -46,14 +52,16 @@ import org.dspace.content.Community; import org.dspace.content.Item; import org.dspace.content.service.ItemService; import org.dspace.eperson.EPerson; +import org.dspace.eperson.RegistrationTypeEnum; import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.RegistrationDataService; import org.dspace.orcid.OrcidToken; import org.dspace.orcid.client.OrcidClient; import org.dspace.orcid.exception.OrcidClientException; import org.dspace.orcid.model.OrcidTokenResponseDTO; import org.dspace.orcid.service.OrcidTokenService; import org.dspace.services.ConfigurationService; -import org.dspace.util.UUIDUtils; +import org.hamcrest.Matchers; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -104,6 +112,9 @@ public class OrcidLoginFilterIT extends AbstractControllerIntegrationTest { @Autowired private OrcidTokenService orcidTokenService; + @Autowired + private RegistrationDataService registrationDataService; + @Before public void setup() { originalOrcidClient = orcidAuthentication.getOrcidClient(); @@ -137,45 +148,76 @@ public class OrcidLoginFilterIT extends AbstractControllerIntegrationTest { @Test public void testEPersonCreationViaOrcidLogin() throws Exception { - when(orcidClientMock.getAccessToken(CODE)).thenReturn(buildOrcidTokenResponse(ORCID, ACCESS_TOKEN)); - when(orcidClientMock.getPerson(ACCESS_TOKEN, ORCID)).thenReturn(buildPerson("Test", "User", "test@email.it")); + String defaultProp = configurationService.getProperty("orcid.registration-data.url"); + configurationService.setProperty("orcid.registration-data.url", "/test-redirect?random-token={0}"); + try { + when(orcidClientMock.getAccessToken(CODE)).thenReturn(buildOrcidTokenResponse(ORCID, ACCESS_TOKEN)); + when(orcidClientMock.getPerson(ACCESS_TOKEN, ORCID)).thenReturn( + buildPerson("Test", "User", "test@email.it")); - MvcResult mvcResult = getClient().perform(get("/api/" + AuthnRest.CATEGORY + "/orcid") - .param("code", CODE)) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl(configurationService.getProperty("dspace.ui.url"))) - .andExpect(cookie().exists("Authorization-cookie")) - .andReturn(); + MvcResult mvcResult = + getClient().perform(get("/api/" + AuthnRest.CATEGORY + "/orcid").param("code", CODE)) + .andExpect(status().is3xxRedirection()) + .andReturn(); - verify(orcidClientMock).getAccessToken(CODE); - verify(orcidClientMock).getPerson(ACCESS_TOKEN, ORCID); - verifyNoMoreInteractions(orcidClientMock); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + assertThat(redirectedUrl, not(emptyString())); - String ePersonId = getEPersonIdFromAuthorizationCookie(mvcResult); + verify(orcidClientMock).getAccessToken(CODE); + verify(orcidClientMock).getPerson(ACCESS_TOKEN, ORCID); + verifyNoMoreInteractions(orcidClientMock); - createdEperson = ePersonService.find(context, UUIDUtils.fromString(ePersonId)); - assertThat(createdEperson, notNullValue()); - assertThat(createdEperson.getEmail(), equalTo("test@email.it")); - assertThat(createdEperson.getFullName(), equalTo("Test User")); - assertThat(createdEperson.getNetid(), equalTo(ORCID)); - assertThat(createdEperson.canLogIn(), equalTo(true)); - assertThat(createdEperson.getMetadata(), hasItem(with("eperson.orcid", ORCID))); - assertThat(createdEperson.getMetadata(), hasItem(with("eperson.orcid.scope", ORCID_SCOPES[0], 0))); - assertThat(createdEperson.getMetadata(), hasItem(with("eperson.orcid.scope", ORCID_SCOPES[1], 1))); + final Pattern pattern = Pattern.compile("test-redirect\\?random-token=([a-zA-Z0-9]+)"); + final Matcher matcher = pattern.matcher(redirectedUrl); + matcher.find(); - assertThat(getOrcidAccessToken(createdEperson), is(ACCESS_TOKEN)); + assertThat(matcher.groupCount(), is(1)); + assertThat(matcher.group(1), not(emptyString())); + + String rdToken = matcher.group(1); + + getClient().perform(get("/api/eperson/registrations/search/findByToken") + .param("token", rdToken)) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.netId", equalTo(ORCID))) + .andExpect(jsonPath("$.registrationType", equalTo(RegistrationTypeEnum.ORCID.toString()))) + .andExpect(jsonPath("$.email", equalTo("test@email.it"))) + .andExpect( + jsonPath("$.registrationMetadata", + Matchers.allOf( + MetadataMatcher.matchMetadata("eperson.orcid", ORCID), + MetadataMatcher.matchMetadata("eperson.firstname", "Test"), + MetadataMatcher.matchMetadata("eperson.lastname", "User") + ) + ) + ); + } finally { + configurationService.setProperty("orcid.registration-data.url", defaultProp); + } } @Test - public void testEPersonCreationViaOrcidLoginWithoutEmail() throws Exception { + public void testRedirectiViaOrcidLoginWithoutEmail() throws Exception { when(orcidClientMock.getAccessToken(CODE)).thenReturn(buildOrcidTokenResponse(ORCID, ACCESS_TOKEN)); when(orcidClientMock.getPerson(ACCESS_TOKEN, ORCID)).thenReturn(buildPerson("Test", "User")); - getClient().perform(get("/api/" + AuthnRest.CATEGORY + "/orcid") - .param("code", CODE)) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost:4000/error?status=401&code=orcid.generic-error")); + MvcResult orcidLogin = + getClient().perform(get("/api/" + AuthnRest.CATEGORY + "/orcid").param("code", CODE)) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String redirectedUrl = orcidLogin.getResponse().getRedirectedUrl(); + + assertThat(redirectedUrl, notNullValue()); + + final Pattern pattern = Pattern.compile("external-login/([a-zA-Z0-9]+)"); + final Matcher matcher = pattern.matcher(redirectedUrl); + matcher.find(); + + assertThat(matcher.groupCount(), is(1)); + assertThat(matcher.group(1), not(emptyString())); verify(orcidClientMock).getAccessToken(CODE); verify(orcidClientMock).getPerson(ACCESS_TOKEN, ORCID); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RegistrationRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RegistrationRestRepositoryIT.java index ad27a51174..66b0f77f09 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RegistrationRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RegistrationRestRepositoryIT.java @@ -7,21 +7,32 @@ */ package org.dspace.app.rest; +import static org.dspace.app.rest.repository.RegistrationRestRepository.TOKEN_QUERY_PARAM; import static org.dspace.app.rest.repository.RegistrationRestRepository.TYPE_FORGOT; import static org.dspace.app.rest.repository.RegistrationRestRepository.TYPE_QUERY_PARAM; import static org.dspace.app.rest.repository.RegistrationRestRepository.TYPE_REGISTER; +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.sql.SQLException; import java.util.Iterator; import java.util.List; @@ -30,17 +41,30 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.dspace.app.rest.matcher.RegistrationMatcher; import org.dspace.app.rest.model.RegistrationRest; +import org.dspace.app.rest.model.patch.AddOperation; +import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.repository.RegistrationRestRepository; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.authorize.AuthorizeException; import org.dspace.builder.EPersonBuilder; +import org.dspace.core.Email; import org.dspace.eperson.CaptchaServiceImpl; +import org.dspace.eperson.EPerson; import org.dspace.eperson.InvalidReCaptchaException; import org.dspace.eperson.RegistrationData; +import org.dspace.eperson.RegistrationTypeEnum; import org.dspace.eperson.dao.RegistrationDataDAO; import org.dspace.eperson.service.CaptchaService; +import org.dspace.eperson.service.RegistrationDataService; import org.dspace.services.ConfigurationService; import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationTest { @@ -50,12 +74,35 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT @Autowired private RegistrationDataDAO registrationDataDAO; @Autowired + private RegistrationDataService registrationDataService; + @Autowired private ConfigurationService configurationService; @Autowired private RegistrationRestRepository registrationRestRepository; @Autowired private ObjectMapper mapper; + private static MockedStatic emailMockedStatic; + + @After + public void tearDown() throws Exception { + Iterator iterator = registrationDataDAO.findAll(context, RegistrationData.class).iterator(); + while (iterator.hasNext()) { + RegistrationData registrationData = iterator.next(); + registrationDataDAO.delete(context, registrationData); + } + } + + @BeforeClass + public static void init() throws Exception { + emailMockedStatic = Mockito.mockStatic(Email.class); + } + + @AfterClass + public static void tearDownClass() throws Exception { + emailMockedStatic.close(); + } + @Test public void findByTokenTestExistingUserTest() throws Exception { String email = eperson.getEmail(); @@ -319,7 +366,7 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT RegistrationRest registrationRest = new RegistrationRest(); registrationRest.setEmail(eperson.getEmail()); - // when reCAPTCHA enabled and request doesn't contain "X-Recaptcha-Token” header + // when reCAPTCHA enabled and request doesn't contain "x-captcha-payload” header getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) .content(mapper.writeValueAsBytes(registrationRest)) @@ -340,10 +387,10 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT registrationRest.setEmail(eperson.getEmail()); String captchaToken = "invalid-captcha-Token"; - // when reCAPTCHA enabled and request contains Invalid "X-Recaptcha-Token” header + // when reCAPTCHA enabled and request contains Invalid "x-captcha-payload” header getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) - .header("X-Recaptcha-Token", captchaToken) + .header("x-captcha-payload", captchaToken) .content(mapper.writeValueAsBytes(registrationRest)) .contentType(contentType)) .andExpect(status().isForbidden()); @@ -376,17 +423,17 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT RegistrationRest registrationRest = new RegistrationRest(); registrationRest.setEmail(eperson.getEmail()); try { - // will throw InvalidReCaptchaException because 'X-Recaptcha-Token' not equal captchaToken + // will throw InvalidReCaptchaException because 'x-captcha-payload' not equal captchaToken getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) - .header("X-Recaptcha-Token", captchaToken1) + .header("x-captcha-payload", captchaToken1) .content(mapper.writeValueAsBytes(registrationRest)) .contentType(contentType)) .andExpect(status().isForbidden()); getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) - .header("X-Recaptcha-Token", captchaToken) + .header("x-captcha-payload", captchaToken) .content(mapper.writeValueAsBytes(registrationRest)) .contentType(contentType)) .andExpect(status().isCreated()); @@ -399,7 +446,7 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT registrationRest.setEmail(newEmail); getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) - .header("X-Recaptcha-Token", captchaToken) + .header("x-captcha-payload", captchaToken) .content(mapper.writeValueAsBytes(registrationRest)) .contentType(contentType)) .andExpect(status().isCreated()); @@ -415,7 +462,7 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT registrationRest.setEmail(newEmail); getClient().perform(post("/api/eperson/registrations") .param(TYPE_QUERY_PARAM, TYPE_REGISTER) - .header("X-Recaptcha-Token", captchaToken) + .header("x-captcha-payload", captchaToken) .content(mapper.writeValueAsBytes(registrationRest)) .contentType(contentType)) .andExpect(status().is(HttpServletResponse.SC_UNAUTHORIZED)); @@ -462,4 +509,507 @@ public class RegistrationRestRepositoryIT extends AbstractControllerIntegrationT .andExpect(status().isBadRequest()); } + @Test + public void givenRegistrationData_whenPatchInvalidValue_thenUnprocessableEntityResponse() + throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(eperson.getEmail()); + registrationRest.setUser(eperson.getID()); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // given RegistrationData with email + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .content(mapper.writeValueAsBytes(registrationRest)) + .contentType(contentType)) + .andExpect(status().isCreated()); + + RegistrationData registrationData = + registrationDataService.findByEmail(context, registrationRest.getEmail()); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = null; + String patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isBadRequest()); + + newMail = "test@email.com"; + patchContent = getPatchContent( + List.of(new AddOperation("/email", newMail)) + ); + + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isUnprocessableEntity()); + + newMail = "invalidemail!!!!"; + patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isUnprocessableEntity()); + } + + @Test + public void givenRegistrationData_whenPatchWithInvalidToken_thenUnprocessableEntityResponse() + throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(eperson.getEmail()); + registrationRest.setUser(eperson.getID()); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // given RegistrationData with email + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .content(mapper.writeValueAsBytes(registrationRest)) + .contentType(contentType)) + .andExpect(status().isCreated()); + + RegistrationData registrationData = + registrationDataService.findByEmail(context, registrationRest.getEmail()); + + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = null; + String newMail = "validemail@email.com"; + String patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isUnauthorized()); + + token = "notexistingtoken"; + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isUnauthorized()); + + context.turnOffAuthorisationSystem(); + registrationData = context.reloadEntity(registrationData); + registrationDataService.markAsExpired(context, registrationData); + context.commit(); + context.restoreAuthSystemState(); + + registrationData = context.reloadEntity(registrationData); + + assertThat(registrationData.getExpires(), notNullValue()); + + token = registrationData.getToken(); + newMail = "validemail@email.com"; + patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().isUnauthorized()); + } + + @Test + public void givenRegistrationDataWithEmail_whenPatchForReplaceEmail_thenSuccessfullResponse() + throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(eperson.getEmail()); + registrationRest.setUser(eperson.getID()); + + // given RegistrationData with email + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .content(mapper.writeValueAsBytes(registrationRest)) + .contentType(contentType)) + .andExpect(status().isCreated()); + + RegistrationData registrationData = + registrationDataService.findByEmail(context, registrationRest.getEmail()); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = "vins-01@fake.mail"; + String patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().is2xxSuccessful()); + } + + @Test + public void givenRegistrationDataWithoutEmail_whenPatchForAddEmail_thenSuccessfullResponse() + throws Exception { + + RegistrationData registrationData = + createNewRegistrationData("0000-1111-2222-3333", RegistrationTypeEnum.ORCID); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = "vins-01@fake.mail"; + String patchContent = getPatchContent( + List.of(new AddOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + // then succesful response returned + .andExpect(status().is2xxSuccessful()); + } + + @Test + public void givenRegistrationDataWithEmail_whenPatchForReplaceEmail_thenNewRegistrationDataCreated() + throws Exception { + + ObjectMapper mapper = new ObjectMapper(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(eperson.getEmail()); + registrationRest.setUser(eperson.getID()); + + // given RegistrationData with email + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .content(mapper.writeValueAsBytes(registrationRest)) + .contentType(contentType)) + .andExpect(status().isCreated()); + + RegistrationData registrationData = + registrationDataService.findByEmail(context, registrationRest.getEmail()); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = "vins-01@fake.mail"; + String patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then email updated with new registration + RegistrationData newRegistration = registrationDataService.findByEmail(context, newMail); + assertThat(newRegistration, notNullValue()); + assertThat(newRegistration.getToken(), not(emptyOrNullString())); + assertThat(newRegistration.getEmail(), equalTo(newMail)); + + assertThat(newRegistration.getEmail(), not(equalTo(registrationData.getEmail()))); + assertThat(newRegistration.getToken(), not(equalTo(registrationData.getToken()))); + + registrationData = context.reloadEntity(registrationData); + assertThat(registrationData, nullValue()); + } + + @Test + public void givenRegistrationDataWithoutEmail_whenPatchForReplaceEmail_thenNewRegistrationDataCreated() + throws Exception { + RegistrationData registrationData = + createNewRegistrationData("0000-1111-2222-3333", RegistrationTypeEnum.ORCID); + + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = "vins-01@fake.mail"; + String patchContent = getPatchContent( + List.of(new AddOperation("/email", newMail)) + ); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then email updated with new registration + RegistrationData newRegistration = registrationDataService.findByEmail(context, newMail); + assertThat(newRegistration, notNullValue()); + assertThat(newRegistration.getToken(), not(emptyOrNullString())); + assertThat(newRegistration.getEmail(), equalTo(newMail)); + + assertThat(newRegistration.getEmail(), not(equalTo(registrationData.getEmail()))); + assertThat(newRegistration.getToken(), not(equalTo(registrationData.getToken()))); + + registrationData = context.reloadEntity(registrationData); + assertThat(registrationData, nullValue()); + } + + @Test + public void givenRegistrationDataWithoutEmail_whenPatchForAddEmail_thenExternalLoginSent() throws Exception { + RegistrationData registrationData = + createNewRegistrationData("0000-1111-2222-3333", RegistrationTypeEnum.ORCID); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + String token = registrationData.getToken(); + String newMail = "vins-01@fake.mail"; + String patchContent = getPatchContent( + List.of(new AddOperation("/email", newMail)) + ); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then verification email sent + verify(spy, times(1)).addRecipient(newMail); + verify(spy).addArgument( + ArgumentMatchers.contains( + RegistrationTypeEnum.ORCID.getLink() + ) + ); + verify(spy, times(1)).send(); + } + + @Test + public void givenRegistrationDataWithEmail_whenPatchForNewEmail_thenExternalLoginSent() throws Exception { + RegistrationData registrationData = + createNewRegistrationData("0000-1111-2222-3333", RegistrationTypeEnum.ORCID); + + String token = registrationData.getToken(); + String newMail = "vincenzo.mecca@orcid.com"; + String patchContent = getPatchContent( + List.of(new AddOperation("/email", newMail)) + ); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + verify(spy, times(1)).addRecipient(newMail); + verify(spy).addArgument( + ArgumentMatchers.contains( + registrationData.getRegistrationType().getLink() + ) + ); + verify(spy, times(1)).send(); + + registrationData = registrationDataService.findByEmail(context, newMail); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + token = registrationData.getToken(); + newMail = "vins-01@fake.mail"; + patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", newMail)) + ); + + spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then verification email sent + verify(spy, times(1)).addRecipient(newMail); + verify(spy).addArgument( + ArgumentMatchers.contains( + registrationData.getRegistrationType().getLink() + ) + ); + verify(spy, times(1)).send(); + } + + @Test + public void givenRegistrationDataWithEmail_whenPatchForExistingEPersonEmail_thenReviewAccountLinkSent() + throws Exception { + ObjectMapper mapper = new ObjectMapper(); + RegistrationRest registrationRest = new RegistrationRest(); + registrationRest.setEmail(eperson.getEmail()); + registrationRest.setNetId("0000-0000-0000-0000"); + + // given RegistrationData with email + getClient().perform(post("/api/eperson/registrations") + .param(TYPE_QUERY_PARAM, TYPE_REGISTER) + .content(mapper.writeValueAsBytes(registrationRest)) + .contentType(contentType)) + .andExpect(status().isCreated()); + + RegistrationData registrationData = + registrationDataService.findByEmail(context, registrationRest.getEmail()); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + context.turnOffAuthorisationSystem(); + final EPerson vins = + EPersonBuilder.createEPerson(context) + .withEmail("vins-01@fake.mail") + .withNameInMetadata("Vincenzo", "Mecca") + .withOrcid("0101-0101-0101-0101") + .build(); + context.restoreAuthSystemState(); + + String token = registrationData.getToken(); + String vinsEmail = vins.getEmail(); + String patchContent = getPatchContent( + List.of(new ReplaceOperation("/email", vins.getEmail())) + ); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then verification email sent + verify(spy, times(1)).addRecipient(vinsEmail); + verify(spy).addArgument( + ArgumentMatchers.contains( + RegistrationTypeEnum.VALIDATION_ORCID.getLink() + ) + ); + verify(spy, times(1)).send(); + } + + @Test + public void givenRegistrationDataWithoutEmail_whenPatchForExistingAccount_thenReviewAccountSent() throws Exception { + RegistrationData registrationData = + createNewRegistrationData("0000-1111-2222-3333", RegistrationTypeEnum.ORCID); + + assertThat(registrationData, notNullValue()); + assertThat(registrationData.getToken(), not(emptyOrNullString())); + + context.turnOffAuthorisationSystem(); + final EPerson vins = + EPersonBuilder.createEPerson(context) + .withEmail("vins-01@fake.mail") + .withNameInMetadata("Vincenzo", "Mecca") + .withOrcid("0101-0101-0101-0101") + .build(); + context.commit(); + context.restoreAuthSystemState(); + + String token = registrationData.getToken(); + String vinsEmail = vins.getEmail(); + String patchContent = getPatchContent( + List.of(new AddOperation("/email", vins.getEmail())) + ); + + Email spy = Mockito.spy(Email.class); + doNothing().when(spy).send(); + + emailMockedStatic.when(() -> Email.getEmail(any())).thenReturn(spy); + + // when patch for replace email + getClient().perform(patch("/api/eperson/registrations/" + registrationData.getID()) + .param(TOKEN_QUERY_PARAM, token) + .content(patchContent) + .contentType(contentType)) + .andExpect(status().is2xxSuccessful()); + + // then verification email sent + verify(spy, times(1)).addRecipient(vinsEmail); + verify(spy).addArgument( + ArgumentMatchers.contains( + RegistrationTypeEnum.VALIDATION_ORCID.getLink() + ) + ); + verify(spy, times(1)).send(); + } + + + private RegistrationData createNewRegistrationData( + String netId, RegistrationTypeEnum type + ) throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + RegistrationData registrationData = + registrationDataService.create(context, netId, type); + context.commit(); + context.restoreAuthSystemState(); + return registrationData; + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java index e3681877eb..9a03688096 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java @@ -27,13 +27,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.net.MalformedURLException; -import java.net.URISyntaxException; import java.sql.SQLException; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.UUID; @@ -61,6 +60,7 @@ import org.dspace.content.Item; import org.dspace.services.ConfigurationService; import org.exparity.hamcrest.date.LocalDateTimeMatchers; import org.hamcrest.Matchers; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -102,6 +102,13 @@ public class RequestItemRepositoryIT private Bitstream bitstream; + private Map altchaPayload; + + @After + public void tearDown() { + configurationService.setProperty("captcha.provider", "google"); + } + @Before public void init() throws SQLException, AuthorizeException, IOException { @@ -130,6 +137,18 @@ public class RequestItemRepositoryIT .withName("Bitstream") .build(); + altchaPayload = new HashMap<>(); + altchaPayload.put("challenge", "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"); + altchaPayload.put("salt", "salt123"); + altchaPayload.put("number", 1); + altchaPayload.put("signature", "f5cd3ed4161f5f3c914c5778e716d6b446fa277086bbb8fd3e2b0c4b89f18833"); + altchaPayload.put("algorithm", "SHA-256"); + + // Set up altcha configuration + configurationService.setProperty("captcha.provider", "altcha"); + configurationService.setProperty("altcha.algorithm", "SHA-256"); + configurationService.setProperty("altcha.hmac.key", "onetwothreesecret"); + context.restoreAuthSystemState(); } @@ -263,8 +282,6 @@ public class RequestItemRepositoryIT @Test public void testCreateAndReturnNotAuthenticated() throws SQLException, AuthorizeException, IOException, Exception { - System.out.println("createAndReturn (not authenticated)"); - // Fake up a request in REST form. RequestItemRest rir = new RequestItemRest(); rir.setAllfiles(false); @@ -273,10 +290,16 @@ public class RequestItemRepositoryIT rir.setRequestEmail(RequestItemBuilder.REQ_EMAIL); rir.setRequestMessage(RequestItemBuilder.REQ_MESSAGE); rir.setRequestName(RequestItemBuilder.REQ_NAME); + String base64Payload = + "eyJjaGFsbGVuZ2UiOiJhNjY1YTQ1OTIwNDIyZjlkNDE3ZTQ4NjdlZmRjNGZiOGEwNGExZ" + + "jNmZmYxZmEwN2U5OThlODZmN2Y3YTI3YWUzIiwic2FsdCI6InNhbHQxMjMiLCJudW1iZX" + + "IiOjEsInNpZ25hdHVyZSI6ImY1Y2QzZWQ0MTYxZjVmM2M5MTRjNTc3OGU3MTZkNmI0NDZ" + + "mYTI3NzA4NmJiYjhmZDNlMmIwYzRiODlmMTg4MzMiLCJhbGdvcml0aG0iOiJTSEEtMjU2In0="; // Create it and see if it was created correctly. try { getClient().perform(post(URI_ROOT) + .header("x-captcha-payload", base64Payload) .content(mapper.writeValueAsBytes(rir)) .contentType(contentType)) .andExpect(status().isCreated()) @@ -314,8 +337,6 @@ public class RequestItemRepositoryIT @Test public void testCreateAndReturnBadRequest() throws SQLException, AuthorizeException, IOException, Exception { - System.out.println("createAndReturn (bad requests)"); - // Fake up a request in REST form. RequestItemRest rir = new RequestItemRest(); rir.setBitstreamId(bitstream.getID().toString()); @@ -391,7 +412,6 @@ public class RequestItemRepositoryIT @Test public void testCreateWithInvalidCSRF() throws Exception { - // Login via password to retrieve a valid token String token = getAuthToken(eperson.getEmail(), password); @@ -494,6 +514,7 @@ public class RequestItemRepositoryIT Map parameters = Map.of( "acceptRequest", "true", "subject", "subject", + "accessPeriod", "+1DAY", "responseMessage", "Request accepted", "suggestOpenAccess", "true"); String content = mapper @@ -574,7 +595,6 @@ public class RequestItemRepositoryIT @Test public void testPutCompletedRequest() throws Exception { - // Create an item request that is already denied. RequestItem itemRequest = RequestItemBuilder .createRequestItem(context, item, bitstream) @@ -606,38 +626,5 @@ public class RequestItemRepositoryIT assertEquals("Wrong domain class", RequestItemRest.class, instanceClass); } - /** - * Test that generated links include the correct base URL, where the UI URL has a subpath like /subdir - */ - @Test - public void testGetLinkTokenEmailWithSubPath() throws MalformedURLException, URISyntaxException { - RequestItemRepository instance = applicationContext.getBean( - RequestItemRest.CATEGORY + '.' + RequestItemRest.PLURAL_NAME, - RequestItemRepository.class); - String currentDspaceUrl = configurationService.getProperty("dspace.ui.url"); - String newDspaceUrl = currentDspaceUrl + "/subdir"; - // Add a /subdir to the url for this test - configurationService.setProperty("dspace.ui.url", newDspaceUrl); - String expectedUrl = newDspaceUrl + "/request-a-copy/token"; - String generatedLink = instance.getLinkTokenEmail("token"); - // The URLs should match - assertEquals(expectedUrl, generatedLink); - configurationService.reloadConfig(); - } - /** - * Test that generated links include the correct base URL, with NO subpath elements - */ - @Test - public void testGetLinkTokenEmailWithoutSubPath() throws MalformedURLException, URISyntaxException { - RequestItemRepository instance = applicationContext.getBean( - RequestItemRest.CATEGORY + '.' + RequestItemRest.PLURAL_NAME, - RequestItemRepository.class); - String currentDspaceUrl = configurationService.getProperty("dspace.ui.url"); - String expectedUrl = currentDspaceUrl + "/request-a-copy/token"; - String generatedLink = instance.getLinkTokenEmail("token"); - // The URLs should match - assertEquals(expectedUrl, generatedLink); - configurationService.reloadConfig(); - } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java index 493b3bf946..f8c33c8af2 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java @@ -1747,14 +1747,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -1774,7 +1775,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -1789,14 +1792,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -1816,7 +1820,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -1831,14 +1837,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -1865,7 +1872,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -1968,7 +1977,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra verifyNoMoreInteractions(orcidClientMock); profile = context.reloadEntity(profile); + eperson = context.reloadEntity(eperson); + assertThat(eperson.getNetid(), nullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); @@ -2058,7 +2069,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + eperson = context.reloadEntity(eperson); + assertThat(eperson.getNetid(), nullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -2073,14 +2086,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -2100,7 +2114,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -2115,14 +2131,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -2142,7 +2159,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -2194,7 +2213,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra verifyNoMoreInteractions(orcidClientMock); profile = context.reloadEntity(profile); + eperson = context.reloadEntity(eperson); + assertThat(eperson.getNetid(), nullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); @@ -2209,14 +2230,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -2236,7 +2258,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); @@ -2287,7 +2311,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra verifyNoMoreInteractions(orcidClientMock); profile = context.reloadEntity(profile); + eperson = context.reloadEntity(eperson); + assertThat(eperson.getNetid(), nullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); @@ -2340,7 +2366,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra verifyNoMoreInteractions(orcidClientMock); profile = context.reloadEntity(profile); + eperson = context.reloadEntity(eperson); + assertThat(eperson.getNetid(), nullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), empty()); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), empty()); @@ -2355,14 +2383,15 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra context.turnOffAuthorisationSystem(); EPerson ePerson = EPersonBuilder.createEPerson(context) - .withCanLogin(true) - .withOrcid("0000-1111-2222-3333") - .withOrcidScope("/read") - .withOrcidScope("/write") - .withEmail("test@email.it") - .withPassword(password) - .withNameInMetadata("Test", "User") - .build(); + .withCanLogin(true) + .withOrcid("0000-1111-2222-3333") + .withNetId("0000-1111-2222-3333") + .withOrcidScope("/read") + .withOrcidScope("/write") + .withEmail("test@email.it") + .withPassword(password) + .withNameInMetadata("Test", "User") + .build(); OrcidTokenBuilder.create(context, ePerson, "3de2e370-8aa9-4bbe-8d7e-f5b1577bdad4").build(); @@ -2382,7 +2411,9 @@ public class ResearcherProfileRestRepositoryIT extends AbstractControllerIntegra .andExpect(status().isForbidden()); profile = context.reloadEntity(profile); + ePerson = context.reloadEntity(ePerson); + assertThat(ePerson.getNetid(), notNullValue()); assertThat(getMetadataValues(profile, "person.identifier.orcid"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.scope"), not(empty())); assertThat(getMetadataValues(profile, "dspace.orcid.authenticated"), not(empty())); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/authorization/RequestCopyFeatureIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/authorization/RequestCopyFeatureIT.java index 6fd5fad35c..a91b1aee1e 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/authorization/RequestCopyFeatureIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/authorization/RequestCopyFeatureIT.java @@ -47,6 +47,8 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; + + public class RequestCopyFeatureIT extends AbstractControllerIntegrationTest { @Autowired diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java index 36fc2f2aa1..ff7affb53f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java @@ -102,7 +102,8 @@ public class BitstreamMatcher { return matchEmbeds( "bundle", "format", - "thumbnail" + "thumbnail", + "accessStatus" ); } @@ -115,7 +116,8 @@ public class BitstreamMatcher { "content", "format", "self", - "thumbnail" + "thumbnail", + "accessStatus" ); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/RequestCopyMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/RequestCopyMatcher.java index ef7b9a3eac..3a80a8347e 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/RequestCopyMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/RequestCopyMatcher.java @@ -5,7 +5,6 @@ * * http://www.dspace.org/license/ */ - package org.dspace.app.rest.matcher; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath; diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java index 7dfe3e69e0..471e5d3704 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java @@ -15,7 +15,7 @@ import org.junit.Before; import org.junit.Test; /** - * Test the AccessStatusRestTest class + * Test the AccessStatusRest class */ public class AccessStatusRestTest { @@ -36,4 +36,15 @@ public class AccessStatusRestTest { accessStatusRest.setStatus(DefaultAccessStatusHelper.UNKNOWN); assertNotNull(accessStatusRest.getStatus()); } + + @Test + public void testEmbargoDateIsNullBeforeEmbargoDateSet() throws Exception { + assertNull(accessStatusRest.getEmbargoDate()); + } + + @Test + public void testEmbargoDateIsNotNullAfterEmbargoDateSet() throws Exception { + accessStatusRest.setEmbargoDate("2050-01-01"); + assertNotNull(accessStatusRest.getEmbargoDate()); + } } diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 402f2b26f8..89f9ded2dc 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -878,6 +878,19 @@ access.status.embargo.forever.year = 10000 access.status.embargo.forever.month = 1 access.status.embargo.forever.day = 1 +# How to determine the access status: +# anonymous - Only consider the anonymous group +# current - Consider the current user +# +# For example, if an object is embargoed, "anonymous" will ensure the embargo status is displayed +# for everyone (regardless of permissions). But, "current" will only display the embargo status +# if the current user does not have read permissions on the object. +# +# This configuration doesn't impact the calculation for OAI-PMH. The XOAI plugin will always +# calculate the status with the "anonymous" type. +access.status.for-user.item = anonymous +access.status.for-user.bitstream = current + # implementation of access status helper plugin - replace with local implementation if applicable # This default access status helper provides an item status based on the policies of the primary # bitstream (or first bitstream in the original bundles if no primary file is specified). @@ -1549,23 +1562,6 @@ log.report.dir = ${dspace.dir}/log # google-analytics.bundles = none google-analytics.bundles = ORIGINAL -#################################################################### -#---------------------------------------------------------------# -#----------------REQUEST ITEM CONFIGURATION---------------------# -#---------------------------------------------------------------# - -# Configuration of request-item. Possible values: -# all - Anonymous users can request an item -# logged - Login is mandatory to request an item -# empty/commented out - request-copy not allowed -request.item.type = all -# Should all Request Copy emails go to the helpdesk instead of the item submitter? -request.item.helpdesk.override = false -# Should a rejection of a copy request send an email back to the requester? -# Defaults to "true", which means a rejection email is sent back. -# Setting it to "false" results in a silent rejection. -request.item.reject.email = true - #------------------------------------------------------------------# #------------------SUBMISSION CONFIGURATION------------------------# #------------------------------------------------------------------# @@ -1595,12 +1591,15 @@ solr-database-resync.cron = 0 15 2 * * ? # process-cleaner.days = 14 #---------------------------------------------------------------# -#----------------GOOGLE CAPTCHA CONFIGURATION-------------------# +#--------------------CAPTCHA CONFIGURATION----------------------# #---------------------------------------------------------------# # Enable CAPTCHA verification on ePerson registration - +# (see modules/requestitem.cfg to enable CAPTCHA verification for request-a-copy) registration.verification.enabled = false +# Captcha provider to use, either google (default) or altcha (see modules/altcha.cfg) +# captcha.provider = google + # version we want to use, possible values (v2 or v3) #google.recaptcha.version = @@ -1624,6 +1623,43 @@ google.recaptcha.site-verify = https://www.google.com/recaptcha/api/siteverify # checkbox - The "I'm not a robot" Checkbox requires the user to click a checkbox indicating the user is not a robot. #google.recaptcha.mode = +#------------------------------------------------------------------# +#---------------REGISTRATION DATA CONFIGURATION--------------------# +#------------------------------------------------------------------# + +# Configuration for the duration of the token depending on the type +# the format used should be compatible with the standard DURATION format (ISO-8601), +# but without the prefix `PT`: +# +# - PT1H -> 1H // hours +# - PT1M -> 1M // minutes +# - PT1S -> 1S // seconds +# +# reference: https://www.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm +# +# Sets the token expiration to complete the login with orcid to be 1H +eperson.registration-data.token.orcid.expiration = 1H +# Sets the token expiration for the email validation sent with orcid login to be 1H +eperson.registration-data.token.validation_orcid.expiration = 1H +# Sets the token expiration for the forgot token type to be 24H +eperson.registration-data.token.forgot.expiration = 24H +# Sets the token expiration for the register token type to be 24H +eperson.registration-data.token.register.expiration = 24H +# Sets the token expiration for the invitation token type to be 24H +eperson.registration-data.token.invitation.expiration = 24H +# Sets the token expiration for the change_password token type to be 1H +eperson.registration-data.token.change_password.expiration = 1H + +# Configuration that enables the schedulable tasks related to the registration, as of now the class schedules a cleanup +# of the registationdata table. This action will remove all the expired token from that table. +# Just take a look to org.dspace.app.scheduler.eperson.RegistrationDataScheduler for a deeper understanding. +# The property `enabled` should be setted to true to enable it. +eperson.registration-data.scheduler.enabled = true +# Configuration for the task that deletes expired registrations. +# Its value should be compatible with the cron format. +# By default it's scheduled to be run every 15 minutes. +eperson.registration-data.scheduler.expired-registration-data.cron = 0 0/15 * * * ? + #------------------------------------------------------------------# #-------------------MODULE CONFIGURATIONS--------------------------# #------------------------------------------------------------------# @@ -1647,8 +1683,8 @@ module_dir = modules # However, please note that "include" statements in local.cfg will be loaded # PRIOR to those below (and therefore may override configs in these default # module configuration files). - include = ${module_dir}/actuator.cfg +include = ${module_dir}/altcha.cfg include = ${module_dir}/altmetrics.cfg include = ${module_dir}/assetstore.cfg include = ${module_dir}/authentication.cfg @@ -1676,6 +1712,7 @@ include = ${module_dir}/openaire-client.cfg include = ${module_dir}/orcid.cfg include = ${module_dir}/qaevents.cfg include = ${module_dir}/rdf.cfg +include = ${module_dir}/requestitem.cfg include = ${module_dir}/rest.cfg include = ${module_dir}/iiif.cfg include = ${module_dir}/saml-relying-party.cfg diff --git a/dspace/config/emails/orcid b/dspace/config/emails/orcid new file mode 100644 index 0000000000..61ce24ce61 --- /dev/null +++ b/dspace/config/emails/orcid @@ -0,0 +1,22 @@ +## E-mail sent to DSpace users when they try to register with an ORCID account +## +## Parameters: {0} is expanded to a special registration URL +## +## See org.dspace.core.Email for information on the format of this file. +## +#set($subject = "${config.get('dspace.name')} Account Registration") +#set($phone = ${config.get('mail.message.helpdesk.telephone')}) +To complete registration for a DSpace account, please click the link +below: + + ${params[0]} + +If you need assistance with your account, please email + + ${config.get("mail.helpdesk")} +#if( $phone ) + +or call us at ${phone}. +#end + +The ${config.get("dspace.name")} Team diff --git a/dspace/config/emails/request_item.granted_token b/dspace/config/emails/request_item.granted_token new file mode 100644 index 0000000000..969a109163 --- /dev/null +++ b/dspace/config/emails/request_item.granted_token @@ -0,0 +1,39 @@ +## Sent to the person requesting a copy of a restricted document when the +## request is granted. +## +## Parameters: +## {0} name of the requestor +## {1} Handle URL of the requested Item +## {2} title of the requested Item +## {3} name of the grantor +## {4} email address of the grantor (unused) +## {5} custom message sent by the grantor. +## {6} URL of the requested Item including the secure access token +## {7} expiration date of the granted access (timestamp, formatted in requestitem.cfg) + +#set($subject = 'Request for Copy of Restricted Document is Granted') +Dear ${params[0]}: + +Your request for a copy of the file(s) from the below document has been approved. The following link will grant access to the files you requested. + +Secure link: ${params[6]} + + ${params[2]} + ${params[1]} + +#if( $params[7] ) + + +Your access will last until ${params[7]}. +#end +#if( $params[5] ) + + + +An additional message from the approver follows: + +${params[5]} +#end + +Best regards, +The ${config.get('dspace.name')} Team diff --git a/dspace/config/emails/validation_orcid b/dspace/config/emails/validation_orcid new file mode 100644 index 0000000000..f874fb0005 --- /dev/null +++ b/dspace/config/emails/validation_orcid @@ -0,0 +1,22 @@ +## E-mail sent to DSpace users when they confirm the orcid email address for the account +## +## Parameters: {0} is expanded to a special registration URL +## +## See org.dspace.core.Email for information on the format of this file. +## +#set($subject = "${config.get('dspace.name')} Account Registration") +#set($phone = ${config.get('mail.message.helpdesk.telephone')}) +To confirm your email and create the needed account, please click the link +below: + + ${params[0]} + +If you need assistance with your account, please email + + ${config.get("mail.helpdesk")} +#if( $phone ) + +or call us at ${phone}. +#end + +The ${config.get("dspace.name")} Team diff --git a/dspace/config/hibernate.cfg.xml b/dspace/config/hibernate.cfg.xml index 23701b0279..da84fc7886 100644 --- a/dspace/config/hibernate.cfg.xml +++ b/dspace/config/hibernate.cfg.xml @@ -73,6 +73,7 @@ +