diff --git a/orcid-api-common/src/main/resources/orcid-oauth2-api-common-config.xml b/orcid-api-common/src/main/resources/orcid-oauth2-api-common-config.xml
index fea74d3b950..77aff8a541b 100644
--- a/orcid-api-common/src/main/resources/orcid-oauth2-api-common-config.xml
+++ b/orcid-api-common/src/main/resources/orcid-oauth2-api-common-config.xml
@@ -55,6 +55,7 @@
+
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhone.java b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhone.java
new file mode 100644
index 00000000000..87c25198097
--- /dev/null
+++ b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhone.java
@@ -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;
+ }
+
+}
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java
new file mode 100644
index 00000000000..68dc6958801
--- /dev/null
+++ b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java
@@ -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);
+
+}
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/TwoFactorAuthenticationManager.java b/orcid-core/src/main/java/org/orcid/core/manager/TwoFactorAuthenticationManager.java
index 229bc486429..b343469eee9 100644
--- a/orcid-core/src/main/java/org/orcid/core/manager/TwoFactorAuthenticationManager.java
+++ b/orcid-core/src/main/java/org/orcid/core/manager/TwoFactorAuthenticationManager.java
@@ -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);
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/impl/RecoveryPhoneManagerImpl.java b/orcid-core/src/main/java/org/orcid/core/manager/impl/RecoveryPhoneManagerImpl.java
new file mode 100644
index 00000000000..26c29c4c494
--- /dev/null
+++ b/orcid-core/src/main/java/org/orcid/core/manager/impl/RecoveryPhoneManagerImpl.java
@@ -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);
+ }
+
+}
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/impl/TwoFactorAuthenticationManagerImpl.java b/orcid-core/src/main/java/org/orcid/core/manager/impl/TwoFactorAuthenticationManagerImpl.java
index 4d8fa7d40d8..85109b16f29 100644
--- a/orcid-core/src/main/java/org/orcid/core/manager/impl/TwoFactorAuthenticationManagerImpl.java
+++ b/orcid-core/src/main/java/org/orcid/core/manager/impl/TwoFactorAuthenticationManagerImpl.java
@@ -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;
@@ -43,6 +44,9 @@ public class TwoFactorAuthenticationManagerImpl implements TwoFactorAuthenticati
@Resource
private BackupCodeManager backupCodeManager;
+ @Resource
+ private RecoveryPhoneManager recoveryPhoneManager;
+
@Resource
private ProfileEventDao profileEventDao;
@@ -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() {
+ @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);
@@ -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;
}
diff --git a/orcid-core/src/main/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImpl.java b/orcid-core/src/main/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImpl.java
index 1257f31ffc8..f01b07db624 100644
--- a/orcid-core/src/main/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImpl.java
+++ b/orcid-core/src/main/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImpl.java
@@ -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;
@@ -129,6 +130,9 @@ public class ProfileEntityManagerImpl extends ProfileEntityManagerReadOnlyImpl i
@Resource
protected BackupCodeDao backupCodeDao;
+
+ @Resource
+ private RecoveryPhoneManager recoveryPhoneManager;
@Resource
private ProfileLastModifiedDao profileLastModifiedDao;
@@ -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);
diff --git a/orcid-core/src/main/java/org/orcid/core/togglz/Features.java b/orcid-core/src/main/java/org/orcid/core/togglz/Features.java
index 997407fdc9d..16536948ff1 100644
--- a/orcid-core/src/main/java/org/orcid/core/togglz/Features.java
+++ b/orcid-core/src/main/java/org/orcid/core/togglz/Features.java
@@ -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);
}
diff --git a/orcid-core/src/main/java/org/orcid/core/utils/cache/redis/RedisClient.java b/orcid-core/src/main/java/org/orcid/core/utils/cache/redis/RedisClient.java
index bf4551b2c0a..2c4f3ee8f39 100644
--- a/orcid-core/src/main/java/org/orcid/core/utils/cache/redis/RedisClient.java
+++ b/orcid-core/src/main/java/org/orcid/core/utils/cache/redis/RedisClient.java
@@ -37,6 +37,7 @@ 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;
@@ -44,6 +45,10 @@ public class RedisClient {
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;
@@ -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 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));
diff --git a/orcid-core/src/main/java/org/orcid/pojo/TwoFactorAuthStatus.java b/orcid-core/src/main/java/org/orcid/pojo/TwoFactorAuthStatus.java
index 5c7fc896e59..29030c6c063 100644
--- a/orcid-core/src/main/java/org/orcid/pojo/TwoFactorAuthStatus.java
+++ b/orcid-core/src/main/java/org/orcid/pojo/TwoFactorAuthStatus.java
@@ -10,6 +10,14 @@ public class TwoFactorAuthStatus extends AuthChallenge {
private Date recoveryCodeCreationDate;
+ private String maskedRecoveryPhoneNumber;
+
+ private Date recoveryPhoneCreationDate;
+
+ private Date recoveryPhoneLastModifiedDate;
+
+ private boolean recoveryPhoneModified;
+
public boolean isEnabled() {
return enabled;
}
@@ -33,4 +41,36 @@ public Date getRecoveryCodeCreationDate() {
public void setRecoveryCodeCreationDate(Date recoveryCodeCreationDate) {
this.recoveryCodeCreationDate = recoveryCodeCreationDate;
}
+
+ public String getMaskedRecoveryPhoneNumber() {
+ return maskedRecoveryPhoneNumber;
+ }
+
+ public void setMaskedRecoveryPhoneNumber(String maskedRecoveryPhoneNumber) {
+ this.maskedRecoveryPhoneNumber = maskedRecoveryPhoneNumber;
+ }
+
+ public Date getRecoveryPhoneCreationDate() {
+ return recoveryPhoneCreationDate;
+ }
+
+ public void setRecoveryPhoneCreationDate(Date recoveryPhoneCreationDate) {
+ this.recoveryPhoneCreationDate = recoveryPhoneCreationDate;
+ }
+
+ public Date getRecoveryPhoneLastModifiedDate() {
+ return recoveryPhoneLastModifiedDate;
+ }
+
+ public void setRecoveryPhoneLastModifiedDate(Date recoveryPhoneLastModifiedDate) {
+ this.recoveryPhoneLastModifiedDate = recoveryPhoneLastModifiedDate;
+ }
+
+ public boolean isRecoveryPhoneModified() {
+ return recoveryPhoneModified;
+ }
+
+ public void setRecoveryPhoneModified(boolean recoveryPhoneModified) {
+ this.recoveryPhoneModified = recoveryPhoneModified;
+ }
}
diff --git a/orcid-core/src/main/resources/orcid-core-context.xml b/orcid-core/src/main/resources/orcid-core-context.xml
index c2fd0a2958a..2a2e288e4b7 100644
--- a/orcid-core/src/main/resources/orcid-core-context.xml
+++ b/orcid-core/src/main/resources/orcid-core-context.xml
@@ -729,6 +729,8 @@
+
+
@@ -1039,6 +1041,7 @@
+
@@ -1048,6 +1051,7 @@
+
diff --git a/orcid-core/src/test/java/org/orcid/core/manager/RecoveryPhoneManagerTest.java b/orcid-core/src/test/java/org/orcid/core/manager/RecoveryPhoneManagerTest.java
new file mode 100644
index 00000000000..c4ecac956b8
--- /dev/null
+++ b/orcid-core/src/test/java/org/orcid/core/manager/RecoveryPhoneManagerTest.java
@@ -0,0 +1,206 @@
+package org.orcid.core.manager;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Date;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.orcid.core.manager.impl.RecoveryPhoneManagerImpl;
+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.orcid.test.OrcidJUnit4ClassRunner;
+import org.springframework.test.context.ContextConfiguration;
+
+@RunWith(OrcidJUnit4ClassRunner.class)
+@ContextConfiguration(locations = { "classpath:test-orcid-core-context.xml" })
+public class RecoveryPhoneManagerTest {
+
+ private static final String ORCID = "0000-0000-0000-0001";
+
+ private static final String PHONE = "+441234567890";
+
+ private static final String ENCRYPTED = "encrypted-number";
+
+ @Mock
+ private ProfileRecoveryPhoneDao profileRecoveryPhoneDao;
+
+ @Mock
+ private EncryptionManager encryptionManager;
+
+ @Mock
+ private ProfileEventDao profileEventDao;
+
+ @InjectMocks
+ private RecoveryPhoneManagerImpl recoveryPhoneManager;
+
+ @Before
+ public void init() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ @Test
+ public void getRecoveryPhoneReturnsNullWhenNoneStored() {
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(null);
+ assertNull(recoveryPhoneManager.getRecoveryPhone(ORCID));
+ }
+
+ @Test
+ public void getRecoveryPhoneMapsTheLastFourAndTheDates() {
+ Date created = new Date(1000L);
+ Date modified = new Date(2000L);
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(entity(ENCRYPTED, "7890", created, modified));
+
+ RecoveryPhone recoveryPhone = recoveryPhoneManager.getRecoveryPhone(ORCID);
+
+ assertEquals("7890", recoveryPhone.getLastFour());
+ assertEquals(created, recoveryPhone.getDateCreated());
+ assertEquals(modified, recoveryPhone.getLastModified());
+ }
+
+ @Test
+ public void getRecoveryPhoneNeverDecrypts() {
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(entity(ENCRYPTED, "7890", new Date(), new Date()));
+
+ recoveryPhoneManager.getRecoveryPhone(ORCID);
+
+ // The point of keeping the number on its own accessor: the Account settings panel polls this
+ // one for the mask and the dates, and no poll should cost a decryption.
+ verifyNoInteractions(encryptionManager);
+ }
+
+ @Test
+ public void getDecryptedPhoneNumberDecryptsTheStoredNumber() {
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(entity(ENCRYPTED, "7890", new Date(), new Date()));
+ when(encryptionManager.decryptForInternalUse(ENCRYPTED)).thenReturn(PHONE);
+
+ String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(ORCID);
+
+ // The stored column is what gets decrypted, and the caller gets the number back in full:
+ // the recovery flows text it without the user re-typing it.
+ verify(encryptionManager).decryptForInternalUse(ENCRYPTED);
+ assertEquals(PHONE, phoneNumber);
+ }
+
+ @Test
+ public void getDecryptedPhoneNumberReturnsNullWhenNoneStored() {
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(null);
+ assertNull(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID));
+ }
+
+ @Test
+ public void saveStoresTheEncryptedNumberAndTheLastFour() {
+ Date now = new Date();
+ when(encryptionManager.encryptForInternalUse(PHONE)).thenReturn(ENCRYPTED);
+ when(profileRecoveryPhoneDao.upsert(ORCID, ENCRYPTED, "7890"))
+ .thenReturn(new ProfileRecoveryPhoneDao.UpsertResult(entity(ENCRYPTED, "7890", now, now), true));
+
+ RecoveryPhone saved = recoveryPhoneManager.saveRecoveryPhone(ORCID, PHONE);
+
+ // Answered from the row the upsert wrote, never from a second lookup
+ assertEquals("7890", saved.getLastFour());
+ assertEquals(now, saved.getDateCreated());
+ verify(profileRecoveryPhoneDao, never()).findByOrcid(anyString());
+ verify(profileRecoveryPhoneDao).upsert(ORCID, ENCRYPTED, "7890");
+ // Reversibly encrypted, never hashed, and the plain number never reaches the DAO.
+ verify(encryptionManager, never()).hashForInternalUse(anyString());
+ verify(profileRecoveryPhoneDao, never()).upsert(eq(ORCID), eq(PHONE), anyString());
+ assertEquals(ProfileEventType.PROFILE_RECOVERY_PHONE_ADDED, capturedEventType());
+ }
+
+ /*
+ * findByOrcid runs on the read-only pool. Here it says there is no row - the
+ * answer a lagging replica gives seconds after the primary stored one - while
+ * the upsert, on the primary, found the row and replaced it. The event follows
+ * the upsert: recording this as an "added" would be wrong in the audit trail.
+ */
+ @Test
+ public void savingOverAnExistingNumberIsRecordedAsAnUpdate() {
+ Date created = new Date(System.currentTimeMillis() - 60_000L);
+ Date modified = new Date();
+ when(profileRecoveryPhoneDao.findByOrcid(ORCID)).thenReturn(null);
+ when(encryptionManager.encryptForInternalUse(anyString())).thenReturn("new-encrypted");
+ when(profileRecoveryPhoneDao.upsert(ORCID, "new-encrypted", "7890"))
+ .thenReturn(new ProfileRecoveryPhoneDao.UpsertResult(entity("new-encrypted", "7890", created, modified), false));
+
+ RecoveryPhone saved = recoveryPhoneManager.saveRecoveryPhone(ORCID, PHONE);
+
+ assertEquals(created, saved.getDateCreated());
+ assertEquals(modified, saved.getLastModified());
+ verify(profileRecoveryPhoneDao).upsert(ORCID, "new-encrypted", "7890");
+ assertEquals(ProfileEventType.PROFILE_RECOVERY_PHONE_UPDATED, capturedEventType());
+ }
+
+ @Test
+ public void lastFourIsTakenFromTheDigitsOnly() {
+ when(encryptionManager.encryptForInternalUse(anyString())).thenReturn(ENCRYPTED);
+ when(profileRecoveryPhoneDao.upsert(eq(ORCID), anyString(), anyString()))
+ .thenReturn(new ProfileRecoveryPhoneDao.UpsertResult(entity(ENCRYPTED, "9876", new Date(), new Date()), true));
+
+ recoveryPhoneManager.saveRecoveryPhone(ORCID, "+1 (555) 010-9876");
+
+ verify(profileRecoveryPhoneDao).upsert(eq(ORCID), anyString(), eq("9876"));
+ }
+
+ @Test
+ public void removeIsSilentWhenThereWasNothingToRemove() {
+ when(profileRecoveryPhoneDao.deleteByOrcid(ORCID)).thenReturn(false);
+
+ recoveryPhoneManager.removeRecoveryPhone(ORCID);
+
+ verify(profileEventDao, never()).persist(any(ProfileEventEntity.class));
+ }
+
+ @Test
+ public void removeRecordsAnEventWhenANumberWasDeleted() {
+ when(profileRecoveryPhoneDao.deleteByOrcid(ORCID)).thenReturn(true);
+
+ recoveryPhoneManager.removeRecoveryPhone(ORCID);
+
+ assertEquals(ProfileEventType.PROFILE_RECOVERY_PHONE_REMOVED, capturedEventType());
+ }
+
+ private ProfileEventType capturedEventType() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ProfileEventEntity.class);
+ verify(profileEventDao).persist(captor.capture());
+ return captor.getValue().getType();
+ }
+
+ private static ProfileRecoveryPhoneEntity entity(String encrypted, String lastFour, Date created, Date modified) {
+ ProfileRecoveryPhoneEntity entity = new ProfileRecoveryPhoneEntity() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public Date getDateCreated() {
+ return created;
+ }
+
+ @Override
+ public Date getLastModified() {
+ return modified;
+ }
+ };
+ entity.setEncryptedPhoneNumber(encrypted);
+ entity.setLastFour(lastFour);
+ return entity;
+ }
+
+}
diff --git a/orcid-core/src/test/java/org/orcid/core/manager/TwoFactorAuthenticationManagerTest.java b/orcid-core/src/test/java/org/orcid/core/manager/TwoFactorAuthenticationManagerTest.java
index d58ebd18b84..f25c85122b9 100644
--- a/orcid-core/src/test/java/org/orcid/core/manager/TwoFactorAuthenticationManagerTest.java
+++ b/orcid-core/src/test/java/org/orcid/core/manager/TwoFactorAuthenticationManagerTest.java
@@ -2,11 +2,14 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -17,6 +20,7 @@
import org.jboss.aerogear.security.otp.api.Base32;
import org.junit.Before;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -30,6 +34,7 @@
import org.orcid.persistence.dao.ProfileEventDao;
import org.orcid.persistence.jpa.entities.ProfileEntity;
import org.orcid.persistence.jpa.entities.ProfileEventEntity;
+import org.orcid.persistence.jpa.entities.ProfileEventType;
import org.orcid.pojo.AuthChallenge;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
@@ -48,6 +53,9 @@ public class TwoFactorAuthenticationManagerTest {
@Mock
private BackupCodeManager backupCodeManager;
+ @Mock
+ private RecoveryPhoneManager recoveryPhoneManager;
+
@Mock
private ProfileDao profileDao;
@@ -128,6 +136,35 @@ public void testDisable2FA() {
verify(profileDao).disable2FA(anyString());
verify(profileEventDao).persist(any(ProfileEventEntity.class));
verify(backupCodeManager).removeUnusedBackupCodes(anyString());
+ // turning 2FA off resets every 2FA backup option
+ verify(recoveryPhoneManager).removeRecoveryPhone(anyString());
+ }
+
+ @Test
+ public void testDisable2FAByRecoveryPhone() {
+ twoFactorAuthenticationManager.disable2FAByRecoveryPhone("orcid");
+
+ // the whole R3.5 transaction: 2FA off, unused backup codes invalidated, the recovery number consumed
+ verify(profileDao, times(1)).disable2FA(eq("orcid"));
+ verify(backupCodeManager, times(1)).removeUnusedBackupCodes(eq("orcid"));
+ verify(recoveryPhoneManager, times(1)).removeRecoveryPhone(eq("orcid"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ProfileEventEntity.class);
+ verify(profileEventDao, times(1)).persist(captor.capture());
+ assertEquals("orcid", captor.getValue().getOrcid());
+ assertEquals(ProfileEventType.PROFILE_2FA_DISABLED_BY_RECOVERY_PHONE, captor.getValue().getType());
+ }
+
+ @Test
+ public void testDisable2FAByRecoveryPhoneDoesNotRecordASelfServiceDisable() {
+ twoFactorAuthenticationManager.disable2FAByRecoveryPhone("orcid");
+
+ // support tells a recovery phone disable from a self service one by the event type alone, so exactly one
+ // event is recorded and it is not the self service or the admin one
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ProfileEventEntity.class);
+ verify(profileEventDao, times(1)).persist(captor.capture());
+ assertNotEquals(ProfileEventType.PROFILE_2FA_DISABLED, captor.getValue().getType());
+ assertNotEquals(ProfileEventType.PROFILE_2FA_DISABLED_BY_ADMIN, captor.getValue().getType());
}
@Test
@@ -136,6 +173,7 @@ public void testAdminDisable2FA() {
verify(profileDao).disable2FA(anyString());
verify(profileEventDao).persist(any(ProfileEventEntity.class));
verify(backupCodeManager).removeUnusedBackupCodes(anyString());
+ verify(recoveryPhoneManager).removeRecoveryPhone(anyString());
}
@Test
diff --git a/orcid-core/src/test/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImplTest.java b/orcid-core/src/test/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImplTest.java
index 03821873d5d..06dbf925acf 100644
--- a/orcid-core/src/test/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImplTest.java
+++ b/orcid-core/src/test/java/org/orcid/core/manager/v3/impl/ProfileEntityManagerImplTest.java
@@ -44,6 +44,7 @@
import org.orcid.core.locale.LocaleManager;
import org.orcid.core.manager.ClientDetailsEntityCacheManager;
import org.orcid.core.manager.EncryptionManager;
+import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.core.manager.v3.AddressManager;
import org.orcid.core.manager.v3.AffiliationsManager;
import org.orcid.core.manager.v3.BiographyManager;
@@ -179,6 +180,8 @@ public class ProfileEntityManagerImplTest {
private ResearcherUrlManager researcherUrlManager;
@Mock
private RedisClient redisClient;
+ @Mock
+ private RecoveryPhoneManager recoveryPhoneManager;
@Before
public void setUp() {
@@ -217,6 +220,7 @@ public void setUp() {
inject(ProfileEntityManagerImpl.class, "biographyManager", biographyManager);
inject(ProfileEntityManagerImpl.class, "emailFrequencyManager", emailFrequencyManager);
inject(ProfileEntityManagerImpl.class, "redisClient", redisClient);
+ inject(ProfileEntityManagerImpl.class, "recoveryPhoneManager", recoveryPhoneManager);
doAnswer(invocation -> {
TransactionCallback> callback = (TransactionCallback>) invocation.getArguments()[0];
@@ -367,6 +371,25 @@ public void deactivateRecordClearsRecordHidesEmailsAndDisablesTokens() {
verify(profileHistoryEventManager).recordEvent(ProfileHistoryEventType.SET_DEFAULT_VIS_TO_PRIVATE, "orcid", "deactivated/deprecated");
}
+ /*
+ * Deactivation clears 2FA at the DAO, which used to leave the recovery phone
+ * number - encrypted, but personal data about a named person - behind in
+ * profile_recovery_phone. It has to go the same way it does when 2FA is turned
+ * off through the manager. Deprecation runs the same clearRecord, so this one
+ * test covers both doors.
+ */
+ @Test
+ public void deactivateRecordRemovesTheRecoveryPhoneNumber() {
+ when(profileDao.updateDefaultVisibility("orcid", org.orcid.jaxb.model.common_v2.Visibility.PRIVATE.name())).thenReturn(true);
+ when(recordNameManagerV3.exists("orcid")).thenReturn(false);
+ when(biographyManager.exists("orcid")).thenReturn(false);
+
+ assertTrue(manager.deactivateRecord("orcid"));
+
+ verify(profileDao).disable2FA("orcid");
+ verify(recoveryPhoneManager).removeRecoveryPhone("orcid");
+ }
+
@Test
public void enableDeveloperToolsDelegates() {
when(profileDao.updateDeveloperTools("orcid", true)).thenReturn(true);
diff --git a/orcid-core/src/test/java/org/orcid/core/utils/cache/redis/RedisClientTest.java b/orcid-core/src/test/java/org/orcid/core/utils/cache/redis/RedisClientTest.java
new file mode 100644
index 00000000000..306fb440f91
--- /dev/null
+++ b/orcid-core/src/test/java/org/orcid/core/utils/cache/redis/RedisClientTest.java
@@ -0,0 +1,75 @@
+package org.orcid.core.utils.cache.redis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import redis.clients.jedis.JedisClientConfig;
+
+/**
+ * TLS was hardcoded on, which meant a plaintext Redis never answered the handshake and every
+ * connect blocked for the full socket timeout -- once per client, per Spring context. These tests
+ * pin the flag and, more importantly, pin the default: production must keep getting TLS.
+ *
+ * Deliberately a plain unit test with no Spring context. RedisClient carries no
+ * {@code @Profile("!unitTests")} (unlike SessionCacheConfig and JedisPoolBuilder), so any
+ * context-loading test really attempts a TLS connect and waits out the timeout.
+ */
+public class RedisClientTest {
+
+ private static final String HOST = "localhost";
+ private static final int PORT = 6379;
+ private static final String PASSWORD = "";
+ private static final int EXPIRY = 600;
+ private static final int TIMEOUT = 10000;
+
+ @Test
+ public void sslIsEnabledByDefaultOnEveryConstructor() {
+ assertTrue("3-arg constructor must default to TLS",
+ new RedisClient(HOST, PORT, PASSWORD).buildClientConfig().isSsl());
+ assertTrue("4-arg constructor must default to TLS",
+ new RedisClient(HOST, PORT, PASSWORD, EXPIRY).buildClientConfig().isSsl());
+ assertTrue("5-arg constructor must default to TLS",
+ new RedisClient(HOST, PORT, PASSWORD, EXPIRY, TIMEOUT).buildClientConfig().isSsl());
+ }
+
+ @Test
+ public void sslCanBeTurnedOff() {
+ JedisClientConfig config = new RedisClient(HOST, PORT, PASSWORD, EXPIRY, TIMEOUT, false).buildClientConfig();
+ assertFalse("ssl=false must reach the Jedis config", config.isSsl());
+ }
+
+ @Test
+ public void sslCanBeTurnedOnExplicitly() {
+ JedisClientConfig config = new RedisClient(HOST, PORT, PASSWORD, EXPIRY, TIMEOUT, true).buildClientConfig();
+ assertTrue(config.isSsl());
+ }
+
+ @Test
+ public void aBlankPasswordMeansNoAuthentication() {
+ // Jedis sends AUTH "" if a password is set at all, and a Redis without requirepass
+ // rejects that outright, so a blank password must not reach the config.
+ assertNull("empty password must not be sent",
+ new RedisClient(HOST, PORT, "", EXPIRY, TIMEOUT, false).buildClientConfig().getPassword());
+ assertNull("blank password must not be sent",
+ new RedisClient(HOST, PORT, " ", EXPIRY, TIMEOUT, false).buildClientConfig().getPassword());
+ assertNull("null password must not be sent",
+ new RedisClient(HOST, PORT, null, EXPIRY, TIMEOUT, false).buildClientConfig().getPassword());
+ }
+
+ @Test
+ public void arealPasswordIsStillSent() {
+ assertEquals("s3cret",
+ new RedisClient(HOST, PORT, "s3cret", EXPIRY, TIMEOUT, true).buildClientConfig().getPassword());
+ }
+
+ @Test
+ public void theOtherSettingsStillReachTheConfig() {
+ JedisClientConfig config = new RedisClient(HOST, PORT, PASSWORD, EXPIRY, 1234, false).buildClientConfig();
+ assertEquals(1234, config.getConnectionTimeoutMillis());
+ assertEquals(1234, config.getSocketTimeoutMillis());
+ }
+}
diff --git a/orcid-persistence/src/main/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDao.java b/orcid-persistence/src/main/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDao.java
new file mode 100644
index 00000000000..b04a76b89a9
--- /dev/null
+++ b/orcid-persistence/src/main/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDao.java
@@ -0,0 +1,44 @@
+package org.orcid.persistence.dao;
+
+import org.orcid.persistence.jpa.entities.ProfileRecoveryPhoneEntity;
+
+public interface ProfileRecoveryPhoneDao extends GenericDao {
+
+ ProfileRecoveryPhoneEntity findByOrcid(String orcid);
+
+ /**
+ * Stores the recovery phone for a record, replacing any existing one. A
+ * record can only ever have a single recovery phone number.
+ *
+ * @return the row as it now stands, flushed so its dates are the stored
+ * ones, and whether this call created it. A caller that needs to
+ * know what was written reads it from here rather than looking the
+ * row up again: {@link #findByOrcid} runs on the read-only
+ * transaction manager, which is a second connection inside a write
+ * and, in a deployed environment, a replica that may not yet carry
+ * the row.
+ */
+ UpsertResult upsert(String orcid, String encryptedPhoneNumber, String lastFour);
+
+ boolean deleteByOrcid(String orcid);
+
+ /** What an upsert did: the stored row and whether it was created by that call. */
+ final class UpsertResult {
+ private final ProfileRecoveryPhoneEntity entity;
+ private final boolean inserted;
+
+ public UpsertResult(ProfileRecoveryPhoneEntity entity, boolean inserted) {
+ this.entity = entity;
+ this.inserted = inserted;
+ }
+
+ public ProfileRecoveryPhoneEntity getEntity() {
+ return entity;
+ }
+
+ public boolean isInserted() {
+ return inserted;
+ }
+ }
+
+}
diff --git a/orcid-persistence/src/main/java/org/orcid/persistence/dao/impl/ProfileRecoveryPhoneDaoImpl.java b/orcid-persistence/src/main/java/org/orcid/persistence/dao/impl/ProfileRecoveryPhoneDaoImpl.java
new file mode 100644
index 00000000000..9a16ee99a0b
--- /dev/null
+++ b/orcid-persistence/src/main/java/org/orcid/persistence/dao/impl/ProfileRecoveryPhoneDaoImpl.java
@@ -0,0 +1,66 @@
+package org.orcid.persistence.dao.impl;
+
+import java.util.List;
+
+import jakarta.persistence.Query;
+
+import org.orcid.persistence.dao.ProfileRecoveryPhoneDao;
+import org.orcid.persistence.jpa.entities.ProfileRecoveryPhoneEntity;
+import org.springframework.transaction.annotation.Transactional;
+
+public class ProfileRecoveryPhoneDaoImpl extends GenericDaoImpl implements ProfileRecoveryPhoneDao {
+
+ public ProfileRecoveryPhoneDaoImpl() {
+ super(ProfileRecoveryPhoneEntity.class);
+ }
+
+ /**
+ * Reads go to the read-only pool, as every DAO read does since PD-13463. Inside
+ * {@link #upsert} this method is called on {@code this}, not through the proxy, so
+ * that lookup stays in the write transaction: an existence check that decides
+ * between insert and update must not be answered by a replica.
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ @Transactional(value = "transactionManagerReadOnly", readOnly = true)
+ public ProfileRecoveryPhoneEntity findByOrcid(String orcid) {
+ Query query = entityManager.createQuery("FROM ProfileRecoveryPhoneEntity WHERE orcid = :orcid");
+ query.setParameter("orcid", orcid);
+ List results = query.getResultList();
+ return results.isEmpty() ? null : results.get(0);
+ }
+
+ @Override
+ @Transactional
+ public UpsertResult upsert(String orcid, String encryptedPhoneNumber, String lastFour) {
+ // The lookup is a call on this, not on the proxy, so it runs inside this write
+ // transaction on the primary: the existence check that decides insert or update
+ // has to see the row this same connection may have written a moment ago
+ ProfileRecoveryPhoneEntity existing = findByOrcid(orcid);
+ if (existing == null) {
+ ProfileRecoveryPhoneEntity entity = new ProfileRecoveryPhoneEntity();
+ entity.setOrcid(orcid);
+ entity.setEncryptedPhoneNumber(encryptedPhoneNumber);
+ entity.setLastFour(lastFour);
+ this.persist(entity);
+ // dateCreated and lastModified were stamped by the BaseEntity lifecycle callback
+ return new UpsertResult(entity, true);
+ }
+ existing.setEncryptedPhoneNumber(encryptedPhoneNumber);
+ existing.setLastFour(lastFour);
+ this.merge(existing);
+ // The update callback that stamps lastModified runs at flush, and the transaction
+ // this joined commits after the caller has already built its answer from the row
+ this.flush();
+ return new UpsertResult(existing, false);
+ }
+
+ @Override
+ @Transactional
+ public boolean deleteByOrcid(String orcid) {
+ Query query = entityManager.createQuery("DELETE FROM ProfileRecoveryPhoneEntity WHERE orcid = :orcid");
+ query.setParameter("orcid", orcid);
+ return query.executeUpdate() > 0;
+ }
+
+}
diff --git a/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileEventType.java b/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileEventType.java
index 0251efbca88..d2d99d6d5ed 100644
--- a/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileEventType.java
+++ b/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileEventType.java
@@ -36,7 +36,10 @@ public enum ProfileEventType {
EMAIL_VIS_2019_SENT, EMAIL_VIS_2019_SKIPPED, EMAIL_VIS_2019_FAILED,
// 2FA enable/disable events
- PROFILE_2FA_ENABLED, PROFILE_2FA_DISABLED, PROFILE_2FA_DISABLED_BY_ADMIN,
+ PROFILE_2FA_ENABLED, PROFILE_2FA_DISABLED, PROFILE_2FA_DISABLED_BY_ADMIN, PROFILE_2FA_DISABLED_BY_RECOVERY_PHONE,
+
+ // 2FA recovery phone number events
+ PROFILE_RECOVERY_PHONE_ADDED, PROFILE_RECOVERY_PHONE_UPDATED, PROFILE_RECOVERY_PHONE_REMOVED,
//Send email to encourage users to add works to their record
ADD_WORKS_FIRST_REMINDER_SENT,
diff --git a/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileRecoveryPhoneEntity.java b/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileRecoveryPhoneEntity.java
new file mode 100644
index 00000000000..9104bd36ae1
--- /dev/null
+++ b/orcid-persistence/src/main/java/org/orcid/persistence/jpa/entities/ProfileRecoveryPhoneEntity.java
@@ -0,0 +1,75 @@
+package org.orcid.persistence.jpa.entities;
+
+import java.io.Serializable;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.SequenceGenerator;
+import jakarta.persistence.Table;
+
+/**
+ * The 2FA recovery phone number for a record.
+ *
+ * The number is held in E.164 form, reversibly encrypted with the same
+ * mechanism as the 2FA secret, alongside the last four digits, which are all
+ * that is ever displayed back to the user. The encryption has to be reversible
+ * because the Registry sends a text to the stored number without the user
+ * re-typing it; the encrypted column is the only place the full number exists.
+ */
+@Entity
+@Table(name = "profile_recovery_phone")
+public class ProfileRecoveryPhoneEntity extends BaseEntity implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private String orcid;
+
+ private String encryptedPhoneNumber;
+
+ private String lastFour;
+
+ @Id
+ @Column(name = "id")
+ @GeneratedValue(strategy = GenerationType.AUTO, generator = "profile_recovery_phone_seq")
+ @SequenceGenerator(name = "profile_recovery_phone_seq", sequenceName = "profile_recovery_phone_seq", allocationSize = 1)
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ @Column(name = "orcid", length = 19)
+ public String getOrcid() {
+ return orcid;
+ }
+
+ public void setOrcid(String orcid) {
+ this.orcid = orcid;
+ }
+
+ @Column(name = "encrypted_phone_number", nullable = false)
+ public String getEncryptedPhoneNumber() {
+ return encryptedPhoneNumber;
+ }
+
+ public void setEncryptedPhoneNumber(String encryptedPhoneNumber) {
+ this.encryptedPhoneNumber = encryptedPhoneNumber;
+ }
+
+ @Column(name = "last_four", length = 4)
+ public String getLastFour() {
+ return lastFour;
+ }
+
+ public void setLastFour(String lastFour) {
+ this.lastFour = lastFour;
+ }
+
+}
diff --git a/orcid-persistence/src/main/resources/META-INF/persistence.xml b/orcid-persistence/src/main/resources/META-INF/persistence.xml
index 1a1cc1b847e..86953a6f711 100644
--- a/orcid-persistence/src/main/resources/META-INF/persistence.xml
+++ b/orcid-persistence/src/main/resources/META-INF/persistence.xml
@@ -87,6 +87,7 @@
org.orcid.persistence.jpa.entities.InvalidRecordDataChangeEntity
org.orcid.persistence.jpa.entities.BackupCodeEntity
+ org.orcid.persistence.jpa.entities.ProfileRecoveryPhoneEntity
org.orcid.persistence.jpa.entities.ProfileHistoryEventEntity
org.orcid.persistence.jpa.entities.RejectedGroupingSuggestionEntity
org.orcid.persistence.jpa.entities.ValidatedPublicProfileEntity
diff --git a/orcid-persistence/src/main/resources/db-master.xml b/orcid-persistence/src/main/resources/db-master.xml
index add9b5c1f45..8a2821f2ada 100644
--- a/orcid-persistence/src/main/resources/db-master.xml
+++ b/orcid-persistence/src/main/resources/db-master.xml
@@ -431,4 +431,6 @@
+
+
\ No newline at end of file
diff --git a/orcid-persistence/src/main/resources/db/updates/create_profile_recovery_phone_table.xml b/orcid-persistence/src/main/resources/db/updates/create_profile_recovery_phone_table.xml
new file mode 100644
index 00000000000..31b58ec3585
--- /dev/null
+++ b/orcid-persistence/src/main/resources/db/updates/create_profile_recovery_phone_table.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ create unique index profile_recovery_phone_orcid_index on profile_recovery_phone(orcid);
+
+
+
+
+ SELECT 1 FROM pg_roles WHERE rolname='orcidro'
+
+ GRANT SELECT ON profile_recovery_phone to orcidro;
+
+
+
diff --git a/orcid-persistence/src/main/resources/db/updates/recovery_phone_encrypted_number.xml b/orcid-persistence/src/main/resources/db/updates/recovery_phone_encrypted_number.xml
new file mode 100644
index 00000000000..dbf85fe79f4
--- /dev/null
+++ b/orcid-persistence/src/main/resources/db/updates/recovery_phone_encrypted_number.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/orcid-persistence/src/main/resources/orcid-persistence-context.xml b/orcid-persistence/src/main/resources/orcid-persistence-context.xml
index c5d2f719e12..d6dd324cb00 100644
--- a/orcid-persistence/src/main/resources/orcid-persistence-context.xml
+++ b/orcid-persistence/src/main/resources/orcid-persistence-context.xml
@@ -258,6 +258,8 @@
+
+
diff --git a/orcid-persistence/src/test/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDaoTest.java b/orcid-persistence/src/test/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDaoTest.java
new file mode 100644
index 00000000000..59890e0bd4b
--- /dev/null
+++ b/orcid-persistence/src/test/java/org/orcid/persistence/dao/ProfileRecoveryPhoneDaoTest.java
@@ -0,0 +1,128 @@
+package org.orcid.persistence.dao;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Date;
+
+import jakarta.annotation.Resource;
+
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.orcid.persistence.dao.ProfileRecoveryPhoneDao.UpsertResult;
+import org.orcid.persistence.jpa.entities.ProfileRecoveryPhoneEntity;
+import org.orcid.test.DBUnitTest;
+import org.orcid.test.OrcidJUnit4ClassRunner;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * Drives the DAO through its Spring proxy against the test database, so the
+ * read-only transaction manager on findByOrcid and the write transaction on
+ * upsert are the ones that run, not a mock's idea of them.
+ */
+@RunWith(OrcidJUnit4ClassRunner.class)
+@ContextConfiguration(locations = { "classpath:test-orcid-persistence-context.xml" })
+public class ProfileRecoveryPhoneDaoTest extends DBUnitTest {
+
+ private static final String ORCID = "4444-4444-4444-4441";
+
+ @Resource(name = "profileRecoveryPhoneDao")
+ private ProfileRecoveryPhoneDao dao;
+
+ @Resource(name = "transactionTemplate")
+ private TransactionTemplate transactionTemplate;
+
+ @BeforeClass
+ public static void initDBUnitData() throws Exception {
+ initDBUnitData(Arrays.asList("/data/SubjectEntityData.xml", "/data/SourceClientDetailsEntityData.xml", "/data/ProfileEntityData.xml"));
+ }
+
+ @AfterClass
+ public static void removeDBUnitData() throws Exception {
+ removeDBUnitData(Arrays.asList("/data/ProfileEntityData.xml", "/data/SubjectEntityData.xml"));
+ }
+
+ @After
+ public void removeTheRow() {
+ dao.deleteByOrcid(ORCID);
+ }
+
+ @Test
+ public void findByOrcidAnswersNullWhenNothingIsStored() {
+ assertNull(dao.findByOrcid(ORCID));
+ }
+
+ @Test
+ public void upsertCreatesTheRowAndSaysSo() {
+ UpsertResult result = dao.upsert(ORCID, "encrypted-one", "7890");
+
+ assertTrue(result.isInserted());
+ ProfileRecoveryPhoneEntity written = result.getEntity();
+ assertNotNull(written.getId());
+ assertNotNull(written.getDateCreated());
+ assertEquals(written.getDateCreated(), written.getLastModified());
+
+ ProfileRecoveryPhoneEntity read = dao.findByOrcid(ORCID);
+ assertEquals("7890", read.getLastFour());
+ assertEquals("encrypted-one", read.getEncryptedPhoneNumber());
+ assertEquals(written.getId(), read.getId());
+ }
+
+ @Test
+ public void upsertReplacesTheRowAndSaysSo() {
+ UpsertResult first = dao.upsert(ORCID, "encrypted-one", "7890");
+ UpsertResult second = dao.upsert(ORCID, "encrypted-two", "4321");
+
+ assertFalse(second.isInserted());
+ assertEquals(first.getEntity().getId(), second.getEntity().getId());
+ assertEquals(first.getEntity().getDateCreated(), second.getEntity().getDateCreated());
+
+ ProfileRecoveryPhoneEntity read = dao.findByOrcid(ORCID);
+ assertEquals("4321", read.getLastFour());
+ assertEquals("encrypted-two", read.getEncryptedPhoneNumber());
+ }
+
+ /*
+ * The manager builds its answer from the returned row while its own
+ * transaction is still open. The entity callback that stamps lastModified
+ * only runs at flush, so upsert has to flush before returning or the row
+ * it hands back still carries the previous date. Asserting inside an outer
+ * transaction is what makes that visible: without the flush the commit
+ * would stamp the date later and a plain call could never tell.
+ */
+ @Test
+ public void upsertAnswersWithTheFlushedRowInsideAnOuterTransaction() throws Exception {
+ Date before = dao.upsert(ORCID, "encrypted-one", "7890").getEntity().getLastModified();
+ // The stamp has millisecond resolution; a replacement inside the same
+ // millisecond would be indistinguishable from no stamp at all
+ Thread.sleep(5);
+
+ // Read while the outer transaction is still open: execute() commits on the
+ // way out, and a commit stamps the date too, which would hide a missing flush
+ Date stampedBeforeCommit = transactionTemplate.execute(status -> {
+ UpsertResult second = dao.upsert(ORCID, "encrypted-two", "4321");
+ assertFalse(second.isInserted());
+ return second.getEntity().getLastModified();
+ });
+
+ assertTrue("lastModified must already be stamped when upsert returns", stampedBeforeCommit.after(before));
+ assertEquals(stampedBeforeCommit, dao.findByOrcid(ORCID).getLastModified());
+ }
+
+ @Test
+ public void deleteByOrcidReportsWhetherARowWent() {
+ assertFalse(dao.deleteByOrcid(ORCID));
+ dao.upsert(ORCID, "encrypted-one", "7890");
+ assertTrue(dao.deleteByOrcid(ORCID));
+ assertFalse(dao.deleteByOrcid(ORCID));
+ assertNull(dao.findByOrcid(ORCID));
+ }
+}
diff --git a/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidationResult.java b/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidationResult.java
index 46a9e1ab5df..2e1b45cd850 100644
--- a/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidationResult.java
+++ b/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidationResult.java
@@ -2,22 +2,35 @@
public class PhoneNumberValidationResult {
+ /** Generic reason, used when nothing more specific can be determined. */
+ public static final String INVALID = "INVALID_PHONE_NUMBER";
+
+ public static final String TOO_SHORT = "PHONE_TOO_SHORT";
+
+ public static final String TOO_LONG = "PHONE_TOO_LONG";
+
private final boolean valid;
private final String e164Number;
+ private final String errorCode;
private final String errorMessage;
- private PhoneNumberValidationResult(boolean valid, String e164Number, String errorMessage) {
+ private PhoneNumberValidationResult(boolean valid, String e164Number, String errorCode, String errorMessage) {
this.valid = valid;
this.e164Number = e164Number;
+ this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
public static PhoneNumberValidationResult valid(String e164Number) {
- return new PhoneNumberValidationResult(true, e164Number, null);
+ return new PhoneNumberValidationResult(true, e164Number, null, null);
}
public static PhoneNumberValidationResult invalid(String errorMessage) {
- return new PhoneNumberValidationResult(false, null, errorMessage);
+ return new PhoneNumberValidationResult(false, null, INVALID, errorMessage);
+ }
+
+ public static PhoneNumberValidationResult invalid(String errorCode, String errorMessage) {
+ return new PhoneNumberValidationResult(false, null, errorCode, errorMessage);
}
public boolean isValid() {
@@ -28,6 +41,10 @@ public String getE164Number() {
return e164Number;
}
+ public String getErrorCode() {
+ return errorCode;
+ }
+
public String getErrorMessage() {
return errorMessage;
}
diff --git a/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidator.java b/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidator.java
index 76ff4728492..cdd6d1c4d66 100644
--- a/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidator.java
+++ b/orcid-utils/src/main/java/org/orcid/utils/phone/PhoneNumberValidator.java
@@ -22,11 +22,30 @@ public PhoneNumberValidationResult validate(String rawPhoneNumber, String defaul
try {
PhoneNumber phoneNumber = phoneNumberUtil.parse(rawPhoneNumber, StringUtils.defaultIfBlank(defaultRegion, DEFAULT_REGION));
if (!phoneNumberUtil.isValidNumber(phoneNumber)) {
- return PhoneNumberValidationResult.invalid("Phone number is invalid");
+ // Length problems get their own reason so the UI can tell the user what to fix
+ switch (phoneNumberUtil.isPossibleNumberWithReason(phoneNumber)) {
+ case TOO_SHORT:
+ case INVALID_LENGTH:
+ // Only long enough to dial locally, which is short of what we need
+ case IS_POSSIBLE_LOCAL_ONLY:
+ return PhoneNumberValidationResult.invalid(PhoneNumberValidationResult.TOO_SHORT, "Phone number is too short");
+ case TOO_LONG:
+ return PhoneNumberValidationResult.invalid(PhoneNumberValidationResult.TOO_LONG, "Phone number is too long");
+ default:
+ return PhoneNumberValidationResult.invalid("Phone number is invalid");
+ }
}
return PhoneNumberValidationResult.valid(phoneNumberUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164));
} catch (NumberParseException e) {
- return PhoneNumberValidationResult.invalid(e.getMessage());
+ switch (e.getErrorType()) {
+ case TOO_LONG:
+ return PhoneNumberValidationResult.invalid(PhoneNumberValidationResult.TOO_LONG, "Phone number is too long");
+ case TOO_SHORT_AFTER_IDD:
+ case TOO_SHORT_NSN:
+ return PhoneNumberValidationResult.invalid(PhoneNumberValidationResult.TOO_SHORT, "Phone number is too short");
+ default:
+ return PhoneNumberValidationResult.invalid(e.getMessage());
+ }
}
}
}
diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/LogVerificationCodeSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/LogVerificationCodeSender.java
new file mode 100644
index 00000000000..329840e7e00
--- /dev/null
+++ b/orcid-utils/src/main/java/org/orcid/utils/sms/LogVerificationCodeSender.java
@@ -0,0 +1,104 @@
+package org.orcid.utils.sms;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+import org.springframework.stereotype.Component;
+
+/**
+ * Writes the verification code to the application log instead of texting it.
+ *
+ * This sender is for local and development use only, and it is gated so that it cannot exist anywhere else: the bean
+ * is registered only where the environment selects {@code org.orcid.sms.provider=log}, and {@link #sendCode} refuses
+ * unless the configuration it reads says the same. The gate is what keeps it local: senders are looked up by name,
+ * and a caller can ask for a name, so a bean that merely expects not to be chosen is not safe enough.
+ *
+ * It sends no message to anyone. It exists so that an automated end to end run can read a code it could never
+ * receive by text.
+ *
+ * Even here the recipient is masked: the full number must never appear in a log line, not even from the local sender.
+ */
+@Component
+@Conditional(LogVerificationCodeSender.LogProviderConfiguredCondition.class)
+public class LogVerificationCodeSender implements VerificationCodeSender {
+
+ public static final String PROVIDER = "log";
+
+ static final String PROVIDER_PROPERTY = "org.orcid.sms.provider";
+
+ private static final Logger LOG = LoggerFactory.getLogger(LogVerificationCodeSender.class);
+
+ /** Fixed length mask, so the mask does not leak how long the number is. The same mask the Registry serves over HTTP. */
+ private static final String MASK = "***********";
+
+ private static final int VISIBLE_CHARACTERS = 4;
+
+ /** A counter, not a random value: the id only has to be non-null and distinct within the run. */
+ private static final AtomicLong MESSAGE_ID_SEQUENCE = new AtomicLong();
+
+ @Value("${org.orcid.sms.provider:aws}")
+ private String configuredProvider;
+
+ @Override
+ public String getProvider() {
+ return PROVIDER;
+ }
+
+ @Override
+ public SmsSendResult sendCode(String toE164Number, String code, String locale) {
+ // Belt and braces behind the bean gate, so that a wiring mistake fails closed: a sender that puts live
+ // verification codes in the log refuses outright wherever the log provider was not the configured one, and
+ // says so without writing the code first.
+ if (!isLogProviderSelected(configuredProvider)) {
+ return SmsSendResult.failure(PROVIDER, "SMS_PROVIDER_NOT_CONFIGURED",
+ "The log sender only sends when org.orcid.sms.provider=log");
+ }
+ LOG.info("RECOVERY_PHONE_CODE to={} code={} locale={}", mask(toE164Number), code, locale);
+ return SmsSendResult.success(PROVIDER, PROVIDER + "-" + MESSAGE_ID_SEQUENCE.incrementAndGet(), "logged");
+ }
+
+ /**
+ * Keeps only the last four characters of the number, behind the fixed length mask. A null, blank or short value
+ * is masked whole rather than printed, so nothing that is too short to spare four characters can be read back out
+ * of the log.
+ */
+ static String mask(String toE164Number) {
+ if (StringUtils.length(toE164Number) <= VISIBLE_CHARACTERS) {
+ return MASK;
+ }
+ return MASK + toE164Number.substring(toE164Number.length() - VISIBLE_CHARACTERS);
+ }
+
+ private static boolean isLogProviderSelected(String provider) {
+ return PROVIDER.equalsIgnoreCase(StringUtils.trim(provider));
+ }
+
+ void setConfiguredProvider(String configuredProvider) {
+ this.configuredProvider = configuredProvider;
+ }
+
+ /**
+ * Keeps the bean out of every context that has not asked for it. The classpath scanner evaluates
+ * {@code @Conditional} itself, so it applies to the plain {@code } this application is
+ * wired with; there is no Spring Boot here, so {@code @ConditionalOnProperty} is not available.
+ *
+ * The scan runs long before the property placeholder merges the ORCID configuration file, so the Spring
+ * Environment is the only source a condition can read: select this sender with
+ * {@code -Dorg.orcid.sms.provider=log} on the JVM. A system property also wins over the properties file when the
+ * {@code @Value} above is resolved, so one setting satisfies both gates and neither opens on its own.
+ */
+ static class LogProviderConfiguredCondition implements Condition {
+
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ return isLogProviderSelected(context.getEnvironment().getProperty(PROVIDER_PROPERTY));
+ }
+ }
+}
diff --git a/orcid-utils/src/test/java/org/orcid/utils/sms/LogVerificationCodeSenderTest.java b/orcid-utils/src/test/java/org/orcid/utils/sms/LogVerificationCodeSenderTest.java
new file mode 100644
index 00000000000..0f59fb3c134
--- /dev/null
+++ b/orcid-utils/src/test/java/org/orcid/utils/sms/LogVerificationCodeSenderTest.java
@@ -0,0 +1,167 @@
+package org.orcid.utils.sms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.orcid.utils.sms.LogVerificationCodeSender.LogProviderConfiguredCondition;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.mock.env.MockEnvironment;
+
+public class LogVerificationCodeSenderTest {
+
+ private static final String PHONE_NUMBER = "+50688887777";
+
+ private LogVerificationCodeSender sender = new LogVerificationCodeSender();
+
+ @Before
+ public void selectTheLogProvider() {
+ sender.setConfiguredProvider(LogVerificationCodeSender.PROVIDER);
+ }
+
+ @Test
+ public void providerIsLog() {
+ assertEquals("log", LogVerificationCodeSender.PROVIDER);
+ assertEquals(LogVerificationCodeSender.PROVIDER, sender.getProvider());
+ }
+
+ @Test
+ public void theBeanIsOnlyRegisteredWhereTheEnvironmentSelectsTheLogProvider() {
+ LogProviderConfiguredCondition condition = new LogProviderConfiguredCondition();
+
+ assertTrue(condition.matches(contextConfiguredWith("log"), null));
+ assertFalse(condition.matches(contextConfiguredWith("aws"), null));
+ assertFalse(condition.matches(contextConfiguredWith(null), null));
+ }
+
+ @Test
+ public void sendCodeReportsSuccessForTheLogProvider() {
+ SmsSendResult result = sender.sendCode(PHONE_NUMBER, "123456", "en");
+
+ assertTrue(result.isSuccess());
+ assertEquals(LogVerificationCodeSender.PROVIDER, result.getProvider());
+ assertTrue(result.getProviderMessageId() != null && result.getProviderMessageId().startsWith("log-"));
+ }
+
+ /**
+ * What this test can and cannot prove.
+ *
+ * It cannot assert that the line reaches a log, because on this module's unit test classpath it does not reach
+ * one at all: slf4j-api is 1.7.24 while the only binding present is log4j-slf4j2-impl, which implements the
+ * slf4j 2.x service interface. A 1.7 api looks for the old StaticLoggerBinder, finds nothing, and falls back to
+ * a no-op logger, so every LOG call in this module is silently discarded under test. Attaching a log4j2 appender
+ * captures nothing for the same reason.
+ *
+ * So the masking rule (R1.2) is proved here against the helper that produces the value, and that the line really
+ * is written, in the format the end to end suite reads, is proved against a running Registry instead.
+ */
+ @Test
+ public void sendCodeRefusesWhenAnotherProviderIsConfigured() {
+ sender.setConfiguredProvider("aws");
+
+ SmsSendResult result = sender.sendCode(PHONE_NUMBER, "123456", "en");
+
+ assertFalse(result.isSuccess());
+ assertEquals(LogVerificationCodeSender.PROVIDER, result.getProvider());
+ assertEquals("SMS_PROVIDER_NOT_CONFIGURED", result.getErrorCode());
+ assertEquals(null, result.getProviderMessageId());
+ // Deliberately no assertion about the log here: see the note above -- an appender on this classpath
+ // captures nothing whatever the sender does, so "nothing was logged" would pass for the wrong reason.
+ }
+
+ @Test
+ public void sendCodeGivesEachMessageItsOwnId() {
+ String firstId = sender.sendCode(PHONE_NUMBER, "123456", "en").getProviderMessageId();
+ String secondId = sender.sendCode(PHONE_NUMBER, "654321", "en").getProviderMessageId();
+
+ assertNotNull(firstId);
+ assertNotNull(secondId);
+ assertFalse(firstId.equals(secondId));
+ }
+
+ @Test
+ public void maskKeepsOnlyTheLastFourCharacters() {
+ String masked = LogVerificationCodeSender.mask(PHONE_NUMBER);
+
+ assertEquals("***********7777", masked);
+ assertFalse(masked.contains(PHONE_NUMBER));
+ assertFalse(masked.contains("5068"));
+ }
+
+ @Test
+ public void maskIsFixedLengthSoItDoesNotLeakHowLongTheNumberIs() {
+ assertEquals(15, LogVerificationCodeSender.mask("+15550001111").length());
+ assertEquals(15, LogVerificationCodeSender.mask("+5068888777766").length());
+ }
+
+ @Test
+ public void maskHidesANumberTooShortToSpareFourCharacters() {
+ assertEquals("***********", LogVerificationCodeSender.mask("123"));
+ assertEquals("***********", LogVerificationCodeSender.mask("1234"));
+ assertEquals("***********", LogVerificationCodeSender.mask(""));
+ assertEquals("***********", LogVerificationCodeSender.mask(null));
+ }
+
+ @Test
+ public void sendCodeDoesNotThrowForANullOrShortNumber() {
+ SmsSendResult nullNumber = sender.sendCode(null, "123456", "en");
+ SmsSendResult shortNumber = sender.sendCode("123", "123456", null);
+
+ assertTrue(nullNumber.isSuccess());
+ assertNotNull(nullNumber.getProviderMessageId());
+ assertTrue(shortNumber.isSuccess());
+ assertNotNull(shortNumber.getProviderMessageId());
+ }
+
+ private static ConditionContext contextConfiguredWith(String provider) {
+ MockEnvironment environment = new MockEnvironment();
+ if (provider != null) {
+ environment.setProperty(LogVerificationCodeSender.PROVIDER_PROPERTY, provider);
+ }
+ return new StubConditionContext(environment);
+ }
+
+ /** The condition reads nothing but the environment, so the rest of the context is not part of what is tested. */
+ private static class StubConditionContext implements ConditionContext {
+
+ private final Environment environment;
+
+ StubConditionContext(Environment environment) {
+ this.environment = environment;
+ }
+
+ @Override
+ public BeanDefinitionRegistry getRegistry() {
+ return null;
+ }
+
+ @Override
+ public ConfigurableListableBeanFactory getBeanFactory() {
+ return null;
+ }
+
+ @Override
+ public Environment getEnvironment() {
+ return environment;
+ }
+
+ @Override
+ public ResourceLoader getResourceLoader() {
+ return null;
+ }
+
+ @Override
+ public ClassLoader getClassLoader() {
+ return null;
+ }
+ }
+
+ /** Collects what the sender writes, so a test can assert on what did, and what did not, reach the log. */
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeSendCodeResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeSendCodeResponse.java
new file mode 100644
index 00000000000..4119319f452
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeSendCodeResponse.java
@@ -0,0 +1,77 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * The outcome of asking for a code during an authentication challenge, where
+ * the user never types the number: it is the one stored on the record.
+ *
+ * It carries the masked number as well as the outcome, so the panel can tell
+ * the user where the code went. The full number is never returned.
+ */
+public class RecoveryPhoneChallengeSendCodeResponse {
+
+ private boolean success;
+
+ private String errorCode;
+
+ /**
+ * Seconds the user has to wait before another code can be sent. Drives the
+ * countdown in the UI so the client and the throttle cannot disagree.
+ */
+ private int resendAfterSeconds;
+
+ private String maskedRecoveryPhoneNumber;
+
+ public static RecoveryPhoneChallengeSendCodeResponse failure(String errorCode) {
+ RecoveryPhoneChallengeSendCodeResponse response = new RecoveryPhoneChallengeSendCodeResponse();
+ response.setSuccess(false);
+ response.setErrorCode(errorCode);
+ return response;
+ }
+
+ /**
+ * Restates the outcome of the underlying send, adding the masked number.
+ * A failed send keeps its resend countdown, since a refused resend is the
+ * one failure the countdown belongs to.
+ */
+ public static RecoveryPhoneChallengeSendCodeResponse from(RecoveryPhoneSendCodeResponse sendCodeResponse, String maskedRecoveryPhoneNumber) {
+ RecoveryPhoneChallengeSendCodeResponse response = new RecoveryPhoneChallengeSendCodeResponse();
+ response.setSuccess(sendCodeResponse.isSuccess());
+ response.setErrorCode(sendCodeResponse.getErrorCode());
+ response.setResendAfterSeconds(sendCodeResponse.getResendAfterSeconds());
+ response.setMaskedRecoveryPhoneNumber(maskedRecoveryPhoneNumber);
+ return response;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public int getResendAfterSeconds() {
+ return resendAfterSeconds;
+ }
+
+ public void setResendAfterSeconds(int resendAfterSeconds) {
+ this.resendAfterSeconds = resendAfterSeconds;
+ }
+
+ public String getMaskedRecoveryPhoneNumber() {
+ return maskedRecoveryPhoneNumber;
+ }
+
+ public void setMaskedRecoveryPhoneNumber(String maskedRecoveryPhoneNumber) {
+ this.maskedRecoveryPhoneNumber = maskedRecoveryPhoneNumber;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeVerifyRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeVerifyRequest.java
new file mode 100644
index 00000000000..d24a752f9d9
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneChallengeVerifyRequest.java
@@ -0,0 +1,33 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * What the user sends to pass an authentication challenge with their recovery
+ * phone number: their password, and the code we texted to the stored number.
+ *
+ * The number itself is never part of this request. The Registry sent the code
+ * to the number it holds, and checks the code against that same number, so a
+ * client cannot point the challenge at a number of its own.
+ */
+public class RecoveryPhoneChallengeVerifyRequest {
+
+ private String password;
+
+ private String verificationCode;
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getVerificationCode() {
+ return verificationCode;
+ }
+
+ public void setVerificationCode(String verificationCode) {
+ this.verificationCode = verificationCode;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeEntry.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeEntry.java
new file mode 100644
index 00000000000..5a9392dd2aa
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeEntry.java
@@ -0,0 +1,118 @@
+package org.orcid.frontend.recoveryphone;
+
+import org.apache.commons.lang3.StringUtils;
+import org.codehaus.jettison.json.JSONException;
+import org.codehaus.jettison.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The single pending recovery phone verification code per record.
+ *
+ * Sending a code overwrites the entry, so a code sent to a previous number
+ * stops working as soon as the user asks for a new one. The number the code was
+ * sent to is kept alongside it so the save step can refuse a code that was
+ * issued for a different number.
+ */
+public class RecoveryPhoneCodeEntry {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RecoveryPhoneCodeEntry.class);
+
+ private static final String KEY_PREFIX = "recovery-phone-code-";
+
+ private static final String CODE_FIELD = "code";
+ private static final String PHONE_FIELD = "phone";
+ private static final String PROVIDER_FIELD = "provider";
+ private static final String MESSAGE_ID_FIELD = "messageId";
+ private static final String ATTEMPTS_FIELD = "attempts";
+ private static final String SENT_AT_FIELD = "sentAt";
+
+ private final String code;
+
+ private final String phoneE164;
+
+ private final String provider;
+
+ private final String providerMessageId;
+
+ private int attempts;
+
+ private final long sentAt;
+
+ public RecoveryPhoneCodeEntry(String code, String phoneE164, String provider, String providerMessageId, int attempts, long sentAt) {
+ this.code = code;
+ this.phoneE164 = phoneE164;
+ this.provider = provider;
+ this.providerMessageId = providerMessageId;
+ this.attempts = attempts;
+ this.sentAt = sentAt;
+ }
+
+ public static String redisKey(String orcid) {
+ return KEY_PREFIX + orcid;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getPhoneE164() {
+ return phoneE164;
+ }
+
+ public String getProvider() {
+ return provider;
+ }
+
+ public String getProviderMessageId() {
+ return providerMessageId;
+ }
+
+ public int getAttempts() {
+ return attempts;
+ }
+
+ public int incrementAttempts() {
+ return ++attempts;
+ }
+
+ public long getSentAt() {
+ return sentAt;
+ }
+
+ public String serialize() {
+ try {
+ JSONObject json = new JSONObject();
+ json.put(CODE_FIELD, code);
+ json.put(PHONE_FIELD, phoneE164);
+ json.put(PROVIDER_FIELD, provider);
+ json.put(MESSAGE_ID_FIELD, providerMessageId == null ? JSONObject.NULL : providerMessageId);
+ json.put(ATTEMPTS_FIELD, attempts);
+ json.put(SENT_AT_FIELD, sentAt);
+ return json.toString();
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to serialize recovery phone code entry", e);
+ }
+ }
+
+ /**
+ * @param value
+ * the raw stored value, or null when there is no pending code
+ * @return the parsed entry, or null when there is nothing usable stored
+ */
+ public static RecoveryPhoneCodeEntry parse(String value) {
+ if (StringUtils.isBlank(value)) {
+ return null;
+ }
+ try {
+ JSONObject json = new JSONObject(value);
+ return new RecoveryPhoneCodeEntry(json.getString(CODE_FIELD), json.getString(PHONE_FIELD), json.getString(PROVIDER_FIELD),
+ json.isNull(MESSAGE_ID_FIELD) ? null : json.getString(MESSAGE_ID_FIELD), json.getInt(ATTEMPTS_FIELD),
+ json.getLong(SENT_AT_FIELD));
+ } catch (JSONException e) {
+ LOG.error("Unable to parse the recovery phone code entry, treating it as missing", e);
+ return null;
+ }
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeStore.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeStore.java
new file mode 100644
index 00000000000..5e0ce7795e6
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneCodeStore.java
@@ -0,0 +1,112 @@
+package org.orcid.frontend.recoveryphone;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import jakarta.annotation.Resource;
+
+import org.orcid.core.utils.cache.redis.RedisClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * Holds the pending recovery phone verification code for a record, keyed by
+ * ORCID iD so one record can never consume or interfere with another's code.
+ *
+ * Redis is the real store: the registry runs on several nodes and the request
+ * that verifies a code is not necessarily the one that sent it. When Redis is
+ * unavailable {@link #save} reports failure so the caller can refuse to send a
+ * code nobody would be able to confirm.
+ *
+ * The in-memory map is a local development convenience only. It is off unless
+ * {@code org.orcid.sms.code.allowInMemoryStore} is set, and it is never correct
+ * on a multi-node deployment.
+ */
+@Component
+public class RecoveryPhoneCodeStore {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RecoveryPhoneCodeStore.class);
+
+ @Resource
+ private RedisClient redisClient;
+
+ @Value("${org.orcid.sms.code.allowInMemoryStore:false}")
+ private boolean allowInMemoryStore;
+
+ private final Map inMemoryEntries = new ConcurrentHashMap<>();
+
+ /**
+ * @return true when the code was stored and can be confirmed later
+ */
+ public boolean save(String orcid, RecoveryPhoneCodeEntry entry, int ttlSeconds) {
+ String key = RecoveryPhoneCodeEntry.redisKey(orcid);
+ if (redisClient.set(key, entry.serialize(), ttlSeconds)) {
+ return true;
+ }
+ if (allowInMemoryStore) {
+ LOG.warn("Redis unavailable, storing the recovery phone code in memory. This is only valid for local development.");
+ inMemoryEntries.put(key, new InMemoryEntry(entry.serialize(), System.currentTimeMillis() + (ttlSeconds * 1000L)));
+ return true;
+ }
+ LOG.error("Unable to store the recovery phone verification code, Redis is unavailable");
+ return false;
+ }
+
+ public RecoveryPhoneCodeEntry get(String orcid) {
+ String key = RecoveryPhoneCodeEntry.redisKey(orcid);
+ RecoveryPhoneCodeEntry entry = RecoveryPhoneCodeEntry.parse(redisClient.get(key));
+ if (entry != null) {
+ return entry;
+ }
+ if (allowInMemoryStore) {
+ InMemoryEntry inMemory = inMemoryEntries.get(key);
+ if (inMemory != null) {
+ if (inMemory.isExpired()) {
+ inMemoryEntries.remove(key);
+ return null;
+ }
+ return RecoveryPhoneCodeEntry.parse(inMemory.getValue());
+ }
+ }
+ return null;
+ }
+
+ public void remove(String orcid) {
+ String key = RecoveryPhoneCodeEntry.redisKey(orcid);
+ redisClient.remove(key);
+ if (allowInMemoryStore) {
+ inMemoryEntries.remove(key);
+ }
+ }
+
+ void setRedisClient(RedisClient redisClient) {
+ this.redisClient = redisClient;
+ }
+
+ void setAllowInMemoryStore(boolean allowInMemoryStore) {
+ this.allowInMemoryStore = allowInMemoryStore;
+ }
+
+ private static class InMemoryEntry {
+
+ private final String value;
+
+ private final long expiresAt;
+
+ InMemoryEntry(String value, long expiresAt) {
+ this.value = value;
+ this.expiresAt = expiresAt;
+ }
+
+ String getValue() {
+ return value;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() > expiresAt;
+ }
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveRequest.java
new file mode 100644
index 00000000000..22495e930dd
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveRequest.java
@@ -0,0 +1,40 @@
+package org.orcid.frontend.recoveryphone;
+
+public class RecoveryPhoneSaveRequest {
+
+ private String phoneNumber;
+
+ private String verificationCode;
+
+ /**
+ * Where the form is being shown: SETTINGS, ONBOARDING or INTERSTITIAL. It
+ * decides which proof of identity the request is accepted on, never what
+ * the request is allowed to do.
+ */
+ private String context;
+
+ public String getPhoneNumber() {
+ return phoneNumber;
+ }
+
+ public void setPhoneNumber(String phoneNumber) {
+ this.phoneNumber = phoneNumber;
+ }
+
+ public String getVerificationCode() {
+ return verificationCode;
+ }
+
+ public void setVerificationCode(String verificationCode) {
+ this.verificationCode = verificationCode;
+ }
+
+ public String getContext() {
+ return context;
+ }
+
+ public void setContext(String context) {
+ this.context = context;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveResponse.java
new file mode 100644
index 00000000000..13c813ce3d3
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSaveResponse.java
@@ -0,0 +1,78 @@
+package org.orcid.frontend.recoveryphone;
+
+import org.orcid.pojo.ajaxForm.Date;
+
+/**
+ * The outcome of saving a recovery phone number, carrying the refreshed panel
+ * state so the settings page does not have to re-read the status straight away.
+ */
+public class RecoveryPhoneSaveResponse {
+
+ private boolean success;
+
+ private String errorCode;
+
+ private String maskedRecoveryPhoneNumber;
+
+ private Date recoveryPhoneCreationDate;
+
+ private Date recoveryPhoneLastModifiedDate;
+
+ private boolean recoveryPhoneModified;
+
+ public static RecoveryPhoneSaveResponse failure(String errorCode) {
+ RecoveryPhoneSaveResponse response = new RecoveryPhoneSaveResponse();
+ response.setSuccess(false);
+ response.setErrorCode(errorCode);
+ return response;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public String getMaskedRecoveryPhoneNumber() {
+ return maskedRecoveryPhoneNumber;
+ }
+
+ public void setMaskedRecoveryPhoneNumber(String maskedRecoveryPhoneNumber) {
+ this.maskedRecoveryPhoneNumber = maskedRecoveryPhoneNumber;
+ }
+
+ public Date getRecoveryPhoneCreationDate() {
+ return recoveryPhoneCreationDate;
+ }
+
+ public void setRecoveryPhoneCreationDate(Date recoveryPhoneCreationDate) {
+ this.recoveryPhoneCreationDate = recoveryPhoneCreationDate;
+ }
+
+ public Date getRecoveryPhoneLastModifiedDate() {
+ return recoveryPhoneLastModifiedDate;
+ }
+
+ public void setRecoveryPhoneLastModifiedDate(Date recoveryPhoneLastModifiedDate) {
+ this.recoveryPhoneLastModifiedDate = recoveryPhoneLastModifiedDate;
+ }
+
+ public boolean isRecoveryPhoneModified() {
+ return recoveryPhoneModified;
+ }
+
+ public void setRecoveryPhoneModified(boolean recoveryPhoneModified) {
+ this.recoveryPhoneModified = recoveryPhoneModified;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeRequest.java
new file mode 100644
index 00000000000..1116255df44
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeRequest.java
@@ -0,0 +1,40 @@
+package org.orcid.frontend.recoveryphone;
+
+public class RecoveryPhoneSendCodeRequest {
+
+ private String phoneNumber;
+
+ private String locale;
+
+ /**
+ * Where the form is being shown: SETTINGS, ONBOARDING or INTERSTITIAL. It
+ * decides which proof of identity the request is accepted on, never what
+ * the request is allowed to do.
+ */
+ private String context;
+
+ public String getPhoneNumber() {
+ return phoneNumber;
+ }
+
+ public void setPhoneNumber(String phoneNumber) {
+ this.phoneNumber = phoneNumber;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public void setLocale(String locale) {
+ this.locale = locale;
+ }
+
+ public String getContext() {
+ return context;
+ }
+
+ public void setContext(String context) {
+ this.context = context;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeResponse.java
new file mode 100644
index 00000000000..b234bdbc1fb
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSendCodeResponse.java
@@ -0,0 +1,63 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * The outcome of asking for a verification code. The code itself and the full
+ * phone number are never returned.
+ */
+public class RecoveryPhoneSendCodeResponse {
+
+ private boolean success;
+
+ private String errorCode;
+
+ /**
+ * Seconds the user has to wait before another code can be sent. Drives the
+ * countdown in the UI so the client and the throttle cannot disagree.
+ */
+ private int resendAfterSeconds;
+
+ public static RecoveryPhoneSendCodeResponse success(int resendAfterSeconds) {
+ RecoveryPhoneSendCodeResponse response = new RecoveryPhoneSendCodeResponse();
+ response.setSuccess(true);
+ response.setResendAfterSeconds(resendAfterSeconds);
+ return response;
+ }
+
+ public static RecoveryPhoneSendCodeResponse failure(String errorCode) {
+ RecoveryPhoneSendCodeResponse response = new RecoveryPhoneSendCodeResponse();
+ response.setSuccess(false);
+ response.setErrorCode(errorCode);
+ return response;
+ }
+
+ public static RecoveryPhoneSendCodeResponse failure(String errorCode, int resendAfterSeconds) {
+ RecoveryPhoneSendCodeResponse response = failure(errorCode);
+ response.setResendAfterSeconds(resendAfterSeconds);
+ return response;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public int getResendAfterSeconds() {
+ return resendAfterSeconds;
+ }
+
+ public void setResendAfterSeconds(int resendAfterSeconds) {
+ this.resendAfterSeconds = resendAfterSeconds;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeRequest.java
new file mode 100644
index 00000000000..1512ddf9ae8
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeRequest.java
@@ -0,0 +1,32 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * Asks for a code to be sent to the recovery number stored on the account the
+ * credentials belong to.
+ *
+ * The caller cannot choose the number: it is read from the record, so a user
+ * who has lost their authenticator cannot redirect the text somewhere else.
+ */
+public class RecoveryPhoneSigninSendCodeRequest {
+
+ private String username;
+
+ private String password;
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeResponse.java
new file mode 100644
index 00000000000..e54ae506af6
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninSendCodeResponse.java
@@ -0,0 +1,63 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * The outcome of asking, from the sign in screen, for a code to be sent to the
+ * recovery number on the account.
+ *
+ * The caller is anonymous, so this carries nothing it did not already supply
+ * beyond the masked number: never the code, never the number in full.
+ */
+public class RecoveryPhoneSigninSendCodeResponse {
+
+ private boolean success;
+
+ private String errorCode;
+
+ /**
+ * Seconds the user has to wait before another code can be sent. Drives the
+ * countdown in the UI so the client and the throttle cannot disagree.
+ */
+ private int resendAfterSeconds;
+
+ private String maskedRecoveryPhoneNumber;
+
+ public static RecoveryPhoneSigninSendCodeResponse failure(String errorCode) {
+ RecoveryPhoneSigninSendCodeResponse response = new RecoveryPhoneSigninSendCodeResponse();
+ response.setSuccess(false);
+ response.setErrorCode(errorCode);
+ return response;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public int getResendAfterSeconds() {
+ return resendAfterSeconds;
+ }
+
+ public void setResendAfterSeconds(int resendAfterSeconds) {
+ this.resendAfterSeconds = resendAfterSeconds;
+ }
+
+ public String getMaskedRecoveryPhoneNumber() {
+ return maskedRecoveryPhoneNumber;
+ }
+
+ public void setMaskedRecoveryPhoneNumber(String maskedRecoveryPhoneNumber) {
+ this.maskedRecoveryPhoneNumber = maskedRecoveryPhoneNumber;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyRequest.java
new file mode 100644
index 00000000000..a91b803b715
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyRequest.java
@@ -0,0 +1,39 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * Confirms the code that was texted to the recovery number, which disables 2FA
+ * on the account so the ordinary sign in can be replayed without a code.
+ */
+public class RecoveryPhoneSigninVerifyRequest {
+
+ private String username;
+
+ private String password;
+
+ private String verificationCode;
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getVerificationCode() {
+ return verificationCode;
+ }
+
+ public void setVerificationCode(String verificationCode) {
+ this.verificationCode = verificationCode;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyResponse.java
new file mode 100644
index 00000000000..c69761b5b95
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninVerifyResponse.java
@@ -0,0 +1,55 @@
+package org.orcid.frontend.recoveryphone;
+
+/**
+ * The outcome of confirming a recovery number code from the sign in screen.
+ *
+ * On success 2FA is off, and the ORCID iD is returned so the client can replay
+ * the ordinary sign in against the record it has just recovered.
+ */
+public class RecoveryPhoneSigninVerifyResponse {
+
+ private boolean success;
+
+ private String errorCode;
+
+ private String orcid;
+
+ public static RecoveryPhoneSigninVerifyResponse failure(String errorCode) {
+ RecoveryPhoneSigninVerifyResponse response = new RecoveryPhoneSigninVerifyResponse();
+ response.setSuccess(false);
+ response.setErrorCode(errorCode);
+ return response;
+ }
+
+ public static RecoveryPhoneSigninVerifyResponse success(String orcid) {
+ RecoveryPhoneSigninVerifyResponse response = new RecoveryPhoneSigninVerifyResponse();
+ response.setSuccess(true);
+ response.setOrcid(orcid);
+ return response;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public String getOrcid() {
+ return orcid;
+ }
+
+ public void setOrcid(String orcid) {
+ this.orcid = orcid;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationService.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationService.java
new file mode 100644
index 00000000000..29332118e7f
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationService.java
@@ -0,0 +1,277 @@
+package org.orcid.frontend.recoveryphone;
+
+import java.security.SecureRandom;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.orcid.utils.phone.PhoneNumberValidationResult;
+import org.orcid.utils.phone.PhoneNumberValidator;
+import org.orcid.utils.sms.SmsSendResult;
+import org.orcid.utils.sms.VerificationCodeSender;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * Sends and confirms the one time codes used to prove a user controls the phone
+ * number they are setting as their 2FA recovery number.
+ *
+ * ORCID generates and checks the code; the provider only delivers it. Codes are
+ * held against the ORCID iD rather than the phone number, so a pending code
+ * belongs to one record and asking for a new one always retires the old one.
+ */
+@Component
+public class RecoveryPhoneVerificationService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RecoveryPhoneVerificationService.class);
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ public static final String INVALID_CODE = "INVALID_CODE";
+ public static final String CODE_EXPIRED = "CODE_EXPIRED";
+ public static final String TOO_MANY_ATTEMPTS = "TOO_MANY_ATTEMPTS";
+ public static final String PHONE_MISMATCH = "PHONE_MISMATCH";
+ public static final String RESEND_TOO_SOON = "RESEND_TOO_SOON";
+ public static final String SMS_SEND_FAILED = "SMS_SEND_FAILED";
+ public static final String SMS_RECIPIENT_NOT_ALLOWED = "SMS_RECIPIENT_NOT_ALLOWED";
+ public static final String SMS_PROVIDER_NOT_CONFIGURED = "SMS_PROVIDER_NOT_CONFIGURED";
+ public static final String CODE_STORAGE_UNAVAILABLE = "CODE_STORAGE_UNAVAILABLE";
+
+ @Autowired
+ private PhoneNumberValidator phoneNumberValidator;
+
+ @Autowired
+ private RecoveryPhoneCodeStore recoveryPhoneCodeStore;
+
+ private Map sendersByProvider = new HashMap();
+
+ @Value("${org.orcid.sms.provider:aws}")
+ private String provider;
+
+ @Value("${org.orcid.sms.defaultRegion:US}")
+ private String defaultRegion;
+
+ @Value("${org.orcid.sms.regexFilter:}")
+ private String regexFilter;
+
+ @Value("${org.orcid.sms.code.length:6}")
+ private int codeLength;
+
+ @Value("${org.orcid.sms.code.ttlSeconds:300}")
+ private int codeTtlSeconds;
+
+ @Value("${org.orcid.sms.code.maxAttempts:5}")
+ private int maxAttempts;
+
+ @Value("${org.orcid.sms.code.resendBufferSeconds:30}")
+ private int resendBufferSeconds;
+
+ @Autowired
+ public void setSenders(List senders) {
+ sendersByProvider.clear();
+ if (senders != null) {
+ for (VerificationCodeSender sender : senders) {
+ sendersByProvider.put(StringUtils.lowerCase(sender.getProvider()), sender);
+ }
+ }
+ }
+
+ /**
+ * Validates the number, sends a fresh code to it and stores that code
+ * against the record. Any previously issued code stops working.
+ */
+ public RecoveryPhoneSendCodeResponse sendCode(String orcid, RecoveryPhoneSendCodeRequest request) {
+ PhoneNumberValidationResult validationResult = phoneNumberValidator.validate(request == null ? null : request.getPhoneNumber(),
+ defaultRegion);
+ if (!validationResult.isValid()) {
+ return RecoveryPhoneSendCodeResponse.failure(validationResult.getErrorCode());
+ }
+
+ String phoneE164 = validationResult.getE164Number();
+ if (StringUtils.isNotBlank(regexFilter) && !phoneE164.matches(regexFilter)) {
+ return RecoveryPhoneSendCodeResponse.failure(SMS_RECIPIENT_NOT_ALLOWED);
+ }
+
+ // One pending code per record: refuse a resend until the buffer has passed
+ RecoveryPhoneCodeEntry pending = recoveryPhoneCodeStore.get(orcid);
+ if (pending != null) {
+ int remaining = remainingResendSeconds(pending);
+ if (remaining > 0) {
+ return RecoveryPhoneSendCodeResponse.failure(RESEND_TOO_SOON, remaining);
+ }
+ }
+
+ String selectedProvider = StringUtils.lowerCase(StringUtils.defaultIfBlank(provider, "aws"));
+ VerificationCodeSender sender = sendersByProvider.get(selectedProvider);
+ if (sender == null) {
+ return RecoveryPhoneSendCodeResponse.failure(SMS_PROVIDER_NOT_CONFIGURED);
+ }
+
+ String code = generateCode();
+ SmsSendResult result = sender.sendCode(phoneE164, code, sanitizeLocale(request.getLocale()));
+ if (!result.isSuccess()) {
+ LOG.warn("Unable to send a recovery phone verification code for {}: {}", orcid, result.getErrorCode());
+ return RecoveryPhoneSendCodeResponse.failure(SMS_SEND_FAILED);
+ }
+
+ RecoveryPhoneCodeEntry entry = new RecoveryPhoneCodeEntry(code, phoneE164, result.getProvider(), result.getProviderMessageId(), 0,
+ System.currentTimeMillis());
+ if (!recoveryPhoneCodeStore.save(orcid, entry, codeTtlSeconds)) {
+ // Better to fail loudly than to leave the user with a code we cannot confirm
+ return RecoveryPhoneSendCodeResponse.failure(CODE_STORAGE_UNAVAILABLE);
+ }
+ return RecoveryPhoneSendCodeResponse.success(resendBufferSeconds);
+ }
+
+ /**
+ * Confirms the code the user typed against the pending code for the record,
+ * and checks it was issued for the number they are trying to save.
+ *
+ * @return null when the code is good, otherwise the error code to report
+ */
+ public String verifyCode(String orcid, String rawPhoneNumber, String code) {
+ if (StringUtils.isBlank(code)) {
+ return INVALID_CODE;
+ }
+
+ PhoneNumberValidationResult validationResult = phoneNumberValidator.validate(rawPhoneNumber, defaultRegion);
+ if (!validationResult.isValid()) {
+ return validationResult.getErrorCode();
+ }
+
+ RecoveryPhoneCodeEntry entry = recoveryPhoneCodeStore.get(orcid);
+ if (entry == null) {
+ return CODE_EXPIRED;
+ }
+
+ // Redis drops the entry on its own, but never accept a stale code even if
+ // the store still has it
+ int remainingTtl = remainingTtlSeconds(entry);
+ if (remainingTtl <= 0) {
+ recoveryPhoneCodeStore.remove(orcid);
+ return CODE_EXPIRED;
+ }
+
+ // The code was sent to a different number, so it cannot authorise this one
+ if (!StringUtils.equals(entry.getPhoneE164(), validationResult.getE164Number())) {
+ return PHONE_MISMATCH;
+ }
+
+ if (entry.incrementAttempts() > maxAttempts) {
+ recoveryPhoneCodeStore.remove(orcid);
+ return TOO_MANY_ATTEMPTS;
+ }
+
+ if (!constantTimeEquals(entry.getCode(), code.trim())) {
+ // Persist the incremented attempt count, keeping the original expiry window
+ recoveryPhoneCodeStore.save(orcid, entry, remainingTtl);
+ return INVALID_CODE;
+ }
+
+ recoveryPhoneCodeStore.remove(orcid);
+ VerificationCodeSender sender = sendersByProvider.get(entry.getProvider());
+ if (sender != null) {
+ // Best effort provider feedback; a failure here does not undo a code ORCID already confirmed
+ sender.reportResult(entry.getPhoneE164(), entry.getCode(), entry.getProviderMessageId(), true);
+ }
+ return null;
+ }
+
+ /**
+ * The E.164 form of a number the caller has already validated, for storing
+ * against the record.
+ */
+ public String normalize(String rawPhoneNumber) {
+ PhoneNumberValidationResult validationResult = phoneNumberValidator.validate(rawPhoneNumber, defaultRegion);
+ return validationResult.isValid() ? validationResult.getE164Number() : null;
+ }
+
+ public void discardPendingCode(String orcid) {
+ recoveryPhoneCodeStore.remove(orcid);
+ }
+
+ private int remainingResendSeconds(RecoveryPhoneCodeEntry entry) {
+ long elapsed = (System.currentTimeMillis() - entry.getSentAt()) / 1000L;
+ long remaining = resendBufferSeconds - elapsed;
+ return remaining > 0 ? (int) remaining : 0;
+ }
+
+ private int remainingTtlSeconds(RecoveryPhoneCodeEntry entry) {
+ long elapsed = (System.currentTimeMillis() - entry.getSentAt()) / 1000L;
+ long remaining = codeTtlSeconds - elapsed;
+ return remaining > 0 ? (int) remaining : 0;
+ }
+
+ /**
+ * Accepts only a plausible BCP 47 tag from the caller; anything else is
+ * dropped so senders fall back to English.
+ */
+ private static String sanitizeLocale(String locale) {
+ if (StringUtils.isBlank(locale)) {
+ return null;
+ }
+ String trimmed = locale.trim();
+ return trimmed.matches("[A-Za-z]{2,8}([_-][A-Za-z0-9]{1,8}){0,3}") ? trimmed : null;
+ }
+
+ private String generateCode() {
+ int length = codeLength > 0 ? codeLength : 6;
+ StringBuilder builder = new StringBuilder(length);
+ for (int i = 0; i < length; i++) {
+ builder.append(RANDOM.nextInt(10));
+ }
+ return builder.toString();
+ }
+
+ private static boolean constantTimeEquals(String expected, String actual) {
+ if (expected == null || actual == null || expected.length() != actual.length()) {
+ return false;
+ }
+ int result = 0;
+ for (int i = 0; i < expected.length(); i++) {
+ result |= expected.charAt(i) ^ actual.charAt(i);
+ }
+ return result == 0;
+ }
+
+ void setPhoneNumberValidator(PhoneNumberValidator phoneNumberValidator) {
+ this.phoneNumberValidator = phoneNumberValidator;
+ }
+
+ void setRecoveryPhoneCodeStore(RecoveryPhoneCodeStore recoveryPhoneCodeStore) {
+ this.recoveryPhoneCodeStore = recoveryPhoneCodeStore;
+ }
+
+ void setProvider(String provider) {
+ this.provider = provider;
+ }
+
+ void setDefaultRegion(String defaultRegion) {
+ this.defaultRegion = defaultRegion;
+ }
+
+ void setRegexFilter(String regexFilter) {
+ this.regexFilter = regexFilter;
+ }
+
+ void setCodeLength(int codeLength) {
+ this.codeLength = codeLength;
+ }
+
+ void setCodeTtlSeconds(int codeTtlSeconds) {
+ this.codeTtlSeconds = codeTtlSeconds;
+ }
+
+ void setMaxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ }
+
+ void setResendBufferSeconds(int resendBufferSeconds) {
+ this.resendBufferSeconds = resendBufferSeconds;
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninController.java b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninController.java
new file mode 100644
index 00000000000..0bdda042513
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninController.java
@@ -0,0 +1,276 @@
+package org.orcid.frontend.web.controllers;
+
+import jakarta.annotation.Resource;
+import jakarta.persistence.NoResultException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+
+import org.apache.commons.lang3.StringUtils;
+import org.orcid.authorization.authentication.MFAWebAuthenticationDetails;
+import org.orcid.core.manager.ProfileEntityCacheManager;
+import org.orcid.core.manager.RecoveryPhone;
+import org.orcid.core.manager.RecoveryPhoneManager;
+import org.orcid.core.manager.TwoFactorAuthenticationManager;
+import org.orcid.core.togglz.Features;
+import org.orcid.frontend.email.RecordEmailSender;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService;
+import org.orcid.frontend.web.exception.VerificationCodeFor2FARequiredException;
+import org.orcid.utils.OrcidStringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import java.util.Locale;
+
+/**
+ * Lets a user who still knows their password, but has lost both their
+ * authentication app and their 2FA recovery codes, receive a text on the
+ * recovery number stored on their record and get back in.
+ *
+ * These are the only recovery phone endpoints reachable without a session, so
+ * everything they do is driven by the credentials in the body: they are used
+ * from the 2FA step of the ordinary sign in screen and, unchanged, from the
+ * OAuth sign in screen (R4.1). Confirming a code turns 2FA off, after which the
+ * browser replays the ordinary sign in with no code at all.
+ */
+@Controller
+@RequestMapping(value = { "/signin/recoveryPhone" })
+public class RecoveryPhoneSigninController extends BaseController {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RecoveryPhoneSigninController.class);
+
+ /**
+ * Fixed length mask, so the mask does not leak how long the number is. The
+ * 2FA controller masks the same way; the constant is repeated rather than
+ * shared so neither controller has to widen its own internals.
+ */
+ private static final String RECOVERY_PHONE_MASK = "***********";
+
+ static final String FEATURE_DISABLED = "FEATURE_DISABLED";
+
+ static final String BAD_CREDENTIALS = "BAD_CREDENTIALS";
+
+ /** Wire value kept in step with the signed in recovery phone endpoints. */
+ static final String TWO_FACTOR_DISABLED = "2FA_DISABLED";
+
+ static final String NO_RECOVERY_PHONE = "NO_RECOVERY_PHONE";
+
+ /**
+ * The provider bean, deliberately not the "authenticationManager"
+ * ProviderManager: Spring Security wires that manager with an event
+ * publisher, so a return that is not an exception publishes an
+ * authentication success event. On an account whose 2FA is off that would
+ * put a sign in that never happened in the audit trail, and run every
+ * listener on that event, for a caller who only posted a password to a
+ * recovery endpoint. The provider does the identical lockout accounting
+ * R3.2 depends on and publishes nothing, so do not "simplify" this back to
+ * the manager.
+ */
+ @Resource(name = "authenticationProvider")
+ private AuthenticationProvider authenticationProvider;
+
+ @Resource
+ private RecoveryPhoneManager recoveryPhoneManager;
+
+ @Resource
+ private RecoveryPhoneVerificationService recoveryPhoneVerificationService;
+
+ @Resource
+ private TwoFactorAuthenticationManager twoFactorAuthenticationManager;
+
+ @Resource
+ private ProfileEntityCacheManager profileEntityCacheManager;
+
+ @Resource
+ private RecordEmailSender recordEmailSender;
+
+ /**
+ * Sends a fresh code to the recovery number stored on the account the given
+ * credentials belong to (R3.2).
+ */
+ @RequestMapping(value = "/sendCode.json", method = RequestMethod.POST)
+ public @ResponseBody RecoveryPhoneSigninSendCodeResponse sendCode(HttpServletRequest request,
+ @RequestBody RecoveryPhoneSigninSendCodeRequest form) {
+ // Checked before anything else so the endpoint is inert, rather than a
+ // credential check, while the feature is off
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ return RecoveryPhoneSigninSendCodeResponse.failure(FEATURE_DISABLED);
+ }
+
+ String credentialsFailure = verifyCredentials(request, form.getUsername(), form.getPassword());
+ if (credentialsFailure != null) {
+ return RecoveryPhoneSigninSendCodeResponse.failure(credentialsFailure);
+ }
+
+ String orcid = resolveOrcid(form.getUsername());
+ if (orcid == null) {
+ return RecoveryPhoneSigninSendCodeResponse.failure(BAD_CREDENTIALS);
+ }
+
+ RecoveryPhone recoveryPhone = recoveryPhoneManager.getRecoveryPhone(orcid);
+ if (recoveryPhone == null) {
+ // Say so rather than leaving the user waiting for a text (R3.4)
+ return RecoveryPhoneSigninSendCodeResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ // The number comes from the record, never from the request, so nobody
+ // can point the text at a phone of their own, and this is the only
+ // point in the flow that needs it in the clear
+ String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(orcid);
+ if (phoneNumber == null) {
+ // A stored recovery phone always carries a number, so this is only
+ // reachable if the number went away between the two reads
+ return RecoveryPhoneSigninSendCodeResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ RecoveryPhoneSendCodeRequest sendCodeRequest = new RecoveryPhoneSendCodeRequest();
+ sendCodeRequest.setPhoneNumber(phoneNumber);
+ sendCodeRequest.setLocale(requestLocale());
+
+ RecoveryPhoneSendCodeResponse sendCodeResponse = recoveryPhoneVerificationService.sendCode(orcid, sendCodeRequest);
+
+ RecoveryPhoneSigninSendCodeResponse response = new RecoveryPhoneSigninSendCodeResponse();
+ response.setSuccess(sendCodeResponse.isSuccess());
+ response.setErrorCode(sendCodeResponse.getErrorCode());
+ response.setResendAfterSeconds(sendCodeResponse.getResendAfterSeconds());
+ // The mask goes back on a refused resend too, so the screen can keep
+ // naming the number the user is waiting on
+ response.setMaskedRecoveryPhoneNumber(RECOVERY_PHONE_MASK + recoveryPhone.getLastFour());
+ return response;
+ }
+
+ /**
+ * Confirms the code that was texted to the recovery number and, on success,
+ * turns 2FA off so the ordinary sign in can proceed on the password alone
+ * (R3.5).
+ */
+ @RequestMapping(value = "/verify.json", method = RequestMethod.POST)
+ public @ResponseBody RecoveryPhoneSigninVerifyResponse verify(HttpServletRequest request,
+ @RequestBody RecoveryPhoneSigninVerifyRequest form) {
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ return RecoveryPhoneSigninVerifyResponse.failure(FEATURE_DISABLED);
+ }
+
+ String credentialsFailure = verifyCredentials(request, form.getUsername(), form.getPassword());
+ if (credentialsFailure != null) {
+ return RecoveryPhoneSigninVerifyResponse.failure(credentialsFailure);
+ }
+
+ String orcid = resolveOrcid(form.getUsername());
+ if (orcid == null) {
+ return RecoveryPhoneSigninVerifyResponse.failure(BAD_CREDENTIALS);
+ }
+
+ // Answered from the read that costs no decryption, as in sendCode
+ if (recoveryPhoneManager.getRecoveryPhone(orcid) == null) {
+ return RecoveryPhoneSigninVerifyResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(orcid);
+ if (phoneNumber == null) {
+ return RecoveryPhoneSigninVerifyResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ String verificationFailure = recoveryPhoneVerificationService.verifyCode(orcid, phoneNumber, form.getVerificationCode());
+ if (verificationFailure != null) {
+ // Nothing about the account changes until the code is right
+ return RecoveryPhoneSigninVerifyResponse.failure(verificationFailure);
+ }
+
+ twoFactorAuthenticationManager.disable2FAByRecoveryPhone(orcid);
+ // Load bearing, and before anything that can throw: the browser
+ // re-submits the ordinary sign in straight away, and a cached profile
+ // still carrying using2FA=true would demand a 2FA code that no longer
+ // exists
+ profileEntityCacheManager.remove(orcid);
+ try {
+ recordEmailSender.send2FADisabledEmail(orcid);
+ } catch (RuntimeException e) {
+ // The notification must not be able to fail the operation it is
+ // reporting: 2FA is already off, the number and the backup codes are
+ // already gone, and telling the caller it failed would only send
+ // them back through a recovery they no longer need
+ LOG.error("Unable to send the 2FA disabled email for: " + orcid, e);
+ }
+ LOG.info("2FA disabled through the recovery phone number for: " + orcid);
+
+ return RecoveryPhoneSigninVerifyResponse.success(orcid);
+ }
+
+ /**
+ * Proves the password without establishing a session.
+ *
+ * The check deliberately goes through the real authentication provider
+ * rather than comparing the hash here: a wrong password on these endpoints
+ * then counts toward the same sign in lockout as a wrong password on the
+ * sign in form (R3.2), instead of handing out an unthrottled password
+ * oracle beside a throttled one. The security context is never touched, no
+ * success handler runs and no authentication event is published, so nothing
+ * here logs anyone in or says that anyone did.
+ *
+ * @return null when the password is right and the account is using 2FA,
+ * otherwise the error code to report
+ */
+ private String verifyCredentials(HttpServletRequest request, String username, String password) {
+ if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
+ return BAD_CREDENTIALS;
+ }
+
+ UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
+ HttpSession session = request.getSession(false);
+ // No 2FA codes: the whole point is that the user has none to give
+ token.setDetails(new MFAWebAuthenticationDetails(request.getRemoteAddr(), session != null ? session.getId() : null, null, null));
+
+ try {
+ Authentication result = authenticationProvider.authenticate(token);
+ if (result != null && result.isAuthenticated()) {
+ // The password is right but there is no 2FA to recover from
+ return TWO_FACTOR_DISABLED;
+ }
+ return BAD_CREDENTIALS;
+ } catch (VerificationCodeFor2FARequiredException e) {
+ // The password was accepted and the account is using 2FA: the one
+ // state these endpoints exist to serve
+ return null;
+ } catch (AuthenticationException e) {
+ // Covers a wrong password, a locked account and the bad 2FA code
+ // exceptions, none of which we distinguish for an anonymous caller
+ return BAD_CREDENTIALS;
+ }
+ }
+
+ private String resolveOrcid(String username) {
+ if (OrcidStringUtils.isValidOrcid(username)) {
+ return username;
+ }
+ try {
+ return emailManagerReadOnly.findOrcidIdByEmail(username);
+ } catch (NoResultException e) {
+ // The password was just accepted for this username, so there should
+ // be a record behind it; report it as a credential failure rather
+ // than failing the request
+ LOG.warn("No record found for a username that just authenticated");
+ return null;
+ }
+ }
+
+ private String requestLocale() {
+ Locale locale = getLocale();
+ return locale == null ? null : locale.toLanguageTag();
+ }
+
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationController.java b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationController.java
index 0ff5a544346..8108cfaff7a 100644
--- a/orcid-web/src/main/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationController.java
+++ b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationController.java
@@ -4,11 +4,22 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.StringUtils;
import org.orcid.core.manager.BackupCodeManager;
import org.orcid.core.manager.EncryptionManager;
import org.orcid.core.manager.ProfileEntityCacheManager;
+import org.orcid.core.manager.RecoveryPhone;
+import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.core.manager.TwoFactorAuthenticationManager;
+import org.orcid.core.togglz.Features;
import org.orcid.frontend.email.RecordEmailSender;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeVerifyRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService;
import org.orcid.persistence.jpa.entities.ProfileEntity;
import org.orcid.pojo.*;
import org.slf4j.Logger;
@@ -25,6 +36,7 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
@Controller
@RequestMapping(value = { "/2FA" })
@@ -32,9 +44,54 @@ public class TwoFactorAuthenticationController extends BaseController {
private static final Logger LOG = LoggerFactory.getLogger(TwoFactorAuthenticationController.class);
+ private static final String RECOVERY_PHONE_ELEVATION_ATTRIBUTE = "RECOVERY_PHONE_ELEVATION_TS";
+
+ /** How long a passed authentication challenge lets the user keep working. */
+ private static final long RECOVERY_PHONE_ELEVATION_TTL_MILLIS = 15 * 60 * 1000L;
+
+ /** Fixed length mask, so the mask does not leak how long the number is. */
+ private static final String RECOVERY_PHONE_MASK = "***********";
+
+ /** Ignore the sub second gap between the insert and its last_modified. */
+ // A row's two timestamps are stamped by the same lifecycle callback when it is created, so they
+ // differ by at most a few milliseconds until the number is actually changed. A minute here would
+ // read a number corrected right after it was added as never modified, which the panel then dates
+ // from the wrong event.
+ private static final long RECOVERY_PHONE_MODIFIED_THRESHOLD_MILLIS = 1000L;
+
+ static final String FEATURE_DISABLED = "FEATURE_DISABLED";
+
+ static final String TWO_FACTOR_DISABLED = "2FA_DISABLED";
+
+ static final String CHALLENGE_REQUIRED = "CHALLENGE_REQUIRED";
+
+ /** The account has no recovery phone number to send a code to. */
+ static final String NO_RECOVERY_PHONE = "NO_RECOVERY_PHONE";
+
+ /** The password given with a challenge is not the account's password. */
+ static final String INVALID_PASSWORD = "INVALID_PASSWORD";
+
+ /**
+ * Where the recovery phone form is being shown. The context decides which
+ * proof of identity the request is accepted on; it never widens what the
+ * request may do, and every context is still ROLE_USER on the signed in
+ * record.
+ */
+ static final String CONTEXT_SETTINGS = "SETTINGS";
+
+ static final String CONTEXT_ONBOARDING = "ONBOARDING";
+
+ static final String CONTEXT_INTERSTITIAL = "INTERSTITIAL";
+
@Resource
private TwoFactorAuthenticationManager twoFactorAuthenticationManager;
+ @Resource
+ private RecoveryPhoneManager recoveryPhoneManager;
+
+ @Resource
+ private RecoveryPhoneVerificationService recoveryPhoneVerificationService;
+
@Resource
private ProfileEntityCacheManager profileEntityCacheManager;
@@ -58,10 +115,313 @@ public class TwoFactorAuthenticationController extends BaseController {
status.setTwoFactorCreationDate(org.orcid.pojo.ajaxForm.Date.valueOf(creationDate));
status.setRecoveryCodeCreationDate(org.orcid.pojo.ajaxForm.Date.valueOf(creationDate));
}
+ if (Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ applyRecoveryPhoneState(orcid, status);
+ }
}
return status;
}
+ /**
+ * Verifies the user before they add or change their recovery phone number.
+ *
+ * The number has to be confirmed by text, which takes longer than a TOTP
+ * code stays valid, so a passed challenge elevates the session for a short
+ * window instead of being replayed on the final save.
+ */
+ @RequestMapping(value = "/recoveryPhone/verifyAuthChallenge.json", method = RequestMethod.POST)
+ public @ResponseBody AuthChallenge verifyRecoveryPhoneAuthChallenge(HttpServletRequest request, @RequestBody AuthChallenge form) {
+ String orcid = getCurrentUserOrcid();
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive() || !twoFactorAuthenticationManager.userUsing2FA(orcid)) {
+ form.setSuccess(false);
+ return form;
+ }
+
+ ProfileEntity profile = profileEntityCacheManager.retrieve(orcid);
+ if (form.getPassword() == null || !encryptionManager.hashMatches(form.getPassword(), profile.getEncryptedPassword())) {
+ form.setInvalidPassword(true);
+ return form;
+ }
+ if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(orcid, form)) {
+ return form;
+ }
+
+ request.getSession().setAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE, System.currentTimeMillis());
+ form.setSuccess(true);
+ return form;
+ }
+
+ @RequestMapping(value = "/recoveryPhone/sendCode.json", method = RequestMethod.POST)
+ public @ResponseBody RecoveryPhoneSendCodeResponse sendRecoveryPhoneCode(HttpServletRequest request,
+ @RequestBody RecoveryPhoneSendCodeRequest form) {
+ String orcid = getCurrentUserOrcid();
+ String guardFailure = guardRecoveryPhoneRequest(request, orcid, form.getContext());
+ if (guardFailure != null) {
+ return RecoveryPhoneSendCodeResponse.failure(guardFailure);
+ }
+ return recoveryPhoneVerificationService.sendCode(orcid, form);
+ }
+
+ @RequestMapping(value = "/recoveryPhone/save.json", method = RequestMethod.POST)
+ public @ResponseBody RecoveryPhoneSaveResponse saveRecoveryPhone(HttpServletRequest request, @RequestBody RecoveryPhoneSaveRequest form) {
+ String orcid = getCurrentUserOrcid();
+ String guardFailure = guardRecoveryPhoneRequest(request, orcid, form.getContext());
+ if (guardFailure != null) {
+ return RecoveryPhoneSaveResponse.failure(guardFailure);
+ }
+
+ String verificationFailure = recoveryPhoneVerificationService.verifyCode(orcid, form.getPhoneNumber(), form.getVerificationCode());
+ if (verificationFailure != null) {
+ return RecoveryPhoneSaveResponse.failure(verificationFailure);
+ }
+
+ String phoneE164 = recoveryPhoneVerificationService.normalize(form.getPhoneNumber());
+ RecoveryPhone saved = recoveryPhoneManager.saveRecoveryPhone(orcid, phoneE164);
+ request.getSession().removeAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE);
+
+ // The answer is built from the row the save wrote, not read back: a read goes to
+ // the read-only pool, and on a deployed environment that is a replica which can
+ // still be answering with the previous number, or with none, for a moment after
+ // the primary has committed
+ RecoveryPhoneSaveResponse response = new RecoveryPhoneSaveResponse();
+ response.setSuccess(true);
+ TwoFactorAuthStatus status = new TwoFactorAuthStatus();
+ applyRecoveryPhoneState(saved, status);
+ response.setMaskedRecoveryPhoneNumber(status.getMaskedRecoveryPhoneNumber());
+ response.setRecoveryPhoneCreationDate(status.getRecoveryPhoneCreationDate());
+ response.setRecoveryPhoneLastModifiedDate(status.getRecoveryPhoneLastModifiedDate());
+ response.setRecoveryPhoneModified(status.isRecoveryPhoneModified());
+ return response;
+ }
+
+ /**
+ * Sends a code to the number already stored on the record, as the first
+ * half of passing an authentication challenge with the recovery phone.
+ *
+ * This endpoint deliberately does not ask for the session elevation the
+ * other recovery phone endpoints need: it is the challenge. Anyone who
+ * could pass the ordinary challenge would have no reason to be here, since
+ * they reach this route precisely because they have lost their
+ * authentication app and their recovery codes (R5.1). What it costs an
+ * attacker is a text sent to a number they do not hold.
+ */
+ @RequestMapping(value = "/recoveryPhone/challenge/sendCode.json", method = RequestMethod.POST)
+ public @ResponseBody RecoveryPhoneChallengeSendCodeResponse sendRecoveryPhoneChallengeCode() {
+ String orcid = getCurrentUserOrcid();
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ return RecoveryPhoneChallengeSendCodeResponse.failure(FEATURE_DISABLED);
+ }
+ if (!twoFactorAuthenticationManager.userUsing2FA(orcid)) {
+ return RecoveryPhoneChallengeSendCodeResponse.failure(TWO_FACTOR_DISABLED);
+ }
+ RecoveryPhone recoveryPhone = recoveryPhoneManager.getRecoveryPhone(orcid);
+ if (recoveryPhone == null) {
+ // Tell the user there is no number rather than leave them waiting
+ // for a text that is never sent (R3.4)
+ return RecoveryPhoneChallengeSendCodeResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ // The user never types a number here: the code goes to the stored one,
+ // and only the mask comes back out. The number is asked for separately,
+ // since RecoveryPhone carries the mask and the dates only
+ String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(orcid);
+ if (phoneNumber == null) {
+ // A stored recovery phone always carries a number, so this is only
+ // reachable if the number went away between the two reads
+ return RecoveryPhoneChallengeSendCodeResponse.failure(NO_RECOVERY_PHONE);
+ }
+
+ RecoveryPhoneSendCodeRequest sendCodeRequest = new RecoveryPhoneSendCodeRequest();
+ sendCodeRequest.setPhoneNumber(phoneNumber);
+ Locale locale = getLocale();
+ if (locale != null) {
+ sendCodeRequest.setLocale(locale.toString());
+ }
+ RecoveryPhoneSendCodeResponse sendCodeResponse = recoveryPhoneVerificationService.sendCode(orcid, sendCodeRequest);
+ return RecoveryPhoneChallengeSendCodeResponse.from(sendCodeResponse, RECOVERY_PHONE_MASK + recoveryPhone.getLastFour());
+ }
+
+ /**
+ * Passes an authentication challenge with the recovery phone number, which
+ * costs the user their 2FA: the number is a one time way back in, and
+ * using it disables 2FA and resets every 2FA backup option (R5.3).
+ *
+ * It answers with {@link AuthChallenge}, as
+ * {@link #verifyRecoveryPhoneAuthChallenge} does, so the frontend's
+ * challenge component keeps one response shape across both ways of
+ * passing the same challenge.
+ */
+ @RequestMapping(value = "/recoveryPhone/challenge/verify.json", method = RequestMethod.POST)
+ public @ResponseBody AuthChallenge verifyRecoveryPhoneChallengeCode(HttpServletRequest request, @RequestBody RecoveryPhoneChallengeVerifyRequest form) {
+ String orcid = getCurrentUserOrcid();
+ AuthChallenge result = new AuthChallenge();
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ result.setSuccess(false);
+ result.getErrors().add(FEATURE_DISABLED);
+ return result;
+ }
+ if (!twoFactorAuthenticationManager.userUsing2FA(orcid)) {
+ result.setSuccess(false);
+ result.getErrors().add(TWO_FACTOR_DISABLED);
+ return result;
+ }
+
+ // The password is checked before the code, so someone without the
+ // password can neither spend the code's attempts nor learn anything
+ // about it
+ ProfileEntity profile = profileEntityCacheManager.retrieve(orcid);
+ if (form.getPassword() == null || !encryptionManager.hashMatches(form.getPassword(), profile.getEncryptedPassword())) {
+ result.setInvalidPassword(true);
+ result.getErrors().add(INVALID_PASSWORD);
+ return result;
+ }
+
+ // Nothing here wants the mask, so the number comes from the one path
+ // that decrypts it; a record with no number stored answers null
+ String phoneE164 = recoveryPhoneManager.getDecryptedPhoneNumber(orcid);
+ if (phoneE164 == null) {
+ result.setSuccess(false);
+ result.getErrors().add(NO_RECOVERY_PHONE);
+ return result;
+ }
+
+ String verificationFailure = recoveryPhoneVerificationService.verifyCode(orcid, phoneE164, form.getVerificationCode());
+ if (verificationFailure != null) {
+ // Nothing is disabled unless the code was right
+ result.setSuccess(false);
+ result.getErrors().add(verificationFailure);
+ return result;
+ }
+
+ twoFactorAuthenticationManager.disable2FAByRecoveryPhone(orcid);
+ // The cache is evicted as soon as the record changes and before
+ // anything that can fail, so nothing downstream keeps answering that
+ // 2FA is on after it has been turned off
+ profileEntityCacheManager.remove(orcid);
+ try {
+ recordEmailSender.send2FADisabledEmail(orcid);
+ } catch (RuntimeException e) {
+ // The notification must not be able to fail the operation it is
+ // reporting: 2FA is already off, the number and the backup codes
+ // are already gone, and reporting a failed challenge would send the
+ // user back through a recovery they no longer need
+ LOG.error("Unable to send the 2FA disabled email for: " + orcid, e);
+ }
+ // 2FA is off, so the action this challenge was guarding proceeds on the
+ // password alone; an elevation granted earlier has nothing left to guard
+ request.getSession().removeAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE);
+ result.setSuccess(true);
+ return result;
+ }
+
+ /**
+ * @param context
+ * where the form is being shown, which decides what counts as
+ * recent proof of identity
+ * @return the error code to report, or null when the request may proceed
+ */
+ private String guardRecoveryPhoneRequest(HttpServletRequest request, String orcid, String context) {
+ if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) {
+ return FEATURE_DISABLED;
+ }
+ if (!twoFactorAuthenticationManager.userUsing2FA(orcid)) {
+ return TWO_FACTOR_DISABLED;
+ }
+ if (sessionIsElevated(request) || interstitialIsElevatedByRecentLogin(orcid, context)) {
+ return null;
+ }
+ return CHALLENGE_REQUIRED;
+ }
+
+ /** A challenge passed on this session within the elevation window. */
+ private boolean sessionIsElevated(HttpServletRequest request) {
+ Object elevatedAt = request.getSession().getAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE);
+ if (!(elevatedAt instanceof Long)) {
+ return false;
+ }
+ return System.currentTimeMillis() - (Long) elevatedAt <= RECOVERY_PHONE_ELEVATION_TTL_MILLIS;
+ }
+
+ /**
+ * The add-a-number interstitial is let in on a recent sign in rather than
+ * on a challenge of its own. The user completed 2FA seconds earlier to
+ * reach it, so a fresh login is the same proof a challenge would collect,
+ * and an interstitial has nowhere to put a password challenge: it is a
+ * dialog the user cannot dismiss, sitting between them and their record.
+ * The window is the same 15 minutes, so a session left open on the
+ * interstitial goes cold exactly as an elevated session does (R6.3).
+ *
+ * The context is posted in the request body, so it is a claim about where
+ * the form is being shown and never a mode the client may switch on: the
+ * conditions the interstitial is actually shown under are checked again
+ * here, server side. With all of them, the most a stolen session can do
+ * without the password is add a first recovery number, and even that needs
+ * the code texted to that number before anything is saved. What it does
+ * not close is a stolen session used inside the same fifteen minutes as
+ * the real user's sign in, which is the trade R6.3 makes deliberately.
+ */
+ private boolean interstitialIsElevatedByRecentLogin(String orcid, String context) {
+ if (!CONTEXT_INTERSTITIAL.equals(resolveContext(context))) {
+ return false;
+ }
+ if (!Features.LOGIN_RECOVERY_PHONE_INTERSTITIAL.isActive()) {
+ // The relaxed path dies with the interstitial that justifies it (R7.4)
+ return false;
+ }
+ if (recoveryPhoneManager.getRecoveryPhone(orcid) != null) {
+ // The interstitial is only ever offered when no number is stored,
+ // so it can add a first one and never replace one. A replacement
+ // without a challenge would let a hijacked session point the
+ // recovery number at a phone it holds, and that number then turns
+ // 2FA off at the next sign in (R6.1)
+ return false;
+ }
+ if (!orcid.equals(getRealUserOrcid())) {
+ // A delegate or an admin switched into the record is not the
+ // account owner, and their own sign in is no proof of this one (R6.1)
+ return false;
+ }
+ // last_login is read from the database, not from the profile cache: the
+ // cached entity is loaded while the user is being authenticated, before
+ // the success handler writes last_login, so it still carries the
+ // previous sign in. The database is also where the authorization server
+ // puts it when it is the one serving the sign in
+ java.util.Date lastLogin = profileEntityManager.getLastLogin(orcid);
+ if (lastLogin == null) {
+ // Nothing to date the sign in by, so this is not proof of anything
+ return false;
+ }
+ return System.currentTimeMillis() - lastLogin.getTime() <= RECOVERY_PHONE_ELEVATION_TTL_MILLIS;
+ }
+
+ /** SETTINGS is what an unstated context means: the strictest of the three. */
+ private static String resolveContext(String context) {
+ return StringUtils.isBlank(context) ? CONTEXT_SETTINGS : context.trim();
+ }
+
+ private void applyRecoveryPhoneState(String orcid, TwoFactorAuthStatus status) {
+ applyRecoveryPhoneState(recoveryPhoneManager.getRecoveryPhone(orcid), status);
+ }
+
+ private void applyRecoveryPhoneState(RecoveryPhone recoveryPhone, TwoFactorAuthStatus status) {
+ if (recoveryPhone == null) {
+ return;
+ }
+ status.setMaskedRecoveryPhoneNumber(RECOVERY_PHONE_MASK + recoveryPhone.getLastFour());
+ if (recoveryPhone.getDateCreated() != null) {
+ status.setRecoveryPhoneCreationDate(org.orcid.pojo.ajaxForm.Date.valueOf(recoveryPhone.getDateCreated()));
+ }
+ if (recoveryPhone.getLastModified() != null) {
+ status.setRecoveryPhoneLastModifiedDate(org.orcid.pojo.ajaxForm.Date.valueOf(recoveryPhone.getLastModified()));
+ }
+ // The dates we hand out only carry a day, so whether the number has ever
+ // been changed is decided here from the full timestamps
+ if (recoveryPhone.getDateCreated() != null && recoveryPhone.getLastModified() != null) {
+ long deltaMillis = recoveryPhone.getLastModified().getTime() - recoveryPhone.getDateCreated().getTime();
+ status.setRecoveryPhoneModified(deltaMillis > RECOVERY_PHONE_MODIFIED_THRESHOLD_MILLIS);
+ }
+ }
+
@RequestMapping("/setup")
public ModelAndView get2FASetupPage() {
TwoFactorAuthStatus status = get2FAStatus();
@@ -111,15 +471,27 @@ public byte[] generateQrCode(HttpServletResponse response) {
}
@RequestMapping(value = "/register.json", method = RequestMethod.POST)
- public @ResponseBody TwoFactorAuthRegistration validateVerificationCode(@RequestBody TwoFactorAuthRegistration registration) {
+ public @ResponseBody TwoFactorAuthRegistration validateVerificationCode(HttpServletRequest request, @RequestBody TwoFactorAuthRegistration registration) {
String orcid = getCurrentUserOrcid();
boolean valid = twoFactorAuthenticationManager.verificationCodeIsValid(registration.getVerificationCode(), orcid);
registration.setValid(valid);
if (valid) {
+ // Read before enable2FA, which is what makes this true for everyone
+ boolean wasAlreadyUsing2FA = twoFactorAuthenticationManager.userUsing2FA(orcid);
List backupCodes = twoFactorAuthenticationManager.enable2FA(orcid);
- registration.setBackupCodes(backupCodes);
+ registration.setBackupCodes(backupCodes);
//send email notification
recordEmailSender.send2FAEnabledEmail(orcid);
+ if (!wasAlreadyUsing2FA) {
+ // Step 1 of 2FA setup elevates the session for the recovery phone
+ // step that follows it: the user has just typed a live time based
+ // code, and asking for their password one screen later is the
+ // wrong trade (R2.6). Only setup gets that: on an account that
+ // already has 2FA on there is no step 2 to carry, and letting a
+ // time based code elevate there would put it in the place of the
+ // password challenge that guards changing a recovery number
+ request.getSession().setAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE, System.currentTimeMillis());
+ }
}
return registration;
}
diff --git a/orcid-web/src/main/resources/orcid-core-context-spam.xml b/orcid-web/src/main/resources/orcid-core-context-spam.xml
index 52d59220fb5..b984b495d99 100644
--- a/orcid-web/src/main/resources/orcid-core-context-spam.xml
+++ b/orcid-web/src/main/resources/orcid-core-context-spam.xml
@@ -700,6 +700,8 @@
+
+
diff --git a/orcid-web/src/main/resources/orcid-frontend-security.xml b/orcid-web/src/main/resources/orcid-frontend-security.xml
index b542821d62a..32c6766980a 100644
--- a/orcid-web/src/main/resources/orcid-frontend-security.xml
+++ b/orcid-web/src/main/resources/orcid-frontend-security.xml
@@ -253,6 +253,8 @@
access="IS_AUTHENTICATED_ANONYMOUSLY" />
+
+
+
diff --git a/orcid-web/src/test/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationServiceTest.java b/orcid-web/src/test/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationServiceTest.java
new file mode 100644
index 00000000000..fb64a0cc77c
--- /dev/null
+++ b/orcid-web/src/test/java/org/orcid/frontend/recoveryphone/RecoveryPhoneVerificationServiceTest.java
@@ -0,0 +1,273 @@
+package org.orcid.frontend.recoveryphone;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.orcid.utils.phone.PhoneNumberValidator;
+import org.orcid.utils.sms.SmsSendResult;
+import org.orcid.utils.sms.VerificationCodeSender;
+
+public class RecoveryPhoneVerificationServiceTest {
+
+ private static final String ORCID = "0000-0000-0000-0001";
+
+ private static final String OTHER_ORCID = "0000-0000-0000-0002";
+
+ private static final String PHONE = "+441234567890";
+
+ private RecoveryPhoneVerificationService service;
+
+ private CapturingSender awsSender;
+
+ private FakeStore store;
+
+ @Before
+ public void setUp() {
+ store = new FakeStore();
+ service = new RecoveryPhoneVerificationService();
+ service.setPhoneNumberValidator(new PhoneNumberValidator());
+ service.setRecoveryPhoneCodeStore(store);
+ service.setProvider("aws");
+ service.setDefaultRegion("GB");
+ service.setRegexFilter("");
+ service.setCodeLength(6);
+ service.setCodeTtlSeconds(300);
+ service.setMaxAttempts(3);
+ service.setResendBufferSeconds(30);
+ awsSender = new CapturingSender("aws");
+ service.setSenders(Arrays.asList(awsSender));
+ }
+
+ private static RecoveryPhoneSendCodeRequest sendRequest(String phone) {
+ RecoveryPhoneSendCodeRequest request = new RecoveryPhoneSendCodeRequest();
+ request.setPhoneNumber(phone);
+ return request;
+ }
+
+ private RecoveryPhoneSendCodeResponse send(String orcid, String phone) {
+ return service.sendCode(orcid, sendRequest(phone));
+ }
+
+ @Test
+ public void sendCodeDispatchesACodeAndReportsTheResendBuffer() {
+ RecoveryPhoneSendCodeResponse response = send(ORCID, PHONE);
+
+ assertTrue(response.isSuccess());
+ assertEquals(30, response.getResendAfterSeconds());
+ assertEquals(PHONE, awsSender.lastTo);
+ assertEquals(6, awsSender.lastCode.length());
+ }
+
+ @Test
+ public void sendCodeNeverReturnsTheCodeOrTheNumber() {
+ RecoveryPhoneSendCodeResponse response = send(ORCID, PHONE);
+
+ String serialized = response.getErrorCode() + String.valueOf(response.getResendAfterSeconds()) + response.isSuccess();
+ assertFalse(serialized.contains(awsSender.lastCode));
+ assertFalse(serialized.contains(PHONE));
+ }
+
+ @Test
+ public void tooShortAndTooLongNumbersAreReportedSeparately() {
+ assertEquals("PHONE_TOO_SHORT", send(ORCID, "+441234").getErrorCode());
+ assertEquals("PHONE_TOO_LONG", send(ORCID, "+4412345678901234").getErrorCode());
+ }
+
+ @Test
+ public void anUnparseableNumberIsReportedAsInvalid() {
+ assertEquals("INVALID_PHONE_NUMBER", send(ORCID, "?123456789").getErrorCode());
+ }
+
+ @Test
+ public void aNumberOutsideTheSafetyFilterIsRefused() {
+ service.setRegexFilter("\\+506.*");
+ assertEquals(RecoveryPhoneVerificationService.SMS_RECIPIENT_NOT_ALLOWED, send(ORCID, PHONE).getErrorCode());
+ }
+
+ @Test
+ public void resendIsRefusedUntilTheBufferHasPassed() {
+ send(ORCID, PHONE);
+
+ RecoveryPhoneSendCodeResponse response = send(ORCID, PHONE);
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneVerificationService.RESEND_TOO_SOON, response.getErrorCode());
+ assertTrue(response.getResendAfterSeconds() > 0);
+ }
+
+ @Test
+ public void resendIsAllowedOnceTheBufferHasPassed() {
+ send(ORCID, PHONE);
+ store.ageEntriesBySeconds(ORCID, 31);
+
+ assertTrue(send(ORCID, PHONE).isSuccess());
+ }
+
+ @Test
+ public void aNewCodeRetiresThePreviousOne() {
+ send(ORCID, PHONE);
+ String firstCode = awsSender.lastCode;
+ store.ageEntriesBySeconds(ORCID, 31);
+ send(ORCID, PHONE);
+
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, service.verifyCode(ORCID, PHONE, firstCode));
+ assertNull(service.verifyCode(ORCID, PHONE, awsSender.lastCode));
+ }
+
+ @Test
+ public void aCorrectCodeVerifiesAndIsThenConsumed() {
+ send(ORCID, PHONE);
+ String code = awsSender.lastCode;
+
+ assertNull(service.verifyCode(ORCID, PHONE, code));
+ assertEquals(RecoveryPhoneVerificationService.CODE_EXPIRED, service.verifyCode(ORCID, PHONE, code));
+ }
+
+ @Test
+ public void aCodeSentToOneNumberCannotAuthoriseAnother() {
+ send(ORCID, PHONE);
+
+ assertEquals(RecoveryPhoneVerificationService.PHONE_MISMATCH, service.verifyCode(ORCID, "+441234567891", awsSender.lastCode));
+ }
+
+ @Test
+ public void aCodeBelongsToTheRecordItWasSentFor() {
+ send(ORCID, PHONE);
+
+ assertEquals(RecoveryPhoneVerificationService.CODE_EXPIRED, service.verifyCode(OTHER_ORCID, PHONE, awsSender.lastCode));
+ }
+
+ @Test
+ public void wrongCodesAreRefusedAndEventuallyExhaustTheAttempts() {
+ send(ORCID, PHONE);
+
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, service.verifyCode(ORCID, PHONE, "000000"));
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, service.verifyCode(ORCID, PHONE, "000000"));
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, service.verifyCode(ORCID, PHONE, "000000"));
+ assertEquals(RecoveryPhoneVerificationService.TOO_MANY_ATTEMPTS, service.verifyCode(ORCID, PHONE, "000000"));
+ // the entry is gone, so even the right code no longer works
+ assertEquals(RecoveryPhoneVerificationService.CODE_EXPIRED, service.verifyCode(ORCID, PHONE, awsSender.lastCode));
+ }
+
+ @Test
+ public void anExpiredCodeIsRefused() {
+ send(ORCID, PHONE);
+ store.ageEntriesBySeconds(ORCID, 301);
+
+ assertEquals(RecoveryPhoneVerificationService.CODE_EXPIRED, service.verifyCode(ORCID, PHONE, awsSender.lastCode));
+ }
+
+ @Test
+ public void aBlankCodeIsRefusedWithoutTouchingTheStore() {
+ send(ORCID, PHONE);
+
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, service.verifyCode(ORCID, PHONE, " "));
+ assertNotNull(store.get(ORCID));
+ }
+
+ @Test
+ public void sendFailsWhenTheCodeCannotBeStored() {
+ store.failSaves = true;
+
+ RecoveryPhoneSendCodeResponse response = send(ORCID, PHONE);
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneVerificationService.CODE_STORAGE_UNAVAILABLE, response.getErrorCode());
+ }
+
+ @Test
+ public void sendFailsWhenTheProviderRejectsTheMessage() {
+ awsSender.fail = true;
+
+ RecoveryPhoneSendCodeResponse response = send(ORCID, PHONE);
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneVerificationService.SMS_SEND_FAILED, response.getErrorCode());
+ assertNull(store.get(ORCID));
+ }
+
+ @Test
+ public void normalizeReturnsTheE164FormOrNull() {
+ assertEquals(PHONE, service.normalize("01234 567890"));
+ assertNull(service.normalize("?12345"));
+ }
+
+ /**
+ * Stands in for the redis backed store, and lets a test pretend time has
+ * passed by rewriting the entry's sent-at stamp.
+ */
+ private static class FakeStore extends RecoveryPhoneCodeStore {
+
+ private final Map entries = new HashMap<>();
+
+ private boolean failSaves;
+
+ @Override
+ public boolean save(String orcid, RecoveryPhoneCodeEntry entry, int ttlSeconds) {
+ if (failSaves) {
+ return false;
+ }
+ // round trip through the serialized form, as the real store does
+ entries.put(orcid, RecoveryPhoneCodeEntry.parse(entry.serialize()));
+ return true;
+ }
+
+ @Override
+ public RecoveryPhoneCodeEntry get(String orcid) {
+ return entries.get(orcid);
+ }
+
+ @Override
+ public void remove(String orcid) {
+ entries.remove(orcid);
+ }
+
+ void ageEntriesBySeconds(String orcid, int seconds) {
+ RecoveryPhoneCodeEntry entry = entries.get(orcid);
+ if (entry != null) {
+ entries.put(orcid, new RecoveryPhoneCodeEntry(entry.getCode(), entry.getPhoneE164(), entry.getProvider(),
+ entry.getProviderMessageId(), entry.getAttempts(), entry.getSentAt() - (seconds * 1000L)));
+ }
+ }
+ }
+
+ private static class CapturingSender implements VerificationCodeSender {
+
+ private final String provider;
+
+ private String lastTo;
+
+ private String lastCode;
+
+ private boolean fail;
+
+ CapturingSender(String provider) {
+ this.provider = provider;
+ }
+
+ @Override
+ public String getProvider() {
+ return provider;
+ }
+
+ @Override
+ public SmsSendResult sendCode(String to, String code, String locale) {
+ this.lastTo = to;
+ this.lastCode = code;
+ if (fail) {
+ return SmsSendResult.failure(provider, "PROVIDER_ERROR", "boom");
+ }
+ return SmsSendResult.success(provider, "message-id", "PENDING");
+ }
+ }
+
+}
diff --git a/orcid-web/src/test/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninControllerTest.java b/orcid-web/src/test/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninControllerTest.java
new file mode 100644
index 00000000000..a346223fe23
--- /dev/null
+++ b/orcid-web/src/test/java/org/orcid/frontend/web/controllers/RecoveryPhoneSigninControllerTest.java
@@ -0,0 +1,490 @@
+package org.orcid.frontend.web.controllers;
+
+import jakarta.annotation.Resource;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+import org.orcid.authorization.authentication.MFAWebAuthenticationDetails;
+import org.orcid.core.manager.ProfileEntityCacheManager;
+import org.orcid.core.manager.RecoveryPhone;
+import org.orcid.core.manager.RecoveryPhoneManager;
+import org.orcid.core.manager.TwoFactorAuthenticationManager;
+import org.orcid.core.manager.v3.read_only.EmailManagerReadOnly;
+import org.orcid.core.togglz.Features;
+import org.orcid.frontend.email.RecordEmailSender;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService;
+import org.orcid.frontend.web.exception.VerificationCodeFor2FARequiredException;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.togglz.junit.TogglzRule;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+public class RecoveryPhoneSigninControllerTest {
+
+ private static final String ORCID = "0000-0000-0000-0001";
+
+ private static final String EMAIL = "user@orcid.org";
+
+ private static final String PASSWORD = "correct-horse";
+
+ private static final String STORED_NUMBER = "+441234567890";
+
+ private static final String REMOTE_ADDRESS = "198.51.100.7";
+
+ @Mock
+ private AuthenticationProvider authenticationProvider;
+
+ @Mock
+ private RecoveryPhoneManager recoveryPhoneManager;
+
+ @Mock
+ private RecoveryPhoneVerificationService recoveryPhoneVerificationService;
+
+ @Mock
+ private TwoFactorAuthenticationManager twoFactorAuthenticationManager;
+
+ @Mock
+ private ProfileEntityCacheManager profileEntityCacheManager;
+
+ @Mock
+ private RecordEmailSender recordEmailSender;
+
+ @Mock
+ private EmailManagerReadOnly emailManagerReadOnly;
+
+ @Spy
+ @InjectMocks
+ private RecoveryPhoneSigninController controller;
+
+ private final MockHttpServletRequest request = new MockHttpServletRequest();
+
+ @Rule
+ public TogglzRule togglzRule = TogglzRule.allDisabled(Features.class);
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ request.setRemoteAddr(REMOTE_ADDRESS);
+ doReturn(Locale.ENGLISH).when(controller).getLocale();
+ }
+
+ private static RecoveryPhoneSigninSendCodeRequest sendCodeRequest(String username) {
+ RecoveryPhoneSigninSendCodeRequest form = new RecoveryPhoneSigninSendCodeRequest();
+ form.setUsername(username);
+ form.setPassword(PASSWORD);
+ return form;
+ }
+
+ private static RecoveryPhoneSigninVerifyRequest verifyRequest(String username, String code) {
+ RecoveryPhoneSigninVerifyRequest form = new RecoveryPhoneSigninVerifyRequest();
+ form.setUsername(username);
+ form.setPassword(PASSWORD);
+ form.setVerificationCode(code);
+ return form;
+ }
+
+ private static RecoveryPhone storedRecoveryPhone() {
+ Date now = new Date();
+ return new RecoveryPhone("7890", now, now);
+ }
+
+ /** The password is right and the account is using 2FA: the recoverable state. */
+ private void passwordIsCorrectAnd2FAIsOn() {
+ when(authenticationProvider.authenticate(any(Authentication.class))).thenThrow(new VerificationCodeFor2FARequiredException());
+ }
+
+ /** The password is right but the account has nothing to recover from. */
+ private void passwordIsCorrectAnd2FAIsOff() {
+ when(authenticationProvider.authenticate(any(Authentication.class)))
+ .thenReturn(new UsernamePasswordAuthenticationToken(ORCID, PASSWORD, Collections.emptyList()));
+ }
+
+ private void passwordIsWrong() {
+ when(authenticationProvider.authenticate(any(Authentication.class))).thenThrow(new BadCredentialsException("Invalid username or password"));
+ }
+
+ /** The mask and the dates come from one read, the number from the other. */
+ private void aRecoveryPhoneIsStored() {
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone());
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(STORED_NUMBER);
+ }
+
+ @Test
+ public void testSendCodeIsInertWhenTheFeatureIsOff() {
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.FEATURE_DISABLED, response.getErrorCode());
+ verify(authenticationProvider, never()).authenticate(any(Authentication.class));
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testVerifyIsInertWhenTheFeatureIsOff() {
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.FEATURE_DISABLED, response.getErrorCode());
+ verify(authenticationProvider, never()).authenticate(any(Authentication.class));
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ /**
+ * A wrong password has to reach the authentication provider, because that is
+ * what counts it toward the sign in lockout (R3.2).
+ */
+ @Test
+ public void testAWrongPasswordIsCheckedThroughTheAuthenticationProviderAndSendsNoCode() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsWrong();
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, response.getErrorCode());
+ assertNull(response.getMaskedRecoveryPhoneNumber());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ }
+
+ /**
+ * The token carries the credentials and the caller's address, and no 2FA
+ * codes at all, which is what makes the provider answer "2FA required"
+ * rather than trying to verify something.
+ */
+ @Test
+ public void testTheAuthenticationTokenCarriesNo2FACodes() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsWrong();
+
+ controller.sendCode(request, sendCodeRequest(ORCID));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Authentication.class);
+ verify(authenticationProvider).authenticate(captor.capture());
+ Authentication token = captor.getValue();
+ assertEquals(ORCID, token.getName());
+ assertEquals(PASSWORD, token.getCredentials());
+ MFAWebAuthenticationDetails details = (MFAWebAuthenticationDetails) token.getDetails();
+ assertNull(details.getVerificationCode());
+ assertNull(details.getRecoveryCode());
+ assertEquals(REMOTE_ADDRESS, details.getRemoteAddress());
+ }
+
+ @Test
+ public void testSendCodeRefusesAnAccountThatIsNotUsing2FA() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOff();
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.TWO_FACTOR_DISABLED, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeSaysSoWhenNoNumberIsStored() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(null);
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.NO_RECOVERY_PHONE, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ // Saying "no recovery phone" costs no decryption
+ verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString());
+ }
+
+ /**
+ * The row says there is a number but the number cannot be read: the caller
+ * is told there is none rather than being texted at null.
+ */
+ @Test
+ public void testSendCodeSaysSoWhenTheStoredNumberCannotBeRead() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone());
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(null);
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.NO_RECOVERY_PHONE, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ /**
+ * The number is read from the record, never taken from the request, and only
+ * its last four digits come back.
+ */
+ @Test
+ public void testSendCodeTextsTheStoredNumberAndReturnsTheMask() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertTrue(response.isSuccess());
+ assertNull(response.getErrorCode());
+ assertEquals(30, response.getResendAfterSeconds());
+ assertEquals("***********7890", response.getMaskedRecoveryPhoneNumber());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(RecoveryPhoneSendCodeRequest.class);
+ verify(recoveryPhoneVerificationService).sendCode(eq(ORCID), captor.capture());
+ assertEquals(STORED_NUMBER, captor.getValue().getPhoneNumber());
+ assertEquals("en", captor.getValue().getLocale());
+ verify(recoveryPhoneManager).getDecryptedPhoneNumber(ORCID);
+ }
+
+ @Test
+ public void testSendCodePassesThroughAThrottledResend() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.failure(RecoveryPhoneVerificationService.RESEND_TOO_SOON, 12));
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(ORCID));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneVerificationService.RESEND_TOO_SOON, response.getErrorCode());
+ assertEquals(12, response.getResendAfterSeconds());
+ assertEquals("***********7890", response.getMaskedRecoveryPhoneNumber());
+ }
+
+ /** An email address is as good a username here as an iD is. */
+ @Test
+ public void testAnEmailAddressResolvesToTheRecord() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(emailManagerReadOnly.findOrcidIdByEmail(EMAIL)).thenReturn(ORCID);
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(EMAIL));
+
+ assertTrue(response.isSuccess());
+ verify(emailManagerReadOnly).findOrcidIdByEmail(EMAIL);
+ verify(recoveryPhoneManager).getRecoveryPhone(ORCID);
+ verify(recoveryPhoneVerificationService).sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testAnOrcidIdUsernameIsNotLookedUpAsAnEmail() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+
+ controller.sendCode(request, sendCodeRequest(ORCID));
+
+ verify(emailManagerReadOnly, never()).findOrcidIdByEmail(anyString());
+ }
+
+ @Test
+ public void testVerifyRejectsAWrongPasswordWithoutDisabling2FA() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsWrong();
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, response.getErrorCode());
+ assertNull(response.getOrcid());
+ verify(recoveryPhoneVerificationService, never()).verifyCode(anyString(), anyString(), anyString());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ @Test
+ public void testVerifySaysSoWhenNoNumberIsStored() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(null);
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertEquals(RecoveryPhoneSigninController.NO_RECOVERY_PHONE, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).verifyCode(anyString(), anyString(), anyString());
+ verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString());
+ }
+
+ @Test
+ public void testVerifySaysSoWhenTheStoredNumberCannotBeRead() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone());
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(null);
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneSigninController.NO_RECOVERY_PHONE, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).verifyCode(anyString(), anyString(), anyString());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ /** A wrong code changes nothing at all about the account. */
+ @Test
+ public void testVerifyReportsAWrongCodeAndLeaves2FAEnabled() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, STORED_NUMBER, "000000"))
+ .thenReturn(RecoveryPhoneVerificationService.INVALID_CODE);
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "000000"));
+
+ assertFalse(response.isSuccess());
+ assertEquals(RecoveryPhoneVerificationService.INVALID_CODE, response.getErrorCode());
+ assertNull(response.getOrcid());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ verify(recordEmailSender, never()).send2FADisabledEmail(anyString());
+ verify(profileEntityCacheManager, never()).remove(anyString());
+ }
+
+ @Test
+ public void testVerifyPassesThroughAnExhaustedAttemptCount() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, STORED_NUMBER, "000000"))
+ .thenReturn(RecoveryPhoneVerificationService.TOO_MANY_ATTEMPTS);
+
+ assertEquals(RecoveryPhoneVerificationService.TOO_MANY_ATTEMPTS,
+ controller.verify(request, verifyRequest(ORCID, "000000")).getErrorCode());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ /**
+ * The good code disables 2FA, evicts the cached profile and only then tells
+ * the user by email: the sign in the browser replays next must not meet a
+ * cached using2FA=true, so the eviction cannot sit behind anything that can
+ * fail (R3.5).
+ */
+ @Test
+ public void testVerifyDisables2FAAndEvictsTheCachedProfileBeforeTheEmail() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, STORED_NUMBER, "123456")).thenReturn(null);
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertTrue(response.isSuccess());
+ assertNull(response.getErrorCode());
+ assertEquals(ORCID, response.getOrcid());
+
+ InOrder inOrder = inOrder(twoFactorAuthenticationManager, profileEntityCacheManager, recordEmailSender);
+ inOrder.verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID);
+ inOrder.verify(profileEntityCacheManager).remove(ORCID);
+ inOrder.verify(recordEmailSender).send2FADisabledEmail(ORCID);
+ }
+
+ /**
+ * The disable is irreversible, so a failing notification must not report the
+ * operation as failed: 2FA is off, the number and the backup codes are gone,
+ * and the browser's next sign in has to find the cache already evicted.
+ */
+ @Test
+ public void testAFailing2FADisabledEmailStillLeavesTheRecoverySuccessful() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, STORED_NUMBER, "123456")).thenReturn(null);
+ doThrow(new RuntimeException("mail server is down")).when(recordEmailSender).send2FADisabledEmail(ORCID);
+
+ RecoveryPhoneSigninVerifyResponse response = controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ assertTrue(response.isSuccess());
+ assertNull(response.getErrorCode());
+ assertEquals(ORCID, response.getOrcid());
+ verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID);
+ verify(profileEntityCacheManager).remove(ORCID);
+ }
+
+ /** The code is checked against the stored number, not one the caller sent. */
+ @Test
+ public void testVerifyChecksTheCodeAgainstTheStoredNumber() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ aRecoveryPhoneIsStored();
+ when(recoveryPhoneVerificationService.verifyCode(anyString(), anyString(), anyString())).thenReturn(null);
+
+ controller.verify(request, verifyRequest(ORCID, "123456"));
+
+ verify(recoveryPhoneVerificationService).verifyCode(ORCID, STORED_NUMBER, "123456");
+ }
+
+ @Test
+ public void testBlankCredentialsNeverReachTheAuthenticationProvider() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ RecoveryPhoneSigninSendCodeRequest form = new RecoveryPhoneSigninSendCodeRequest();
+ form.setUsername(ORCID);
+ form.setPassword("");
+
+ assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, controller.sendCode(request, form).getErrorCode());
+ verify(authenticationProvider, never()).authenticate(any(Authentication.class));
+ }
+
+ /**
+ * The credential check is bound to the provider bean and not to the
+ * "authenticationManager" ProviderManager, which publishes an authentication
+ * success event: on an account with 2FA off that event would record a sign
+ * in that never happened, and run every listener on it, for a caller who
+ * only posted a password to a recovery endpoint (R3.2).
+ */
+ @Test
+ public void testTheCredentialCheckIsBoundToTheProviderAndNotToTheManager() throws Exception {
+ Field field = RecoveryPhoneSigninController.class.getDeclaredField("authenticationProvider");
+ assertEquals(AuthenticationProvider.class, field.getType());
+ assertEquals("authenticationProvider", field.getAnnotation(Resource.class).name());
+ for (Field declared : RecoveryPhoneSigninController.class.getDeclaredFields()) {
+ assertFalse("No authentication manager may be injected here", AuthenticationManager.class.isAssignableFrom(declared.getType()));
+ }
+ }
+
+ @Test
+ public void testAUsernameThatResolvesToNoRecordIsACredentialFailure() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ passwordIsCorrectAnd2FAIsOn();
+ when(emailManagerReadOnly.findOrcidIdByEmail(EMAIL)).thenReturn(null);
+
+ RecoveryPhoneSigninSendCodeResponse response = controller.sendCode(request, sendCodeRequest(EMAIL));
+
+ assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, response.getErrorCode());
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ }
+}
diff --git a/orcid-web/src/test/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationControllerTest.java b/orcid-web/src/test/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationControllerTest.java
index 8419c86316a..9789097711a 100644
--- a/orcid-web/src/test/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationControllerTest.java
+++ b/orcid-web/src/test/java/org/orcid/frontend/web/controllers/TwoFactorAuthenticationControllerTest.java
@@ -1,7 +1,9 @@
package org.orcid.frontend.web.controllers;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -9,16 +11,30 @@
import org.orcid.core.manager.BackupCodeManager;
import org.orcid.core.manager.EncryptionManager;
import org.orcid.core.manager.ProfileEntityCacheManager;
+import org.orcid.core.manager.RecoveryPhone;
+import org.orcid.core.manager.RecoveryPhoneManager;
import org.orcid.core.manager.TwoFactorAuthenticationManager;
+import org.orcid.core.manager.v3.ProfileEntityManager;
import org.orcid.frontend.email.RecordEmailSender;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeVerifyRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse;
+import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService;
import org.orcid.persistence.jpa.entities.ProfileEntity;
+import org.orcid.core.togglz.Features;
import org.orcid.pojo.*;
+import org.springframework.mock.web.MockHttpSession;
import org.springframework.web.servlet.ModelAndView;
+import org.togglz.junit.TogglzRule;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
@@ -34,9 +50,18 @@ public class TwoFactorAuthenticationControllerTest {
@Mock
private ProfileEntityCacheManager profileEntityCacheManager;
+ @Mock
+ private ProfileEntityManager profileEntityManager;
+
@Mock
private BackupCodeManager backupCodeManager;
+ @Mock
+ private RecoveryPhoneManager recoveryPhoneManager;
+
+ @Mock
+ private RecoveryPhoneVerificationService recoveryPhoneVerificationService;
+
@Mock
private RecordEmailSender recordEmailSender;
@@ -53,13 +78,22 @@ public class TwoFactorAuthenticationControllerTest {
@Mock
private HttpServletResponse response;
+ private final MockHttpSession session = new MockHttpSession();
+
+ @Rule
+ public TogglzRule togglzRule = TogglzRule.allDisabled(Features.class);
+
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
+ when(request.getSession()).thenReturn(session);
doReturn(ORCID).when(controller).getCurrentUserOrcid();
+ // The session is the account owner's own unless a test says otherwise
+ doReturn(ORCID).when(controller).getRealUserOrcid();
doReturn("redirectUrl").when(controller).calculateRedirectUrl(anyString());
doReturn("redirectUrl").when(controller).calculateRedirectUrl(any(HttpServletRequest.class), any(HttpServletResponse.class), anyBoolean());
doAnswer(invocation -> invocation.getArgument(0)).when(controller).getMessage(anyString(), any());
+ doReturn(Locale.ENGLISH).when(controller).getLocale();
}
@Test
@@ -169,7 +203,7 @@ public void testValidateVerificationCode_Valid() {
List backupCodes = Arrays.asList("code1", "code2");
when(twoFactorAuthenticationManager.enable2FA(ORCID)).thenReturn(backupCodes);
- TwoFactorAuthRegistration result = controller.validateVerificationCode(registration);
+ TwoFactorAuthRegistration result = controller.validateVerificationCode(request, registration);
assertTrue(result.isValid());
assertEquals(backupCodes, result.getBackupCodes());
}
@@ -180,7 +214,7 @@ public void testValidateVerificationCode_Invalid() {
registration.setVerificationCode("654321");
when(twoFactorAuthenticationManager.verificationCodeIsValid("654321", ORCID)).thenReturn(false);
- TwoFactorAuthRegistration result = controller.validateVerificationCode(registration);
+ TwoFactorAuthRegistration result = controller.validateVerificationCode(request, registration);
assertFalse(result.isValid());
assertNull(result.getBackupCodes());
}
@@ -234,4 +268,614 @@ public void testPost2FAVerificationCode_Invalid() {
assertEquals(1, result.getErrors().size());
assertEquals("2FA.verificationCode.invalid", result.getErrors().get(0));
}
+
+ // Recovery phone number
+
+ private void enableRecoveryPhoneFeature() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true);
+ }
+
+ /** Both flags: the interstitial carries a second one of its own (R7.4). */
+ private void enableInterstitialFeature() {
+ enableRecoveryPhoneFeature();
+ togglzRule.enable(Features.LOGIN_RECOVERY_PHONE_INTERSTITIAL);
+ }
+
+ private void elevateSession() {
+ session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis());
+ }
+
+ private static RecoveryPhoneSendCodeRequest sendCodeRequest() {
+ RecoveryPhoneSendCodeRequest form = new RecoveryPhoneSendCodeRequest();
+ form.setPhoneNumber("+441234567890");
+ return form;
+ }
+
+ private static RecoveryPhoneSaveRequest saveRequest() {
+ RecoveryPhoneSaveRequest form = new RecoveryPhoneSaveRequest();
+ form.setPhoneNumber("+441234567890");
+ form.setVerificationCode("123456");
+ return form;
+ }
+
+ /**
+ * What the record carries for a stored number: the mask and the dates.
+ *
+ * Built with the real constructor rather than mocked. RecoveryPhone is an
+ * immutable value object, so there is nothing to fake; and a helper that
+ * stubs a mock cannot be called from inside another when(...), which is
+ * where every call below sits. Mockito rejects that with
+ * UnfinishedStubbingException, and its message points at the outer stub
+ * rather than at the nested one.
+ */
+ private static RecoveryPhone storedRecoveryPhone(String lastFour, java.util.Date dateCreated, java.util.Date lastModified) {
+ return new RecoveryPhone(lastFour, dateCreated, lastModified);
+ }
+
+ /**
+ * Puts a profile in the cache and records a last login of the given age in
+ * the database, or no last login at all when millisAgo is null.
+ *
+ * The cached profile deliberately carries a sign in from a day ago, out of
+ * every window: the cache is loaded while the user is being authenticated,
+ * before the success handler writes last_login, so what it holds is always
+ * the previous sign in. Only the database read may be believed.
+ */
+ private ProfileEntity profileWithLastLogin(Long millisAgo) {
+ ProfileEntity profile = new ProfileEntity();
+ profile.setEncryptedPassword("hashed");
+ profile.setLastLogin(new java.util.Date(System.currentTimeMillis() - (24 * 60 * 60 * 1000L)));
+ when(profileEntityCacheManager.retrieve(ORCID)).thenReturn(profile);
+ if (millisAgo != null) {
+ when(profileEntityManager.getLastLogin(ORCID)).thenReturn(new java.util.Date(System.currentTimeMillis() - millisAgo));
+ }
+ return profile;
+ }
+
+ private static RecoveryPhoneChallengeVerifyRequest challengeVerifyRequest(String password, String verificationCode) {
+ RecoveryPhoneChallengeVerifyRequest form = new RecoveryPhoneChallengeVerifyRequest();
+ form.setPassword(password);
+ form.setVerificationCode(verificationCode);
+ return form;
+ }
+
+ @Test
+ public void testStatusOmitsRecoveryPhoneWhenFeatureIsOff() {
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true);
+
+ TwoFactorAuthStatus status = controller.get2FAStatus();
+
+ assertNull(status.getMaskedRecoveryPhoneNumber());
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ }
+
+ @Test
+ public void testStatusMasksTheRecoveryPhoneNumber() {
+ enableRecoveryPhoneFeature();
+ java.util.Date created = new java.util.Date();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", created, created));
+
+ TwoFactorAuthStatus status = controller.get2FAStatus();
+
+ assertEquals("***********7890", status.getMaskedRecoveryPhoneNumber());
+ assertNotNull(status.getRecoveryPhoneCreationDate());
+ assertFalse(status.isRecoveryPhoneModified());
+ }
+
+ @Test
+ public void testStatusReportsANumberChangedSecondsAfterItWasAddedAsModified() {
+ // A user who adds a number and corrects it straight away has changed it; the panel must date
+ // the row from the change, not from the first entry.
+ enableRecoveryPhoneFeature();
+ java.util.Date created = new java.util.Date(1_600_000_000_000L);
+ java.util.Date modified = new java.util.Date(1_600_000_000_000L + (5 * 1000L));
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", created, modified));
+
+ assertTrue(controller.get2FAStatus().isRecoveryPhoneModified());
+ }
+
+ @Test
+ public void testStatusReportsANumberThatHasBeenChangedAsModified() {
+ enableRecoveryPhoneFeature();
+ java.util.Date created = new java.util.Date(1_600_000_000_000L);
+ java.util.Date modified = new java.util.Date(1_600_000_000_000L + (10 * 60 * 1000L));
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", created, modified));
+
+ assertTrue(controller.get2FAStatus().isRecoveryPhoneModified());
+ }
+
+ @Test
+ public void testAuthChallengeRejectsAWrongPassword() {
+ enableRecoveryPhoneFeature();
+ ProfileEntity profile = new ProfileEntity();
+ profile.setEncryptedPassword("hashed");
+ when(profileEntityCacheManager.retrieve(ORCID)).thenReturn(profile);
+ when(encryptionManager.hashMatches("nope", "hashed")).thenReturn(false);
+
+ AuthChallenge form = new AuthChallenge();
+ form.setPassword("nope");
+ AuthChallenge result = controller.verifyRecoveryPhoneAuthChallenge(request, form);
+
+ assertTrue(result.isInvalidPassword());
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ @Test
+ public void testAuthChallengeElevatesTheSessionOnSuccess() {
+ enableRecoveryPhoneFeature();
+ ProfileEntity profile = new ProfileEntity();
+ profile.setEncryptedPassword("hashed");
+ when(profileEntityCacheManager.retrieve(ORCID)).thenReturn(profile);
+ when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true);
+ when(twoFactorAuthenticationManager.validateTwoFactorAuthForm(eq(ORCID), any(AuthChallenge.class))).thenReturn(true);
+
+ AuthChallenge form = new AuthChallenge();
+ form.setPassword("correct");
+ AuthChallenge result = controller.verifyRecoveryPhoneAuthChallenge(request, form);
+
+ assertTrue(result.isSuccess());
+ assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ @Test
+ public void testSendCodeNeedsAPassedChallenge() {
+ enableRecoveryPhoneFeature();
+
+ RecoveryPhoneSendCodeResponse response = controller.sendRecoveryPhoneCode(request, sendCodeRequest());
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, response.getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeRefusesAnExpiredChallenge() {
+ enableRecoveryPhoneFeature();
+ session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis() - (16 * 60 * 1000L));
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, sendCodeRequest()).getErrorCode());
+ }
+
+ @Test
+ public void testSendCodeIsRefusedWhenTheFeatureIsOff() {
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true);
+ elevateSession();
+
+ assertEquals(TwoFactorAuthenticationController.FEATURE_DISABLED,
+ controller.sendRecoveryPhoneCode(request, sendCodeRequest()).getErrorCode());
+ }
+
+ @Test
+ public void testSendCodeIsRefusedWhen2FAIsOff() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(false);
+ elevateSession();
+
+ assertEquals(TwoFactorAuthenticationController.TWO_FACTOR_DISABLED,
+ controller.sendRecoveryPhoneCode(request, sendCodeRequest()).getErrorCode());
+ }
+
+ @Test
+ public void testSendCodeDelegatesOnceElevated() {
+ enableRecoveryPhoneFeature();
+ elevateSession();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+
+ RecoveryPhoneSendCodeResponse response = controller.sendRecoveryPhoneCode(request, sendCodeRequest());
+
+ assertTrue(response.isSuccess());
+ assertEquals(30, response.getResendAfterSeconds());
+ }
+
+ @Test
+ public void testSaveReportsAFailedCodeAndKeepsTheElevation() {
+ enableRecoveryPhoneFeature();
+ elevateSession();
+ when(recoveryPhoneVerificationService.verifyCode(eq(ORCID), anyString(), anyString())).thenReturn("INVALID_CODE");
+
+ RecoveryPhoneSaveResponse response = controller.saveRecoveryPhone(request, saveRequest());
+
+ assertFalse(response.isSuccess());
+ assertEquals("INVALID_CODE", response.getErrorCode());
+ verify(recoveryPhoneManager, never()).saveRecoveryPhone(anyString(), anyString());
+ assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ @Test
+ public void testSaveStoresTheNumberAndClearsTheElevation() {
+ enableRecoveryPhoneFeature();
+ elevateSession();
+ java.util.Date now = new java.util.Date();
+ when(recoveryPhoneVerificationService.verifyCode(eq(ORCID), anyString(), anyString())).thenReturn(null);
+ when(recoveryPhoneVerificationService.normalize("+441234567890")).thenReturn("+441234567890");
+ when(recoveryPhoneManager.saveRecoveryPhone(ORCID, "+441234567890")).thenReturn(storedRecoveryPhone("7890", now, now));
+
+ RecoveryPhoneSaveResponse response = controller.saveRecoveryPhone(request, saveRequest());
+
+ assertTrue(response.isSuccess());
+ assertEquals("***********7890", response.getMaskedRecoveryPhoneNumber());
+ assertFalse(response.isRecoveryPhoneModified());
+ verify(recoveryPhoneManager).saveRecoveryPhone(ORCID, "+441234567890");
+ // Answered from the row the save wrote. A read after the write would go to the
+ // read-only pool, a replica on a deployed environment, and could still carry the
+ // previous number or none at all
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ // Onboarding: the live 2FA code posted to register.json stands in for a
+ // challenge on the recovery phone step that follows it (R2.6)
+
+ @Test
+ public void testOnboardingElevatesTheSessionForTheRecoveryPhoneStep() {
+ TwoFactorAuthRegistration registration = new TwoFactorAuthRegistration();
+ registration.setVerificationCode("123456");
+ when(twoFactorAuthenticationManager.verificationCodeIsValid("123456", ORCID)).thenReturn(true);
+ when(twoFactorAuthenticationManager.enable2FA(ORCID)).thenReturn(Arrays.asList("code1", "code2"));
+
+ assertTrue(controller.validateVerificationCode(request, registration).isValid());
+ assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+
+ // and that elevation is what carries step 2 of setup, with no second
+ // challenge in between
+ enableRecoveryPhoneFeature();
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_ONBOARDING);
+
+ assertTrue(controller.sendRecoveryPhoneCode(request, form).isSuccess());
+ }
+
+ @Test
+ public void testOnboardingDoesNotElevateOnAnInvalidCode() {
+ TwoFactorAuthRegistration registration = new TwoFactorAuthRegistration();
+ registration.setVerificationCode("654321");
+ when(twoFactorAuthenticationManager.verificationCodeIsValid("654321", ORCID)).thenReturn(false);
+
+ assertFalse(controller.validateVerificationCode(request, registration).isValid());
+
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ verify(twoFactorAuthenticationManager, never()).enable2FA(anyString());
+ }
+
+ @Test
+ public void testRegisterDoesNotElevateWhen2FAWasAlreadyOn() {
+ // The elevation belongs to step 1 of setup. On an account that is
+ // already using 2FA there is no step 2 to carry, and a time based code
+ // there must not stand in for the password challenge that guards
+ // changing a recovery number (R2.6)
+ enableRecoveryPhoneFeature();
+ TwoFactorAuthRegistration registration = new TwoFactorAuthRegistration();
+ registration.setVerificationCode("123456");
+ when(twoFactorAuthenticationManager.verificationCodeIsValid("123456", ORCID)).thenReturn(true);
+ when(twoFactorAuthenticationManager.enable2FA(ORCID)).thenReturn(Arrays.asList("code1", "code2"));
+
+ assertTrue(controller.validateVerificationCode(request, registration).isValid());
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+
+ // so the recovery phone endpoints still ask for the challenge
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_ONBOARDING);
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ }
+
+ // Interstitial: a recent sign in stands in for a challenge, since the user
+ // completed 2FA to reach it and it has nowhere to put one (R6.3). The
+ // context is a claim the client makes, so every condition the interstitial
+ // is shown under is checked again here (R6.1, R7.4)
+
+ @Test
+ public void testSendCodeAcceptsAFreshLastLoginFromTheInterstitial() {
+ enableInterstitialFeature();
+ profileWithLastLogin(60 * 1000L);
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ RecoveryPhoneSendCodeResponse response = controller.sendRecoveryPhoneCode(request, form);
+
+ assertTrue(response.isSuccess());
+ // The cached profile says a day ago and the database says a minute ago:
+ // only the database has the sign in the user has just completed (R6.3)
+ verify(profileEntityManager).getLastLogin(ORCID);
+ // No challenge was passed: the sign in is what let this request in
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ @Test
+ public void testSendCodeRefusesAStaleLastLoginFromTheInterstitial() {
+ enableInterstitialFeature();
+ profileWithLastLogin(16 * 60 * 1000L);
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeRefusesTheInterstitialWithNoRecordedLastLogin() {
+ enableInterstitialFeature();
+ profileWithLastLogin(null);
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeRefusesTheInterstitialWhenItsOwnFeatureIsOff() {
+ // TWO_FACTOR_RECOVERY_PHONE on its own is not enough: the relaxed path
+ // belongs to the interstitial and goes away with its flag (R7.4)
+ enableRecoveryPhoneFeature();
+ profileWithLastLogin(60 * 1000L);
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeRefusesTheInterstitialWhenANumberIsAlreadyStored() {
+ // The interstitial is only offered when there is no number, so it can
+ // add a first one and never replace one. Replacing without the password
+ // would let a stolen session point the number at a phone it holds, and
+ // that number turns 2FA off at the next sign in (R6.1)
+ enableInterstitialFeature();
+ profileWithLastLogin(60 * 1000L);
+ java.util.Date now = new java.util.Date();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", now, now));
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSaveRefusesTheInterstitialWhenANumberIsAlreadyStored() {
+ enableInterstitialFeature();
+ profileWithLastLogin(60 * 1000L);
+ java.util.Date now = new java.util.Date();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", now, now));
+ RecoveryPhoneSaveRequest form = saveRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, controller.saveRecoveryPhone(request, form).getErrorCode());
+ verify(recoveryPhoneManager, never()).saveRecoveryPhone(anyString(), anyString());
+ }
+
+ @Test
+ public void testSendCodeRefusesTheInterstitialForAnImpersonatedSession() {
+ // A delegate or an admin switched into the record: their sign in is
+ // not the account owner's, and the interstitial is never shown to
+ // them in the first place (R6.1)
+ enableInterstitialFeature();
+ profileWithLastLogin(60 * 1000L);
+ doReturn("0000-0000-0000-0002").when(controller).getRealUserOrcid();
+ RecoveryPhoneSendCodeRequest form = sendCodeRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, form).getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSendCodeStillDemandsTheChallengeOutsideTheInterstitial() {
+ enableInterstitialFeature();
+ // A sign in this fresh would let the interstitial through
+ profileWithLastLogin(60 * 1000L);
+
+ RecoveryPhoneSendCodeRequest settings = sendCodeRequest();
+ settings.setContext(TwoFactorAuthenticationController.CONTEXT_SETTINGS);
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, settings).getErrorCode());
+
+ // An unstated context is settings, the strictest of the three
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, sendCodeRequest()).getErrorCode());
+
+ // Onboarding has its own elevation and does not ride on the sign in
+ RecoveryPhoneSendCodeRequest onboarding = sendCodeRequest();
+ onboarding.setContext(TwoFactorAuthenticationController.CONTEXT_ONBOARDING);
+ assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED,
+ controller.sendRecoveryPhoneCode(request, onboarding).getErrorCode());
+
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testSaveAcceptsAFreshLastLoginFromTheInterstitial() {
+ enableInterstitialFeature();
+ profileWithLastLogin(60 * 1000L);
+ java.util.Date now = new java.util.Date();
+ when(recoveryPhoneVerificationService.verifyCode(eq(ORCID), anyString(), anyString())).thenReturn(null);
+ when(recoveryPhoneVerificationService.normalize("+441234567890")).thenReturn("+441234567890");
+ // Nothing stored while the guard looks; the save answers with what it wrote
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(null);
+ when(recoveryPhoneManager.saveRecoveryPhone(ORCID, "+441234567890")).thenReturn(storedRecoveryPhone("7890", now, now));
+ RecoveryPhoneSaveRequest form = saveRequest();
+ form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL);
+
+ assertTrue(controller.saveRecoveryPhone(request, form).isSuccess());
+ verify(recoveryPhoneManager).saveRecoveryPhone(ORCID, "+441234567890");
+ }
+
+ // The recovery phone as the authentication challenge itself (R5)
+
+ @Test
+ public void testChallengeSendCodeUsesTheStoredNumberAndReturnsTheMask() {
+ enableRecoveryPhoneFeature();
+ java.util.Date now = new java.util.Date();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(storedRecoveryPhone("7890", now, now));
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn("+441234567890");
+ when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class)))
+ .thenReturn(RecoveryPhoneSendCodeResponse.success(30));
+
+ // No elevation on this session: this endpoint is the challenge (R5.1)
+ RecoveryPhoneChallengeSendCodeResponse response = controller.sendRecoveryPhoneChallengeCode();
+
+ assertTrue(response.isSuccess());
+ assertEquals(30, response.getResendAfterSeconds());
+ assertEquals("***********7890", response.getMaskedRecoveryPhoneNumber());
+
+ ArgumentCaptor sentRequest = ArgumentCaptor.forClass(RecoveryPhoneSendCodeRequest.class);
+ verify(recoveryPhoneVerificationService).sendCode(eq(ORCID), sentRequest.capture());
+ assertEquals("+441234567890", sentRequest.getValue().getPhoneNumber());
+ // The locale is the server's, not something the client can set here
+ assertEquals("en", sentRequest.getValue().getLocale());
+ }
+
+ @Test
+ public void testChallengeSendCodeIsRefusedWhenNoNumberIsStored() {
+ enableRecoveryPhoneFeature();
+ when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(null);
+
+ RecoveryPhoneChallengeSendCodeResponse response = controller.sendRecoveryPhoneChallengeCode();
+
+ assertFalse(response.isSuccess());
+ assertEquals(TwoFactorAuthenticationController.NO_RECOVERY_PHONE, response.getErrorCode());
+ assertNull(response.getMaskedRecoveryPhoneNumber());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testChallengeSendCodeIsRefusedWhenTheFeatureIsOff() {
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true);
+
+ RecoveryPhoneChallengeSendCodeResponse response = controller.sendRecoveryPhoneChallengeCode();
+
+ assertFalse(response.isSuccess());
+ assertEquals(TwoFactorAuthenticationController.FEATURE_DISABLED, response.getErrorCode());
+ verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testChallengeSendCodeIsRefusedWhen2FAIsOff() {
+ togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE);
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(false);
+
+ assertEquals(TwoFactorAuthenticationController.TWO_FACTOR_DISABLED,
+ controller.sendRecoveryPhoneChallengeCode().getErrorCode());
+ verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class));
+ }
+
+ @Test
+ public void testChallengeVerifyRejectsAWrongPasswordAndDisablesNothing() {
+ enableRecoveryPhoneFeature();
+ profileWithLastLogin(60 * 1000L);
+ when(encryptionManager.hashMatches("nope", "hashed")).thenReturn(false);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("nope", "123456"));
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.isInvalidPassword());
+ assertTrue(result.getErrors().contains(TwoFactorAuthenticationController.INVALID_PASSWORD));
+ // The code is never looked at, so a wrong password cannot spend its attempts
+ verify(recoveryPhoneVerificationService, never()).verifyCode(anyString(), anyString(), anyString());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ verify(recordEmailSender, never()).send2FADisabledEmail(anyString());
+ }
+
+ @Test
+ public void testChallengeVerifyRejectsAWrongCodeAndDisablesNothing() {
+ enableRecoveryPhoneFeature();
+ profileWithLastLogin(60 * 1000L);
+ when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true);
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn("+441234567890");
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, "+441234567890", "000000"))
+ .thenReturn(RecoveryPhoneVerificationService.INVALID_CODE);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("correct", "000000"));
+
+ assertFalse(result.isSuccess());
+ assertFalse(result.isInvalidPassword());
+ assertTrue(result.getErrors().contains(RecoveryPhoneVerificationService.INVALID_CODE));
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ verify(recordEmailSender, never()).send2FADisabledEmail(anyString());
+ verify(profileEntityCacheManager, never()).remove(anyString());
+ }
+
+ @Test
+ public void testChallengeVerifyIsRefusedWhenNoNumberIsStored() {
+ enableRecoveryPhoneFeature();
+ profileWithLastLogin(60 * 1000L);
+ when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true);
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(null);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("correct", "123456"));
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.getErrors().contains(TwoFactorAuthenticationController.NO_RECOVERY_PHONE));
+ verify(recoveryPhoneVerificationService, never()).verifyCode(anyString(), anyString(), anyString());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ @Test
+ public void testChallengeVerifyIsRefusedWhenTheFeatureIsOff() {
+ when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("correct", "123456"));
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.getErrors().contains(TwoFactorAuthenticationController.FEATURE_DISABLED));
+ verify(encryptionManager, never()).hashMatches(anyString(), anyString());
+ verify(twoFactorAuthenticationManager, never()).disable2FAByRecoveryPhone(anyString());
+ }
+
+ @Test
+ public void testChallengeVerifyDisables2FAOnAGoodCode() {
+ enableRecoveryPhoneFeature();
+ // An elevation from an earlier challenge has nothing left to guard
+ elevateSession();
+ profileWithLastLogin(60 * 1000L);
+ when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true);
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn("+441234567890");
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, "+441234567890", "123456")).thenReturn(null);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("correct", "123456"));
+
+ assertTrue(result.isSuccess());
+ assertTrue(result.getErrors().isEmpty());
+ // The code is checked against the stored number, never one a client sent
+ verify(recoveryPhoneVerificationService).verifyCode(ORCID, "+441234567890", "123456");
+ verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID);
+ verify(recordEmailSender).send2FADisabledEmail(ORCID);
+ verify(profileEntityCacheManager).remove(ORCID);
+ assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS"));
+ }
+
+ @Test
+ public void testChallengeVerifySucceedsWhenTheEmailCannotBeSent() {
+ // The email only reports something that has already happened and
+ // cannot be undone. Telling the user the challenge failed while their
+ // 2FA is off, and leaving the cache saying it is still on, is worse
+ // than a missing notification (R5.3)
+ enableRecoveryPhoneFeature();
+ profileWithLastLogin(60 * 1000L);
+ when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true);
+ when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn("+441234567890");
+ when(recoveryPhoneVerificationService.verifyCode(ORCID, "+441234567890", "123456")).thenReturn(null);
+ doThrow(new RuntimeException("the mail server is down")).when(recordEmailSender).send2FADisabledEmail(ORCID);
+
+ AuthChallenge result = controller.verifyRecoveryPhoneChallengeCode(request, challengeVerifyRequest("correct", "123456"));
+
+ assertTrue(result.isSuccess());
+ assertTrue(result.getErrors().isEmpty());
+ verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID);
+ verify(profileEntityCacheManager).remove(ORCID);
+ }
}