refac: GET /v1/auth/me

This commit is contained in:
fsylvain committed 2026-09-25 15:17:39 +02:00
1 parent 0e050c36ff
commit 8d17a12e92
5 files changed
+393

No files matched your search

+47
View File
@@ -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)
}
+2
View File
@@ -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
}
+89
View File
@@ -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
+64
View File
@@ -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.
+191
View File
@@ -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)
}
}
})
}