From 7258ca81812a24edd382438ce6e9ebc538549427 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 13 Feb 2026 23:46:16 +0900 Subject: feat(auth): store JWT in HTTP-only cookie instead of JS-accessible cookie Prevent XSS-based token theft by making the JWT inaccessible to JavaScript. The backend now sets/clears the cookie via Set-Cookie headers, and the frontend retrieves user info from /api/me instead of decoding the JWT directly. - Add JWTCookieMiddleware to parse cookie and inject claims into context - Add /me and /logout endpoints to OpenAPI spec and handlers - Update PostLogin to return user object + Set-Cookie header - Replace Authorization header auth with cookie-based auth throughout - Rewrite frontend auth to use /api/me instead of jwt-decode - Remove jwt-decode dependency - Configure CORS with credentials for local dev Co-Authored-By: Claude Opus 4.6 --- backend/api/handler.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 6 deletions(-) (limited to 'backend/api/handler.go') diff --git a/backend/api/handler.go b/backend/api/handler.go index d2883a9..3b04665 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "log" "net/http" @@ -12,12 +13,14 @@ import ( "github.com/oapi-codegen/nullable" "albatross-2026-backend/auth" + "albatross-2026-backend/config" "albatross-2026-backend/db" ) type Handler struct { - q *db.Queries - hub GameHubInterface + q *db.Queries + hub GameHubInterface + conf *config.Config } type GameHubInterface interface { @@ -25,6 +28,18 @@ type GameHubInterface interface { EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error } +type postLoginCookieResponse struct { + cookie http.Cookie + body PostLogin200JSONResponse +} + +func (r postLoginCookieResponse) VisitPostLoginResponse(w http.ResponseWriter) error { + http.SetCookie(w, &r.cookie) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + return json.NewEncoder(w).Encode(r.body) +} + func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) { username := request.Body.Username password := request.Body.Password @@ -44,7 +59,7 @@ func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) }, nil } - user, err := h.q.GetUserByID(ctx, int32(userID)) + dbUser, err := h.q.GetUserByID(ctx, int32(userID)) if err != nil { return PostLogin401JSONResponse{ UnauthorizedJSONResponse: UnauthorizedJSONResponse{ @@ -53,13 +68,76 @@ func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) }, nil } - jwt, err := auth.NewJWT(&user) + jwt, err := auth.NewJWT(&dbUser) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return PostLogin200JSONResponse{ - Token: jwt, + return postLoginCookieResponse{ + cookie: http.Cookie{ + Name: "albatross_token", + Value: jwt, + Path: h.conf.BasePath, + MaxAge: 86400, + HttpOnly: true, + Secure: !h.conf.IsLocal, + SameSite: http.SameSiteLaxMode, + }, + body: PostLogin200JSONResponse{ + User: User{ + UserID: int(dbUser.UserID), + Username: dbUser.Username, + DisplayName: dbUser.DisplayName, + IconPath: dbUser.IconPath, + IsAdmin: dbUser.IsAdmin, + Label: toNullable(dbUser.Label), + }, + }, + }, nil +} + +func (h *Handler) GetMe(ctx context.Context, _ GetMeRequestObject, claims *auth.JWTClaims) (GetMeResponseObject, error) { + dbUser, err := h.q.GetUserByID(ctx, int32(claims.UserID)) + if err != nil { + return GetMe401JSONResponse{ + UnauthorizedJSONResponse: UnauthorizedJSONResponse{ + Message: "Unauthorized", + }, + }, nil + } + return GetMe200JSONResponse{ + User: User{ + UserID: int(dbUser.UserID), + Username: dbUser.Username, + DisplayName: dbUser.DisplayName, + IconPath: dbUser.IconPath, + IsAdmin: dbUser.IsAdmin, + Label: toNullable(dbUser.Label), + }, + }, nil +} + +type postLogoutCookieResponse struct { + cookie http.Cookie +} + +func (r postLogoutCookieResponse) VisitPostLogoutResponse(w http.ResponseWriter) error { + http.SetCookie(w, &r.cookie) + w.WriteHeader(200) + return nil +} + +func (h *Handler) PostLogout(_ context.Context, _ PostLogoutRequestObject, _ *auth.JWTClaims) (PostLogoutResponseObject, error) { + return postLogoutCookieResponse{ + cookie: http.Cookie{ + Name: "albatross_token", + Value: "", + Path: h.conf.BasePath, + MaxAge: -1, + HttpOnly: true, + Secure: !h.conf.IsLocal, + SameSite: http.SameSiteLaxMode, + }, }, nil } -- cgit v1.3.1