Skip to content

Commit a1b38d0

Browse files
committed
Add e-INFRA CZ AAI identity linking (netid aliases + voperson auto-link)
1 parent f4ff817 commit a1b38d0

18 files changed

Lines changed: 885 additions & 4 deletions

File tree

dspace-api/src/main/java/org/dspace/authenticate/clarin/ClarinShibAuthentication.java

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import org.dspace.eperson.factory.EPersonServiceFactory;
5151
import org.dspace.eperson.service.EPersonService;
5252
import org.dspace.eperson.service.GroupService;
53+
import org.dspace.eperson.service.clarin.ClarinIdentityService;
5354
import org.dspace.services.ConfigurationService;
5455
import org.dspace.services.factory.DSpaceServicesFactory;
5556

@@ -124,6 +125,7 @@ public class ClarinShibAuthentication implements AuthenticationMethod {
124125
ClarinServiceFactory.getInstance().getClarinUserRegistration();
125126
protected ClarinVerificationTokenService clarinVerificationTokenService = ClarinServiceFactory.getInstance()
126127
.getClarinVerificationTokenService();
128+
protected ClarinIdentityService identityService = EPersonServiceFactory.getInstance().getClarinIdentityService();
127129

128130
/**
129131
* Authenticate the given or implicit credentials. This is the heart of the
@@ -558,7 +560,18 @@ protected EPerson findEPerson(Context context, HttpServletRequest request, Strin
558560

559561
// 1) First, look for a netid header.
560562
if (netidHeaders != null) {
561-
eperson = findEpersonByNetId(netidHeaders, shibheaders, ePersonService, context, true);
563+
eperson = findEpersonByNetId(netidHeaders, shibheaders, identityService, context, true);
564+
if (eperson != null) {
565+
foundNetID = true;
566+
}
567+
}
568+
569+
// 1b) CLARIN: an allowlisted identity proxy (e.g. e-INFRA CZ/Perun) may release
570+
// voperson_external_id, listing this user's other registered external identities.
571+
// If exactly one existing EPerson already holds one of them, auto-link this login
572+
// to it instead of falling through to email matching / auto-registration.
573+
if (eperson == null && netidHeaders != null) {
574+
eperson = tryAutoLink(context, netidHeaders);
562575
if (eperson != null) {
563576
foundNetID = true;
564577
}
@@ -643,6 +656,37 @@ protected EPerson findEPerson(Context context, HttpServletRequest request, Strin
643656
return eperson;
644657
}
645658

659+
/**
660+
* Try to auto-link this login to an existing EPerson via the allowlisted proxy's
661+
* {@code voperson_external_id} attribute (Perun's "merging by all registered external
662+
* identities"). See {@link ClarinIdentityService#autoLink}.
663+
*
664+
* @return the EPerson to log into, or null if auto-linking does not apply/match.
665+
*/
666+
protected EPerson tryAutoLink(Context context, String[] netidHeaders) throws SQLException {
667+
String idp = shibheaders.get_idp();
668+
if (!identityService.isAllowlistedProxy(idp)) {
669+
return null;
670+
}
671+
672+
String vopersonExternalIdHeader = configurationService
673+
.getProperty("authentication-shibboleth.voperson-external-id-header");
674+
if (vopersonExternalIdHeader == null) {
675+
return null;
676+
}
677+
List<String> releasedEppns = shibheaders.get(vopersonExternalIdHeader);
678+
if (releasedEppns == null || releasedEppns.isEmpty()) {
679+
return null;
680+
}
681+
682+
String proxyNetid = getFirstNetId(netidHeaders);
683+
if (proxyNetid == null) {
684+
return null;
685+
}
686+
687+
return identityService.autoLink(context, releasedEppns, idp, proxyNetid);
688+
}
689+
646690
/**
647691
* Register a new eperson object. This method is called when no existing user was
648692
* found for the NetID or Email and autoregister is enabled. When these conditions
@@ -1310,9 +1354,12 @@ public String getEmailAcceptedOrNull(String email) {
13101354

13111355
/**
13121356
* Find an EPerson by a NetID header. The method will go through all the netid headers and try to find a user.
1357+
* Resolution goes through {@link ClarinIdentityService}'s alias table only - {@code EPerson.netid} is a
1358+
* denormalized display field that nothing at login reads.
13131359
*/
13141360
public static EPerson findEpersonByNetId(String[] netidHeaders, ShibHeaders shibheaders,
1315-
EPersonService ePersonService, Context context, boolean logAllowed)
1361+
ClarinIdentityService identityService, Context context,
1362+
boolean logAllowed)
13161363
throws SQLException {
13171364
// Go through all the netid headers and try to find a user. It could be e.g., `eppn`, `persistent-id`,..
13181365
for (String netidHeader : netidHeaders) {
@@ -1322,7 +1369,7 @@ public static EPerson findEpersonByNetId(String[] netidHeaders, ShibHeaders shib
13221369
continue;
13231370
}
13241371

1325-
EPerson eperson = ePersonService.findByNetid(context, netid);
1372+
EPerson eperson = identityService.resolve(context, netid);
13261373

13271374
if (eperson == null && logAllowed) {
13281375
log.info(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.eperson.clarin;
9+
10+
import java.sql.SQLException;
11+
import java.util.ArrayList;
12+
import java.util.Date;
13+
import java.util.LinkedHashSet;
14+
import java.util.List;
15+
import java.util.Set;
16+
17+
import org.apache.commons.lang3.ArrayUtils;
18+
import org.apache.commons.lang3.StringUtils;
19+
import org.apache.logging.log4j.LogManager;
20+
import org.apache.logging.log4j.Logger;
21+
import org.dspace.core.Context;
22+
import org.dspace.eperson.EPerson;
23+
import org.dspace.eperson.dao.clarin.EPersonNetidAliasDAO;
24+
import org.dspace.eperson.service.EPersonService;
25+
import org.dspace.eperson.service.clarin.ClarinIdentityService;
26+
import org.dspace.services.ConfigurationService;
27+
import org.springframework.beans.factory.annotation.Autowired;
28+
29+
/**
30+
* @see ClarinIdentityService
31+
*
32+
* @author Ondrej Kosarko
33+
*/
34+
public class ClarinIdentityServiceImpl implements ClarinIdentityService {
35+
36+
private static final Logger log = LogManager.getLogger(ClarinIdentityServiceImpl.class);
37+
38+
public static final String SOURCE_MIGRATION = "migration";
39+
public static final String SOURCE_AUTO_VOPERSON = "auto-voperson";
40+
public static final String SOURCE_ADMIN = "admin";
41+
42+
@Autowired
43+
private EPersonNetidAliasDAO ePersonNetidAliasDAO;
44+
45+
@Autowired
46+
private ConfigurationService configurationService;
47+
48+
@Autowired
49+
private EPersonService ePersonService;
50+
51+
@Override
52+
public EPerson resolve(Context context, String netid) throws SQLException {
53+
if (StringUtils.isBlank(netid)) {
54+
return null;
55+
}
56+
EPersonNetidAlias alias = ePersonNetidAliasDAO.findByNetid(context, netid);
57+
if (alias != null) {
58+
return alias.getEPerson();
59+
}
60+
// Fall back to the legacy denormalized eperson.netid column: EPersons whose netid was
61+
// set directly (e.g. ClarinShibAuthentication#updateEPerson locking a legacy account to
62+
// its first-seen netid) never get an alias row of their own, only pre-existing netids
63+
// captured by the migration backfill do. Without this fallback such accounts would
64+
// silently stop resolving by netid once login moved to alias-only lookup.
65+
return ePersonService.findByNetid(context, netid);
66+
}
67+
68+
@Override
69+
public EPersonNetidAlias attach(Context context, EPerson ePerson, String netid, String source, EPerson createdBy)
70+
throws SQLException {
71+
EPersonNetidAlias existing = ePersonNetidAliasDAO.findByNetid(context, netid);
72+
if (existing != null) {
73+
if (!existing.getEPerson().getID().equals(ePerson.getID())) {
74+
throw new IllegalStateException(
75+
"netid '" + netid + "' is already attached to a different EPerson (" +
76+
existing.getEPerson().getID() + "), refusing to reattach to " + ePerson.getID());
77+
}
78+
return existing;
79+
}
80+
81+
EPersonNetidAlias alias = new EPersonNetidAlias();
82+
alias.setEPerson(ePerson);
83+
alias.setNetid(netid);
84+
alias.setSource(source);
85+
alias.setCreatedBy(createdBy);
86+
alias.setCreatedDate(new Date());
87+
return ePersonNetidAliasDAO.create(context, alias);
88+
}
89+
90+
@Override
91+
public List<EPersonNetidAlias> findAliases(Context context, EPerson ePerson) throws SQLException {
92+
return ePersonNetidAliasDAO.findByEPerson(context, ePerson);
93+
}
94+
95+
@Override
96+
public void detach(Context context, EPersonNetidAlias alias) throws SQLException {
97+
ePersonNetidAliasDAO.delete(context, alias);
98+
}
99+
100+
@Override
101+
public boolean isAllowlistedProxy(String authority) {
102+
if (StringUtils.isBlank(authority)) {
103+
return false;
104+
}
105+
String[] allowlist = configurationService.getArrayProperty("identity.auto-link.proxy-allowlist");
106+
return ArrayUtils.contains(allowlist, authority);
107+
}
108+
109+
@Override
110+
public EPerson autoLink(Context context, List<String> releasedEppns, String proxyAuthority, String proxyNetid)
111+
throws SQLException {
112+
if (releasedEppns == null || releasedEppns.isEmpty() || StringUtils.isBlank(proxyNetid)) {
113+
return null;
114+
}
115+
if (!isAllowlistedProxy(proxyAuthority)) {
116+
log.warn("Auto-link via proxy '{}' refused: proxy is not on the identity.auto-link.proxy-allowlist.",
117+
proxyAuthority);
118+
return null;
119+
}
120+
121+
// Already linked (e.g. concurrent/repeat login racing this method) - nothing to do.
122+
EPerson alreadyLinked = resolve(context, proxyNetid);
123+
if (alreadyLinked != null) {
124+
return alreadyLinked;
125+
}
126+
127+
Set<EPerson> matches = new LinkedHashSet<>();
128+
List<String> matchedOn = new ArrayList<>();
129+
for (String eppn : releasedEppns) {
130+
if (StringUtils.isBlank(eppn)) {
131+
continue;
132+
}
133+
for (EPersonNetidAlias alias : ePersonNetidAliasDAO.findByValuePrefix(context, eppn)) {
134+
if (matches.add(alias.getEPerson())) {
135+
matchedOn.add(eppn);
136+
}
137+
}
138+
}
139+
140+
if (matches.isEmpty()) {
141+
return null;
142+
}
143+
if (matches.size() > 1) {
144+
log.warn("Auto-link via proxy '{}' matched {} different EPersons for released identities {} - " +
145+
"refusing to guess, flag for admin merge (proxy netid '{}').",
146+
proxyAuthority, matches.size(), matchedOn, proxyNetid);
147+
return null;
148+
}
149+
150+
EPerson matched = matches.iterator().next();
151+
attach(context, matched, proxyNetid, SOURCE_AUTO_VOPERSON, null);
152+
log.info("Auto-linked netid '{}' to EPerson {} via proxy '{}' voperson_external_id match.",
153+
proxyNetid, matched.getID(), proxyAuthority);
154+
return matched;
155+
}
156+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.eperson.clarin;
9+
10+
import java.util.Date;
11+
import javax.persistence.Column;
12+
import javax.persistence.Entity;
13+
import javax.persistence.FetchType;
14+
import javax.persistence.GeneratedValue;
15+
import javax.persistence.GenerationType;
16+
import javax.persistence.Id;
17+
import javax.persistence.JoinColumn;
18+
import javax.persistence.ManyToOne;
19+
import javax.persistence.SequenceGenerator;
20+
import javax.persistence.Table;
21+
import javax.persistence.Temporal;
22+
import javax.persistence.TemporalType;
23+
24+
import org.dspace.core.ReloadableEntity;
25+
import org.dspace.eperson.EPerson;
26+
27+
/**
28+
* An alias binding one identity value (a formatted "value[authority]" netid,
29+
* where authority is a SAML IdP entityID or OIDC issuer URL) to an EPerson.
30+
*
31+
* This table is the primary source netid-based login resolution consults;
32+
* {@link EPerson#getNetid()} is otherwise a denormalized display field, but
33+
* is still consulted as a fallback by
34+
* {@link org.dspace.eperson.service.clarin.ClarinIdentityService#resolve}
35+
* for EPersons that do not (yet) have an alias row of their own.
36+
*
37+
* @author Ondrej Kosarko
38+
*/
39+
@Entity
40+
@Table(name = "eperson_netid_alias")
41+
public class EPersonNetidAlias implements ReloadableEntity<Integer> {
42+
43+
@Id
44+
@Column(name = "eperson_netid_alias_id")
45+
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "eperson_netid_alias_id_seq")
46+
@SequenceGenerator(name = "eperson_netid_alias_id_seq", sequenceName = "eperson_netid_alias_id_seq",
47+
allocationSize = 1)
48+
private Integer id;
49+
50+
@ManyToOne(fetch = FetchType.LAZY)
51+
@JoinColumn(name = "eperson_id", nullable = false)
52+
private EPerson ePerson;
53+
54+
/**
55+
* Formatted "value[authority]" identity, e.g. "novak@cuni.cz[https://cas.cuni.cz/idp/shibboleth]".
56+
*/
57+
@Column(name = "netid", nullable = false, unique = true)
58+
private String netid;
59+
60+
/**
61+
* How this alias was created: 'migration' | 'auto-voperson' | 'admin' | 'merge'.
62+
*/
63+
@Column(name = "source", nullable = false)
64+
private String source;
65+
66+
@ManyToOne(fetch = FetchType.LAZY)
67+
@JoinColumn(name = "created_by")
68+
private EPerson createdBy;
69+
70+
@Column(name = "created_date", nullable = false)
71+
@Temporal(TemporalType.TIMESTAMP)
72+
private Date createdDate;
73+
74+
protected EPersonNetidAlias() {
75+
}
76+
77+
@Override
78+
public Integer getID() {
79+
return id;
80+
}
81+
82+
public EPerson getEPerson() {
83+
return ePerson;
84+
}
85+
86+
public void setEPerson(EPerson ePerson) {
87+
this.ePerson = ePerson;
88+
}
89+
90+
public String getNetid() {
91+
return netid;
92+
}
93+
94+
public void setNetid(String netid) {
95+
this.netid = netid;
96+
}
97+
98+
public String getSource() {
99+
return source;
100+
}
101+
102+
public void setSource(String source) {
103+
this.source = source;
104+
}
105+
106+
public EPerson getCreatedBy() {
107+
return createdBy;
108+
}
109+
110+
public void setCreatedBy(EPerson createdBy) {
111+
this.createdBy = createdBy;
112+
}
113+
114+
public Date getCreatedDate() {
115+
return createdDate;
116+
}
117+
118+
public void setCreatedDate(Date createdDate) {
119+
this.createdDate = createdDate;
120+
}
121+
}

0 commit comments

Comments
 (0)