Skip to content

fix(login): invalidate outstanding login tokens on logout and password change - #38265

Open
swicken wants to merge 8 commits into
mainfrom
session-logout-token-invalidation
Open

swicken wants to merge 8 commits into
mainfrom
session-logout-token-invalidation

Conversation

@swicken

@swicken swicken commented Oct 27, 2026

Copy link
Copy Markdown
Member

Proposed Changes

A back-end login issues a login token (the rme cookie, which is also accepted as a Bearer token). The token is valid as long as its id matches the user's stored rememberMeToken. Until now, logging out or changing the password did not change that value, so a login token issued before the logout or password change kept working until it expired. This PR gives the user a new rememberMeToken on logout and on every password change, so every login token issued before then stops working.

Fixes dotCMS/private-issues#960

Logout (7ef7118)

  • UserFactory.updateRememberMeToken(userId, value) updates the one column (update user_ set skinid = ? where userid = ?) and then evicts the user from UserCache. Writing a single column means the change cannot overwrite a concurrent change to other user fields, which a full UserAPI.save could.
  • UserAPI.invalidateLoginTokens(userId) (@WrapInTransaction) wraps it, and LazyUserAPIWrapper delegates it.
  • LoginServiceImpl calls it first in doActionLogout and in the doLogout override. That covers REST /api/v1/logout, the UI's /dotAdmin/logout, OAuth logout, invalid-token cleanup, CookiesFilter and SAML logout. Any failure is logged at warn, and the logout carries on.
  • Cluster: the value lives in the database, and the cache eviction reaches the other nodes through the normal cache invalidation, so every node validates against the new value. There is no node-local state.

Password change (24605d0)

  • UserAPIImpl.save(..., validatePassword=true, ...) and UserAPIImpl.updatePassword set a new rememberMeToken on the user object they already save. So there is no extra write, and nothing for a later save to overwrite. That covers the legacy user editor and current-user edit (UserAPI.save with validatePassword=true) and reset-password completion (ResetPasswordResource to UserManagerImpl.resetPassword to updatePassword).
  • UserResource.updateUser (PUT /api/v1/users) does the same when the form carries a password.
  • Not changed: the password re-hash on login, which keeps the same password and must not log the user out.

Login As and logout without a session (97f41ac)

  • During Login As, logout gives the administrator a new value, not the user being impersonated. Ending Login As does not go through logout, so it changes nothing.
  • If the request has no session user, logout falls back to the rme cookie, then to a Bearer value. It validates each one with ApiTokenAPI.fromJwt (signature, expiry, current rememberMeToken, active user) and accepts only a login token. A token that fails validation, or an API token, changes nothing. This covers a logout from a browser whose session has already timed out.

Behavior change

Logout now ends token-based access for that user everywhere, like "log out of all devices". Other browsers' live HTTP sessions keep working until they time out, but can no longer be revived from their old cookie. Scripts that use a login token as a Bearer token stop working when that user logs out in the UI or changes their password. API tokens are not affected, and remain the supported way to authenticate scripts. When the OAuth callback fails in a browser that already had a session, the cleanup now ends that user's other login tokens too.

Rollback is safe: there is no schema change, and an older build simply keeps validating against whatever value is stored.

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (add notes if applicable)

Tests

LoginTokenInvalidationTest (new, registered in MainSuite2a), 9 tests, 0 failures:

  • A token issued before logout, updatePassword, save with a new password, or PUT /api/v1/users with a password is rejected afterwards. A fresh login token works.
  • save without a password change keeps the token.
  • Logout during Login As changes the administrator's value and leaves the impersonated user's alone.
  • Logout with only a valid rme cookie and no session changes the value. A cookie with an altered signature or an old token id changes nothing.
  • LoginServiceAPI.doLogout (the SAML path) changes the value.

On the code before this PR, the logout, updatePassword, save-with-new-password, Login As, PUT /api/v1/users and cookie-only tests fail on their "must be rejected" assertions. MainSuite2a is the shortest MainSuite: its median job time over four recent merged PRs is 22.7 min, against 39.5 to 45.5 min for the others.

These were also checked by hand against 26.10.23-02 with the changed classes swapped in. After GET /api/v1/logout, the old rme cookie, the full pre-logout cookie set, and the token as Bearer all got 401; before the change they got 200. With two browsers sharing a login token, a UI logout in one makes that token stop working in both, while the other browser's live session continues.

