From e637117f40c25ab4d04df74fe8dd419ae2642f5b Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 31 Dec 2025 15:16:45 -0300 Subject: [PATCH] feat(security): migrate auth to httpOnly cookies - Frontend: remove localStorage tokens, use sessionStorage for user data, add credentials include - Backend: add logout endpoint to clear cookie --- .../internal/api/handlers/core_handlers.go | 23 ++++++++++++++ backend/internal/router/router.go | 1 + frontend/src/lib/api.ts | 11 +++---- frontend/src/lib/auth.ts | 31 ++++++++++++------- frontend/src/lib/storage.ts | 26 ++-------------- 5 files changed, 50 insertions(+), 42 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 1032bb9..9b1b6f1 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -108,6 +108,29 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } +// Logout clears the authentication cookie. +// @Summary User Logout +// @Description Clears the httpOnly JWT cookie, effectively logging the user out. +// @Tags Auth +// @Success 200 {object} object +// @Router /api/v1/auth/logout [post] +func (h *CoreHandlers) Logout(w http.ResponseWriter, r *http.Request) { + // Clear the JWT cookie by setting it to expire in the past + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: "", + Path: "/", + Expires: time.Now().Add(-24 * time.Hour), // Expire in the past + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteLaxMode, + MaxAge: -1, // Delete cookie immediately + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) +} + // RegisterCandidate handles public registration for candidates func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) { var req dto.RegisterCandidateRequest diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 76512cb..b8ef67b 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -144,6 +144,7 @@ func NewRouter() http.Handler { // --- CORE ROUTES --- // Public mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login) + mux.HandleFunc("POST /api/v1/auth/logout", coreHandlers.Logout) mux.HandleFunc("POST /api/v1/auth/register", coreHandlers.RegisterCandidate) mux.HandleFunc("POST /api/v1/auth/register/candidate", coreHandlers.RegisterCandidate) mux.HandleFunc("POST /api/v1/auth/register/company", coreHandlers.CreateCompany) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2cde149..5a161d5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,11 +19,9 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi // Ensure config is loaded before making request await initConfig(); - // Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy) - const token = localStorage.getItem("auth_token") || localStorage.getItem("token"); + // Token is now in httpOnly cookie, sent automatically via credentials: include const headers = { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }; @@ -554,13 +552,12 @@ export const profileApi = { // We save the key. The frontend or backend should resolve it to a full URL if needed. // For now, assuming saving the key is what's requested ("salvando as chaves"). // We use the generic updateProfile method. - const token = localStorage.getItem("token"); const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, { method: "PATCH", headers: { "Content-Type": "application/json", - ...(token ? { "Authorization": `Bearer ${token}` } : {}) }, + credentials: "include", // Use httpOnly cookie body: JSON.stringify({ avatarUrl: key }) }); @@ -578,16 +575,16 @@ async function backofficeRequest(endpoint: string, options: RequestInit = {}) // Ensure config is loaded before making request await initConfig(); - const token = localStorage.getItem("token"); + // Token is now in httpOnly cookie, sent automatically via credentials: include const headers = { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }; const response = await fetch(`${getBackofficeUrl()}${endpoint}`, { ...options, headers, + credentials: "include", // Use httpOnly cookie }); if (!response.ok) { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index a5ce716..4ebbaab 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -30,6 +30,7 @@ export async function login( headers: { "Content-Type": "application/json", }, + credentials: "include", // Send and receive cookies body: JSON.stringify({ email, password }), }); @@ -63,10 +64,9 @@ export async function login( profileComplete: 80, // Mocked for now }; + // Store user info in sessionStorage (not token - token is in httpOnly cookie) if (typeof window !== "undefined") { - localStorage.setItem(AUTH_KEY, JSON.stringify(user)); - localStorage.setItem("auth_token", data.token); - localStorage.setItem("token", data.token); // Legacy support + sessionStorage.setItem(AUTH_KEY, JSON.stringify(user)); } return user; @@ -76,23 +76,31 @@ export async function login( } } -export function logout(): void { +export async function logout(): Promise { if (typeof window !== "undefined") { - localStorage.removeItem(AUTH_KEY); - localStorage.removeItem("auth_token"); + sessionStorage.removeItem(AUTH_KEY); + // Call backend to clear the httpOnly cookie + try { + await fetch(`${getApiV1Url()}/auth/logout`, { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("[AUTH] Logout error:", error); + } } } export function getCurrentUser(): User | null { if (typeof window !== "undefined") { - const stored = localStorage.getItem(AUTH_KEY); + const stored = sessionStorage.getItem(AUTH_KEY); if (stored) { const user = JSON.parse(stored); - console.log("%c[AUTH] User Loaded from Storage", "color: #10b981", user.email); + console.log("%c[AUTH] User Loaded from Session", "color: #10b981", user.email); return user; } } - console.warn("%c[AUTH] No user found in storage", "color: #f59e0b"); + console.warn("%c[AUTH] No user found in session", "color: #f59e0b"); return null; } @@ -147,9 +155,8 @@ export async function registerCandidate(data: RegisterCandidateData): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - - if (!token) { - throw new Error('Not authenticated'); - } - const response = await fetch(`${getApiV1Url()}/storage/upload-url`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, + credentials: 'include', // Use httpOnly cookie body: JSON.stringify({ filename, contentType, @@ -77,18 +71,12 @@ export async function uploadFileToS3( * Get a pre-signed URL for downloading a file */ export async function getDownloadUrl(key: string): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - - if (!token) { - throw new Error('Not authenticated'); - } - const response = await fetch(`${getApiV1Url()}/storage/download-url`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, + credentials: 'include', // Use httpOnly cookie body: JSON.stringify({ key }), }); @@ -125,17 +113,9 @@ export async function uploadFile( * Delete a file from storage */ export async function deleteFile(key: string): Promise { - const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - - if (!token) { - throw new Error('Not authenticated'); - } - const response = await fetch(`${getApiV1Url()}/storage/files?key=${encodeURIComponent(key)}`, { method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}`, - }, + credentials: 'include', // Use httpOnly cookie }); if (!response.ok) {