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

This commit is contained in:
Tiago Yamamoto 2025-12-31 15:16:45 -03:00
parent 54a77382b7
commit e637117f40
5 changed files with 50 additions and 42 deletions

View file

@ -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

View file

@ -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)

View file

@ -19,11 +19,9 @@ async function apiRequest<T>(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<T>(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) {

View file

@ -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<void> {
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<vo
export function getToken(): string | null {
if (typeof window !== "undefined") {
return localStorage.getItem("auth_token");
}
// Token is now in httpOnly cookie, not accessible from JS
// This function is kept for backward compatibility but returns null
return null;
}

View file

@ -26,18 +26,12 @@ export async function getUploadUrl(
contentType: string,
folder: 'logos' | 'resumes' | 'documents' | 'avatars' = 'documents'
): Promise<UploadUrlResponse> {
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<DownloadUrlResponse> {
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<void> {
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) {