From 8d17a12e923e4f1df49929306fea0d3364b2fc43 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 25 Sep 2026 15:17:39 +0200 Subject: [PATCH] refac: GET /v1/auth/me --- api/handlers/auth/meHandler.go | 47 +++++++ api/router.go | 2 + features/get_current_user.feature | 89 ++++++++++++++ features/get_current_user.qa.md | 64 ++++++++++ test/get_current_user_http_test.go | 191 +++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 api/handlers/auth/meHandler.go create mode 100644 features/get_current_user.feature create mode 100644 features/get_current_user.qa.md create mode 100644 test/get_current_user_http_test.go diff --git a/api/handlers/auth/meHandler.go b/api/handlers/auth/meHandler.go new file mode 100644 index 0000000..001dcf7 --- /dev/null +++ b/api/handlers/auth/meHandler.go @@ -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) +} diff --git a/api/router.go b/api/router.go index e053225..7cad8a5 100644 --- a/api/router.go +++ b/api/router.go @@ -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 } diff --git a/features/get_current_user.feature b/features/get_current_user.feature new file mode 100644 index 0000000..ec2d090 --- /dev/null +++ b/features/get_current_user.feature @@ -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 diff --git a/features/get_current_user.qa.md b/features/get_current_user.qa.md new file mode 100644 index 0000000..6f8b5a3 --- /dev/null +++ b/features/get_current_user.qa.md @@ -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. diff --git a/test/get_current_user_http_test.go b/test/get_current_user_http_test.go new file mode 100644 index 0000000..b1b7737 --- /dev/null +++ b/test/get_current_user_http_test.go @@ -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) + } + } + }) +}