fix: resetting the password or cancelling a registration ends existing sessions: a token issued before the change is refused
This commit is contained in:
1 parent
81c4b62175
commit
f3ec3d2fa2
15 files changed
+844
-13
No files matched your search
@@ -0,0 +1,172 @@
|
||||
Feature: A password reset invalidates every jwt issued before it, and a cancelled registration keeps doing so
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want a jwt minted before a user resets their password to stop working the moment the reset
|
||||
succeeds, while a jwt obtained by logging in again afterward keeps working, and I want the
|
||||
session a cancelled registration already destroys today to keep being destroyed once this
|
||||
slice's new check is layered on top of it
|
||||
So that resetting a password — usually done because a session or credential may be compromised —
|
||||
actually ends every session an attacker might be holding, which is the whole reason people
|
||||
reset a password in the first place
|
||||
|
||||
Background:
|
||||
Given user.User gains a new field, TokenVersion uint32, defaulting to 0 for every existing and
|
||||
newly created user
|
||||
And user.Repository.ResetPassword(userId, hashedPassword) now increments TokenVersion by 1 in
|
||||
the same single write that already sets Password and clears PasswordResetCode and
|
||||
PasswordResetExpiration (features/reset_password.feature), so every successful password
|
||||
reset advances the counter by exactly one
|
||||
And auth.Handler.SetJwtCookie now takes the full user.User instead of a bare userId, and encodes
|
||||
the jwt's claims as {"user_id": user.ID, "token_version": user.TokenVersion}; Login and
|
||||
Register, which already hold the freshly loaded or created user.User at their call site, pass
|
||||
it through unchanged otherwise
|
||||
And a new helper, claims.TokenVersion(r), reads the "token_version" claim from the verified jwt
|
||||
the same way claims.UserId already reads "user_id": missing key or a non-numeric value both
|
||||
return ok=false, never a panic
|
||||
And this slice adds a new middleware, middleware.RequireCurrentToken(getUser
|
||||
*useCases.GetUserUseCase), mounted immediately after jwtauth.Authenticator in both protected
|
||||
groups api/router.go already defines: NewArticleRouter's group (ahead of the existing
|
||||
RequireVerifiedUser) and NewAuthRouter's group (ahead of Verify and CancelRegistration)
|
||||
And RequireCurrentToken resolves user_id with claims.UserId (401 apperr.UnauthorizedErrorMessage
|
||||
if absent or malformed, the same response RequireVerifiedUser already gives), loads the user
|
||||
with getUser.GetUser(userId) (401 apperr.UnauthorizedErrorMessage on user.ErrUserNotFound, 500
|
||||
"Internal server error" on any other error), resolves token_version with claims.TokenVersion
|
||||
(401 apperr.UnauthorizedErrorMessage if absent or malformed), and 401s with
|
||||
apperr.UnauthorizedErrorMessage when that value does not equal the loaded user's TokenVersion;
|
||||
only when all four checks pass does it call next
|
||||
And cancelling a pending registration (features/cancel_pending_registration.feature) already
|
||||
deletes the user row outright, and every protected handler already re-resolves the user by id
|
||||
on each request, so RequireCurrentToken's user.ErrUserNotFound branch already covers a
|
||||
pre-cancellation token without any change to CancelRegistrationUseCase or user.Repository.Delete;
|
||||
this slice only adds regression scenarios for that existing behaviour
|
||||
And nothing in this slice changes RequireVerifiedUser's own IsVerified check, or the status
|
||||
codes and bodies any existing scenario already asserts for a missing or malformed user_id
|
||||
claim; RequireCurrentToken only adds a new way to fail before those checks run
|
||||
|
||||
Scenario: A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified true, password hash of "oldpassword1", TokenVersion 0, whose stored
|
||||
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
|
||||
one hour from now
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends POST /v1/article with that cookie and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 200
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And the user repository's record for id 7 now has TokenVersion 1
|
||||
When the client sends POST /v1/article again with the same pre-reset cookie and body
|
||||
{"title": "Another title", "body": "Some other body"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
And no article was created by that request
|
||||
When the client sends POST /v1/auth/login with body
|
||||
{"username": "alice", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And a jwt cookie is set whose token_version claim is 1
|
||||
When the client sends POST /v1/article with that new cookie and body
|
||||
{"title": "Another title", "body": "Some other body"}
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: A stale jwt is also refused on the auth group's own protected routes, not just article routes
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified false, TokenVersion 0, whose stored PasswordResetCode is the bcrypt hash of
|
||||
"reset-code-1" and whose PasswordResetExpiration is one hour from now
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
When the client sends POST /v1/auth/verify with that pre-reset cookie and body {"code": "anything"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
When the client sends DELETE /v1/auth/register with that same pre-reset cookie
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
And the user repository's record for id 7 still exists
|
||||
|
||||
Scenario: Resetting the password twice invalidates both the original jwt and the jwt from the interim login
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified true, TokenVersion 0, whose stored PasswordResetCode is the bcrypt hash of
|
||||
"reset-code-1" and whose PasswordResetExpiration is one hour from now
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0, called "original cookie"
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And the user repository's record for id 7 now has TokenVersion 1
|
||||
When the client sends POST /v1/auth/login with body
|
||||
{"username": "alice", "password": "newpassword1"}
|
||||
Then a jwt cookie is set whose token_version claim is 1, called "interim cookie"
|
||||
Given the user repository's record for id 7 now also has PasswordResetCode set to the bcrypt
|
||||
hash of "reset-code-2" and PasswordResetExpiration one hour from now
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "alice@example.com", "code": "reset-code-2", "password": "newerpassword1"}
|
||||
Then the response status is 200
|
||||
And the user repository's record for id 7 now has TokenVersion 2
|
||||
When the client sends POST /v1/article with the "original cookie" and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 401
|
||||
When the client sends POST /v1/article with the "interim cookie" and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 401
|
||||
When the client sends POST /v1/auth/login with body
|
||||
{"username": "alice", "password": "newerpassword1"}
|
||||
Then a jwt cookie is set whose token_version claim is 2
|
||||
When the client sends POST /v1/article with that cookie and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: Resetting one user's password never invalidates another user's jwt
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion
|
||||
0, whose stored PasswordResetCode is the bcrypt hash of "reset-code-1" and whose
|
||||
PasswordResetExpiration is one hour from now
|
||||
And a user repository also containing user id 9, username "bob", is_verified true, TokenVersion 0
|
||||
And a valid jwt cookie for user id 9 encoding token_version 0
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And the user repository's record for id 9 still has TokenVersion 0
|
||||
When the client sends POST /v1/article with bob's cookie and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 200
|
||||
|
||||
Scenario Outline: A cancelled registration still refuses a jwt minted before the cancellation, on every protected route
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified false, TokenVersion 0
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends DELETE /v1/auth/register with that cookie
|
||||
Then the response status is 200
|
||||
When the client sends <method> <path> with the same pre-cancellation cookie <and body>
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Examples:
|
||||
| method | path | and body |
|
||||
| POST | /v1/auth/verify | and body {"code": "anything"} |
|
||||
| DELETE | /v1/auth/register | and no body |
|
||||
|
||||
Scenario: A jwt with no token_version claim at all is refused, treated the same as a mismatch
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 0
|
||||
And a validly signed jwt cookie whose claims are exactly {"user_id": 7}, with no token_version key
|
||||
When the client sends POST /v1/article with that cookie and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Scenario: A jwt whose token_version claim is a string, not a number, is refused instead of crashing
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 0
|
||||
And a validly signed jwt cookie whose claims are {"user_id": 7, "token_version": "0"}
|
||||
When the client sends POST /v1/article with that cookie and body
|
||||
{"title": "A valid title", "body": "Some article body"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
And the handler does not panic
|
||||
|
||||
Scenario: The use case itself advances TokenVersion by one on every successful reset, independently of any handler
|
||||
Given a user.Repository fake containing user id 3, email "alice@example.com", TokenVersion 5,
|
||||
whose PasswordResetCode is the bcrypt hash of "reset-code-1" and whose
|
||||
PasswordResetExpiration is one hour from now
|
||||
And a ResetPasswordUseCase built directly over that fake and a hasher
|
||||
When ResetPassword is called with "alice@example.com", "reset-code-1" and "newpassword1"
|
||||
Then the call returns nil
|
||||
And the fake repository's record for id 3 now has TokenVersion 6
|
||||
Reference in new issue
Block a user