Skip to content
Merged
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 @@ -55,6 +55,7 @@
<constructor-arg index="2" value="${org.orcid.core.utils.cache.redis.password}" />
<constructor-arg index="3" value="${org.orcid.core.utils.cache.redis.expiration_in_secs:600}" />
<constructor-arg index="4" value="${org.orcid.core.utils.cache.redis.connection_timeout_millis:10000}" />
<constructor-arg index="5" value="${org.orcid.core.utils.cache.redis.ssl.enabled:true}" />
</bean>

<!-- Client authentication beans -->
Expand Down
39 changes: 39 additions & 0 deletions orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhone.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.orcid.core.manager;

import java.util.Date;

/**
* The stored recovery phone for a record, as far as anything outside the
* persistence layer is allowed to see it: the last four digits and the dates.
* The number itself never leaves the database in a readable form through this
* object. The one path that reads it is
* {@link RecoveryPhoneManager#getDecryptedPhoneNumber(String)}, kept separate
* so that the callers who only want the mask and the dates never decrypt.
*/
public class RecoveryPhone {

private final String lastFour;

private final Date dateCreated;

private final Date lastModified;

public RecoveryPhone(String lastFour, Date dateCreated, Date lastModified) {
this.lastFour = lastFour;
this.dateCreated = dateCreated;
this.lastModified = lastModified;
}

public String getLastFour() {
return lastFour;
}

public Date getDateCreated() {
return dateCreated;
}

public Date getLastModified() {
return lastModified;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.orcid.core.manager;

public interface RecoveryPhoneManager {

/**
* The stored recovery phone for the record, or null if there isn't one.
* It carries the last four digits and the dates only, so it costs no
* decryption: the Account settings panel polls this on every refresh.
*/
RecoveryPhone getRecoveryPhone(String orcid);

/**
* The stored number in E.164 form, or null when there is none.
*
* This is the only path that decrypts the stored number, and it exists for
* the flows that have to send a text to it without the user re-typing it.
* The value must never be written to a log line, put in a RUM attribute key
* or value, or returned over HTTP (R1.2): anything that reaches the user
* carries the last four digits only.
*/
String getDecryptedPhoneNumber(String orcid);

/**
* Stores the given E.164 number as the record's recovery phone, replacing
* any existing one. The number is persisted reversibly encrypted, together
* with the last four digits.
*
* @return the stored recovery phone as it now stands, from the row this
* call wrote. Callers answer from it rather than calling
* {@link #getRecoveryPhone(String)} straight after: that read goes
* to the read-only pool, which is a replica in a deployed
* environment and may not yet carry what was just written.
*/
RecoveryPhone saveRecoveryPhone(String orcid, String e164PhoneNumber);

/**
* Removes the recovery phone, if any. Called when 2FA is disabled, since
* turning 2FA off resets every 2FA backup option.
*/
void removeRecoveryPhone(String orcid);

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ public interface TwoFactorAuthenticationManager {

void disable2FA(String orcid);

/**
* The R3.5 transaction: turns 2FA off, deletes the stored recovery phone number and invalidates the unused backup
* codes, recording a distinct profile event so support can tell a recovery phone disable from a self service one.
* Used by the sign in and the authentication challenge recovery phone flows, where the user proved possession of
* the recovery number instead of a 2FA code, and the number is consumed by that single use.
*
* @param orcid
* the ORCID iD of the record whose 2FA is being disabled
*/
void disable2FAByRecoveryPhone(String orcid);

void adminDisable2FA(String orcid, String adminOrcidId);

boolean userUsing2FA(String orcid);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.orcid.core.manager.impl;

import jakarta.annotation.Resource;

import org.orcid.core.manager.EncryptionManager;
import org.orcid.core.manager.RecoveryPhone;
import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.persistence.dao.ProfileEventDao;
import org.orcid.persistence.dao.ProfileRecoveryPhoneDao;
import org.orcid.persistence.jpa.entities.ProfileEventEntity;
import org.orcid.persistence.jpa.entities.ProfileEventType;
import org.orcid.persistence.jpa.entities.ProfileRecoveryPhoneEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;

public class RecoveryPhoneManagerImpl implements RecoveryPhoneManager {

private static final Logger LOG = LoggerFactory.getLogger(RecoveryPhoneManagerImpl.class);

private static final int LAST_FOUR_LENGTH = 4;

@Resource
private ProfileRecoveryPhoneDao profileRecoveryPhoneDao;

@Resource
private EncryptionManager encryptionManager;

@Resource
private ProfileEventDao profileEventDao;

@Override
public RecoveryPhone getRecoveryPhone(String orcid) {
ProfileRecoveryPhoneEntity entity = profileRecoveryPhoneDao.findByOrcid(orcid);
if (entity == null) {
return null;
}
return toRecoveryPhone(entity);
}

@Override
public String getDecryptedPhoneNumber(String orcid) {
ProfileRecoveryPhoneEntity entity = profileRecoveryPhoneDao.findByOrcid(orcid);
if (entity == null) {
return null;
}
// Decrypting lives here and not in getRecoveryPhone because the Account settings panel polls
// that one for the mask and the dates alone; only the flows that have to text the number pay
// for the crypto, and the result must not reach a log line, a RUM attribute or an HTTP response.
return encryptionManager.decryptForInternalUse(entity.getEncryptedPhoneNumber());
}

@Override
@Transactional
public RecoveryPhone saveRecoveryPhone(String orcid, String e164PhoneNumber) {
// Whether this is a first number or a replacement is the DAO's to say: it decides
// inside its own write transaction. Asking findByOrcid here would open a second,
// read-only connection in the middle of this write and, on a deployed environment,
// ask a replica that may still be missing a row this record wrote seconds ago
ProfileRecoveryPhoneDao.UpsertResult result = profileRecoveryPhoneDao.upsert(orcid,
encryptionManager.encryptForInternalUse(e164PhoneNumber), lastFour(e164PhoneNumber));
boolean isNew = result.isInserted();
profileEventDao.persist(new ProfileEventEntity(orcid,
isNew ? ProfileEventType.PROFILE_RECOVERY_PHONE_ADDED : ProfileEventType.PROFILE_RECOVERY_PHONE_UPDATED));
LOG.info("Recovery phone {} for {}", isNew ? "added" : "updated", orcid);
return toRecoveryPhone(result.getEntity());
}

@Override
@Transactional
public void removeRecoveryPhone(String orcid) {
if (profileRecoveryPhoneDao.deleteByOrcid(orcid)) {
profileEventDao.persist(new ProfileEventEntity(orcid, ProfileEventType.PROFILE_RECOVERY_PHONE_REMOVED));
LOG.info("Recovery phone removed for {}", orcid);
}
}

private static RecoveryPhone toRecoveryPhone(ProfileRecoveryPhoneEntity entity) {
return new RecoveryPhone(entity.getLastFour(), entity.getDateCreated(), entity.getLastModified());
}

private String lastFour(String e164PhoneNumber) {
String digits = e164PhoneNumber.replaceAll("\\D", "");
return digits.length() <= LAST_FOUR_LENGTH ? digits : digits.substring(digits.length() - LAST_FOUR_LENGTH);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.orcid.core.manager.BackupCodeManager;
import org.orcid.core.manager.EncryptionManager;
import org.orcid.core.manager.ProfileEntityCacheManager;
import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.core.manager.TwoFactorAuthenticationManager;
import org.orcid.core.manager.read_only.EmailManagerReadOnly;
import org.orcid.jaxb.model.record_v2.Email;
Expand Down Expand Up @@ -43,6 +44,9 @@ public class TwoFactorAuthenticationManagerImpl implements TwoFactorAuthenticati
@Resource
private BackupCodeManager backupCodeManager;

@Resource
private RecoveryPhoneManager recoveryPhoneManager;

@Resource
private ProfileEventDao profileEventDao;

Expand Down Expand Up @@ -98,12 +102,31 @@ public void disable2FA(String orcid) {
public Boolean doInTransaction(TransactionStatus status) {
profileDao.disable2FA(orcid);
backupCodeManager.removeUnusedBackupCodes(orcid);
recoveryPhoneManager.removeRecoveryPhone(orcid);
profileEventDao.persist(new ProfileEventEntity(orcid, ProfileEventType.PROFILE_2FA_DISABLED));
return true;
}
});
}

@Override
public void disable2FAByRecoveryPhone(String orcid) {
// {}, not %s: slf4j takes its placeholder that way. The line above uses %s and has therefore
// been printing the literal characters instead of the iD for as long as it has existed; that one
// is not this ticket's to change, but there is no reason to copy it.
LOG.warn("2FA disabled by recovery phone for {}", orcid);
transactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
profileDao.disable2FA(orcid);
backupCodeManager.removeUnusedBackupCodes(orcid);
recoveryPhoneManager.removeRecoveryPhone(orcid);
profileEventDao.persist(new ProfileEventEntity(orcid, ProfileEventType.PROFILE_2FA_DISABLED_BY_RECOVERY_PHONE));
return true;
}
});
}

@Override
public void adminDisable2FA(String orcid, String adminOrcidId) {
String message = String.format("Admin %s have disabled 2FA for %s", adminOrcidId, orcid);
Expand All @@ -113,6 +136,7 @@ public void adminDisable2FA(String orcid, String adminOrcidId) {
public Boolean doInTransaction(TransactionStatus status) {
profileDao.disable2FA(orcid);
backupCodeManager.removeUnusedBackupCodes(orcid);
recoveryPhoneManager.removeRecoveryPhone(orcid);
profileEventDao.persist(new ProfileEventEntity(orcid, ProfileEventType.PROFILE_2FA_DISABLED_BY_ADMIN, message));
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.orcid.core.manager.ClientDetailsEntityCacheManager;
import org.orcid.core.manager.EncryptionManager;
import org.orcid.core.manager.ProfileEntityCacheManager;
import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.core.manager.impl.OrcidUrlManager;
import org.orcid.core.manager.v3.*;
import org.orcid.core.manager.v3.read_only.RecordNameManagerReadOnly;
Expand Down Expand Up @@ -129,6 +130,9 @@ public class ProfileEntityManagerImpl extends ProfileEntityManagerReadOnlyImpl i

@Resource
protected BackupCodeDao backupCodeDao;

@Resource
private RecoveryPhoneManager recoveryPhoneManager;

@Resource
private ProfileLastModifiedDao profileLastModifiedDao;
Expand Down Expand Up @@ -678,6 +682,10 @@ private void clearRecord(String orcid, Boolean disableTokens) {
// Admin disabling 2FA, so, we should not notify the user
profileDao.disable2FA(orcid);
backupCodeDao.removedUsedBackupCodes(orcid);
// The recovery phone number is 2FA backup state too, and it is personal data that must
// not outlive the record: clearing 2FA at the DAO leaves the encrypted number behind,
// so it goes the same way it does when 2FA is turned off through the manager.
recoveryPhoneManager.removeRecoveryPhone(orcid);

// delete notifications
notificationManager.deleteNotificationsForRecord(orcid);
Expand Down
8 changes: 7 additions & 1 deletion orcid-core/src/main/java/org/orcid/core/togglz/Features.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ public enum Features implements Feature {
SEND_EMAIL_ON_DEPRECATE_RECORD,

@Label("Send email on reset password")
SEND_EMAIL_ON_RESET_PASSWORD;
SEND_EMAIL_ON_RESET_PASSWORD,

@Label("2FA recovery phone number (add/manage from account settings)")
TWO_FACTOR_RECOVERY_PHONE,

@Label("Login - recovery phone interstitial")
LOGIN_RECOVERY_PHONE_INTERSTITIAL;
public boolean isActive() {
return FeatureContext.getFeatureManager().isActive(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,18 @@ public class RedisClient {

private static final int DEFAULT_CACHE_EXPIRY = 60;
private static final int DEFAULT_TIMEOUT = 10000;
private static final boolean DEFAULT_USE_SSL = true;
public static final int MACH_KEY_BATCH_SIZE = 1000;

private final String redisHost;
private final int redisPort;
private final String redisPassword;
private final int cacheExpiryInSecs;
private final int clientTimeoutInMillis;
// TLS is on unless a deployment says otherwise. A plaintext Redis with TLS forced on does not
// reject the handshake, it simply never answers, so the connect blocks until the socket read
// times out -- once per client, per Spring context.
private final boolean useSsl;
public JedisPool pool;
private SetParams defaultSetParams;

Expand All @@ -54,34 +59,47 @@ public class RedisClient {
private boolean enabled = false;

public RedisClient(String redisHost, int redisPort, String password) {
this.redisHost = redisHost;
this.redisPort = redisPort;
this.redisPassword = password;
this.cacheExpiryInSecs = DEFAULT_CACHE_EXPIRY;
this.clientTimeoutInMillis = DEFAULT_TIMEOUT;
this(redisHost, redisPort, password, DEFAULT_CACHE_EXPIRY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL);
}

public RedisClient(String redisHost, int redisPort, String password, int cacheExpiryInSecs) {
this.redisHost = redisHost;
this.redisPort = redisPort;
this.redisPassword = password;
this.cacheExpiryInSecs = cacheExpiryInSecs;
this.clientTimeoutInMillis = DEFAULT_TIMEOUT;
this(redisHost, redisPort, password, cacheExpiryInSecs, DEFAULT_TIMEOUT, DEFAULT_USE_SSL);
}

public RedisClient(String redisHost, int redisPort, String password, int cacheExpiryInSecs, int clientTimeoutInMillis) {
this(redisHost, redisPort, password, cacheExpiryInSecs, clientTimeoutInMillis, DEFAULT_USE_SSL);
}

public RedisClient(String redisHost, int redisPort, String password, int cacheExpiryInSecs, int clientTimeoutInMillis, boolean useSsl) {
this.redisHost = redisHost;
this.redisPort = redisPort;
this.redisPassword = password;
this.cacheExpiryInSecs = cacheExpiryInSecs;
this.clientTimeoutInMillis = clientTimeoutInMillis;
this.useSsl = useSsl;
}

/** The Jedis configuration this client would connect with. Package-private so it can be
* asserted on without opening a connection. */
JedisClientConfig buildClientConfig() {
DefaultJedisClientConfig.Builder builder = DefaultJedisClientConfig.builder()
.connectionTimeoutMillis(this.clientTimeoutInMillis)
.socketTimeoutMillis(this.clientTimeoutInMillis)
.ssl(this.useSsl);
// A blank password means "no authentication". Setting it anyway makes Jedis send
// AUTH "", which a Redis with no requirepass rejects outright:
// ERR AUTH <password> called without any password configured for the default user
// so the client would fail to connect against an unauthenticated server.
if (this.redisPassword != null && !this.redisPassword.trim().isEmpty()) {
builder.password(this.redisPassword);
}
return builder.build();
}

@PostConstruct
private void init() {
try {
JedisClientConfig config = DefaultJedisClientConfig.builder().connectionTimeoutMillis(this.clientTimeoutInMillis)
.socketTimeoutMillis(this.clientTimeoutInMillis).password(this.redisPassword).ssl(true).build();
JedisClientConfig config = buildClientConfig();
pool = new JedisPool(new HostAndPort(this.redisHost, this.redisPort), config);
defaultSetParams = new SetParams();
defaultSetParams.ex(Long.valueOf(this.cacheExpiryInSecs));
Expand Down
Loading
Loading