Skip to content

TRUNK-6666: Automatic Password Upgrade on Successful Login#6230

Open
solomonfortune wants to merge 12 commits into
openmrs:2.8.xfrom
solomonfortune:TRUNK-6666-lazy-rehash-password-upgrade
Open

TRUNK-6666: Automatic Password Upgrade on Successful Login#6230
solomonfortune wants to merge 12 commits into
openmrs:2.8.xfrom
solomonfortune:TRUNK-6666-lazy-rehash-password-upgrade

Conversation

@solomonfortune

Copy link
Copy Markdown

Description of what I changed

  • Added spring-security-crypto dependency for Argon2id password hashing support
  • Increased users.password column length from 128 to 255 via Liquibase migration to accommodate Argon2id hash output
  • Added isLegacyHash() to Security.java to detect all three legacy hash formats (SHA-512, SHA-1 correct, SHA-1 buggy from ticket Changed String constructor to valueOf #1178)
  • Added Argon2PasswordEncoder instance to Security.java with production-ready default parameters
  • Added unit tests for isLegacyHash() covering all legacy formats and edge cases

Note: This is a draft PR. Tasks remaining — upgrade hook in authenticate(), transaction isolation, and thread safety — will be added in subsequent commits.

Issue I worked on

see https://issues.openmrs.org/browse/TRUNK-6666

Checklist: I completed these to help reviewers :)

  • My IDE is configured to follow the code style of this project.
  • I have added tests to cover my changes.
  • I ran mvn clean package right before creating this pull request and added all formatting changes to my commit.
  • All new and existing tests passed.
  • My pull request is based on the latest changes of the master branch.

@solomonfortune

Copy link
Copy Markdown
Author

@dkayiwa and @ibacher Could you please look through this PR in regards to the Password Authentication Re-work project at your convinient time

@dkayiwa

dkayiwa commented Jul 1, 2026

Copy link
Copy Markdown
Member

Is this still in draft? Or ready for review?

@solomonfortune

Copy link
Copy Markdown
Author

Is this still in draft? Or ready for review?

I had a few tasks i was finalising yesterday and hopefully finished them today. It's now ready for review

@solomonfortune
solomonfortune marked this pull request as ready for review July 2, 2026 08:11
@dkayiwa

dkayiwa commented Jul 2, 2026

Copy link
Copy Markdown
Member

Can you check the build failures?

@solomonfortune

Copy link
Copy Markdown
Author

Can you check the build failures?

@dkayiwa
All 7 test failures from the previous CI run have been resolved. The first root cause was the checkPassword() in LoginCredential.java was not aware of Argon2id hashes. i fixed this by delegating to Argon2PasswordEncoder.matches() when the stored hash is not a legacy format. the second one is upgradePasswordHash() was setting salt to null after upgrade, which broke secret answer verification since the secret answer hash depends on the original salt. this was fixed by preserving the salt value after upgrade.

@dkayiwa

dkayiwa commented Jul 6, 2026

Copy link
Copy Markdown
Member

@solomonfortune did you see the above build failure?

@solomonfortune

Copy link
Copy Markdown
Author

@dkayiwa i think this is a different cause of failure than the first one according to the explanantions that I tried to look through. So let me also address this one too.

@solomonfortune

Copy link
Copy Markdown
Author

@dkayiwa The changeset count in DatabaseUpdaterDatabaseIT has been updated from 904 to 905 to account for the new Liquibase migration added in this PR. That was the cause of the build failures

@dkayiwa

dkayiwa commented Jul 7, 2026

Copy link
Copy Markdown
Member

@claude review

