Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@
import org.dspace.core.Utils;
import org.dspace.eperson.EPerson;
import org.dspace.eperson.Group;
import org.dspace.eperson.clarin.AmbiguousIdentityException;
import org.dspace.eperson.factory.EPersonServiceFactory;
import org.dspace.eperson.service.EPersonService;
import org.dspace.eperson.service.GroupService;
import org.dspace.eperson.service.clarin.ClarinIdentityService;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;

Expand Down Expand Up @@ -124,6 +126,7 @@ public class ClarinShibAuthentication implements AuthenticationMethod {
ClarinServiceFactory.getInstance().getClarinUserRegistration();
protected ClarinVerificationTokenService clarinVerificationTokenService = ClarinServiceFactory.getInstance()
.getClarinVerificationTokenService();
protected ClarinIdentityService identityService = EPersonServiceFactory.getInstance().getClarinIdentityService();

/**
* Authenticate the given or implicit credentials. This is the heart of the
Expand Down Expand Up @@ -558,7 +561,18 @@ protected EPerson findEPerson(Context context, HttpServletRequest request, Strin

// 1) First, look for a netid header.
if (netidHeaders != null) {
eperson = findEpersonByNetId(netidHeaders, shibheaders, ePersonService, context, true);
eperson = findEpersonByNetId(netidHeaders, shibheaders, identityService, context, true);
if (eperson != null) {
foundNetID = true;
}
}

// 1b) CLARIN: an allowlisted identity proxy (e.g. e-INFRA CZ/Perun) may release
// voperson_external_id, listing this user's other registered external identities.
// If exactly one existing EPerson already holds one of them, auto-link this login
// to it instead of falling through to email matching / auto-registration.
if (eperson == null && netidHeaders != null) {
eperson = tryAutoLink(context, netidHeaders);
if (eperson != null) {
foundNetID = true;
}
Expand Down Expand Up @@ -643,6 +657,51 @@ protected EPerson findEPerson(Context context, HttpServletRequest request, Strin
return eperson;
}

/**
* Try to auto-link this login to an existing EPerson via the allowlisted proxy's
* {@code voperson_external_id} attribute (Perun's "merging by all registered external
* identities"). See {@link ClarinIdentityService#autoLink}.
*
* The alias is attached under the netid from the first matching netid header
* ({@link #getFirstNetId}) - the same one {@code registerNewEPerson}/{@code updateEPerson}
* would lock the account to, so the alias always records the netid the rest of the login
* flow actually uses.
*
* @return the EPerson to log into, or null if auto-linking does not apply/match.
*/
protected EPerson tryAutoLink(Context context, String[] netidHeaders) throws SQLException {
String idp = shibheaders.get_idp();
if (!identityService.isAllowlistedProxy(idp)) {
return null;
}

String vopersonExternalIdHeader = configurationService
.getProperty("authentication-shibboleth.voperson-external-id-header");
if (vopersonExternalIdHeader == null) {
return null;
}
List<String> upstreamEppns = shibheaders.get(vopersonExternalIdHeader);
if (upstreamEppns == null || upstreamEppns.isEmpty()) {
return null;
}

String proxyNetid = getFirstNetId(netidHeaders);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why just the first one? Don't we need the full fallback dance? Maybe not, we should be pretty sure of what the proxy releases

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate: the alias must record the same netid registerNewEPerson/updateEPerson would lock the account to — both use getFirstNetId, so tryAutoLink uses it too. Attaching an alias for a netid header the rest of the login flow doesn't use would just create a dangling alias. The proxy is one known source releasing one primary identifier. Added a javadoc sentence spelling this out in 76f7b89.

if (proxyNetid == null) {
return null;
}

try {
return identityService.autoLink(context, upstreamEppns, idp, proxyNetid);
} catch (AmbiguousIdentityException e) {
// Several existing accounts could own this login; auto-registering yet another
// would deepen the duplication. Email matching may still identify an existing
// account, but registration stays blocked until an admin merges/links.
log.warn("Blocking auto-registration for this login: {}", e.getMessage());
this.isDuplicateUser = true;
return null;
}
}

/**
* Register a new eperson object. This method is called when no existing user was
* found for the NetID or Email and autoregister is enabled. When these conditions
Expand Down Expand Up @@ -1310,9 +1369,13 @@ public String getEmailAcceptedOrNull(String email) {

/**
* Find an EPerson by a NetID header. The method will go through all the netid headers and try to find a user.
* Resolution goes through {@link ClarinIdentityService#resolve}, which checks the {@code eperson_netid_alias}
* table first and falls back to the legacy {@code EPerson.netid} column for accounts that have no alias row
* (netids written directly at login are not backfilled into the alias table).
*/
public static EPerson findEpersonByNetId(String[] netidHeaders, ShibHeaders shibheaders,
EPersonService ePersonService, Context context, boolean logAllowed)
ClarinIdentityService identityService, Context context,
boolean logAllowed)
throws SQLException {
// Go through all the netid headers and try to find a user. It could be e.g., `eppn`, `persistent-id`,..
for (String netidHeader : netidHeaders) {
Expand All @@ -1322,7 +1385,7 @@ public static EPerson findEpersonByNetId(String[] netidHeaders, ShibHeaders shib
continue;
}

EPerson eperson = ePersonService.findByNetid(context, netid);
EPerson eperson = identityService.resolve(context, netid);

if (eperson == null && logAllowed) {
log.info(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* 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.clarin;

/**
* Thrown by {@link org.dspace.eperson.service.clarin.ClarinIdentityService#autoLink} when the
* identities released by a proxy match more than one existing EPerson. Picking one of the
* candidates would be a guess, and silently creating yet another account would deepen the
* duplication — the existing accounts need an admin merge/link first, so callers are expected
* to block auto-registration for the current login when they catch this.
*
* @author Ondrej Kosarko
*/
public class AmbiguousIdentityException extends RuntimeException {

public AmbiguousIdentityException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* 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.clarin;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.core.Context;
import org.dspace.eperson.EPerson;
import org.dspace.eperson.dao.clarin.EPersonNetidAliasDAO;
import org.dspace.eperson.service.EPersonService;
import org.dspace.eperson.service.clarin.ClarinIdentityService;
import org.dspace.services.ConfigurationService;
import org.springframework.beans.factory.annotation.Autowired;

/**
* @see ClarinIdentityService
*
* @author Ondrej Kosarko
*/
public class ClarinIdentityServiceImpl implements ClarinIdentityService {

private static final Logger log = LogManager.getLogger(ClarinIdentityServiceImpl.class);

@Autowired
private EPersonNetidAliasDAO ePersonNetidAliasDAO;

@Autowired
private ConfigurationService configurationService;

@Autowired
private EPersonService ePersonService;

@Override
public EPerson resolve(Context context, String netid) throws SQLException {
if (StringUtils.isBlank(netid)) {
return null;
}
EPersonNetidAlias alias = ePersonNetidAliasDAO.findByNetid(context, netid);
if (alias != null) {
return alias.getEPerson();
}
// Alias rows exist only for netids that went through attach() or the migration backfill.
// A netid written straight to eperson.netid after the migration (ClarinShibAuthentication#updateEPerson
// locking a first login to its netid) has no alias row yet, so check the legacy column too.
return ePersonService.findByNetid(context, netid);
}

@Override
public EPersonNetidAlias attach(Context context, EPerson ePerson, String netid, String source, EPerson createdBy)
throws SQLException {
// Check-then-insert is racy under concurrent first logins with the same new netid; this is
// accepted: the unique constraint on netid preserves integrity, the losing request fails
// once and succeeds on retry. Recovering in-session (catching the constraint violation)
// is unreliable after a failed flush, so we deliberately don't attempt it.
EPersonNetidAlias existing = ePersonNetidAliasDAO.findByNetid(context, netid);
if (existing != null) {
if (!existing.getEPerson().getID().equals(ePerson.getID())) {
throw new IllegalStateException(
"netid '" + netid + "' is already attached to a different EPerson (" +
Comment on lines +69 to +73
existing.getEPerson().getID() + "), refusing to reattach to " + ePerson.getID());
}
return existing;
}

EPersonNetidAlias alias = new EPersonNetidAlias();
alias.setEPerson(ePerson);
alias.setNetid(netid);
alias.setSource(source);
alias.setCreatedBy(createdBy);
alias.setCreatedDate(new Date());
return ePersonNetidAliasDAO.create(context, alias);
Comment on lines +69 to +85

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, and deliberately left as check-then-insert. The unique constraint on netid preserves integrity; the worst case under concurrent first logins with the same brand-new netid is that the losing request fails once at flush time and succeeds on retry. Catching the constraint violation in the same session is not a reliable recovery either — after a failed flush the Hibernate session is in an undefined state — so handling it in-place would add complexity without a real guarantee. Documented this decision in a code comment in attach() in 64b523b (which also addresses the other comments from this review).

}

@Override
public List<EPersonNetidAlias> findAliases(Context context, EPerson ePerson) throws SQLException {
return ePersonNetidAliasDAO.findByEPerson(context, ePerson);
}

@Override
public void detach(Context context, EPersonNetidAlias alias) throws SQLException {
ePersonNetidAliasDAO.delete(context, alias);
}

@Override
public boolean isAllowlistedProxy(String authority) {
if (StringUtils.isBlank(authority)) {
return false;
}
String[] allowlist = configurationService.getArrayProperty("identity.auto-link.proxy-allowlist");
return ArrayUtils.contains(allowlist, authority);
}

@Override
public EPerson autoLink(Context context, List<String> upstreamEppns, String proxyAuthority, String proxyNetid)
throws SQLException {
if (upstreamEppns == null || upstreamEppns.isEmpty() || StringUtils.isBlank(proxyNetid)) {
return null;
}
if (!isAllowlistedProxy(proxyAuthority)) {
log.warn("Auto-link via proxy '{}' refused: proxy is not on the identity.auto-link.proxy-allowlist.",
proxyAuthority);
return null;
}

// Already linked (e.g. concurrent/repeat login racing this method) - nothing to do.
EPerson alreadyLinked = resolve(context, proxyNetid);
if (alreadyLinked != null) {
return alreadyLinked;
}

Set<EPerson> matches = new LinkedHashSet<>();
List<String> matchedOn = new ArrayList<>();
for (String eppn : upstreamEppns) {
if (StringUtils.isBlank(eppn)) {
continue;
}
for (EPersonNetidAlias alias : ePersonNetidAliasDAO.findByValueAnyAuthority(context, eppn)) {
if (matches.add(alias.getEPerson())) {
matchedOn.add(eppn);
}
}
}

if (matches.isEmpty()) {
return null;
}
if (matches.size() > 1) {
throw new AmbiguousIdentityException(
"Auto-link via proxy '" + proxyAuthority + "' matched " + matches.size()
+ " different EPersons for released identities " + matchedOn
+ " (proxy netid '" + proxyNetid + "') - refusing to guess, "
+ "the accounts need an admin merge/link first.");
}

EPerson matched = matches.iterator().next();
Comment thread
kosarko marked this conversation as resolved.
attach(context, matched, proxyNetid, SOURCE_AUTO_VOPERSON, null);
log.info("Auto-linked netid '{}' to EPerson {} via proxy '{}' voperson_external_id match.",
proxyNetid, matched.getID(), proxyAuthority);
return matched;
}
}
Loading
Loading