fix: resetting the password or cancelling a registration ends existing sessions: a token issued before the change is refused
This commit is contained in:
1 parent
81c4b62175
commit
f3ec3d2fa2
15 files changed
+840
-9
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user