// Lazy rehash: if password is legacy, upgrade to Argon2id transparently
if (Security.isLegacyHash(passwordOnRecord)) {
try {
upgradePasswordHash(candidateUser, password);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs fixing before merge: upgradePasswordHash is called here as a plain this-scoped call, from authenticate() in the very same class. Spring's transaction advice is applied through a proxy, and self-invocation bypasses that proxy entirely, so the @Transactional(propagation = Propagation.REQUIRES_NEW) on upgradePasswordHash (line 374) is silently ignored. In practice the upgrade write runs inside the exact same transaction as authenticate(), not an isolated one, which contradicts the method's own javadoc ("only the upgrade is rolled back — the authentication that already succeeded is never affected") and defeats the point of REQUIRES_NEW. Because the call is still wrapped in a try/catch here, the existing test (authenticate_shouldUpgradeLegacyPasswordToArgon2idOnSuccessfulLogin) passes either way, so this doesn't show up in CI. To actually get a separate transaction, the call needs to go through the Spring-managed proxy, e.g. inject ContextDAO/self-reference (@Autowired self-injection or AopContext.currentProxy() with exposeProxy) and invoke the annotated method through that, or move upgradePasswordHash to a different bean that this class calls through its injected reference.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upgradePasswordHash() is now called through AopContext.currentProxy() instead of this, ensuring the @transactional(propagation = Propagation.REQUIRES_NEW) annotation is honoured by the Spring proxy. expose-proxy="true" has been added to the AOP configuration in applicationContext-service.xml to enable this.


/**
* The modern password encoder using Argon2id algorithm.
* Used for all new password hashing and lazy rehash upgrades.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this claim actually true? Tracing where password hashes get written outside the lazy-rehash path: HibernateUserDAO.changePassword(User, String) and HibernateUserDAO.changePassword(String, String) (both unchanged by this PR) still hash new passwords with Security.encodeString(pw + salt), never with this Argon2id encoder. Concretely that means a brand-new user's initial password is never Argon2id, and, more importantly, a user who was already upgraded to Argon2id via the login-triggered lazy rehash gets silently downgraded back to a legacy SHA-512-style hash the very next time they change their password through the normal self-service or admin-reset flow. There's no test exercising changePassword() that checks the resulting hash format, so this regression wouldn't be caught. If the intent of this PR is scoped to only the login-rehash path, that's a significant gap worth calling out explicitly (e.g. in the PR description) since it undermines the migration for any account whose password is changed proactively.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both changePassword(User, String) and changePassword(String, String) in HibernateUserDAO now hash new passwords using Security.getArgon2Encoder().encode() instead of Security.encodeString(). The existing salt is preserved after the hash upgrade so secret answer verification continues to work correctly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changePassword() now using Argon2id closes the downgrade-on-password-change gap, but HibernateUserDAO.saveUser() (lines 105-106 in the current head) still hashes a brand-new user's initial password with Security.encodeString(password + salt), not Security.getArgon2Encoder(). That's the method UserService.createUser() calls (and HibernateContextDAO.createUser() via Daemon.createUser()), so every newly created account still starts out on the legacy SHA-512-style hash and only gets upgraded once it logs in and hits the lazy-rehash path.

UserDAO userDAO = (UserDAO) applicationContext.getBean("userDAO");
LoginCredential credential = userDAO.getLoginCredential(user);
assertNotNull(credential.getHashedPassword());
assertTrue(credential.getHashedPassword().startsWith("$argon2id$"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts the stored hash flips to Argon2id, but nothing here or elsewhere in the suite then authenticates against an Argon2id hash, so the modern verification path is never actually run. Every authenticate test starts from the admin account, whose seeded password is a legacy hash (in fact the 39 character buggy SHA-1 from the #1178 dataset, not SHA-512 as the comment says), so isPasswordMatch and LoginCredential.checkPassword only ever take their legacy branch. Security.getArgon2Encoder().matches(rawPassword, storedHash) has no coverage.

That branch is the endpoint of the whole migration: once an account is upgraded, every later login depends on it. If it regresses (argument order, an encoder parameter change, a salt accidentally appended to the input) then every already-upgraded user is locked out on their next login and CI stays green, because nothing logs in against an Argon2id hash. The current argument order is correct, so this is about guarding the path rather than a bug that is live today.

A reliable way to cover it is to seed a user whose stored hash is Security.getArgon2Encoder().encode("somePassword") and assert dao.authenticate succeeds with the right password and fails with a wrong one. Re-authenticating admin a second time in this test will not exercise it, because the native select password, salt query plus the session's MANUAL flush mode re-reads the original legacy hash. A small direct LoginCredential.checkPassword test with an Argon2id hash would cover the other copy. Worth adding before this merges, given the failure mode is a silent lockout.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new user with a pre-seeded Argon2id hash has been added to contextDAOTest.xml. Two new tests have been added to ContextDAOTest. One asserting successful authentication against an Argon2id hash and one asserting correct rejection of a wrong password. A direct LoginCredential.checkPassword() test with an Argon2id hash has also been added to SecurityTest to cover that verification path independently.

* @param salt the legacy salt from the database (may be null for Argon2id)
* @return true if the password matches
*/
private boolean isPasswordMatch(String storedHash, String rawPassword, String salt) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same legacy-versus-Argon2 decision that LoginCredential.checkPassword now also implements (LoginCredential line 149), copied almost verbatim. Two independent copies of the password-verification rule tend to drift: the day someone teaches one branch about another stored format, swaps the encoder, or changes how the salt is applied, the other copy quietly keeps the old behavior and authentication starts depending on which entry point you came through (login through here, change-password through checkPassword). For verification code that is an unpleasant way to get an inconsistency.

I would pull this into one helper, for example Security.passwordMatches(storedHash, rawPassword, salt), and have both callers use it. Not blocking, but cheap to do now while both copies are fresh.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verification logic has been consolidated into a single Security.passwordMatches(storedHash, rawPassword, salt) helper method. Both isPasswordMatch() in HibernateContextDAO and checkPassword() in LoginCredential now delegate to this single method, eliminating the duplicate implementation.

…ity.passwordMatches(), fix Argon2id verification in checkPassword() and changePassword(), add Argon2id authentication tests, fix AopContext proxy for REQUIRES_NEW transaction isolation, update Liquibase changeset count
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@solomonfortune
solomonfortune requested a review from dkayiwa July 13, 2026 09:38
@solomonfortune

Copy link
Copy Markdown
Author

@dkayiwa I went through the comments and tried my best to fix the issues

@dkayiwa

dkayiwa commented Jul 13, 2026

Copy link
Copy Markdown
Member

@claude review


@Autowired
private SearchSessionFactory searchSessionFactory;
private HibernateContextDAO self;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this field is never read, never assigned, and has no setter or Spring wiring for it — looks like a leftover from an earlier self-injection approach that got replaced by AopContext.currentProxy() in authenticate(). Worth deleting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

*/
public static Argon2PasswordEncoder getArgon2Encoder() {
return ARGON2_ENCODER;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not feel like a method that exists. More exactly, callers of the Security class should not need to know what encoding or encoders we are using. Instead of creating new APIs, the focus here should be updating the existing APIs so that existing flows still continue to work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, this PR should have zero new public API space.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, this PR should have zero new public API space.

thanks @ibacher for this feedback. let me work on this immediately. getArgon2Encoder() really leaks the implementation detail and creates unnecessary public API surface.

@solomonfortune solomonfortune Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ibacher getArgon2Encoder() has been made private and upgradePasswordHash() reduced to package-private. passwordMatches() and isLegacyHash() are used by LoginCredential.checkPassword() which lives in a different package and making them package-private would break that. Would you prefer we move the verification logic entirely into Security.java and have LoginCredential.checkPassword() call Security.passwordMatches() keeping it public, or is there another approach you have in mind?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking this against the current head (01ffbff, which is still the PR's tip): none of the three changes described above are actually in the code. getArgon2Encoder() is still public (Security.java:119), passwordMatches() is still public (Security.java:98), and upgradePasswordHash() is still public (HibernateContextDAO.java:374) — none show as private or package-private. There's no commit after 01ffbff (2026-07-08) touching either file, so this looks like the follow-up commit described here didn't actually get pushed. Worth confirming before treating this as addressed, since @ibacher's "zero new public API space" ask is otherwise still open.

@jwnasambu

Copy link
Copy Markdown
Contributor

@claude review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants