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 with the same pre-cancellation cookie 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