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:
parent
54a77382b7
commit
e637117f40
5 changed files with 50 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue