Files
RenewCMS/features/stale_token_invalidation.feature
T

173 lines
12 KiB
Gherkin

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