Additional Info

  • UserDataGen.remove throws an NPE ("replacementUserRole is null") in the test's cleanup on a fresh test database, so the users the test creates are not deleted. This happens on main too. It is logged and does not affect the tests.
  • Possible follow-up: drop skinid from the general user update SQL in UserFactoryImpl.save, and send every change of this value through updateRememberMeToken. Then a full save of a stale user object on another node cannot write an old value back.

A login token (the JWT set as the login cookie) is only checked against
the user's stored login token id, and logout never changed that value, so
a copy of the cookie taken before logout kept working until it expired.

Logout now gives the user a new login token id before the session is
invalidated, so every login token issued to that user earlier is rejected.
The value is written with a single-column update and the user is evicted
from the cache, so other nodes in a cluster read the new value and a
concurrent save of other user fields is not overwritten. A failure is
logged and does not stop the logout. Logging out therefore ends
token-based access for that user on every device; API tokens are not
affected.
…nges

Changing a password did not change the user's login token id, so login
tokens issued with the old password kept working until they expired.

Setting a new password now also gives the user a new login token id, as
part of the same save, on these paths:

- UserAPI.save with validatePassword, used by the legacy user edit
  screen and the current-user edit (PUT /api/v1/users/current).
- The admin user edit endpoint (PUT /api/v1/users) when the request
  carries a password.
- UserAPI.updatePassword, used when a password reset is completed.

A save that does not change the password leaves the login token id
alone, so profile edits and the save done on each login do not end the
user's sessions.
…hout a session

Logout now picks the user whose login token id it changes as follows.

While an administrator is logged in as another user, the session's user
is the impersonated one, but the login cookie the browser holds belongs
to the administrator. Logout now changes the administrator's login token
id and leaves the impersonated user's alone. Ending Login As (returning
to the administrator) still changes nothing.

When the request has no session user, for example /dotAdmin/logout after
the HTTP session has expired, logout now reads the login token sent in
the login cookie or as a Bearer header and changes that user's login
token id. Only a token that passes the usual validation (signature,
expiry and current token id) is used, so a token that does not validate
changes nothing.
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Oct 27, 2026
@claude

claude Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @swicken's task in 52s —— View job


SDK Compatibility Analysis

  • Read SDK breaking-change categories reference (docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md)
  • Get full PR diff (git diff 50bb2ab8...c97750c2)
  • Analyze diff against each category (G-1/G-2/G-3, R-1, U-1/U-2, H-1)
  • Post verdict

Verdict: No SDK breaking change.

All 12 changed files are backend login/session-token code and its integration test:

  • LazyUserAPIWrapper.java, LoginServiceAPIFactory.java, UserAPI.java/UserAPIImpl.java, UserFactory.java/UserFactoryImpl.java, UserPersistence.java — internal logout/password-change logic (invalidateLoginTokens, updateRememberMeToken), no wire-format change.
  • ApiTokenResource.java (PUT /api/v1/users/{userId}/revoke, PUT /api/v1/users/revoke) — swaps an inline userToken.setSkinId(...); save(...) for a call to UserAPI.invalidateLoginTokens(...). Response shape is unchanged (ResponseEntityMapView(Map.of("revoked", userId))).
  • UserResource.java (PUT /api/v1/users) — after a password change, additionally calls invalidateLoginTokens and updates the in-memory rememberMeToken. No change to the request or response JSON shape.
  • MainSuite2a.java, LoginTokenInvalidationTest.java, UserDataGen.java — test-only.

