refac: GET /v1/auth/me
This commit is contained in:
1 parent
0e050c36ff
commit
8d17a12e92
5 files changed
+393
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type MeHandler struct {
|
||||
GetUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler {
|
||||
return &MeHandler{
|
||||
GetUseCase: getUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
loadedUser, err := h.GetUseCase.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)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(newPublicUser(loadedUser))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
|
||||
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||
meHandler := auth.NewMeHandler(container.GetUserUseCase)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
@@ -78,6 +79,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
Feature: A logged-in user fetches their own public profile without triggering the verified-account gate
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the SPA to ask "who is currently logged in" via a single endpoint that answers from the
|
||||
same jwt cookie every other protected route already trusts, distinguishes "no valid session"
|
||||
from "a session for an unverified account" so it can route the user to a pending-verification
|
||||
screen instead of guessing, and never competes with real login attempts for rate-limit headroom
|
||||
So that the SPA can restore a session on load without going through the verification gate and
|
||||
without being throttled like a login attempt
|
||||
|
||||
Background:
|
||||
Given the existing PublicUser struct and newPublicUser(u user.User) converter in
|
||||
api/handlers/auth/verificationHandler.go, exactly the JSON shape POST /v1/auth/verify already
|
||||
returns: id, username, email, is_verified, created_at, updated_at
|
||||
And this slice adds GET /v1/auth/me, handled by a new MeHandler (api/handlers/auth/meHandler.go)
|
||||
built with NewMeHandler(container.GetUserUseCase), the same GetUserUseCase every other
|
||||
protected handler already gets from api/dependecyInjection.go; no new use case or domain
|
||||
method is introduced
|
||||
And GET /v1/auth/me is mounted in api/router.go's NewAuthRouter inside the same group that
|
||||
already carries jwtauth.Verifier(container.Token), jwtauth.Authenticator(container.Token) and
|
||||
middleware.RequireCurrentToken(container.GetUserUseCase) ahead of /verify and
|
||||
DELETE /register, so:
|
||||
- no jwt cookie, or a jwt with a bad signature, is refused with 401 by jwtauth.Authenticator
|
||||
itself before the handler runs, the same as the other routes in that group
|
||||
(features/stale_token_invalidation.feature)
|
||||
- a well-formed but stale token (whose token_version claim no longer matches the stored user,
|
||||
per features/stale_token_invalidation.feature) is refused with 401
|
||||
apperr.UnauthorizedErrorMessage by RequireCurrentToken before the handler runs
|
||||
- a token minted for a user id that no longer exists (a cancelled registration,
|
||||
features/cancel_pending_registration.feature) is refused with the same 401
|
||||
apperr.UnauthorizedErrorMessage by RequireCurrentToken, for the same reason
|
||||
And unlike NewArticleRouter's protected group, this group does not add
|
||||
middleware.RequireVerifiedUser, and unlike /login and /password-reset, it does not add
|
||||
middleware.NewRateLimiter, so an unverified user reaches the handler, and the route has no
|
||||
per-client attempt ceiling
|
||||
And MeHandler.Me resolves user_id with claims.UserId(r) (401 apperr.UnauthorizedErrorMessage if
|
||||
somehow absent or malformed, though RequireCurrentToken already guarantees a valid one before
|
||||
this handler is reached), loads the user with GetUserUseCase.GetUser(userId) (401
|
||||
apperr.UnauthorizedErrorMessage on user.ErrUserNotFound, 500 "Internal server error" on any
|
||||
other error), sets the response header Cache-Control: no-store, and responds 200 with
|
||||
newPublicUser(loadedUser) as JSON, whatever that user's is_verified value is
|
||||
|
||||
Scenario: A verified user fetches their own profile
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
is_verified true, TokenVersion 0
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response header Cache-Control is "no-store"
|
||||
And the response body is the user as JSON with id 7, username "alice", email
|
||||
"alice@example.com", is_verified true
|
||||
|
||||
Scenario: An unverified user still fetches their own profile, with is_verified false, not a 403
|
||||
Given a user repository containing user id 8, username "bob", email "bob@example.com",
|
||||
is_verified false, TokenVersion 0
|
||||
And a valid jwt cookie for user id 8 encoding token_version 0
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response header Cache-Control is "no-store"
|
||||
And the response body is the user as JSON with id 8, username "bob", email "bob@example.com",
|
||||
is_verified false
|
||||
|
||||
Scenario: No jwt cookie at all is refused
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 0
|
||||
When the client sends GET /v1/auth/me with no jwt cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario: A jwt whose token_version no longer matches the stored user is refused
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 1
|
||||
And a jwt cookie for user id 7 encoding token_version 0, minted before the version advanced
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Scenario: A jwt minted for a since-deleted account is refused
|
||||
Given a user repository containing user id 7, username "alice", 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 GET /v1/auth/me with the same cookie
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Scenario: The route is never rate-limited, unlike login and password reset
|
||||
Given a user repository containing user id 7, username "alice", is_verified true, TokenVersion 0
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends GET /v1/auth/me 5 times in immediate succession
|
||||
Then every response status is 200
|
||||
And none of the responses is 429
|
||||
@@ -0,0 +1,64 @@
|
||||
# QA procedure: get_current_user
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`) and following the router
|
||||
wiring pattern in `test/stale_token_invalidation_http_test.go`.
|
||||
|
||||
## Test infrastructure additions needed first
|
||||
|
||||
1. No new `mockUserRepo` methods are needed: `GetUser(id)` already exists and is already exercised
|
||||
by other tests (e.g. `test/stale_token_invalidation_http_test.go`). `DELETE /v1/auth/register`
|
||||
already deletes the row via `CancelRegistrationUseCase`, already used in
|
||||
`test/cancel_pending_registration_http_test.go`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
2. Call `validator.InitValidator()` once.
|
||||
3. Build `getUserUseCase := useCases.NewGetUserUseCase(userRepo)`.
|
||||
4. Build `meHandler := auth.NewMeHandler(getUserUseCase)`.
|
||||
5. Also build `verificationHandler := auth.NewVerificationHandler(useCases.NewVerifyUserUseCase(userRepo,
|
||||
hasher), useCases.NewCancelRegistrationUseCase(userRepo), true)` so the "deleted account" scenario
|
||||
can call `DELETE /v1/auth/register` on the same router.
|
||||
6. Mount on a fresh `chi.NewRouter()`, mirroring `NewAuthRouter`'s protected group:
|
||||
```go
|
||||
r.Route("/v1/auth", 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.Get("/me", meHandler.Me)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
})
|
||||
```
|
||||
with a test `token := jwtauth.New("HS256", []byte("test-secret"), nil)`. No live server.
|
||||
7. Mint jwt cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
|
||||
uint32(v)})`, the same convention as `test/stale_token_invalidation_http_test.go`.
|
||||
|
||||
## HTTP-level checks
|
||||
|
||||
8. Seed `mockUserRepo` with user id 7, username "alice", email "alice@example.com",
|
||||
`IsVerified: true`, `TokenVersion: 0`. Mint a cookie for user 7 with `token_version: 0`.
|
||||
`GET /v1/auth/me` → assert status `200`, `Cache-Control` header equals `"no-store"`, and the
|
||||
decoded JSON body has `id: 7`, `username: "alice"`, `email: "alice@example.com"`,
|
||||
`is_verified: true`.
|
||||
9. Seed user id 8, username "bob", email "bob@example.com", `IsVerified: false`,
|
||||
`TokenVersion: 0`. Mint a matching cookie. `GET /v1/auth/me` → assert status `200` (not 403),
|
||||
`Cache-Control: no-store`, and `is_verified: false` in the body.
|
||||
10. No `jwt` cookie at all against the router from step 6 → assert status `401` (produced by
|
||||
`jwtauth.Authenticator` itself; don't assert an exact body for this case, same rationale as
|
||||
`features/account_verification.qa.md` step 15).
|
||||
11. Seed user id 7 with `TokenVersion: 1`. Mint a cookie encoding `token_version: 0` (stale).
|
||||
`GET /v1/auth/me` → assert status `401` and body exactly `apperr.UnauthorizedErrorMessage`.
|
||||
12. Seed user id 7, `IsVerified: false`, `TokenVersion: 0`. Mint a matching cookie.
|
||||
`DELETE /v1/auth/register` with that cookie → assert status `200`. Then `GET /v1/auth/me` with
|
||||
the same cookie → assert status `401` and body exactly `apperr.UnauthorizedErrorMessage`.
|
||||
13. Seed user id 7, `IsVerified: true`, `TokenVersion: 0`, matching cookie. Send
|
||||
`GET /v1/auth/me` 5 times in a row on the same router/repo → assert every response is `200`
|
||||
and none is `429`.
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
|
||||
- The SPA actually calling `GET /v1/auth/me` on load/refresh to restore session state and route an
|
||||
unverified user to the pending-verification screen (rather than guessing from stored client state)
|
||||
is out of scope for this slice's automated test and should be checked by hand against a running
|
||||
dev build.
|
||||
@@ -0,0 +1,191 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
meHandler := auth.NewMeHandler(getUserUseCase)
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
useCases.NewVerifyUserUseCase(userRepo, hasher),
|
||||
useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
true,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/auth", 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.Get("/me", meHandler.Me)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func mintGetCurrentUserCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
return &http.Cookie{Name: "jwt", Value: tokenString}
|
||||
}
|
||||
|
||||
func sendGetCurrentUserRequest(router *chi.Mux, method, target string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestGetCurrentUserHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
t.Run("A verified user fetches their own profile", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{
|
||||
ID: 7,
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
IsVerified: true,
|
||||
TokenVersion: 0,
|
||||
}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", cookie)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("expected Cache-Control no-store, got %q", got)
|
||||
}
|
||||
var body auth.PublicUser
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal body: %v", err)
|
||||
}
|
||||
if body.ID != 7 || body.Username != "alice" || body.Email != "alice@example.com" || !body.IsVerified {
|
||||
t.Errorf("unexpected body: %+v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[8] = user.User{
|
||||
ID: 8,
|
||||
Username: "bob",
|
||||
Email: "bob@example.com",
|
||||
IsVerified: false,
|
||||
TokenVersion: 0,
|
||||
}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 8, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", cookie)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("expected Cache-Control no-store, got %q", got)
|
||||
}
|
||||
var body auth.PublicUser
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal body: %v", err)
|
||||
}
|
||||
if body.IsVerified {
|
||||
t.Errorf("expected is_verified false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", 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 minted for a since-deleted account is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
cancelRec := sendGetCurrentUserRequest(router, http.MethodDelete, "/v1/auth/register", cookie)
|
||||
if cancelRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", cancelRec.Code, cancelRec.Body.String())
|
||||
}
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", 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("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: expected status 200, got %d: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d: got 429", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user