diff --git a/api/handlers/auth/authHandler.go b/api/handlers/auth/authHandler.go index 8c797f4..f6dc6db 100644 --- a/api/handlers/auth/authHandler.go +++ b/api/handlers/auth/authHandler.go @@ -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) diff --git a/api/middleware/auth.go b/api/middleware/auth.go index 26b0e6e..29d52b2 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -16,22 +16,55 @@ func JsonContentTypeMiddleware(next http.Handler) http.Handler { }) } -func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { +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 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 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) { - userId, ok := claims.UserId(r) + currentUser, ok := resolveCurrentUser(getUser, w, r) + if !ok { + return + } + + tokenVersion, ok := claims.TokenVersion(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return } - currentUser, err := getUser.GetUser(userId) - if err != nil { - if errors.Is(err, user.ErrUserNotFound) { - http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) - return - } - http.Error(w, "Internal server error", http.StatusInternalServerError) + 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 } diff --git a/api/pkg/claims/claims.go b/api/pkg/claims/claims.go index cd15d9f..6955866 100644 --- a/api/pkg/claims/claims.go +++ b/api/pkg/claims/claims.go @@ -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 +} diff --git a/api/router.go b/api/router.go index 5121c6b..4514921 100644 --- a/api/router.go +++ b/api/router.go @@ -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) }) diff --git a/features/stale_token_invalidation.feature b/features/stale_token_invalidation.feature new file mode 100644 index 0000000..05341c9 --- /dev/null +++ b/features/stale_token_invalidation.feature @@ -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 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 diff --git a/features/stale_token_invalidation.qa.md b/features/stale_token_invalidation.qa.md new file mode 100644 index 0000000..f12bdf1 --- /dev/null +++ b/features/stale_token_invalidation.qa.md @@ -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. diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index 98947cf..75a65d4 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -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"` } diff --git a/internal/infrastructure/persistence/mappers/userMapper.go b/internal/infrastructure/persistence/mappers/userMapper.go index 63723eb..decc969 100644 --- a/internal/infrastructure/persistence/mappers/userMapper.go +++ b/internal/infrastructure/persistence/mappers/userMapper.go @@ -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, } diff --git a/internal/infrastructure/persistence/models/userModel.go b/internal/infrastructure/persistence/models/userModel.go index dc35ed8..068b468 100644 --- a/internal/infrastructure/persistence/models/userModel.go +++ b/internal/infrastructure/persistence/models/userModel.go @@ -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"` } diff --git a/internal/infrastructure/persistence/repositories/userRepository.go b/internal/infrastructure/persistence/repositories/userRepository.go index 8c94c47..d0fd335 100644 --- a/internal/infrastructure/persistence/repositories/userRepository.go +++ b/internal/infrastructure/persistence/repositories/userRepository.go @@ -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 diff --git a/test/claims_test.go b/test/claims_test.go new file mode 100644 index 0000000..9631605 --- /dev/null +++ b/test/claims_test.go @@ -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") + } + }) +} diff --git a/test/reset_password_test.go b/test/reset_password_test.go index 9871d1d..c3c6913 100644 --- a/test/reset_password_test.go +++ b/test/reset_password_test.go @@ -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{}) diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go new file mode 100644 index 0000000..fe8a351 --- /dev/null +++ b/test/stale_token_invalidation_http_test.go @@ -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()) + } + }) +} diff --git a/test/user_test.go b/test/user_test.go index 1462d7a..8994cc6 100644 --- a/test/user_test.go +++ b/test/user_test.go @@ -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 } diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 3c5e7b2..fa61e20 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -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 {