None of these touch the categories in the reference doc:

  • No GraphQL schema/resolver changes (graphql.page/graphql.content surface untouched) — rules out G-1/G-2/G-3.
  • No changes to /api/v1/nav, /api/v1/content, or /api/v1/page/* response shapes — rules out R-1. The two REST endpoints touched (/api/v1/users/.../revoke, PUT /api/v1/users) are admin/user-management endpoints, not part of the @dotcms/* content-delivery SDK contract, and their response envelopes are unchanged regardless.
  • No UVE postMessage protocol changes (__DOTCMS_UVE_EVENT__ / DotCMSUVEAction) — rules out U-1/U-2.
  • No changes to SdkVersionWebInterceptor, X-DotCMS-Version/X-DotCMS-Min-SDK headers, or compareVersions()/sdk-compatibility.ts — rules out H-1.

A confirming grep across the full diff for graphql/nav/UVE/postMessage/SdkVersion/sdk-compat/page-api/navigation-api paths returned zero matches.

No comment or label added, per instructions (only required when a breaking change is found).

@swicken swicken added the PR : dotbot review Trigger dotbot AI code review and the post-merge QA test plan label Oct 27, 2026
Comment thread dotCMS/src/main/java/com/dotmarketing/business/UserFactoryImpl.java
Comment thread dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java Outdated
Comment thread dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java
…inTokens

Every change to a user's login token id (user_.skinid) now goes through
UserAPI.invalidateLoginTokens and UserFactory.updateRememberMeToken: logout,
the three password-change paths, the first value set on login, and the two
revoke endpoints in ApiTokenResource. The password paths call it after the
save, in the same transaction, and invalidateLoginTokens returns the new value
so callers can keep the User they hold current.

The general user update in UserFactoryImpl.save no longer writes skinid, so
saving a User loaded before a logout or password change cannot write the old
value back. The insert of a new user still writes it.

updateRememberMeToken normalizes the user id once and uses it for both the
update and the cache eviction, and a blank id now throws DotDataException
instead of a NullPointerException. The Javadoc explains why a random UUID
works: a login JWT is only accepted while its jti matches the stored value.
…cy updates

updateRememberMeToken evicted the user from the cache while the surrounding
transaction was still open. Until the commit, a request on any node could
read the committed old login token id and cache it again, and nothing evicted
that entry afterwards. It now also evicts the user from a commit listener;
with no transaction open the listener runs right away.

The legacy Liferay update (UserPersistence.update) wrote skinid from the
in-memory User when no tracked field had changed, so a User loaded before a
logout could write the replaced value back. That branch now keeps the stored
value. Changes to tracked fields still rotate it to a fresh UUID.
@swicken

swicken commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review. 3b3725b covers the points:

  • The cache key now matches the row updated: the id is normalized once and used for both the update and the cache eviction.
  • A null or blank userId in updateRememberMeToken, and so in invalidateLoginTokens, now throws a DotDataException instead of a NullPointerException. I went with an exception rather than a silent no-op because a caller asking to invalidate tokens should know when nothing happened. The logout path already skips the call when it has no user.
  • The follow-up you flagged is done as well. skinid is no longer written by the general user update, and every change to it goes through updateRememberMeToken, including the first value on login and the revoke endpoints. A stale User saved after a logout no longer brings the old token back, and there is a test for that.
  • da35c85 closes two gaps found in a second review. The cache eviction now also runs after the surrounding transaction commits, and the legacy Liferay update keeps the stored value instead of writing the one from the in-memory User.
  • The OAuth callback cleanup side effect stays as described in the PR body.

@dotCMS-Machine-User dotCMS-Machine-User left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ dotbot review: all reviewer models (meta/muse-spark-1.3, ~z-ai/glm-latest) agree — patch is correct.

approved automatically by dotbot

# Conflicts:
#	dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java
@github-actions

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: meta/muse-spark-1.3 (medium)
  • Overall: patch is correct
  • New findings this run: 0
  • Prior unresolved dotbot findings still relevant: 0
  • Active findings total: 0

Logout, password-change, revoke, and first-login paths rotate the stored login token id via single-column update with cache eviction, and general saves no longer restore stale values. No introduced P0/P1 defect with concrete in-repo evidence was found.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · meta/muse-spark-1.3 · medium

@github-actions

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: ~z-ai/glm-latest (medium)
  • Overall: patch is correct
  • New findings this run: 0
  • Prior unresolved dotbot findings still relevant: 0
  • Active findings total: 0

All token-id rotation paths funnel through UserAPI.invalidateLoginTokens → UserFactory.updateRememberMeToken, which writes only the skinid column, normalizes the id once for both the SQL parameter and cache eviction, and evicts both immediately and after transaction commit. The general UserFactoryImpl update and the legacy UserPersistence update no longer write this column, so a stale User save cannot resurrect an old token id. Logout resolves the user before session invalidation (Login As principal, session user, then a validated rme/Bearer login JWT, excluding API tokens), and the password paths rotate only when a password is present; the previously flagged cache-key normalization issue is fixed in the current code. No P0/P1 defect introduced by this patch was identified.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · ~z-ai/glm-latest · medium

@dotCMS-Machine-User dotCMS-Machine-User left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ dotbot review: all reviewer models (meta/muse-spark-1.3, ~z-ai/glm-latest) agree — patch is correct.

approved automatically by dotbot

@swicken
swicken added this pull request to the merge queue Oct 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 29, 2026

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

Area : Backend PR changes Java/Maven backend code PR : dotbot review Trigger dotbot AI code review and the post-merge QA test plan

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants