fix: resetting the password or cancelling a registration ends existing sessions: a token issued before the change is refused

This commit is contained in:
fsylvain committed 2026-09-25 05:03:34 +02:00
1 parent 81c4b62175
commit f3ec3d2fa2
15 files changed
+840 -9

No files matched your search

+4 -4
View File
@@ -99,8 +99,8 @@ func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useC
}
}
func (h *Handler) SetJwtCookie(w *http.ResponseWriter, userId uint32) error {
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": userId})
func (h *Handler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
@@ -151,7 +151,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
_ = h.SetJwtCookie(&w, dbUser.ID)
_ = h.SetJwtCookie(&w, dbUser)
message, _ := json.Marshal(map[string]any{"message": "User logged in! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
@@ -185,7 +185,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
return
}
_ = h.SetJwtCookie(&w, createdUser.ID)
_ = h.SetJwtCookie(&w, createdUser)
message, _ := json.Marshal(map[string]any{"message": "User registered! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
+38 -5
View File
@@ -16,22 +16,55 @@ func JsonContentTypeMiddleware(next http.Handler) http.Handler {
})
}
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter, r *http.Request) (user.User, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
return user.User{}, false
}
currentUser, err := getUser.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
return user.User{}, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return user.User{}, false
}
return currentUser, true
}
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if tokenVersion != currentUser.TokenVersion {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
+19
View File
@@ -24,3 +24,22 @@ func UserId(r *http.Request) (uint32, bool) {
return uint32(userIdFloat), true
}
func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return 0, false
}
rawTokenVersion, ok := claims["token_version"]
if !ok {
return 0, false
}
tokenVersionFloat, ok := rawTokenVersion.(float64)
if !ok {
return 0, false
}
return uint32(tokenVersionFloat), true
}
+2
View File
@@ -40,6 +40,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -64,6 +65,7 @@ func NewAuthRouter(container *UseCases, isProduction bool) http.Handler {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", authHandler.Verify)
r.Delete("/register", authHandler.CancelRegistration)
})
+172
View File
@@ -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
+93
View File
@@ -0,0 +1,93 @@
# QA procedure: stale_token_invalidation
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`) and reusing the
router/handler wiring pattern from `test/account_verification_http_test.go` and
`test/reset_password_http_test.go` (or equivalent).
## Test infrastructure changes needed
1. `mockUserRepo` needs a `TokenVersion uint32` field on its stored `user.User` values, and its
`ResetPassword` fake must increment it by 1 alongside setting the password hash and clearing the
reset code, mirroring the production contract this slice adds.
2. HTTP-level checks wire `mockUserRepo`, `useCases.NewResetPasswordUseCase(repo, hasher)`,
`useCases.NewAuthenticateUserUseCase(repo, hasher)`, `useCases.NewGetUserUseCase(repo)`, the
article use cases, and the rest of the existing container into `auth.NewHandler(...)` and
`article.NewHandler(...)` with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
3. Build two `chi.NewRouter()` instances (or one router mounting both) that mirror
`api/router.go`:
- the article protected group: `jwtauth.Verifier(token)` → (mounted once, top-level, per
`InitBackendRoutes`) → inside the group: `jwtauth.Authenticator(token)` →
`middleware.RequireCurrentToken(getUserUseCase)` → `middleware.RequireVerifiedUser(getUserUseCase)`
→ `POST /article`;
- the auth protected group: `jwtauth.Verifier(token)` → `jwtauth.Authenticator(token)` →
`middleware.RequireCurrentToken(getUserUseCase)` → `POST /verify`, `DELETE /register`;
- the auth public routes `POST /login`, `POST /password-reset/confirm`, `POST /register` mounted
unprotected, as today.
4. Call `validator.InitValidator()` once per test.
5. Mint jwt cookies directly with `token.Encode(map[string]any{"user_id": uint32(7), "token_version":
uint32(0)})` for "pre-reset" cookies; decode a `Set-Cookie` response header's jwt with
`jwtauth.New(...).Decode(tokenString)` or by parsing the JWT payload to assert its
`token_version` claim, mirroring how `secureAttributeFromResponse` in
`test/runtime_settings_injection_http_test.go` already parses `Set-Cookie`.
## HTTP-level checks
6. Seed user id 7 "alice", `is_verified: true`, `TokenVersion: 0`, `PasswordResetCode` = bcrypt hash
of "reset-code-1", `PasswordResetExpiration` one hour ahead. Mint a jwt cookie encoding
`{"user_id": 7, "token_version": 0}`.
- `POST /v1/article` with that cookie and a valid body → assert `200`.
- `POST /v1/auth/password-reset/confirm` with
`{"email":"alice@example.com","code":"reset-code-1","password":"newpassword1"}` → assert `200`
and `repo.users[7].TokenVersion == 1`.
- `POST /v1/article` again with the *same* pre-reset cookie → assert `401`, body equals
`apperr.UnauthorizedErrorMessage`, and the article use case's create path was not invoked
(article count in the fake repo unchanged).
- `POST /v1/auth/login` with `{"username":"alice","password":"newpassword1"}` → assert `200`, a
`jwt` `Set-Cookie` is present, and decoding it shows `token_version == 1`.
- `POST /v1/article` with that new cookie → assert `200`.
7. Same seed as case 6 minus the article calls. After the password reset:
- `POST /v1/auth/verify` with the pre-reset cookie and `{"code":"anything"}` → assert `401`, body
equals `apperr.UnauthorizedErrorMessage`.
- `DELETE /v1/auth/register` with the pre-reset cookie → assert `401`, body equals
`apperr.UnauthorizedErrorMessage`, and `repo.Get(7)` still succeeds.
8. Double-reset case: seed as in 6, reset once (`TokenVersion` → 1), log in (capture "interim"
cookie, `token_version: 1`), re-seed `PasswordResetCode` to bcrypt hash of "reset-code-2" with a
fresh expiration, reset again with `{"email":"alice@example.com","code":"reset-code-2","password":"newerpassword1"}`
→ assert `TokenVersion == 2`. Assert both the original (`token_version: 0`) and interim
(`token_version: 1`) cookies now get `401` on `POST /v1/article`, and a jwt minted after logging
in with `newerpassword1` (`token_version: 2`) gets `200`.
9. Isolation case: seed user id 7 "alice" as in 6 and user id 9 "bob", `is_verified: true`,
`TokenVersion: 0`. Mint a cookie for user id 9 encoding `token_version: 0`. Reset alice's
password. Assert `repo.users[9].TokenVersion` is still `0`, and `POST /v1/article` with bob's
cookie still returns `200`.
10. Cancelled-registration regression, table-driven over `POST /v1/auth/verify` (body
`{"code":"anything"}`) and `DELETE /v1/auth/register` (no body): seed user id 7 "alice",
`is_verified: false`, `TokenVersion: 0`. Mint a cookie encoding `{"user_id":7,"token_version":0}`.
`DELETE /v1/auth/register` with it → assert `200`. Then send the pre-cancellation cookie against
each of the two routes in turn (fresh repo/router state per sub-case, or re-running the cancel
step) → assert `401` and body `apperr.UnauthorizedErrorMessage` for both.
11. Missing-claim case: seed user id 7, `is_verified: true`, `TokenVersion: 0`. Mint a jwt with
`token.Encode(map[string]any{"user_id": uint32(7)})` (no `token_version` key at all).
`POST /v1/article` → assert `401`, body `apperr.UnauthorizedErrorMessage`.
12. Wrong-type case: mint a jwt with
`token.Encode(map[string]any{"user_id": uint32(7), "token_version": "0"})` (string, not a
number). `POST /v1/article` → assert `401`, body `apperr.UnauthorizedErrorMessage`, and confirm
the test process does not panic (no `recover` needed — a crash would fail the test run outright).
## Use-case-level checks (no HTTP)
13. `mockUserRepo` seeded with user id 3, email "alice@example.com", `TokenVersion: 5`,
`PasswordResetCode` = bcrypt hash of "reset-code-1", expiration one hour ahead. Build
`ResetPasswordUseCase` directly over the fake. Call
`ResetPassword("alice@example.com", "reset-code-1", "newpassword1")` → assert `err == nil` and
`repo.users[3].TokenVersion == 6`.
## Manual verification (not exercised by the Go test)
- The SPA behaviour of being logged out / redirected to login when an API call comes back `401`
after a password reset or a cancelled registration (wherever that's handled globally, e.g. an
axios/fetch interceptor) is out of scope for this slice's automated test and should be checked by
hand against a running dev build.
- Confirming against the real SQLite file that `TokenVersion` persists correctly through
`gorm.AutoMigrate` for existing rows (defaulting to `0`) is a manual check against a real
migration, not re-verified by the in-memory fakes here.
+1
View File
@@ -24,6 +24,7 @@ type User struct {
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
TokenVersion uint32 `json:"token_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -30,6 +30,7 @@ func UserToDomain(m models.User) user.User {
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
TokenVersion: m.TokenVersion,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
@@ -45,6 +46,7 @@ func UserToModel(a user.User) models.User {
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
TokenVersion: a.TokenVersion,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
@@ -14,6 +14,7 @@ type User struct {
VerificationCode *string `gorm:"unique"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -160,6 +160,7 @@ func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (do
localUser.Password = hashedPassword
localUser.PasswordResetCode = mappers.DomainToNullableString("")
localUser.PasswordResetExpiration = time.Time{}
localUser.TokenVersion++
err = u.db.Save(&localUser).Error
if err != nil {
return domainUser.User{}, err
+67
View File
@@ -0,0 +1,67 @@
package test
import (
"RenewCMS/api/pkg/claims"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/jwtauth/v5"
)
func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode map[string]any) *http.Request {
t.Helper()
_, tokenString, err := token.Encode(claimsToEncode)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
decoded, err := token.Decode(tokenString)
if err != nil {
t.Fatalf("failed to decode jwt: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
return req.WithContext(jwtauth.NewContext(req.Context(), decoded, nil))
}
func TestClaimsTokenVersion(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("returns the numeric token_version claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
got, ok := claims.TokenVersion(req)
if !ok {
t.Fatalf("expected ok true")
}
if got != 3 {
t.Errorf("expected 3, got %d", got)
}
})
t.Run("returns ok false when the token_version key is missing", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)})
_, ok := claims.TokenVersion(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok false when token_version is not numeric", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"})
_, ok := claims.TokenVersion(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok false when there is no verified jwt in context", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
_, ok := claims.TokenVersion(req)
if ok {
t.Errorf("expected ok false")
}
})
}
+21
View File
@@ -40,6 +40,27 @@ func TestResetPasswordUseCase(t *testing.T) {
}
})
t.Run("The use case itself advances TokenVersion by one on every successful reset, independently of any handler", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
repo.users[3] = user.User{
ID: 3,
Email: "alice@example.com",
TokenVersion: 5,
PasswordResetCode: "hashed:reset-code-1",
PasswordResetExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.users[3].TokenVersion != 6 {
t.Errorf("expected TokenVersion 6, got %d", repo.users[3].TokenVersion)
}
})
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
+378
View File
@@ -0,0 +1,378 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
authHandler := auth.NewHandler(
token,
getUserUseCase,
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
})
})
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Post("/password-reset", authHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", authHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Post("/verify", authHandler.Verify)
r.Delete("/register", authHandler.CancelRegistration)
})
})
})
return r
}
func newStaleTokenAliceRepo(isVerified bool) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{
ID: 7,
Username: "alice",
Email: "alice@example.com",
Password: "hashed:oldpassword1",
IsVerified: isVerified,
TokenVersion: 0,
PasswordResetCode: "hashed:reset-code-1",
PasswordResetExpiration: time.Now().Add(time.Hour),
}
repo.nextID = 7
return repo
}
func mintStaleTokenCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(claims)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: tokenString}
}
func sendStaleTokenRequest(router *chi.Mux, method, target string, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
}
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeJwtPayload(t *testing.T, tokenString string) map[string]any {
t.Helper()
parts := bytes.Split([]byte(tokenString), []byte("."))
if len(parts) != 3 {
t.Fatalf("expected a 3-part jwt, got %q", tokenString)
}
payload, err := base64.RawURLEncoding.DecodeString(string(parts[1]))
if err != nil {
t.Fatalf("failed to base64-decode jwt payload: %v", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatalf("failed to unmarshal jwt payload: %v", err)
}
return claims
}
func jwtCookieFromResponse(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, value := range rec.Result().Header.Values("Set-Cookie") {
cookie, err := http.ParseSetCookie(value)
if err != nil {
t.Fatalf("failed to parse Set-Cookie header %q: %v", value, err)
}
if cookie.Name == "jwt" {
return cookie
}
}
t.Fatalf("expected a jwt Set-Cookie header")
return nil
}
func tokenVersionClaim(t *testing.T, claims map[string]any) float64 {
t.Helper()
raw, ok := claims["token_version"]
if !ok {
t.Fatalf("expected a token_version claim, got %+v", claims)
}
value, ok := raw.(float64)
if !ok {
t.Fatalf("expected token_version to be numeric, got %T", raw)
}
return value
}
func TestStaleTokenInvalidationHttp(t *testing.T) {
validator.InitValidator()
t.Run("A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
first := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, preResetCookie)
if first.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", first.Code, first.Body.String())
}
resetRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/password-reset/confirm", `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`, nil)
if resetRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", resetRec.Code, resetRec.Body.String())
}
if userRepo.users[7].TokenVersion != 1 {
t.Fatalf("expected TokenVersion 1, got %d", userRepo.users[7].TokenVersion)
}
articlesBefore := len(articleRepo.articles)
second := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "Another title", "body": "Some other body"}`, preResetCookie)
if second.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", second.Code, second.Body.String())
}
if second.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", second.Body.String())
}
if len(articleRepo.articles) != articlesBefore {
t.Errorf("expected no article to be created by the stale request")
}
loginRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "newpassword1"}`, nil)
if loginRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", loginRec.Code, loginRec.Body.String())
}
newCookie := jwtCookieFromResponse(t, loginRec)
if got := tokenVersionClaim(t, decodeJwtPayload(t, newCookie.Value)); got != 1 {
t.Errorf("expected token_version 1, got %v", got)
}
third := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "Another title", "body": "Some other body"}`, newCookie)
if third.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", third.Code, third.Body.String())
}
})
t.Run("A stale jwt is also refused on the auth group's own protected routes, not just article routes", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
resetRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/password-reset/confirm", `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`, nil)
if resetRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", resetRec.Code, resetRec.Body.String())
}
verifyRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/verify", `{"code": "anything"}`, preResetCookie)
if verifyRec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", verifyRec.Code, verifyRec.Body.String())
}
if verifyRec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", verifyRec.Body.String())
}
cancelRec := sendStaleTokenRequest(router, http.MethodDelete, "/v1/auth/register", "", preResetCookie)
if cancelRec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", cancelRec.Code, cancelRec.Body.String())
}
if cancelRec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", cancelRec.Body.String())
}
if _, err := userRepo.Get(7); err != nil {
t.Errorf("expected user 7 to still exist: %v", err)
}
})
t.Run("Resetting the password twice invalidates both the original jwt and the jwt from the interim login", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
originalCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
firstResetRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/password-reset/confirm", `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`, nil)
if firstResetRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", firstResetRec.Code, firstResetRec.Body.String())
}
if userRepo.users[7].TokenVersion != 1 {
t.Fatalf("expected TokenVersion 1, got %d", userRepo.users[7].TokenVersion)
}
interimLoginRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "newpassword1"}`, nil)
interimCookie := jwtCookieFromResponse(t, interimLoginRec)
if got := tokenVersionClaim(t, decodeJwtPayload(t, interimCookie.Value)); got != 1 {
t.Errorf("expected interim token_version 1, got %v", got)
}
updated := userRepo.users[7]
updated.PasswordResetCode = "hashed:reset-code-2"
updated.PasswordResetExpiration = time.Now().Add(time.Hour)
userRepo.users[7] = updated
secondResetRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/password-reset/confirm", `{"email": "alice@example.com", "code": "reset-code-2", "password": "newerpassword1"}`, nil)
if secondResetRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", secondResetRec.Code, secondResetRec.Body.String())
}
if userRepo.users[7].TokenVersion != 2 {
t.Fatalf("expected TokenVersion 2, got %d", userRepo.users[7].TokenVersion)
}
originalArticleRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, originalCookie)
if originalArticleRec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", originalArticleRec.Code, originalArticleRec.Body.String())
}
interimArticleRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, interimCookie)
if interimArticleRec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", interimArticleRec.Code, interimArticleRec.Body.String())
}
finalLoginRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "newerpassword1"}`, nil)
finalCookie := jwtCookieFromResponse(t, finalLoginRec)
if got := tokenVersionClaim(t, decodeJwtPayload(t, finalCookie.Value)); got != 2 {
t.Errorf("expected final token_version 2, got %v", got)
}
finalArticleRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, finalCookie)
if finalArticleRec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", finalArticleRec.Code, finalArticleRec.Body.String())
}
})
t.Run("Resetting one user's password never invalidates another user's jwt", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(true)
userRepo.users[9] = user.User{ID: 9, Username: "bob", IsVerified: true, TokenVersion: 0}
articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
bobCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(9), "token_version": uint32(0)})
resetRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/auth/password-reset/confirm", `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`, nil)
if resetRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", resetRec.Code, resetRec.Body.String())
}
if userRepo.users[9].TokenVersion != 0 {
t.Fatalf("expected bob's TokenVersion to stay 0, got %d", userRepo.users[9].TokenVersion)
}
bobArticleRec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, bobCookie)
if bobArticleRec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", bobArticleRec.Code, bobArticleRec.Body.String())
}
})
cancelledRegistrationCases := []struct {
method string
path string
body string
}{
{http.MethodPost, "/v1/auth/verify", `{"code": "anything"}`},
{http.MethodDelete, "/v1/auth/register", ""},
}
for _, tc := range cancelledRegistrationCases {
t.Run("A cancelled registration still refuses a jwt minted before the cancellation, on every protected route: "+tc.method+" "+tc.path, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preCancelCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
cancelRec := sendStaleTokenRequest(router, http.MethodDelete, "/v1/auth/register", "", preCancelCookie)
if cancelRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", cancelRec.Code, cancelRec.Body.String())
}
rec := sendStaleTokenRequest(router, tc.method, tc.path, tc.body, preCancelCookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
t.Run("A jwt with no token_version claim at all is refused, treated the same as a mismatch", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7)})
rec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A jwt whose token_version claim is a string, not a number, is refused instead of crashing", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"})
rec := sendStaleTokenRequest(router, http.MethodPost, "/v1/article/", `{"title": "A valid title", "body": "Some article body"}`, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
+1
View File
@@ -205,6 +205,7 @@ func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user
u.Password = hashedPassword
u.PasswordResetCode = ""
u.PasswordResetExpiration = time.Time{}
u.TokenVersion++
m.users[userId] = u
return u, nil
}
+40
View File
@@ -131,6 +131,12 @@ func TestTheUserRepositoryReportsAMissingUserAsADomainErrorOnAWrite(t *testing.T
assertWriteResultIsUserNotFound(t, err)
})
t.Run("ResetPassword and hashedPassword newHashedPassword", func(t *testing.T) {
repo := newSeededUserRepository(t)
_, err := repo.ResetPassword(9, "newHashedPassword")
assertWriteResultIsUserNotFound(t, err)
})
t.Run("Delete", func(t *testing.T) {
repo := newSeededUserRepository(t)
err := repo.Delete(9)
@@ -138,6 +144,40 @@ func TestTheUserRepositoryReportsAMissingUserAsADomainErrorOnAWrite(t *testing.T
})
}
func TestTheUserRepositoryResetPasswordAdvancesTokenVersionOnASuccessfulWrite(t *testing.T) {
repo := newSeededUserRepository(t)
seeded, err := repo.GetByUsername("alice")
if err != nil {
t.Fatalf("failed to load seeded user: %v", err)
}
updated, err := repo.ResetPassword(seeded.ID, "newHashedPassword")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if updated.TokenVersion != seeded.TokenVersion+1 {
t.Errorf("expected TokenVersion %d, got %d", seeded.TokenVersion+1, updated.TokenVersion)
}
if updated.Password != "newHashedPassword" {
t.Errorf("expected password to be updated, got %q", updated.Password)
}
if updated.PasswordResetCode != "" {
t.Errorf("expected PasswordResetCode to be cleared, got %q", updated.PasswordResetCode)
}
if !updated.PasswordResetExpiration.IsZero() {
t.Errorf("expected PasswordResetExpiration to be zeroed, got %v", updated.PasswordResetExpiration)
}
reloaded, err := repo.Get(seeded.ID)
if err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if reloaded.TokenVersion != seeded.TokenVersion+1 {
t.Errorf("expected persisted TokenVersion %d, got %d", seeded.TokenVersion+1, reloaded.TokenVersion)
}
}
func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {