Skip to content

fix(auth): check reset link expiry against interactive login timestamp - #64126

Open
silverkszlo wants to merge 1 commit into
masterfrom
fix/password-resetting
Open

silverkszlo wants to merge 1 commit into
masterfrom
fix/password-resetting

Conversation

@silverkszlo

@silverkszlo silverkszlo commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The bug

When the password of a user expires and they want to reset it while they have an open session somewhere, the "Forgot password" flow sends them into a loop and never allows them to reset their password: logging in is being refused because the password expired, and the emailed reset link is being refused as expired as well.

How to reproduce

  • use an existing user account or create one, e.g. "blume" with "blume@example.com"
  • log in to [dev/prod_url] as blume and leave that window open(!)
  • if that hasn't happened yet, enable password_policy: occ app:enable password_policy
  • set an expiry period: occ config:app:set password_policy expiration --value=1
  • expire the password:
    occ user:setting blume password_policy pwd_last_updated $(( $(date +%s) - 10*86400 ))
  • In another private window go to [dev/prod_url] and log in as blume; you'll see "Password is expired, please use forgot password method to reset".
  • go back to login form and click on "Forgot password?" and submit "blume"
  • in the normal browser: open [local/remote_mail_server] and find the password-reset mail
  • click on "Reset your password"
  • it takes you to "Could not reset password because the token is expired."

This solution

VerificationToken compared the reset link's creation time against IUser::getLastLogin(). Despite its name that value is a "last seen" timestamp. Session::validateSession() refreshes it on every request of an already authenticated session, so it kept moving past the reset link's creation time while the user did nothing but leave a session open.

This PR records the time a person authenticated in a browser (lastInteractiveLogin) and compares against that instead. That is wired into a new RecordInteractiveLoginCommand inside the web login Chain, to filter by where the login came from (browser authentication). Remember-me logins are covered by an additional UserLoggedInWithCookieListener.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@silverkszlo
silverkszlo requested a review from a team as a code owner September 8, 2026 15:11
@silverkszlo
silverkszlo requested review from Altahrim, blizzz, leftybournes, provokateurin and salmart-dev and removed request for a team September 8, 2026 15:11
@blizzz
blizzz requested a review from artonge September 8, 2026 21:38
@blizzz

blizzz commented Sep 8, 2026

Copy link
Copy Markdown
Member

it takes you to "Could not reset password because the token is expired."

this step is on the normal browser, or the private browser window? i understand the latter.

PDOException: SQLSTATE[HY000]: General error: 1 no such table: oc_appconfig in /home/runner/work/server/server/3rdparty/doctrine/dbal/src/Driver/PDO/Connection.php:59

Hm, there might be an edge case hidden? 🤔

@blizzz

blizzz commented Sep 8, 2026

Copy link
Copy Markdown
Member

Auth is a tough area to look into, and full of mines 🙃 Great that you looked into it! On first sight it looks fine, but there are actually a things to improve, also to avoid frequent writes to DB.

Findings from a claude review:

  • each basic auth request will lead to a DB write. This could be a thing with simple clients or script that send auth headers instead of using cookies… though cookies login would also cause a write (Claude does not say so, but I do). User::updateLastLoginTimestamp() has a safe guard, maybe the same mechanism can be utilized.
  • above also means each client that uses auth would invoke the recording, e.g. Thunderbird or possible DavX5. Worth to limit it to browsers only? To still cover this case. Or Worth a deliberate decision: if "interactive" means a human authenticated, record from the web login Chain (plus loginWithCookie()), or gate on session-token creation / $regenerateSessionId, rather than on every non-token completeLogin().
  • now a pending link survives the full TOKEN_LIFETIME for an account that is only used via app passwords not sure this is realistic, if a link is double checked for validity not a problem. But if it could be re-used any later point in time by some other party, wouldn't be ideal.
  • suggest to store the value flagged as lazy

@solracsf solracsf added this to the Nextcloud 36 milestone Sep 12, 2026
@silverkszlo
silverkszlo force-pushed the fix/password-resetting branch 3 times, most recently from 8ba768f to 8d5af33 Compare September 16, 2026 11:11
@silverkszlo

Copy link
Copy Markdown
Contributor Author

it takes you to "Could not reset password because the token is expired."

this step is on the normal browser, or the private browser window? i understand the latter.

this step is on the normal browser, so after clicking the reset link in the email

PDOException: SQLSTATE[HY000]: General error: 1 no such table: oc_appconfig in /home/runner/work/server/server/3rdparty/doctrine/dbal/src/Driver/PDO/Connection.php:59

Hm, there might be an edge case hidden? 🤔

might have been, but the error is gone...

@silverkszlo

Copy link
Copy Markdown
Contributor Author

Findings from a claude review:

each basic auth request will lead to a DB write. This could be a thing with simple clients or script that send auth headers instead of using cookies… though cookies login would also cause a write (Claude does not say so, but I do). User::updateLastLoginTimestamp() has a safe guard, maybe the same mechanism can be utilized.

above also means each client that uses auth would invoke the recording, e.g. Thunderbird or possible DavX5. Worth to limit it to browsers only? To still cover this case. Or Worth a deliberate decision: if "interactive" means a human authenticated, record from the web login Chain (plus loginWithCookie()), or gate on session-token creation / $regenerateSessionId, rather than on every non-token completeLogin().

So to mitigate that, Claude came up with wiring LastInteractiveLogin into a RecordInteractiveLoginCommand which can be placed into Chain and by that filter out the browser authentication. Seems sensible to me?

now a pending link survives the full TOKEN_LIFETIME for an account that is only used via app passwords not sure this is realistic, if a link is double checked for validity not a problem. But if it could be re-used any later point in time by some other party, wouldn't be ideal.

Yes, the link would survive the 7 days for an account that is only used via app passwords. But using a stale link requires reading the account's mailbox and a person with that kind of access can also just request a fresh link? Recording app-password logins would reintroduce the bug this PR fixes, if I understand that correctly. But ofc I am not sure if that is the best way to mitigate that.

suggest to store the value flagged as lazy

done

Signed-off-by: silver <s.szmajduch@posteo.de>
Assisted-by: ClaudeCode:claude-opus-5
@silverkszlo
silverkszlo force-pushed the fix/password-resetting branch from 8d5af33 to 00e8cbc Compare September 28, 2026 12:01

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Notify user before password expiration

3 participants