diff --git a/dspace-api/src/main/java/org/dspace/authenticate/clarin/ClarinShibAuthentication.java b/dspace-api/src/main/java/org/dspace/authenticate/clarin/ClarinShibAuthentication.java index ef0b9e4b0a82..65c24de4d8f9 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/clarin/ClarinShibAuthentication.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/clarin/ClarinShibAuthentication.java @@ -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; @@ -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 @@ -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; } @@ -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 upstreamEppns = shibheaders.get(vopersonExternalIdHeader); + if (upstreamEppns == null || upstreamEppns.isEmpty()) { + return null; + } + + String proxyNetid = getFirstNetId(netidHeaders); + 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 @@ -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) { @@ -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( diff --git a/dspace-api/src/main/java/org/dspace/eperson/clarin/AmbiguousIdentityException.java b/dspace-api/src/main/java/org/dspace/eperson/clarin/AmbiguousIdentityException.java new file mode 100644 index 000000000000..da5f5d0324c5 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/clarin/AmbiguousIdentityException.java @@ -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); + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/clarin/ClarinIdentityServiceImpl.java b/dspace-api/src/main/java/org/dspace/eperson/clarin/ClarinIdentityServiceImpl.java new file mode 100644 index 000000000000..cde1a78772eb --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/clarin/ClarinIdentityServiceImpl.java @@ -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 (" + + 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); + } + + @Override + public List 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 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 matches = new LinkedHashSet<>(); + List 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(); + 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; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/clarin/EPersonNetidAlias.java b/dspace-api/src/main/java/org/dspace/eperson/clarin/EPersonNetidAlias.java new file mode 100644 index 000000000000..ef660a18cf4d --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/clarin/EPersonNetidAlias.java @@ -0,0 +1,121 @@ +/** + * 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.util.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.SequenceGenerator; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.dspace.core.ReloadableEntity; +import org.dspace.eperson.EPerson; + +/** + * An alias binding one identity value (a formatted "value[authority]" netid, + * where authority is a SAML IdP entityID or OIDC issuer URL) to an EPerson. + * + * This table is the primary source netid-based login resolution consults; + * {@link EPerson#getNetid()} is otherwise a denormalized display field, but + * is still consulted as a fallback by + * {@link org.dspace.eperson.service.clarin.ClarinIdentityService#resolve} + * for EPersons that do not (yet) have an alias row of their own. + * + * @author Ondrej Kosarko + */ +@Entity +@Table(name = "eperson_netid_alias") +public class EPersonNetidAlias implements ReloadableEntity { + + @Id + @Column(name = "eperson_netid_alias_id") + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "eperson_netid_alias_id_seq") + @SequenceGenerator(name = "eperson_netid_alias_id_seq", sequenceName = "eperson_netid_alias_id_seq", + allocationSize = 1) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "eperson_id", nullable = false) + private EPerson ePerson; + + /** + * Formatted "value[authority]" identity, e.g. "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]". + */ + @Column(name = "netid", nullable = false, unique = true, length = 256) + private String netid; + + /** + * How this alias was created: 'migration' | 'auto-voperson' | 'admin' | 'merge'. + */ + @Column(name = "source", nullable = false, length = 64) + private String source; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private EPerson createdBy; + + @Column(name = "created_date", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdDate; + + protected EPersonNetidAlias() { + } + + @Override + public Integer getID() { + return id; + } + + public EPerson getEPerson() { + return ePerson; + } + + public void setEPerson(EPerson ePerson) { + this.ePerson = ePerson; + } + + public String getNetid() { + return netid; + } + + public void setNetid(String netid) { + this.netid = netid; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public EPerson getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(EPerson createdBy) { + this.createdBy = createdBy; + } + + public Date getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Date createdDate) { + this.createdDate = createdDate; + } +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/clarin/EPersonNetidAliasDAO.java b/dspace-api/src/main/java/org/dspace/eperson/dao/clarin/EPersonNetidAliasDAO.java new file mode 100644 index 000000000000..925500a66383 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/clarin/EPersonNetidAliasDAO.java @@ -0,0 +1,42 @@ +/** + * 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.clarin; + +import java.sql.SQLException; +import java.util.List; + +import org.dspace.core.Context; +import org.dspace.core.GenericDAO; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.clarin.EPersonNetidAlias; + +/** + * Database Access Object interface class for the EPersonNetidAlias object. + * + * @author Ondrej Kosarko + */ +public interface EPersonNetidAliasDAO extends GenericDAO { + + /** + * Find the alias row for an exact formatted "value[authority]" netid. + */ + EPersonNetidAlias findByNetid(Context context, String netid) throws SQLException; + + /** + * Find aliases whose stored netid is {@code value[]} - the netid column + * stores identities formatted by {@code Util#formatNetId(value, authority)}, while + * upstream attributes like voperson_external_id release the bare value without the + * authority suffix. + */ + List findByValueAnyAuthority(Context context, String value) throws SQLException; + + /** + * Find all aliases currently attached to the given EPerson. + */ + List findByEPerson(Context context, EPerson ePerson) throws SQLException; +} diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/clarin/EPersonNetidAliasDAOImpl.java b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/clarin/EPersonNetidAliasDAOImpl.java new file mode 100644 index 000000000000..c171d0e70111 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/clarin/EPersonNetidAliasDAOImpl.java @@ -0,0 +1,69 @@ +/** + * 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.clarin; + +import java.sql.SQLException; +import java.util.List; +import javax.persistence.Query; + +import org.dspace.core.AbstractHibernateDAO; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.clarin.EPersonNetidAlias; +import org.dspace.eperson.dao.clarin.EPersonNetidAliasDAO; + +/** + * Database Access Object implementation class for the EPersonNetidAlias object. + * + * @author Ondrej Kosarko + */ +public class EPersonNetidAliasDAOImpl extends AbstractHibernateDAO + implements EPersonNetidAliasDAO { + + private static final char LIKE_ESCAPE = '\\'; + + /** + * Marks the start of the authority suffix in a formatted netid, i.e. the "[" in + * {@code value[authority]} produced by {@code Util#formatNetId(value, authority)}. + */ + private static final String NETID_AUTHORITY_START = "["; + + @Override + public EPersonNetidAlias findByNetid(Context context, String netid) throws SQLException { + Query query = createQuery(context, "SELECT a FROM EPersonNetidAlias a WHERE a.netid = :netid"); + query.setParameter("netid", netid); + query.setHint("org.hibernate.cacheable", Boolean.TRUE); + return singleResult(query); + } + + @Override + public List findByValueAnyAuthority(Context context, String value) throws SQLException { + Query query = createQuery(context, + "SELECT a FROM EPersonNetidAlias a WHERE a.netid LIKE :pattern ESCAPE '\\'"); + query.setParameter("pattern", escapeLike(value) + NETID_AUTHORITY_START + "%"); + return findMany(context, query); + } + + @Override + public List findByEPerson(Context context, EPerson ePerson) throws SQLException { + Query query = createQuery(context, "SELECT a FROM EPersonNetidAlias a WHERE a.ePerson = :eperson"); + query.setParameter("eperson", ePerson); + return findMany(context, query); + } + + /** + * Escape LIKE wildcards ('%', '_') in a value that is used as a literal + * prefix, so an IdP-asserted identifier cannot widen the match. + */ + private static String escapeLike(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE), LIKE_ESCAPE + "" + LIKE_ESCAPE) + .replace("%", LIKE_ESCAPE + "%") + .replace("_", LIKE_ESCAPE + "_"); + } +} 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 b80c37f13ff5..1e906a0c5b6b 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 @@ -12,6 +12,7 @@ import org.dspace.eperson.service.GroupService; import org.dspace.eperson.service.RegistrationDataService; import org.dspace.eperson.service.SubscribeService; +import org.dspace.eperson.service.clarin.ClarinIdentityService; import org.dspace.services.factory.DSpaceServicesFactory; /** @@ -32,6 +33,8 @@ public abstract class EPersonServiceFactory { public abstract SubscribeService getSubscribeService(); + public abstract ClarinIdentityService getClarinIdentityService(); + public static EPersonServiceFactory getInstance() { return DSpaceServicesFactory.getInstance().getServiceManager() .getServiceByName("ePersonServiceFactory", EPersonServiceFactory.class); 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 c4a6cbe9964c..4ca1ed9a5647 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 @@ -12,6 +12,7 @@ import org.dspace.eperson.service.GroupService; import org.dspace.eperson.service.RegistrationDataService; import org.dspace.eperson.service.SubscribeService; +import org.dspace.eperson.service.clarin.ClarinIdentityService; import org.springframework.beans.factory.annotation.Autowired; /** @@ -32,6 +33,8 @@ public class EPersonServiceFactoryImpl extends EPersonServiceFactory { private AccountService accountService; @Autowired(required = true) private SubscribeService subscribeService; + @Autowired(required = true) + private ClarinIdentityService clarinIdentityService; @Override public EPersonService getEPersonService() { @@ -58,4 +61,9 @@ public SubscribeService getSubscribeService() { return subscribeService; } + @Override + public ClarinIdentityService getClarinIdentityService() { + return clarinIdentityService; + } + } diff --git a/dspace-api/src/main/java/org/dspace/eperson/service/clarin/ClarinIdentityService.java b/dspace-api/src/main/java/org/dspace/eperson/service/clarin/ClarinIdentityService.java new file mode 100644 index 000000000000..8d56087c4f26 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/eperson/service/clarin/ClarinIdentityService.java @@ -0,0 +1,112 @@ +/** + * 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.clarin; + +import java.sql.SQLException; +import java.util.List; + +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.clarin.EPersonNetidAlias; + +/** + * Single identity-resolution point for netid-based authentication methods + * (Shibboleth today, OIDC/ORCID/LDAP if they are ever converted). Backed by + * a proxy-agnostic alias table ({@link EPersonNetidAlias}) that is the + * primary source consulted at login. {@code EPerson.netid} is otherwise a + * denormalized display field nothing here writes to, but {@link #resolve} + * still falls back to it for EPersons whose netid was set without going + * through {@link #attach} (e.g. legacy rows, or a netid locked in by + * {@code ClarinShibAuthentication#updateEPerson} outside of this service). + * + * @author Ondrej Kosarko + */ +public interface ClarinIdentityService { + + /** + * Value persisted in {@code eperson_netid_alias.source} for aliases created by the + * one-off migration backfill of pre-existing legacy netids + * ({@code V7.6_2026.07.08__eperson_netid_alias.sql}). + */ + String SOURCE_MIGRATION = "migration"; + + /** + * Value persisted in {@code eperson_netid_alias.source} for aliases created by + * {@link #autoLink} matching an allowlisted proxy's {@code voperson_external_id}. + */ + String SOURCE_AUTO_VOPERSON = "auto-voperson"; + + /** + * Value persisted in {@code eperson_netid_alias.source} for aliases created manually by + * an admin via the identity-link endpoint. + */ + String SOURCE_ADMIN = "admin"; + + /** + * Resolve a formatted "value[authority]" netid to the EPerson it is aliased + * to. Falls back to a legacy {@code EPerson.netid} column match when no + * alias exists. Returns null if neither matches. + */ + EPerson resolve(Context context, String netid) throws SQLException; + + /** + * Attach a new identity to an EPerson as an alias. No-op (returns the + * existing alias) if this exact netid is already attached to this EPerson. + * + * @param netid formatted "value[authority]" identity + * @param source 'auto-voperson' | 'admin' | 'merge' | 'migration' + * @param createdBy the admin EPerson performing a manual link, or null + * @throws IllegalStateException if the netid is already attached to a + * *different* EPerson + */ + EPersonNetidAlias attach(Context context, EPerson ePerson, String netid, String source, EPerson createdBy) + throws SQLException; + + /** + * All aliases currently attached to an EPerson. + */ + List findAliases(Context context, EPerson ePerson) throws SQLException; + + /** + * Remove an alias, freeing its netid value to be attached elsewhere. Production login and + * merge flows never call this; it exists for admin unlink operations and test fixture + * cleanup (an alias row is not deleted automatically when its EPerson is, so anything that + * attaches an alias to a to-be-deleted EPerson must detach it first). + */ + void detach(Context context, EPersonNetidAlias alias) throws SQLException; + + /** + * Is this SAML IdP / OIDC issuer entityID allowlisted to drive auto-linking + * via {@code voperson_external_id} (config key + * {@code identity.auto-link.proxy-allowlist})? + */ + boolean isAllowlistedProxy(String authority); + + /** + * Auto-linking, section 4 of the design: given the list of eppns released + * by an allowlisted identity proxy in {@code voperson_external_id} (i.e. + * "merging by all registered external identities" per Perun), and the + * formatted netid of the proxy identity actually used to log in: + * + *
    + *
  • exactly one EPerson already holds an alias matching one of the + * released eppns -> attach {@code proxyNetid} to it and return it;
  • + *
  • zero matches -> return null (the caller may fall through to its + * other identification methods);
  • + *
  • more than one match -> refuse to guess and throw + * {@link org.dspace.eperson.clarin.AmbiguousIdentityException}; the + * caller must not auto-register a new EPerson for this login, the + * existing accounts need an admin merge/link first.
  • + *
+ * + * @throws org.dspace.eperson.clarin.AmbiguousIdentityException if the + * released eppns match more than one existing EPerson + */ + EPerson autoLink(Context context, List upstreamEppns, String proxyAuthority, String proxyNetid) + throws SQLException; +} diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.07.08__eperson_netid_alias.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.07.08__eperson_netid_alias.sql new file mode 100644 index 000000000000..19c7f98865a6 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.07.08__eperson_netid_alias.sql @@ -0,0 +1,32 @@ +-- +-- 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/ +-- + +-- CLARIN identity linking: alias table used as the *only* source netid-based +-- login resolution consults. + +CREATE SEQUENCE eperson_netid_alias_id_seq START WITH 1 INCREMENT BY 1; + +CREATE TABLE eperson_netid_alias ( + eperson_netid_alias_id INTEGER NOT NULL DEFAULT NEXTVAL('eperson_netid_alias_id_seq') PRIMARY KEY, + eperson_id UUID NOT NULL, + netid VARCHAR(256) NOT NULL UNIQUE, + source VARCHAR(64) NOT NULL, + created_by UUID, + created_date TIMESTAMP NOT NULL, + FOREIGN KEY (eperson_id) REFERENCES eperson(uuid) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES eperson(uuid) ON DELETE SET NULL +); + +CREATE INDEX eperson_netid_alias_eperson_id_idx ON eperson_netid_alias(eperson_id); + +-- Seed the alias table from every existing netid so existing logins are +-- unaffected once resolution moves to alias-only lookup. +INSERT INTO eperson_netid_alias (eperson_netid_alias_id, eperson_id, netid, source, created_date) +SELECT NEXTVAL('eperson_netid_alias_id_seq'), uuid, netid, 'migration', CURRENT_TIMESTAMP +FROM eperson +WHERE netid IS NOT NULL; diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.07.08__eperson_netid_alias.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.07.08__eperson_netid_alias.sql new file mode 100644 index 000000000000..08f6a767f8a8 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.07.08__eperson_netid_alias.sql @@ -0,0 +1,39 @@ +-- +-- 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/ +-- + +-- CLARIN identity linking: alias table used as the *only* source netid-based +-- login resolution consults. + +CREATE TABLE eperson_netid_alias ( + eperson_netid_alias_id integer NOT NULL PRIMARY KEY, + eperson_id UUID NOT NULL REFERENCES eperson(uuid) ON DELETE CASCADE, + netid VARCHAR(256) NOT NULL UNIQUE, + source VARCHAR(64) NOT NULL, + created_by UUID REFERENCES eperson(uuid) ON DELETE SET NULL, + created_date TIMESTAMP NOT NULL +); + +CREATE INDEX eperson_netid_alias_eperson_id_idx ON eperson_netid_alias(eperson_id); + +CREATE SEQUENCE eperson_netid_alias_id_seq + START WITH 1 + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + +ALTER TABLE eperson_netid_alias + ALTER COLUMN eperson_netid_alias_id + SET DEFAULT nextval('eperson_netid_alias_id_seq'); + +-- Seed the alias table from every existing netid so existing logins are +-- unaffected once resolution moves to alias-only lookup. +INSERT INTO eperson_netid_alias (eperson_netid_alias_id, eperson_id, netid, source, created_date) +SELECT nextval('eperson_netid_alias_id_seq'), uuid, netid, 'migration', CURRENT_TIMESTAMP +FROM eperson +WHERE netid IS NOT NULL; diff --git a/dspace-api/src/test/java/org/dspace/eperson/clarin/ClarinIdentityServiceTest.java b/dspace-api/src/test/java/org/dspace/eperson/clarin/ClarinIdentityServiceTest.java new file mode 100644 index 000000000000..fa752daddef9 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/eperson/clarin/ClarinIdentityServiceTest.java @@ -0,0 +1,160 @@ +/** + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.builder.EPersonBuilder; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.clarin.ClarinIdentityService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests {@link ClarinIdentityService}: alias-only resolution, attach idempotency/conflict, and + * the auto-link decision matrix (no match / exactly one match / ambiguous match). + */ +public class ClarinIdentityServiceTest extends AbstractIntegrationTestWithDatabase { + + private static final String ALLOWLISTED_PROXY = "login.e-infra.cz/idp"; + + private ClarinIdentityService identityService; + + /** + * Any EPerson created ad hoc by a test (in addition to the shared {@code eperson}/{@code admin}), + * tracked here so {@link #cleanupNetidAliases()} can detach its aliases before the superclass + * teardown deletes the builder-tracked EPerson itself. + */ + private EPerson other; + + @Before + @Override + public void setUp() throws Exception { + super.setUp(); + identityService = EPersonServiceFactory.getInstance().getClarinIdentityService(); + + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + configurationService.setProperty("identity.auto-link.proxy-allowlist", ALLOWLISTED_PROXY); + } + + /** + * Alias rows are not builder-tracked, so an alias left attached to a builder-created EPerson + * (e.g. {@code other}) would make the superclass's {@code destroy()} fail with a foreign-key + * violation when it deletes that EPerson. JUnit runs subclass {@code @After} methods before + * superclass ones, so detaching here always runs before that deletion. + */ + @After + public void cleanupNetidAliases() throws Exception { + context.turnOffAuthorisationSystem(); + for (EPersonNetidAlias alias : identityService.findAliases(context, eperson)) { + identityService.detach(context, alias); + } + if (other != null) { + for (EPersonNetidAlias alias : identityService.findAliases(context, other)) { + identityService.detach(context, alias); + } + } + context.restoreAuthSystemState(); + } + + @Test + public void resolveReturnsNullWhenNoAliasExists() throws Exception { + assertNull(identityService.resolve(context, "nobody@example.org[https://idp.example.org]")); + } + + @Test + public void attachThenResolveFindsTheEPerson() throws Exception { + String netid = "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]"; + identityService.attach(context, eperson, netid, "migration", null); + + assertEquals(eperson, identityService.resolve(context, netid)); + } + + @Test + public void attachIsIdempotentForTheSameEPerson() throws Exception { + String netid = "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]"; + EPersonNetidAlias first = identityService.attach(context, eperson, netid, "migration", null); + EPersonNetidAlias second = identityService.attach(context, eperson, netid, "migration", null); + + assertEquals(first.getID(), second.getID()); + } + + @Test + public void attachRefusesToStealAnAliasFromAnotherEPerson() throws Exception { + context.turnOffAuthorisationSystem(); + other = EPersonBuilder.createEPerson(context).withEmail("other@example.org").build(); + context.restoreAuthSystemState(); + String netid = "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]"; + identityService.attach(context, eperson, netid, "migration", null); + + assertThrows(IllegalStateException.class, + () -> identityService.attach(context, other, netid, "admin", null)); + } + + @Test + public void autoLinkIgnoresProxiesNotOnTheAllowlist() throws Exception { + identityService.attach(context, eperson, "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]", + "migration", null); + + EPerson result = identityService.autoLink(context, Collections.singletonList("novak@cuni.cz"), + "https://untrusted-proxy.example.org/idp", "einfra-id[https://untrusted-proxy.example.org/idp]"); + + assertNull(result); + } + + @Test + public void autoLinkAttachesToTheSingleMatchingEPerson() throws Exception { + identityService.attach(context, eperson, "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]", + "migration", null); + String proxyNetid = "hash123[" + ALLOWLISTED_PROXY + "]"; + + EPerson result = identityService.autoLink(context, Collections.singletonList("novak@cuni.cz"), + ALLOWLISTED_PROXY, proxyNetid); + + assertEquals(eperson, result); + assertEquals(eperson, identityService.resolve(context, proxyNetid)); + } + + @Test + public void autoLinkRefusesToGuessWhenMultipleEPersonsMatch() throws Exception { + context.turnOffAuthorisationSystem(); + other = EPersonBuilder.createEPerson(context).withEmail("other@example.org").build(); + context.restoreAuthSystemState(); + identityService.attach(context, eperson, "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]", + "migration", null); + identityService.attach(context, other, "novak@jinauni.cz[https://jinauni.cz/idp/shibboleth]", + "migration", null); + String proxyNetid = "hash123[" + ALLOWLISTED_PROXY + "]"; + List upstreamEppns = Arrays.asList("novak@cuni.cz", "novak@jinauni.cz"); + + assertThrows(AmbiguousIdentityException.class, + () -> identityService.autoLink(context, upstreamEppns, ALLOWLISTED_PROXY, proxyNetid)); + assertNull(identityService.resolve(context, proxyNetid)); + } + + @Test + public void autoLinkFindsNoMatchWhenNoAliasesOverlap() throws Exception { + String proxyNetid = "hash123[" + ALLOWLISTED_PROXY + "]"; + + EPerson result = identityService.autoLink(context, Collections.singletonList("unknown@cuni.cz"), + ALLOWLISTED_PROXY, proxyNetid); + + assertNull(result); + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinIdentityLinkController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinIdentityLinkController.java new file mode 100644 index 000000000000..d5bcc2732599 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ClarinIdentityLinkController.java @@ -0,0 +1,92 @@ +/** + * 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.dspace.app.rest.utils.ContextUtil.obtainContext; + +import java.sql.SQLException; +import java.util.Objects; +import java.util.UUID; +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.lang3.StringUtils; +import org.dspace.app.rest.exception.DSpaceBadRequestException; +import org.dspace.app.util.Util; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.clarin.ClarinIdentityService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.rest.webmvc.ControllerUtils; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +/** + * Admin endpoint for manual identity linking (section 4: "attach netid X as alias of EPerson + * Y", written to {@code eperson_netid_alias} with {@code source='admin'}). Identity + * administration happens here, not via the {@code /netid} PATCH operation, which now only + * edits the denormalized display column. + * + * Endpoint: POST /api/clarin/identity/link?eperson={uuid}&value={identityValue}&authority={idpEntityIdOrIssuer} + * + * @author Ondrej Kosarko + */ +@RestController +@RequestMapping("/api/clarin/identity") +public class ClarinIdentityLinkController { + + @Autowired + private EPersonService ePersonService; + @Autowired + private ClarinIdentityService identityService; + + @PreAuthorize("hasAuthority('ADMIN')") + @RequestMapping(method = RequestMethod.POST, path = "/link") + public ResponseEntity> link(@RequestParam("eperson") UUID epersonUuid, + @RequestParam("value") String value, + @RequestParam("authority") String authority, + HttpServletRequest request) + throws SQLException { + Context context = getContext(request); + + if (StringUtils.isBlank(value) || StringUtils.isBlank(authority)) { + throw new DSpaceBadRequestException("Parameters 'value' and 'authority' must not be blank"); + } + + EPerson ePerson = ePersonService.find(context, epersonUuid); + if (ePerson == null) { + throw new ResourceNotFoundException("No EPerson found for eperson=" + epersonUuid); + } + + String netid = Util.formatNetId(value, authority); + try { + identityService.attach(context, ePerson, netid, ClarinIdentityService.SOURCE_ADMIN, + context.getCurrentUser()); + } catch (IllegalStateException e) { + throw new DSpaceBadRequestException(e.getMessage(), e); + } + context.commit(); + + return ControllerUtils.toEmptyResponse(HttpStatus.CREATED); + } + + private static Context getContext(HttpServletRequest request) { + Context context = obtainContext(request); + if (Objects.isNull(context)) { + throw new IllegalStateException("Cannot obtain the DSpace context from the current request"); + } + return context; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/clarin/ClarinShibbolethLoginFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/clarin/ClarinShibbolethLoginFilter.java index 7916a43a5951..e744ba4574ff 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/clarin/ClarinShibbolethLoginFilter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/clarin/ClarinShibbolethLoginFilter.java @@ -34,6 +34,7 @@ import org.dspace.eperson.EPerson; import org.dspace.eperson.factory.EPersonServiceFactory; import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.clarin.ClarinIdentityService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.web.ContextUtil; @@ -105,6 +106,7 @@ public class ClarinShibbolethLoginFilter extends StatelessLoginFilter { private ClarinVerificationTokenService clarinVerificationTokenService = ClarinServiceFactory.getInstance() .getClarinVerificationTokenService(); private EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); + private ClarinIdentityService identityService = EPersonServiceFactory.getInstance().getClarinIdentityService(); public ClarinShibbolethLoginFilter(String url, AuthenticationManager authenticationManager, RestAuthenticationService restAuthenticationService) { @@ -170,7 +172,7 @@ public Authentication attemptAuthentication(HttpServletRequest req, EPerson ePerson = null; try { ePerson = ClarinShibAuthentication.findEpersonByNetId(shib_headers.getNetIdHeaders(), shib_headers, - ePersonService, context, false); + identityService, context, false); } catch (SQLException e) { // It is logged in the ClarinShibAuthentication class. } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/ClarinShibbolethLoginFilterIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/ClarinShibbolethLoginFilterIT.java index 18ecfad4fd20..c86ef756dfcd 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/ClarinShibbolethLoginFilterIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/ClarinShibbolethLoginFilterIT.java @@ -12,6 +12,7 @@ import static org.dspace.rdf.negotiation.MediaRange.token; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @@ -42,7 +43,10 @@ import org.dspace.content.service.clarin.ClarinVerificationTokenService; import org.dspace.core.I18nUtil; import org.dspace.eperson.EPerson; +import org.dspace.eperson.clarin.EPersonNetidAlias; +import org.dspace.eperson.factory.EPersonServiceFactory; import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.clarin.ClarinIdentityService; import org.dspace.services.ConfigurationService; import org.junit.After; import org.junit.Before; @@ -697,6 +701,64 @@ private EPerson checkUserWasCreated(String netIdValue, String idpValue, String e return ePerson; } + /** + * When an allowlisted identity proxy releases {@code voperson_external_id} values that match + * MORE than one existing EPerson, auto-linking must refuse to guess AND the login must not + * auto-register a new (duplicate) EPerson either - even though the proxy also released an + * email that would normally trigger auto-registration. + */ + @Test + public void shouldNotAutoRegisterWhenProxyIdentitiesMatchMultipleEPersons() throws Exception { + String proxyIdp = "https://login.e-infra.cz/idp/"; + String proxyNetidValue = "einfra-hash123"; + String email = "ambiguous@mail.epic"; + String vopersonHeader = "SHIB-VOPERSON-EXTERNAL-ID"; + configurationService.setProperty("identity.auto-link.proxy-allowlist", proxyIdp); + configurationService.setProperty("authentication-shibboleth.voperson-external-id-header", vopersonHeader); + + ClarinIdentityService identityService = EPersonServiceFactory.getInstance().getClarinIdentityService(); + + // Two existing accounts, each already owning one of the released external identities. + context.turnOffAuthorisationSystem(); + EPerson first = EPersonBuilder.createEPerson(context) + .withEmail("first@example.org") + .withNameInMetadata("First", "User") + .build(); + EPerson second = EPersonBuilder.createEPerson(context) + .withEmail("second@example.org") + .withNameInMetadata("Second", "User") + .build(); + identityService.attach(context, first, "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]", + ClarinIdentityService.SOURCE_MIGRATION, null); + identityService.attach(context, second, "novak@jinauni.cz[https://jinauni.cz/idp/shibboleth]", + ClarinIdentityService.SOURCE_MIGRATION, null); + context.restoreAuthSystemState(); + + try { + getClient().perform(get("/api/authn/shibboleth") + .header("Shib-Identity-Provider", proxyIdp) + .header("SHIB-NETID", proxyNetidValue) + .header("SHIB-MAIL", email) + .header(vopersonHeader, "novak@cuni.cz;novak@jinauni.cz")) + .andExpect(status().is3xxRedirection()); + + // The ambiguous match must not attach the proxy netid to either candidate... + String proxyNetid = Util.formatNetId(proxyNetidValue, proxyIdp); + assertNull(identityService.resolve(context, proxyNetid)); + // ...and must not auto-register a third (duplicate) account for this login either. + assertNull(ePersonService.findByEmail(context, email)); + assertNull(ePersonService.findByNetid(context, proxyNetid)); + } finally { + context.turnOffAuthorisationSystem(); + for (EPerson candidate : new EPerson[] {first, second}) { + for (EPersonNetidAlias alias : identityService.findAliases(context, candidate)) { + identityService.detach(context, alias); + } + } + context.restoreAuthSystemState(); + } + } + private void checkUserIsSignedIn(String token) throws Exception { getClient(token).perform(get("/api/authn/status")) .andExpect(status().isOk()) diff --git a/dspace/config/clarin-dspace.cfg b/dspace/config/clarin-dspace.cfg index a481beb856aa..b1cb8067719f 100644 --- a/dspace/config/clarin-dspace.cfg +++ b/dspace/config/clarin-dspace.cfg @@ -338,6 +338,13 @@ versioning.unarchive.previous.version = false # CLARIN-DSpace default authentication methods plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.PasswordAuthentication,org.dspace.authenticate.clarin.ClarinShibAuthentication +# Identity proxy entityIDs (SAML) / issuer URLs (OIDC) allowed to drive +# auto-linking via voperson_external_id (see ClarinIdentityService). Only +# add a proxy here once it is confirmed that voperson_external_id (or its +# OIDC-claim equivalent) is asserted *after* the proxy has verified the +# linkage, e.g. e-INFRA CZ / Perun. +identity.auto-link.proxy-allowlist = login.e-infra.cz/idp + # self-registration(new registration), for the name/password authentication, is disabled # but existing users can still use the name/password authentication user.registration = false diff --git a/dspace/config/hibernate.cfg.xml b/dspace/config/hibernate.cfg.xml index ab77b7214bcd..2784e0c18327 100644 --- a/dspace/config/hibernate.cfg.xml +++ b/dspace/config/hibernate.cfg.xml @@ -82,6 +82,7 @@ + diff --git a/dspace/config/modules/authentication-shibboleth.cfg b/dspace/config/modules/authentication-shibboleth.cfg index 62815a46d828..c55890a14c81 100644 --- a/dspace/config/modules/authentication-shibboleth.cfg +++ b/dspace/config/modules/authentication-shibboleth.cfg @@ -94,6 +94,13 @@ authentication-shibboleth.netid-header = eppn,persistent-id authentication-shibboleth.email-header = mail authentication-shibboleth.email-use-tomcat-remote-user = false +# CLARIN identity linking (see ClarinIdentityService): header carrying the +# list of a user's other registered external identities (Perun's +# voperson_external_id, urn:oid:1.3.6.1.4.1.34998.3.3.1.5). Only consulted +# for auto-linking when the asserting IdP is in +# identity.auto-link.proxy-allowlist (dspace.cfg). +authentication-shibboleth.voperson-external-id-header = voperson_external_id + # Should we allow new users to be registered automatically? authentication-shibboleth.autoregister = true diff --git a/dspace/config/spring/api/core-dao-services.xml b/dspace/config/spring/api/core-dao-services.xml index 84add010d85e..5ebda470b624 100644 --- a/dspace/config/spring/api/core-dao-services.xml +++ b/dspace/config/spring/api/core-dao-services.xml @@ -58,6 +58,7 @@ + diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 9017ea7fae75..9a27f3ea0a24 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -98,6 +98,7 @@ +