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)
|
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
|
// RegisterCandidate handles public registration for candidates
|
||||||
func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) {
|
func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) {
|
||||||
var req dto.RegisterCandidateRequest
|
var req dto.RegisterCandidateRequest
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ func NewRouter() http.Handler {
|
||||||
// --- CORE ROUTES ---
|
// --- CORE ROUTES ---
|
||||||
// Public
|
// Public
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
|
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", coreHandlers.RegisterCandidate)
|
||||||
mux.HandleFunc("POST /api/v1/auth/register/candidate", coreHandlers.RegisterCandidate)
|
mux.HandleFunc("POST /api/v1/auth/register/candidate", coreHandlers.RegisterCandidate)
|
||||||
mux.HandleFunc("POST /api/v1/auth/register/company", coreHandlers.CreateCompany)
|
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
|
// Ensure config is loaded before making request
|
||||||
await initConfig();
|
await initConfig();
|
||||||
|
|
||||||
// Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy)
|
// Token is now in httpOnly cookie, sent automatically via credentials: include
|
||||||
const token = localStorage.getItem("auth_token") || localStorage.getItem("token");
|
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...options.headers,
|
...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.
|
// 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").
|
// For now, assuming saving the key is what's requested ("salvando as chaves").
|
||||||
// We use the generic updateProfile method.
|
// We use the generic updateProfile method.
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
|
const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { "Authorization": `Bearer ${token}` } : {})
|
|
||||||
},
|
},
|
||||||
|
credentials: "include", // Use httpOnly cookie
|
||||||
body: JSON.stringify({ avatarUrl: key })
|
body: JSON.stringify({ avatarUrl: key })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -578,16 +575,16 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
|
||||||
// Ensure config is loaded before making request
|
// Ensure config is loaded before making request
|
||||||
await initConfig();
|
await initConfig();
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
// Token is now in httpOnly cookie, sent automatically via credentials: include
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
|
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: "include", // Use httpOnly cookie
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export async function login(
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
credentials: "include", // Send and receive cookies
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,10 +64,9 @@ export async function login(
|
||||||
profileComplete: 80, // Mocked for now
|
profileComplete: 80, // Mocked for now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store user info in sessionStorage (not token - token is in httpOnly cookie)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem(AUTH_KEY, JSON.stringify(user));
|
sessionStorage.setItem(AUTH_KEY, JSON.stringify(user));
|
||||||
localStorage.setItem("auth_token", data.token);
|
|
||||||
localStorage.setItem("token", data.token); // Legacy support
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(AUTH_KEY);
|
sessionStorage.removeItem(AUTH_KEY);
|
||||||
localStorage.removeItem("auth_token");
|
// 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 {
|
export function getCurrentUser(): User | null {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const stored = localStorage.getItem(AUTH_KEY);
|
const stored = sessionStorage.getItem(AUTH_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const user = JSON.parse(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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +155,8 @@ export async function registerCandidate(data: RegisterCandidateData): Promise<vo
|
||||||
|
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
if (typeof window !== "undefined") {
|
// Token is now in httpOnly cookie, not accessible from JS
|
||||||
return localStorage.getItem("auth_token");
|
// This function is kept for backward compatibility but returns null
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,12 @@ export async function getUploadUrl(
|
||||||
contentType: string,
|
contentType: string,
|
||||||
folder: 'logos' | 'resumes' | 'documents' | 'avatars' = 'documents'
|
folder: 'logos' | 'resumes' | 'documents' | 'avatars' = 'documents'
|
||||||
): Promise<UploadUrlResponse> {
|
): 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`, {
|
const response = await fetch(`${getApiV1Url()}/storage/upload-url`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
|
credentials: 'include', // Use httpOnly cookie
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
filename,
|
filename,
|
||||||
contentType,
|
contentType,
|
||||||
|
|
@ -77,18 +71,12 @@ export async function uploadFileToS3(
|
||||||
* Get a pre-signed URL for downloading a file
|
* Get a pre-signed URL for downloading a file
|
||||||
*/
|
*/
|
||||||
export async function getDownloadUrl(key: string): Promise<DownloadUrlResponse> {
|
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`, {
|
const response = await fetch(`${getApiV1Url()}/storage/download-url`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
|
credentials: 'include', // Use httpOnly cookie
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,17 +113,9 @@ export async function uploadFile(
|
||||||
* Delete a file from storage
|
* Delete a file from storage
|
||||||
*/
|
*/
|
||||||
export async function deleteFile(key: string): Promise<void> {
|
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)}`, {
|
const response = await fetch(`${getApiV1Url()}/storage/files?key=${encodeURIComponent(key)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
credentials: 'include', // Use httpOnly cookie
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue