Skip to content
Merged

PD-5692 #7741

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ public boolean claimProfileAndUpdatePreferences(String orcid, String email, Avai
profile.setClaimed(true);
profile.setCompletedDate(new Date());
profile.setEncryptedPassword(encryptionManager.hashForInternalUse(claim.getPassword().getValue()));
// A new password satisfies a mandatory password reset
profile.setForcePasswordReset(null);
if (locale != null) {
profile.setLocale(locale.name());
}
Expand Down Expand Up @@ -505,6 +507,8 @@ public Boolean doInTransaction(TransactionStatus status) {
profileEntity.setIndexingStatus(IndexingStatus.PENDING);
if (reactivation != null) {
profileEntity.setEncryptedPassword(encryptionManager.hashForInternalUse(reactivation.getPassword().getValue()));
// A new password satisfies a mandatory password reset
profileEntity.setForcePasswordReset(null);
profileEntity.setActivitiesVisibilityDefault(reactivation.getActivitiesVisibilityDefault().getVisibility().name());
}
profileDao.merge(profileEntity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,11 @@ public interface ProfileEntityManagerReadOnly extends ManagerReadOnlyBase {
Boolean haveMemberPushedWorksOrAffiliationsToRecord(String orcid, String clientId);

Boolean hasToken(String userName, long lastModified);

/**
* Whether the record has to reset its password before it can sign in with
* one. Never cached: which database answers depends on the bean, the
* writable manager reads the primary and the read-only one the replica.
*/
boolean isPasswordResetRequired(String orcid);
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ public Boolean haveMemberPushedWorksOrAffiliationsToRecord(String orcid, String
return profileDao.haveMemberPushedWorksOrAffiliationsToRecord(orcid, clientId);
}

@Override
public boolean isPasswordResetRequired(String orcid) {
if (PojoUtil.isEmpty(orcid)) {
return false;
}
return profileDao.isPasswordResetRequired(orcid);
}

@Override
@Cacheable(value = "count-tokens", key = "#userName.concat('-').concat(#lastModified)")
public Boolean hasToken(String userName, long lastModified) {
Expand Down
5 changes: 4 additions & 1 deletion orcid-core/src/main/java/org/orcid/core/togglz/Features.java
Original file line number Diff line number Diff line change
Expand Up @@ -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("Mandatory password reset: block password sign in and show the reset notice for records flagged with force_password_reset")
FORCE_PASSWORD_RESET;
public boolean isActive() {
return FeatureContext.getFeatureManager().isActive(this);
}
Expand Down
1 change: 1 addition & 0 deletions orcid-core/src/main/resources/i18n/messages_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Pattern.registrationForm.password.eigthCharacters=8 or more characters
Pattern.registrationForm.password.oneNumber=1 number
Pattern.registrationForm.password.letterOrSymbol=1 letter or symbol
Pattern.registrationForm.password.containsEmail=Password must not be the same as your email address
Pattern.registrationForm.password.previouslyUsed=Please enter a new password. Previous passwords cannot be reused.
StringMatchIgnoreCase.registrationForm=The email and confirmed email must match.
admin.profile_deprecation.deprecate_account.success_message=Profile {0} has been successfully deprecated to profile {1}.
change_password.confirmnewpassword=Confirm new password
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,22 @@ public void claimProfileAndUpdatePreferencesUpdatesExistingFrequencySetting() {
verify(emailFrequencyManager, never()).createOnClaim(anyString(), anyBoolean());
}

@Test
public void claimProfileAndUpdatePreferencesClearsForcePasswordReset() {
when(emailManager.verifySetCurrentAndPrimary("orcid", "a@b.com")).thenReturn(true);
ProfileEntity profile = new ProfileEntity();
profile.setForcePasswordReset(new Date());
when(profileDao.find("orcid")).thenReturn(profile);
when(encryptionManager.hashForInternalUse("password#1")).thenReturn("enc");
when(biographyManager.exists("orcid")).thenReturn(false);
when(emailFrequencyManager.emailFrequencyExists("orcid")).thenReturn(true);

manager.claimProfileAndUpdatePreferences("orcid", "a@b.com", null, claim(false));

assertNull(profile.getForcePasswordReset());
verify(profileDao).merge(profile);
}

@Test
public void updateLocaleDelegates() {
manager.updateLocale("orcid", AvailableLocales.EN);
Expand Down Expand Up @@ -631,6 +647,49 @@ public void reactivateFromUserPathRecreatesAdditionalEmailsUpdatesNameAndReturns
verify(emailManager).clearEmailsAfterReactivation("orcid");
}

@Test
public void reactivateFromUserPathClearsForcePasswordReset() {
ProfileEntity profile = new ProfileEntity();
profile.setDeactivationDate(new Date());
profile.setForcePasswordReset(new Date());
when(profileDao.find("orcid")).thenReturn(profile);
when(encryptionManager.hashForInternalUse("reactivation#1")).thenReturn("encrypted");
when(recordNameManagerReadOnlyV3.getRecordName("orcid")).thenReturn(new Name());

manager.reactivate("orcid", "primary@test.org", reactivation());

assertEquals("encrypted", profile.getEncryptedPassword());
assertNull(profile.getForcePasswordReset());
verify(profileDao).merge(profile);
}

@Test
public void reactivateFromAdminPathKeepsForcePasswordReset() {
// An admin reactivation keeps the old password, so the reset is still owed
ProfileEntity profile = new ProfileEntity();
profile.setDeactivationDate(new Date());
Date flagged = new Date();
profile.setForcePasswordReset(flagged);
when(profileDao.find("orcid")).thenReturn(profile);

manager.reactivate("orcid", "primary@test.org", null);

assertEquals(flagged, profile.getForcePasswordReset());
}

@Test
public void isPasswordResetRequiredDelegates() {
when(profileDao.isPasswordResetRequired("orcid")).thenReturn(true);
assertTrue(manager.isPasswordResetRequired("orcid"));
}

@Test
public void isPasswordResetRequiredIsFalseForAnEmptyOrcid() {
assertFalse(manager.isPasswordResetRequired(""));
assertFalse(manager.isPasswordResetRequired(null));
verify(profileDao, never()).isPasswordResetRequired(anyString());
}

@Test
public void updatePasswordHashesAndPersists() {
when(encryptionManager.hashForInternalUse("password")).thenReturn("encrypted");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ public interface ProfileDao extends GenericDao<ProfileEntity, String> {
@Transactional(propagation = Propagation.REQUIRED)
int updateForcePasswordReset(List<String> ids, Date forcePasswordResetDate);

/**
* Whether the record must reset its password before it can sign in with
* one: the force_password_reset date is set on a claimed record that is
* neither deactivated nor deprecated. A narrow, uncached read, because the
* profile entity cache is keyed on last_modified and setting or clearing
* the flag does not change it. Which database answers depends on the bean:
* profileDao reads the primary, profileDaoReadOnly the replica.
*/
@Transactional(value = "transactionManagerReadOnly", readOnly = true)
boolean isPasswordResetRequired(String orcid);

@Transactional(value = "transactionManagerReadOnly", readOnly = true)
public List<String> registeredBetween(Date startDate, Date endDate);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ public String retrievePrimaryAccountOrcid(String deprecatedOrcid) {
@Override
@Transactional
public void changeEncryptedPassword(String orcid, String encryptedPassword) {
Query updateQuery = entityManager.createQuery("update ProfileEntity p set p.encryptedPassword = :encryptedPassword where p.id = :orcid");
// A new password satisfies a mandatory password reset, whichever path set it
Query updateQuery = entityManager.createQuery("update ProfileEntity p set p.encryptedPassword = :encryptedPassword, p.forcePasswordReset = null where p.id = :orcid");
updateQuery.setParameter("orcid", orcid);
updateQuery.setParameter("encryptedPassword", encryptedPassword);
updateQuery.executeUpdate();
Expand Down Expand Up @@ -848,6 +849,15 @@ public int updateForcePasswordReset(List<String> ids, Date forcePasswordResetDat
return query.executeUpdate();
}

@Override
@Transactional(value = "transactionManagerReadOnly", readOnly = true)
public boolean isPasswordResetRequired(String orcid) {
Query query = entityManager.createNativeQuery("SELECT count(*) FROM profile WHERE orcid = :orcid AND force_password_reset IS NOT NULL "
+ "AND claimed = true AND profile_deactivation_date IS NULL AND primary_record IS NULL");
query.setParameter("orcid", orcid);
return ((Number) query.getSingleResult()).longValue() > 0;
}

@SuppressWarnings("unchecked")
@Override
@Transactional(value = "transactionManagerReadOnly", readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,46 @@ public void testUpdateForcePasswordReset() {
assertNull(untouchedProfile.getForcePasswordReset());
}

@Test
@Rollback(true)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void testIsPasswordResetRequired() {
// 4442 is claimed and active, 4447 is unclaimed, 444X is deprecated and
// 0000-0000-0000-0007 is deactivated: only the first one must answer true
List<String> flagged = Arrays.asList("4444-4444-4444-4442", "4444-4444-4444-4447", "4444-4444-4444-444X", "0000-0000-0000-0007");
assertEquals(4, profileDao.updateForcePasswordReset(flagged, new Date()));
entityManager.clear();

assertTrue(profileDao.isPasswordResetRequired("4444-4444-4444-4442"));
assertFalse(profileDao.isPasswordResetRequired("4444-4444-4444-4447"));
assertFalse(profileDao.isPasswordResetRequired("4444-4444-4444-444X"));
assertFalse(profileDao.isPasswordResetRequired("0000-0000-0000-0007"));
// Not flagged, and not a record at all
assertFalse(profileDao.isPasswordResetRequired("4444-4444-4444-4443"));
assertFalse(profileDao.isPasswordResetRequired("9999-9999-9999-9999"));
}

@Test
@Rollback(true)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void testChangeEncryptedPasswordClearsForcePasswordReset() {
String orcid = "4444-4444-4444-4442";
String untouched = "4444-4444-4444-4443";
assertEquals(2, profileDao.updateForcePasswordReset(Arrays.asList(orcid, untouched), new Date()));
entityManager.clear();
assertTrue(profileDao.isPasswordResetRequired(orcid));

profileDao.changeEncryptedPassword(orcid, "new-encrypted-password");
entityManager.clear();

ProfileEntity profile = profileDao.find(orcid);
assertEquals("new-encrypted-password", profile.getEncryptedPassword());
assertNull(profile.getForcePasswordReset());
assertFalse(profileDao.isPasswordResetRequired(orcid));
// Only the record whose password changed is released
assertNotNull(profileDao.find(untouched).getForcePasswordReset());
}

@Test
@Rollback(true)
@Transactional(propagation = Propagation.REQUIRES_NEW)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.orcid.core.security.UnclaimedProfileExistsException;
import org.orcid.frontend.web.exception.Bad2FARecoveryCodeException;
import org.orcid.frontend.web.exception.Bad2FAVerificationCodeException;
import org.orcid.frontend.web.exception.PasswordResetRequiredException;
import org.orcid.frontend.web.exception.VerificationCodeFor2FARequiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
Expand Down Expand Up @@ -60,6 +61,10 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo
} else if (exception.getCause() instanceof InvalidUserTypeException) {
writer.println(",");
writer.println("\"invalidUserType\": true");
} else if (exception instanceof PasswordResetRequiredException) {
// Thrown by OrcidAuthenticationProvider after the password matched
writer.println(",");
writer.println("\"passwordResetRequired\": true");
}
writer.println("}");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.orcid.core.togglz.Features;
import org.orcid.frontend.web.exception.Bad2FARecoveryCodeException;
import org.orcid.frontend.web.exception.Bad2FAVerificationCodeException;
import org.orcid.frontend.web.exception.PasswordResetRequiredException;
import org.orcid.frontend.web.exception.VerificationCodeFor2FARequiredException;
import org.orcid.persistence.jpa.entities.ProfileEntity;
import org.orcid.utils.OrcidStringUtils;
Expand Down Expand Up @@ -129,6 +130,17 @@ public Authentication authenticate(Authentication auth) throws AuthenticationExc
}
}

// A correct password on a record flagged for a mandatory password reset
// does not sign in. Checked after the password and the lock, so a wrong
// password still counts as a failed attempt, and before 2FA, so the user
// is not asked for a code first. The status is read from the primary,
// not the profile cache: the cache is keyed on last_modified, which
// setting or clearing the flag does not change.
if (Features.FORCE_PASSWORD_RESET.isActive() && profileEntityManager.isPasswordResetRequired(profile.getId())) {
LOGGER.info("Correct sign in refused, password reset required for: " + profile.getId());
throw new PasswordResetRequiredException();
}

if (profile.getUsing2FA()) {
String recoveryCode = ((MFAWebAuthenticationDetails) auth.getDetails()).getRecoveryCode();
if (recoveryCode != null && !recoveryCode.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.orcid.core.manager.ProfileEntityCacheManager;
import org.orcid.core.manager.v3.ProfileEntityManager;
import org.orcid.core.manager.v3.read_only.EmailManagerReadOnly;
import org.orcid.core.manager.v3.read_only.ProfileEntityManagerReadOnly;
import org.orcid.core.security.OrcidRoles;
import org.orcid.core.stats.StatisticsManager;
import org.orcid.core.togglz.Features;
Expand Down Expand Up @@ -88,6 +89,9 @@ public class HomeController extends BaseController {
@Resource(name = "emailManagerReadOnlyV3")
protected EmailManagerReadOnly emailManagerReadOnly;

@Resource(name = "profileEntityManagerReadOnlyV3")
private ProfileEntityManagerReadOnly profileEntityManagerReadOnly;

@RequestMapping(value = "/")
public ModelAndView homeHandler(HttpServletRequest request) {
ModelAndView mav = new ModelAndView("home");
Expand Down Expand Up @@ -227,7 +231,13 @@ private void removeSessionIdCookie(HttpServletRequest request, HttpServletRespon
}
if(!PojoUtil.isEmpty(p.getGroupType())) {
info.put("MEMBER_TYPE", p.getGroupType());
}
}
// Mandatory password reset: only for the record's own user, never a
// delegate or an admin acting as them. Read uncached, because the
// flag does not change last_modified and so never evicts p above
if (Features.FORCE_PASSWORD_RESET.isActive() && effectiveOrcid.equals(realUserOrcid)) {
info.put("FORCE_PASSWORD_RESET", String.valueOf(profileEntityManagerReadOnly.isPasswordResetRequired(effectiveOrcid)));
}
}
return info;
}
Expand Down
Loading
Loading