173 lines
12 KiB
Gherkin
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
|