Repository navigation
security: make access tokens revocable (token generation counter) - #12
Open
IbraTech04 wants to merge 1 commit into
Open
IbraTech04 wants to merge 1 commit into
IbraTech04 wants to merge 1 commit into
Conversation
Password change revoked every refresh token but left the already-issued JWT working until its 15-minute expiry, because a JWT is self-contained and there was no jti denylist or version check. A stolen access token stayed usable for up to 15 minutes after the victim changed their password specifically to stop it. Same gap on logout-all-devices. Every token now carries a "tv" claim holding the account's token generation at mint time. AuthTokenFilter compares it against users.token_version on each request and leaves the context unauthenticated when they differ, so the entry point answers 401. The filter already loads the user on every request, so this adds no query. Authorities are still resolved from the database rather than read off the token, which is the existing behaviour and is preserved. StompJwtChannelInterceptor makes the same check at CONNECT: a WebSocket is authenticated once and lives long, so honouring an invalidated token there would outlive the JWT's own expiry rather than merely precede it. AuthenticatedUser.tokenVersionOf returns -1 for any principal that cannot report a generation, so an unexpected UserDetails fails closed rather than silently matching version 0. V9 rather than V8: V8 is taken by the refresh-token hashing migration on its own branch. No ordering dependency; Flyway tolerates the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Collaborator
Author
|
Before reviewing: This commit fundamentally makes authentication stateful - While not inherently a bad thing, at scale this may cause more unnecessary DB lookups. 🤷 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
AuthControllerpassword-change revokes all refresh tokens viarevokeAllUserTokens, but the already-issued JWT access token stays valid until natural expiry (lensbridge.app.jwtExpirationMs=900000, 15 min). There is no jti denylist and no version check, and a JWT is self-contained — so a stolen access token remains usable for up to 15 minutes after the victim changed their password precisely to stop it. Same gap on logout-all-devices.The fix
tvclaim holding the account's token generation at mint time.AuthTokenFiltercompares it againstusers.token_versionand, on mismatch, leaves the security context unauthenticated so the entry point answers 401. The filter already loads the user on every request, so this costs no extra query.StompJwtChannelInterceptormakes the same check at CONNECT. A WebSocket is authenticated once and then lives a long time, so honouring an invalidated token there would outlive the JWT's own expiry rather than merely precede it.AuthenticatedUser.tokenVersionOfreturns -1 for any principal that cannot report a generation, so an unexpectedUserDetailsfails closed instead of silently matching version 0.Verification
AuthTokenFilterRevocationTestcovers the three cases that matter: a current-generation token authenticates; a token from before a password change is rejected despite a valid signature and an unexpired clock; and a principal that cannot report a generation is rejected.StompJwtChannelInterceptorTest's fixture built a plain SpringUser, which now fails closed at -1, so it was updated to construct anAuthenticatedUser— the test was stale against a deliberate design choice, not a bug.Existing tokens carry no
tvclaim and are treated as stale, so live sessions re-authenticate once at deploy.Scope — one half of this unit is NOT done
The audit paired this with a second, unrelated defect:
GlobalExceptionHandlerreturns"Account is disabled. Please verify your email."forDisabledExceptionbut"Invalid username or password"forBadCredentials/UsernameNotFound, which lets an attacker enumerate which*.utoronto.caaddresses are registered-but-unverified. That is not addressed here —GlobalExceptionHandleris untouched. It needs a decision about whether the frontend relies on that message to show a "resend verification" prompt, so it is better as its own change.Merge notes
AuthController.java, which that same PR also edits. Expect a conflict depending on merge order.🤖 Generated with Claude Code
https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm