From e99722d5bee9ef1c12ea3de3d2b2734ff306398c Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 5 Oct 2026 10:44:02 -0600 Subject: [PATCH 1/6] PD-14333 tell the 2FA step whether a recovery number exists --- .../RecoveryPhoneSigninStatusRequest.java | 29 ++++++ .../RecoveryPhoneSigninStatusResponse.java | 55 +++++++++++ .../RecoveryPhoneSigninController.java | 35 +++++++ .../RecoveryPhoneSigninControllerTest.java | 95 +++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusRequest.java create mode 100644 orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusResponse.java diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusRequest.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusRequest.java new file mode 100644 index 0000000000..2c2c2a2d45 --- /dev/null +++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusRequest.java @@ -0,0 +1,29 @@ +package org.orcid.frontend.recoveryphone; + +/** + * Asks whether the account the credentials belong to has a recovery number, + * so the 2FA step of sign in knows which way out to offer (F1.2). + */ +public class RecoveryPhoneSigninStatusRequest { + + 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/RecoveryPhoneSigninStatusResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusResponse.java new file mode 100644 index 0000000000..0ffa37b776 --- /dev/null +++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneSigninStatusResponse.java @@ -0,0 +1,55 @@ +package org.orcid.frontend.recoveryphone; + +/** + * Whether the account behind the credentials has a recovery number stored + * (F1.2). It carries that one boolean and nothing else - not the mask, not the + * dates - because the screen asking only has to choose between offering a + * text and offering the help centre. + */ +public class RecoveryPhoneSigninStatusResponse { + + private boolean success; + + private String errorCode; + + private boolean hasRecoveryPhone; + + public static RecoveryPhoneSigninStatusResponse failure(String errorCode) { + RecoveryPhoneSigninStatusResponse response = new RecoveryPhoneSigninStatusResponse(); + response.setSuccess(false); + response.setErrorCode(errorCode); + return response; + } + + public static RecoveryPhoneSigninStatusResponse success(boolean hasRecoveryPhone) { + RecoveryPhoneSigninStatusResponse response = new RecoveryPhoneSigninStatusResponse(); + response.setSuccess(true); + response.setHasRecoveryPhone(hasRecoveryPhone); + 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 boolean isHasRecoveryPhone() { + return hasRecoveryPhone; + } + + public void setHasRecoveryPhone(boolean hasRecoveryPhone) { + this.hasRecoveryPhone = hasRecoveryPhone; + } + +} 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 index 0bdda04251..de0cac7cc5 100644 --- 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 @@ -17,6 +17,8 @@ import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeResponse; +import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninStatusRequest; +import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninStatusResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService; @@ -98,6 +100,39 @@ public class RecoveryPhoneSigninController extends BaseController { @Resource private RecordEmailSender recordEmailSender; + /** + * Tells the 2FA step of sign in whether the account behind the credentials + * has a recovery number, so the step offers to text one only where there is + * a number to text, and the help centre everywhere else (F1.2). + * + * The answer goes only to a caller holding the password: the check is the + * one sendCode makes, through the same provider, so a wrong password here + * counts toward the sign in lockout like any other. The screen asks right + * after the sign in has accepted that password and said 2FA is on, so all + * this adds for the password holder is whether a number is stored. + */ + @RequestMapping(value = "/status.json", method = RequestMethod.POST) + public @ResponseBody RecoveryPhoneSigninStatusResponse status(HttpServletRequest request, + @RequestBody RecoveryPhoneSigninStatusRequest form) { + if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) { + return RecoveryPhoneSigninStatusResponse.failure(FEATURE_DISABLED); + } + + String credentialsFailure = verifyCredentials(request, form.getUsername(), form.getPassword()); + if (credentialsFailure != null) { + return RecoveryPhoneSigninStatusResponse.failure(credentialsFailure); + } + + String orcid = resolveOrcid(form.getUsername()); + if (orcid == null) { + return RecoveryPhoneSigninStatusResponse.failure(BAD_CREDENTIALS); + } + + // The read that costs no decryption: whether there is a number is all + // this answers + return RecoveryPhoneSigninStatusResponse.success(recoveryPhoneManager.getRecoveryPhone(orcid) != null); + } + /** * Sends a fresh code to the recovery number stored on the account the given * credentials belong to (R3.2). 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 index 50755d50dd..4829654197 100644 --- 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 @@ -23,6 +23,8 @@ import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninSendCodeResponse; +import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninStatusRequest; +import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninStatusResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSigninVerifyResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneVerificationService; @@ -501,4 +503,97 @@ public void testAUsernameThatResolvesToNoRecordIsACredentialFailure() { assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, response.getErrorCode()); verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString()); } + // Which way out the 2FA step offers (F1.2): the recovery number where one + // is stored, the help centre everywhere else + + private static RecoveryPhoneSigninStatusRequest statusRequest(String username) { + RecoveryPhoneSigninStatusRequest form = new RecoveryPhoneSigninStatusRequest(); + form.setUsername(username); + form.setPassword(PASSWORD); + return form; + } + + @Test + public void testStatusIsInertWhenTheFeatureIsOff() { + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(ORCID)); + + assertFalse(response.isSuccess()); + assertEquals(RecoveryPhoneSigninController.FEATURE_DISABLED, response.getErrorCode()); + verify(authenticationProvider, never()).authenticate(any(Authentication.class)); + verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString()); + } + + /** + * The answer goes to the password holder only, and a wrong password goes + * through the provider, which is what counts it toward the lockout (R3.2). + */ + @Test + public void testStatusChecksAWrongPasswordThroughTheProviderAndAnswersNothing() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + passwordIsWrong(); + + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(ORCID)); + + assertFalse(response.isSuccess()); + assertEquals(RecoveryPhoneSigninController.BAD_CREDENTIALS, response.getErrorCode()); + assertFalse(response.isHasRecoveryPhone()); + verify(authenticationProvider).authenticate(any(Authentication.class)); + verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString()); + } + + @Test + public void testStatusRefusesAnAccountThatIsNotUsing2FA() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + passwordIsCorrectAnd2FAIsOff(); + + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(ORCID)); + + assertFalse(response.isSuccess()); + assertEquals(RecoveryPhoneSigninController.TWO_FACTOR_DISABLED, response.getErrorCode()); + verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString()); + } + + @Test + public void testStatusSaysSoWhenANumberIsStored() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + passwordIsCorrectAnd2FAIsOn(); + aRecoveryPhoneIsStored(); + + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(ORCID)); + + assertTrue(response.isSuccess()); + assertNull(response.getErrorCode()); + assertTrue(response.isHasRecoveryPhone()); + // Asking costs no decryption and sends nothing + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class)); + } + + @Test + public void testStatusSaysSoWhenNoNumberIsStored() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + passwordIsCorrectAnd2FAIsOn(); + when(recoveryPhoneManager.getRecoveryPhone(ORCID)).thenReturn(null); + + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(ORCID)); + + assertTrue(response.isSuccess()); + assertFalse(response.isHasRecoveryPhone()); + verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class)); + } + + /** An email signs in as well as an iD, so it has to reach the same record. */ + @Test + public void testStatusResolvesAnEmailToTheRecord() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + passwordIsCorrectAnd2FAIsOn(); + when(emailManagerReadOnly.findOrcidIdByEmail(EMAIL)).thenReturn(ORCID); + aRecoveryPhoneIsStored(); + + RecoveryPhoneSigninStatusResponse response = controller.status(request, statusRequest(EMAIL)); + + assertTrue(response.isSuccess()); + assertTrue(response.isHasRecoveryPhone()); + verify(recoveryPhoneManager).getRecoveryPhone(ORCID); + } } From 6d98d4186dc96ca8475fa98d9b100e4e2eac6715 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 5 Oct 2026 10:45:09 -0600 Subject: [PATCH 2/6] PD-14423 accept the recovery phone interstitial on either flow's flag --- .../java/org/orcid/core/togglz/Features.java | 5 ++- .../TwoFactorAuthenticationController.java | 8 +++- ...TwoFactorAuthenticationControllerTest.java | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) 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 16536948ff..7e929cc6f8 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 @@ -119,7 +119,10 @@ public enum Features implements Feature { TWO_FACTOR_RECOVERY_PHONE, @Label("Login - recovery phone interstitial") - LOGIN_RECOVERY_PHONE_INTERSTITIAL; + LOGIN_RECOVERY_PHONE_INTERSTITIAL, + + @Label("OAUTH - recovery phone interstitial") + OAUTH_RECOVERY_PHONE_INTERSTITIAL; public boolean isActive() { return FeatureContext.getFeatureManager().isActive(this); } 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 922e456ce4..3085eee10c 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 @@ -371,8 +371,12 @@ 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) + if (!Features.LOGIN_RECOVERY_PHONE_INTERSTITIAL.isActive() && !Features.OAUTH_RECOVERY_PHONE_INTERSTITIAL.isActive()) { + // The relaxed path dies with the interstitial that justifies it + // (R7.4). Either flow's flag keeps it open: which flow the form is + // in is as much a claim of the client's as the context is, so the + // server cannot tell the two apart, and both rest on the same + // proof - a sign in completed inside the window (F2.2) return false; } if (recoveryPhoneManager.getRecoveryPhone(orcid) != null) { 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 ca474b63fd..9933ee2c19 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 @@ -957,4 +957,47 @@ public void testChallengeVerifySucceedsWhenTheEmailCannotBeSent() { verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID); verify(profileEntityCacheManager).remove(ORCID); } + + // The OAuth interstitial carries a flag of its own (F2.1, PD-14423), and + // either flow's flag keeps the relaxed path open (F2.2) + + @Test + public void testSendCodeAcceptsTheInterstitialWithOnlyTheOauthFlagOn() { + enableRecoveryPhoneFeature(); + togglzRule.enable(Features.OAUTH_RECOVERY_PHONE_INTERSTITIAL); + profileWithLastLogin(60 * 1000L); + when(recoveryPhoneVerificationService.sendCode(eq(ORCID), any(RecoveryPhoneSendCodeRequest.class))) + .thenReturn(RecoveryPhoneSendCodeResponse.success(30)); + RecoveryPhoneSendCodeRequest form = sendCodeRequest(); + form.setContext(TwoFactorAuthenticationController.CONTEXT_INTERSTITIAL); + + assertTrue(controller.sendRecoveryPhoneCode(request, form).isSuccess()); + } + + @Test + public void testSaveAcceptsTheInterstitialWithOnlyTheOauthFlagOn() { + enableRecoveryPhoneFeature(); + togglzRule.enable(Features.OAUTH_RECOVERY_PHONE_INTERSTITIAL); + profileWithLastLogin(60 * 1000L); + when(recoveryPhoneVerificationService.verifyCode(ORCID, "+441234567890", "123456")).thenReturn(null); + when(recoveryPhoneVerificationService.normalize("+441234567890")).thenReturn("+441234567890"); + java.util.Date now = new java.util.Date(); + 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"); + } + + @Test + public void testTheOauthFlagDoesNotRelaxTheGuardOutsideTheInterstitial() { + enableRecoveryPhoneFeature(); + togglzRule.enable(Features.OAUTH_RECOVERY_PHONE_INTERSTITIAL); + profileWithLastLogin(60 * 1000L); + + assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, + controller.sendRecoveryPhoneCode(request, sendCodeRequest()).getErrorCode()); + verify(recoveryPhoneVerificationService, never()).sendCode(anyString(), any(RecoveryPhoneSendCodeRequest.class)); + } } From f1ddf301a4073604918acd44b4a6e3a984421b58 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 5 Oct 2026 10:45:30 -0600 Subject: [PATCH 3/6] PD-14450 return the recovery number to its owner after challenge --- .../core/manager/RecoveryPhoneManager.java | 10 +- .../RecoveryPhoneNumberResponse.java | 55 +++++++++ .../TwoFactorAuthenticationController.java | 41 +++++++ ...TwoFactorAuthenticationControllerTest.java | 112 ++++++++++++++++++ 4 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneNumberResponse.java 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 index 68dc695880..63d756cc1f 100644 --- a/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java +++ b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java @@ -12,11 +12,13 @@ public interface RecoveryPhoneManager { /** * 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. + * This is the only path that decrypts the stored number. It exists for the + * flows that have to send a text to it without the user re-typing it, and + * for the one response allowed to carry it: the manage page's prefilled + * field, owner only, behind a passed authentication challenge (F4.2). * 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. + * or value, or returned over HTTP anywhere else (R1.2): everything else + * that reaches the user carries the last four digits only. */ String getDecryptedPhoneNumber(String orcid); diff --git a/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneNumberResponse.java b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneNumberResponse.java new file mode 100644 index 0000000000..8087c348fc --- /dev/null +++ b/orcid-web/src/main/java/org/orcid/frontend/recoveryphone/RecoveryPhoneNumberResponse.java @@ -0,0 +1,55 @@ +package org.orcid.frontend.recoveryphone; + +/** + * The stored recovery number in full, in E.164 form, for the one field that + * shows it: the manage page's phone field, which starts from the number on + * file (F4.2). This is the only response that carries the number in the + * clear; every other one carries the mask `***********NNNN` (R1.2). + */ +public class RecoveryPhoneNumberResponse { + + private boolean success; + + private String errorCode; + + private String phoneNumber; + + public static RecoveryPhoneNumberResponse failure(String errorCode) { + RecoveryPhoneNumberResponse response = new RecoveryPhoneNumberResponse(); + response.setSuccess(false); + response.setErrorCode(errorCode); + return response; + } + + public static RecoveryPhoneNumberResponse success(String phoneNumber) { + RecoveryPhoneNumberResponse response = new RecoveryPhoneNumberResponse(); + response.setSuccess(true); + response.setPhoneNumber(phoneNumber); + 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 getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + +} 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 3085eee10c..f926e0af34 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 @@ -15,6 +15,7 @@ import org.orcid.frontend.email.RecordEmailSender; import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeSendCodeResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeVerifyRequest; +import org.orcid.frontend.recoveryphone.RecoveryPhoneNumberResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest; @@ -78,6 +79,12 @@ public class TwoFactorAuthenticationController extends BaseController { /** The password given with a challenge is not the account's password. */ static final String INVALID_PASSWORD = "INVALID_PASSWORD"; + /** + * The session's real user is not the record's owner: a delegate, or an + * admin switched into the record. + */ + static final String NOT_ACCOUNT_OWNER = "NOT_ACCOUNT_OWNER"; + /** * 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 @@ -158,6 +165,40 @@ public class TwoFactorAuthenticationController extends BaseController { return form; } + /** + * Hands the stored recovery number back in full for the manage page, whose + * phone field starts from the number on file rather than empty (F4.2). + * + * Who may see it is decided here and nowhere else. Only the account owner - + * never a delegate, never an admin switched into the record - and only on + * a session that passed the authentication challenge inside the elevation + * window. The interstitial's recent sign in does not count: the + * interstitial only ever adds a first number, so it has none to show. A + * POST, answered no-store, so no cache along the way keeps a copy. + */ + @RequestMapping(value = "/recoveryPhone/number.json", method = RequestMethod.POST) + public @ResponseBody RecoveryPhoneNumberResponse getRecoveryPhoneNumber(HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + String orcid = getCurrentUserOrcid(); + if (!Features.TWO_FACTOR_RECOVERY_PHONE.isActive()) { + return RecoveryPhoneNumberResponse.failure(FEATURE_DISABLED); + } + if (!orcid.equals(getRealUserOrcid())) { + return RecoveryPhoneNumberResponse.failure(NOT_ACCOUNT_OWNER); + } + if (!twoFactorAuthenticationManager.userUsing2FA(orcid)) { + return RecoveryPhoneNumberResponse.failure(TWO_FACTOR_DISABLED); + } + if (!sessionIsElevated(request)) { + return RecoveryPhoneNumberResponse.failure(CHALLENGE_REQUIRED); + } + String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(orcid); + if (phoneNumber == null) { + return RecoveryPhoneNumberResponse.failure(NO_RECOVERY_PHONE); + } + return RecoveryPhoneNumberResponse.success(phoneNumber); + } + @RequestMapping(value = "/recoveryPhone/sendCode.json", method = RequestMethod.POST) public @ResponseBody RecoveryPhoneSendCodeResponse sendRecoveryPhoneCode(HttpServletRequest request, @RequestBody RecoveryPhoneSendCodeRequest form) { 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 9933ee2c19..f579bdb4b3 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 @@ -18,6 +18,7 @@ import org.orcid.frontend.email.RecordEmailSender; import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeSendCodeResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneChallengeVerifyRequest; +import org.orcid.frontend.recoveryphone.RecoveryPhoneNumberResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveRequest; import org.orcid.frontend.recoveryphone.RecoveryPhoneSaveResponse; import org.orcid.frontend.recoveryphone.RecoveryPhoneSendCodeRequest; @@ -957,6 +958,117 @@ public void testChallengeVerifySucceedsWhenTheEmailCannotBeSent() { verify(twoFactorAuthenticationManager).disable2FAByRecoveryPhone(ORCID); verify(profileEntityCacheManager).remove(ORCID); } + // The full number, for the manage page's prefilled field (F4.2): the one + // response allowed to carry it, so each refusal below is asserted as never + // having decrypted it at all + + private static final String STORED_E164 = "+441234567890"; + + @Test + public void testNumberIsInertWhenTheFeatureIsOff() { + when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true); + elevateSession(); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertFalse(result.isSuccess()); + assertEquals(TwoFactorAuthenticationController.FEATURE_DISABLED, result.getErrorCode()); + assertNull(result.getPhoneNumber()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberIsRefusedToADelegateEvenOnAnElevatedSession() { + // A delegate, or an admin switched into the record, who somehow holds + // an elevated session is still not the account owner (F4.2) + enableRecoveryPhoneFeature(); + elevateSession(); + doReturn("0000-0000-0000-0002").when(controller).getRealUserOrcid(); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertFalse(result.isSuccess()); + assertEquals(TwoFactorAuthenticationController.NOT_ACCOUNT_OWNER, result.getErrorCode()); + assertNull(result.getPhoneNumber()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberIsRefusedWhen2FAIsOff() { + togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); + when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(false); + elevateSession(); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertEquals(TwoFactorAuthenticationController.TWO_FACTOR_DISABLED, result.getErrorCode()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberDemandsThePassedChallenge() { + enableRecoveryPhoneFeature(); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertFalse(result.isSuccess()); + assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, result.getErrorCode()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberIsRefusedOnceTheChallengeHasExpired() { + enableRecoveryPhoneFeature(); + session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis() - (9 * 60 * 1000L)); + + assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, + controller.getRecoveryPhoneNumber(request, response).getErrorCode()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + /** + * The interstitial's recent sign in lets it add a first number without a + * challenge. It is no proof for reading one back: the interstitial never + * shows a number, and a sign in is not the challenge the manage page asks. + */ + @Test + public void testNumberIsNotLetThroughByARecentSignIn() { + enableInterstitialFeature(); + togglzRule.enable(Features.OAUTH_RECOVERY_PHONE_INTERSTITIAL); + profileWithLastLogin(60 * 1000L); + + assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, + controller.getRecoveryPhoneNumber(request, response).getErrorCode()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberSaysSoWhenNoneIsStored() { + enableRecoveryPhoneFeature(); + elevateSession(); + when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(null); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertFalse(result.isSuccess()); + assertEquals(TwoFactorAuthenticationController.NO_RECOVERY_PHONE, result.getErrorCode()); + } + + @Test + public void testNumberReturnsTheStoredNumberToTheOwnerAndIsNeverCached() { + enableRecoveryPhoneFeature(); + session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis() - (7 * 60 * 1000L)); + when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(STORED_E164); + + RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); + + assertTrue(result.isSuccess()); + assertNull(result.getErrorCode()); + assertEquals(STORED_E164, result.getPhoneNumber()); + verify(response).setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + // Reading it is not a change: the elevation stays for the save + assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + } // The OAuth interstitial carries a flag of its own (F2.1, PD-14423), and // either flow's flag keeps the relaxed path open (F2.2) From 1186f6dfb8f15985d3adddc450e60bfa1d0ab970 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 5 Oct 2026 11:48:44 -0600 Subject: [PATCH 4/6] PD-14450 only a passed challenge reads the number back --- .../impl/RecoveryPhoneManagerImpl.java | 5 ++- .../TwoFactorAuthenticationController.java | 28 ++++++++++++- ...TwoFactorAuthenticationControllerTest.java | 40 +++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) 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 index 26c29c4c49..702f2ad312 100644 --- 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 @@ -45,8 +45,9 @@ public String getDecryptedPhoneNumber(String orcid) { 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. + // that one for the mask and the dates alone; only the flows that have to text the number, and + // the manage page's owner-only read behind a passed challenge (F4.2), pay for the crypto. The + // result must not reach a log line, a RUM attribute or any other HTTP response. return encryptionManager.decryptForInternalUse(entity.getEncryptedPhoneNumber()); } 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 f926e0af34..850330a678 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 @@ -47,6 +47,13 @@ public class TwoFactorAuthenticationController extends BaseController { private static final String RECOVERY_PHONE_ELEVATION_ATTRIBUTE = "RECOVERY_PHONE_ELEVATION_TS"; + /** + * When the account verification challenge was last passed on this session. + * Kept apart from the elevation, which 2FA setup also grants, because only a + * passed challenge lets the number on file be read back (F4.2). + */ + private static final String RECOVERY_PHONE_CHALLENGE_ATTRIBUTE = "RECOVERY_PHONE_CHALLENGE_TS"; + /** * How long a passed authentication challenge, or the sign in the * interstitial rides on, lets the user keep working. @@ -160,7 +167,9 @@ public class TwoFactorAuthenticationController extends BaseController { return form; } - request.getSession().setAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE, System.currentTimeMillis()); + long passedAt = System.currentTimeMillis(); + request.getSession().setAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE, passedAt); + request.getSession().setAttribute(RECOVERY_PHONE_CHALLENGE_ATTRIBUTE, passedAt); form.setSuccess(true); return form; } @@ -189,7 +198,7 @@ public class TwoFactorAuthenticationController extends BaseController { if (!twoFactorAuthenticationManager.userUsing2FA(orcid)) { return RecoveryPhoneNumberResponse.failure(TWO_FACTOR_DISABLED); } - if (!sessionIsElevated(request)) { + if (!challengePassedRecently(request)) { return RecoveryPhoneNumberResponse.failure(CHALLENGE_REQUIRED); } String phoneNumber = recoveryPhoneManager.getDecryptedPhoneNumber(orcid); @@ -226,6 +235,7 @@ public class TwoFactorAuthenticationController extends BaseController { String phoneE164 = recoveryPhoneVerificationService.normalize(form.getPhoneNumber()); RecoveryPhone saved = recoveryPhoneManager.saveRecoveryPhone(orcid, phoneE164); request.getSession().removeAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE); + request.getSession().removeAttribute(RECOVERY_PHONE_CHALLENGE_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 @@ -358,6 +368,7 @@ public class TwoFactorAuthenticationController extends BaseController { // 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); + request.getSession().removeAttribute(RECOVERY_PHONE_CHALLENGE_ATTRIBUTE); result.setSuccess(true); return result; } @@ -381,6 +392,19 @@ private String guardRecoveryPhoneRequest(HttpServletRequest request, String orci return CHALLENGE_REQUIRED; } + /** + * The account verification challenge itself passed on this session within + * the window. Narrower than {@link #sessionIsElevated}: 2FA setup elevates + * the session too, and that grant must not read the number back (F4.2). + */ + private boolean challengePassedRecently(HttpServletRequest request) { + Object passedAt = request.getSession().getAttribute(RECOVERY_PHONE_CHALLENGE_ATTRIBUTE); + if (!(passedAt instanceof Long)) { + return false; + } + return System.currentTimeMillis() - (Long) passedAt <= RECOVERY_PHONE_ELEVATION_TTL_MILLIS; + } + /** A challenge passed on this session within the elevation window. */ private boolean sessionIsElevated(HttpServletRequest request) { Object elevatedAt = request.getSession().getAttribute(RECOVERY_PHONE_ELEVATION_ATTRIBUTE); 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 f579bdb4b3..84ab8e5786 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 @@ -287,6 +287,13 @@ private void elevateSession() { session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis()); } + /** What a passed account verification challenge leaves on the session. */ + private void passChallenge(long millisAgo) { + long passedAt = System.currentTimeMillis() - millisAgo; + session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", passedAt); + session.setAttribute("RECOVERY_PHONE_CHALLENGE_TS", passedAt); + } + private static RecoveryPhoneSendCodeRequest sendCodeRequest() { RecoveryPhoneSendCodeRequest form = new RecoveryPhoneSendCodeRequest(); form.setPhoneNumber("+441234567890"); @@ -417,6 +424,8 @@ public void testAuthChallengeElevatesTheSessionOnSuccess() { assertTrue(result.isSuccess()); assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + // and the narrower grant only a passed challenge gives (F4.2) + assertNotNull(session.getAttribute("RECOVERY_PHONE_CHALLENGE_TS")); } @Test @@ -537,7 +546,7 @@ public void testSaveReportsAFailedCodeAndKeepsTheElevation() { @Test public void testSaveStoresTheNumberAndClearsTheElevation() { enableRecoveryPhoneFeature(); - elevateSession(); + passChallenge(0); java.util.Date now = new java.util.Date(); when(recoveryPhoneVerificationService.verifyCode(eq(ORCID), anyString(), anyString())).thenReturn(null); when(recoveryPhoneVerificationService.normalize("+441234567890")).thenReturn("+441234567890"); @@ -554,6 +563,8 @@ public void testSaveStoresTheNumberAndClearsTheElevation() { // previous number or none at all verify(recoveryPhoneManager, never()).getRecoveryPhone(anyString()); assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + // the number was just replaced, so nothing on the session reads it back + assertNull(session.getAttribute("RECOVERY_PHONE_CHALLENGE_TS")); } // Onboarding: the live 2FA code posted to register.json stands in for a @@ -967,7 +978,7 @@ public void testChallengeVerifySucceedsWhenTheEmailCannotBeSent() { @Test public void testNumberIsInertWhenTheFeatureIsOff() { when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(true); - elevateSession(); + passChallenge(0); RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); @@ -982,7 +993,7 @@ public void testNumberIsRefusedToADelegateEvenOnAnElevatedSession() { // A delegate, or an admin switched into the record, who somehow holds // an elevated session is still not the account owner (F4.2) enableRecoveryPhoneFeature(); - elevateSession(); + passChallenge(0); doReturn("0000-0000-0000-0002").when(controller).getRealUserOrcid(); RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); @@ -997,7 +1008,7 @@ public void testNumberIsRefusedToADelegateEvenOnAnElevatedSession() { public void testNumberIsRefusedWhen2FAIsOff() { togglzRule.enable(Features.TWO_FACTOR_RECOVERY_PHONE); when(twoFactorAuthenticationManager.userUsing2FA(ORCID)).thenReturn(false); - elevateSession(); + passChallenge(0); RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); @@ -1019,7 +1030,7 @@ public void testNumberDemandsThePassedChallenge() { @Test public void testNumberIsRefusedOnceTheChallengeHasExpired() { enableRecoveryPhoneFeature(); - session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis() - (9 * 60 * 1000L)); + passChallenge(9 * 60 * 1000L); assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, controller.getRecoveryPhoneNumber(request, response).getErrorCode()); @@ -1042,10 +1053,25 @@ public void testNumberIsNotLetThroughByARecentSignIn() { verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); } + /** + * Turning 2FA on elevates the session for the onboarding step that follows + * (R2.6). That elevation is not the account verification challenge, so it + * does not read the number back (F4.2). + */ @Test - public void testNumberSaysSoWhenNoneIsStored() { + public void testNumberIsNotLetThroughBy2FASetup() { enableRecoveryPhoneFeature(); elevateSession(); + + assertEquals(TwoFactorAuthenticationController.CHALLENGE_REQUIRED, + controller.getRecoveryPhoneNumber(request, response).getErrorCode()); + verify(recoveryPhoneManager, never()).getDecryptedPhoneNumber(anyString()); + } + + @Test + public void testNumberSaysSoWhenNoneIsStored() { + enableRecoveryPhoneFeature(); + passChallenge(0); when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(null); RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); @@ -1057,7 +1083,7 @@ public void testNumberSaysSoWhenNoneIsStored() { @Test public void testNumberReturnsTheStoredNumberToTheOwnerAndIsNeverCached() { enableRecoveryPhoneFeature(); - session.setAttribute("RECOVERY_PHONE_ELEVATION_TS", System.currentTimeMillis() - (7 * 60 * 1000L)); + passChallenge(7 * 60 * 1000L); when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn(STORED_E164); RecoveryPhoneNumberResponse result = controller.getRecoveryPhoneNumber(request, response); From 4c1b6f6113271055eec4ff9bca332915d114ac61 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Mon, 5 Oct 2026 14:26:49 -0600 Subject: [PATCH 5/6] PD-14450 assert the challenge grant's lifecycle; decrypting paths named --- .../java/org/orcid/core/manager/RecoveryPhoneManager.java | 7 ++++--- .../controllers/TwoFactorAuthenticationControllerTest.java | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) 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 index 63d756cc1f..df387dbd4c 100644 --- a/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java +++ b/orcid-core/src/main/java/org/orcid/core/manager/RecoveryPhoneManager.java @@ -13,9 +13,10 @@ public interface RecoveryPhoneManager { * The stored number in E.164 form, or null when there is none. * * This is the only path that decrypts the stored number. It exists for the - * flows that have to send a text to it without the user re-typing it, and - * for the one response allowed to carry it: the manage page's prefilled - * field, owner only, behind a passed authentication challenge (F4.2). + * flows that have to send a text to it without the user re-typing it, for + * the ones that check a code against it, and for the one response allowed + * to carry it: the manage page's prefilled field, owner only, behind a + * passed authentication challenge (F4.2). * The value must never be written to a log line, put in a RUM attribute key * or value, or returned over HTTP anywhere else (R1.2): everything else * that reaches the user carries the last four digits only. 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 84ab8e5786..4c9cb6d326 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 @@ -407,6 +407,7 @@ public void testAuthChallengeRejectsAWrongPassword() { assertTrue(result.isInvalidPassword()); assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + assertNull(session.getAttribute("RECOVERY_PHONE_CHALLENGE_TS")); } @Test @@ -579,6 +580,9 @@ public void testOnboardingElevatesTheSessionForTheRecoveryPhoneStep() { assertTrue(controller.validateVerificationCode(request, registration).isValid()); assertNotNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + // Setup is not a challenge: what it grants does not read the number + // back (F4.2) + assertNull(session.getAttribute("RECOVERY_PHONE_CHALLENGE_TS")); // and that elevation is what carries step 2 of setup, with no second // challenge in between @@ -931,7 +935,7 @@ public void testChallengeVerifyIsRefusedWhenTheFeatureIsOff() { public void testChallengeVerifyDisables2FAOnAGoodCode() { enableRecoveryPhoneFeature(); // An elevation from an earlier challenge has nothing left to guard - elevateSession(); + passChallenge(60 * 1000L); profileWithLastLogin(60 * 1000L); when(encryptionManager.hashMatches("correct", "hashed")).thenReturn(true); when(recoveryPhoneManager.getDecryptedPhoneNumber(ORCID)).thenReturn("+441234567890"); @@ -947,6 +951,7 @@ public void testChallengeVerifyDisables2FAOnAGoodCode() { verify(recordEmailSender).send2FADisabledEmail(ORCID); verify(profileEntityCacheManager).remove(ORCID); assertNull(session.getAttribute("RECOVERY_PHONE_ELEVATION_TS")); + assertNull(session.getAttribute("RECOVERY_PHONE_CHALLENGE_TS")); } @Test From 99980b7668d15dbb3a56ed072f1f591e1de64eae Mon Sep 17 00:00:00 2001 From: cryptalith Date: Wed, 7 Oct 2026 13:57:16 -0600 Subject: [PATCH 6/6] Drop duplicate togglz-junit that new CI runners reject --- orcid-api-web/pom.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/orcid-api-web/pom.xml b/orcid-api-web/pom.xml index 2379d667ab..d209cabb95 100644 --- a/orcid-api-web/pom.xml +++ b/orcid-api-web/pom.xml @@ -67,13 +67,6 @@ - - - org.togglz - togglz-junit - 2.4.1.Final - test - ${project.groupId} orcid-test