security: migrate access token to httpOnly cookie (Actions 5.1.1.1-5.1.1.3)

Backend changes (Action 5.1.1.1):
- Set access_token cookie in Login, Register, and Refresh handlers
- Cookie uses same configuration as refresh_token (httpOnly, Secure, SameSite)
- Expiry matches AccessTokenTTL (5 minutes)
- Update logout handler to clear access_token cookie

Backend middleware (Action 5.1.1.1):
- Update auth middleware to read access token from cookie first
- Fallback to Authorization header for backward compatibility
- Update OptionalAuth with same cookie-first logic

Frontend changes (Actions 5.1.1.2 & 5.1.1.3):
- Remove localStorage token storage from TokenStorage service
- TokenStorage now returns null for getAccessToken/getRefreshToken (httpOnly cookies not accessible)
- Remove Authorization header logic from API client
- Remove token expiration checks (can't check httpOnly cookies from JS)
- Update AuthContext to remove localStorage usage
- Update tokenRefresh to work without reading tokens from JS
- Simplify refresh logic: periodic refresh every 4 minutes (no expiration checks)

Security improvements:
- Access tokens no longer exposed to XSS attacks (httpOnly cookies)
- Tokens automatically sent with requests via withCredentials: true
- Backend reads tokens from cookies, not Authorization headers
- All users will need to re-login after deployment (breaking change)

Breaking change: All users must re-login after deployment
This commit is contained in:
senke 2026-01-16 01:02:03 +01:00
parent 1f9c5da884
commit d9b6510802
6 changed files with 204 additions and 368 deletions

View file

@ -35,18 +35,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const checkAuth = async () => {
try {
const token = localStorage.getItem('access_token');
if (token) {
const userData = await authService.getCurrentUser();
setUser(userData);
}
// SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not localStorage
// Just call getCurrentUser - if it works, user is authenticated
const userData = await authService.getCurrentUser();
setUser(userData);
} catch (error) {
logger.error('Auth check failed', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Tokens are in httpOnly cookies, cleared by backend on logout
setUser(null);
} finally {
setIsLoading(false);
}
@ -54,9 +53,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const login = async (credentials: any) => {
try {
const { user, token } = await authService.login(credentials);
localStorage.setItem('access_token', token.access_token);
localStorage.setItem('refresh_token', token.refresh_token);
// SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend
const { user } = await authService.login(credentials);
setUser(user);
addToast('Welcome back!', 'success');
} catch (error: unknown) {
@ -68,9 +66,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const register = async (data: any) => {
try {
const { user, token } = await authService.register(data);
localStorage.setItem('access_token', token.access_token);
localStorage.setItem('refresh_token', token.refresh_token);
// SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend
const { user } = await authService.register(data);
setUser(user);
addToast('Account created successfully', 'success');
} catch (error: unknown) {
@ -81,18 +78,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
};
const logout = async () => {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
await authService.logout();
} catch (e) {
logger.error('Logout error', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
}
try {
// SECURITY: Action 5.1.1.2 - Backend clears httpOnly cookies on logout
await authService.logout();
} catch (e) {
logger.error('Logout error', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
}
localStorage.clear();
// Backend clears cookies, just clear local state
setUser(null);
addToast('Logged out', 'info');
};

View file

@ -7,7 +7,7 @@ import axios, {
import toast from '@/utils/toast';
import { z } from 'zod';
import { TokenStorage } from '../tokenStorage';
import { refreshToken, isTokenExpiringSoon } from '../tokenRefresh';
import { refreshToken } from '../tokenRefresh';
import { env } from '@/config/env';
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
import { formatUserFriendlyError } from '@/utils/errorMessages';
@ -247,9 +247,8 @@ let failedQueue: Array<{
reject: (error?: any) => void;
}> = [];
// Cache pour éviter les refresh proactifs multiples
let lastProactiveRefreshTime = 0;
const PROACTIVE_REFRESH_COOLDOWN_MS = 5000; // 5 secondes entre refresh proactifs
// SECURITY: Action 5.1.1.3 - Removed proactive refresh cooldown logic
// Tokens are in httpOnly cookies, can't check expiration from JS
/**
* Sleep utility function
@ -416,12 +415,14 @@ const getRequestId = (config: InternalAxiosRequestConfig): string => {
};
// T0177: Fonction pour traiter la queue de requêtes en attente
const processQueue = (error: Error | null, token: string | null = null) => {
// SECURITY: Action 5.1.1.3 - processQueue no longer needs token parameter
// Cookies are automatically sent with requests via withCredentials: true
const processQueue = (error: Error | null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
prom.resolve(undefined); // No token needed, cookies are sent automatically
}
});
@ -437,94 +438,9 @@ apiClient.interceptors.request.use(
config.headers['X-API-Version'] = env.API_VERSION;
}
// INT-AUTH-004: Vérifier l'expiration du token avant d'envoyer la requête
// Buffer de 60 secondes pour éviter les 401 inutiles
const PRE_REQUEST_REFRESH_BUFFER_MS = 60 * 1000; // 60 secondes
const token = TokenStorage.getAccessToken();
const isRefreshEndpoint = config.url?.includes('/auth/refresh');
const isCSRFRoute = config.url?.includes('/csrf-token');
// Ne pas vérifier l'expiration pour les endpoints de refresh et CSRF pour éviter les boucles
if (token && !isRefreshEndpoint && !isCSRFRoute) {
// Vérifier si le token expire bientôt (dans moins de 60s)
if (isTokenExpiringSoon(token, PRE_REQUEST_REFRESH_BUFFER_MS)) {
// Si un refresh est déjà en cours, attendre qu'il se termine
if (isRefreshing) {
logger.debug(
'[API] Token expiring soon but refresh already in progress, waiting...',
{
url: config.url,
},
);
// Attendre que le refresh se termine (max 5s)
let waitCount = 0;
while (isRefreshing && waitCount < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
waitCount++;
}
// Récupérer le nouveau token après le refresh
const newToken = TokenStorage.getAccessToken();
if (newToken && config.headers) {
config.headers.Authorization = `Bearer ${newToken} `;
}
} else {
// Vérifier le cooldown pour éviter les refresh proactifs multiples
const now = Date.now();
const timeSinceLastRefresh = now - lastProactiveRefreshTime;
if (timeSinceLastRefresh < PROACTIVE_REFRESH_COOLDOWN_MS) {
// Trop tôt depuis le dernier refresh, utiliser le token actuel
logger.debug('[API] Skipping proactive refresh (cooldown)', {
url: config.url,
time_since_last_refresh_ms: timeSinceLastRefresh,
cooldown_ms: PROACTIVE_REFRESH_COOLDOWN_MS,
});
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token} `;
}
} else {
// Rafraîchir proactivement le token
try {
lastProactiveRefreshTime = now;
logger.debug(
'[API] Token expiring soon, refreshing proactively before request',
{
url: config.url,
buffer_seconds: PRE_REQUEST_REFRESH_BUFFER_MS / 1000,
},
);
await refreshToken();
const newToken = TokenStorage.getAccessToken();
if (newToken && config.headers) {
config.headers.Authorization = `Bearer ${newToken} `;
}
} catch (refreshError) {
// Si le refresh échoue, continuer avec le token actuel
// L'interceptor de réponse gérera l'erreur 401 si nécessaire
logger.warn(
'[API] Proactive token refresh failed, continuing with current token',
{
url: config.url,
error: refreshError,
},
);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token} `;
}
}
}
}
} else {
// Token valide, utiliser normalement
if (config.headers) {
config.headers.Authorization = `Bearer ${token} `;
}
}
} else if (token && config.headers) {
// Token présent mais endpoint de refresh/CSRF, utiliser sans vérification
config.headers.Authorization = `Bearer ${token} `;
}
// SECURITY: Action 5.1.1.3 - Tokens are in httpOnly cookies, not accessible from JS
// Cookies are automatically sent with requests via withCredentials: true
// No need to set Authorization header - backend reads from cookie
// Pour FormData, laisser Axios gérer automatiquement le Content-Type avec boundary
// Ne pas forcer application/json si c'est un FormData
@ -544,12 +460,12 @@ apiClient.interceptors.request.use(
const isAuthRoute =
config.url?.includes('/auth/login') ||
config.url?.includes('/auth/register');
const isCSRFRoute = config.url?.includes('/csrf-token');
if (
isStateChanging &&
!isCSRFRoute &&
!isAuthRoute &&
token &&
config.headers
) {
// CRITIQUE FIX #25: S'assurer que le token CSRF est toujours présent pour les requêtes mutantes
@ -1161,10 +1077,9 @@ apiClient.interceptors.response.use(
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (originalRequest.headers && token) {
originalRequest.headers.Authorization = `Bearer ${token} `;
}
.then(() => {
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
// Backend reads access token from httpOnly cookie automatically
logger.debug(
'[API] Replaying queued request after successful refresh',
{
@ -1195,12 +1110,8 @@ apiClient.interceptors.response.use(
try {
// INT-AUTH-003: Refresh automatique du token
// SECURITY: Action 5.1.1.3 - Refresh uses cookies, no need to set Authorization header
await refreshToken();
const newToken = TokenStorage.getAccessToken();
if (!newToken) {
throw new Error('Failed to get new access token after refresh');
}
logger.info(
'[API] Token refresh successful, retrying original request',
@ -1211,13 +1122,13 @@ apiClient.interceptors.response.use(
},
);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken} `;
}
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
// Backend reads access token from httpOnly cookie automatically
// Cookies are sent automatically via withCredentials: true
// INT-AUTH-003: Traiter la queue et retry la requête originale
// Toutes les requêtes en queue seront rejouées avec le nouveau token
processQueue(null, newToken);
// Toutes les requêtes en queue seront rejouées (cookies sont automatiquement envoyés)
processQueue(null);
return apiClient(originalRequest);
} catch (refreshError) {
// INT-AUTH-003: Gérer cas refresh échoué (expiration, révocation, erreur réseau)
@ -1228,7 +1139,7 @@ apiClient.interceptors.response.use(
});
// Rejeter toutes les requêtes en queue
processQueue(refreshError as Error, null);
processQueue(refreshError as Error);
// Nettoyer les tokens
TokenStorage.clearTokens();

View file

@ -123,42 +123,19 @@ export function isTokenExpiringSoon(
/**
* Rafraîchit le token d'accès en utilisant le refresh token
* T0176: Appelle l'endpoint POST /api/v1/auth/refresh et met à jour les tokens
* T0176: Appelle l'endpoint POST /api/v1/auth/refresh
* SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not accessible from JS
* @returns Promise qui se résout quand le token est rafraîchi
* @throws Error si le refresh token n'est pas disponible ou si le refresh échoue
* @throws Error si le refresh échoue
*/
export async function refreshToken(): Promise<void> {
// SECURITY: Détecter si le backend utilise des cookies httpOnly
// Si le refresh token n'est pas dans localStorage mais que l'utilisateur est authentifié,
// c'est qu'on utilise des cookies httpOnly
const refreshTokenFromStorage = TokenStorage.getRefreshToken();
const hasAccessToken = !!TokenStorage.getAccessToken();
// Si pas de refresh token dans localStorage mais access token présent,
// on suppose que le backend utilise des cookies httpOnly
const useHttpOnlyCookies =
(!refreshTokenFromStorage || refreshTokenFromStorage === 'cookie-based') &&
hasAccessToken;
// Vérifier qu'on a un moyen de rafraîchir (soit localStorage, soit cookies httpOnly)
if (
!useHttpOnlyCookies &&
(!refreshTokenFromStorage || refreshTokenFromStorage.trim() === '')
) {
throw new Error('No refresh token available');
}
try {
// T0176: Appeler l'endpoint POST /auth/refresh
// T0177: Utiliser refreshClient pour éviter les interceptors (qui causeraient une boucle)
const client = getRefreshClient();
// SECURITY: Si on utilise des cookies httpOnly, ne pas envoyer le refresh token dans le body
// Le cookie sera envoyé automatiquement via withCredentials
const requestBody = useHttpOnlyCookies
? {} // Le refresh token est dans le cookie httpOnly, envoyé automatiquement
: { refresh_token: refreshTokenFromStorage }; // Mode legacy: envoyer dans le body
// SECURITY: Action 5.1.1.2 - Refresh token is in httpOnly cookie, sent automatically via withCredentials
// No need to send refresh_token in body - backend reads from cookie
const response = await client.post<{
success?: boolean;
data?: RefreshTokenResponse;
@ -166,24 +143,18 @@ export async function refreshToken(): Promise<void> {
access_token?: string;
refresh_token?: string;
expires_in?: number;
}>('/auth/refresh', requestBody);
}>('/auth/refresh', {});
// Le backend peut retourner deux formats :
// 1. { success: true, data: { access_token, refresh_token, expires_in } }
// 2. { access_token, refresh_token, expires_in } (format direct)
let access_token: string;
let refresh_token: string;
let expires_in: number;
if (response.data?.success && response.data?.data) {
// Format wrapper
access_token = response.data.data.access_token;
refresh_token = response.data.data.refresh_token;
expires_in = response.data.data.expires_in;
} else if (response.data?.access_token) {
// Format direct
access_token = response.data.access_token;
refresh_token = response.data.refresh_token || 'cookie-based';
expires_in = response.data.expires_in || 3600;
} else {
throw new Error(
@ -191,28 +162,16 @@ export async function refreshToken(): Promise<void> {
);
}
if (!access_token) {
throw new Error('Invalid refresh response: missing access_token');
}
// SECURITY: Si on utilise des cookies httpOnly, le refresh_token sera dans le cookie
// Sinon, utiliser le refresh_token de la réponse (mode legacy)
const finalRefreshToken = useHttpOnlyCookies
? refresh_token || 'cookie-based' // Le backend sette le cookie, on utilise une valeur placeholder
: refresh_token;
if (!finalRefreshToken) {
throw new Error('Invalid refresh response: missing refresh_token');
}
// T0176: Mettre à jour les tokens stockés
// SECURITY: Access token en mémoire uniquement, refresh token dans cookie httpOnly (ou localStorage en mode legacy)
TokenStorage.setTokens(access_token, finalRefreshToken);
// SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend
// No need to store tokens in frontend - TokenStorage.setTokens is now a no-op
TokenStorage.setTokens('', 'cookie-based');
// INT-016: Programmer le refresh proactif pour le nouveau token
scheduleProactiveRefresh(access_token, expires_in);
// SECURITY: Action 5.1.1.2 - Use expires_in from response (default 5 minutes = 300 seconds)
// scheduleProactiveRefresh only needs expiresIn now (can't check token from httpOnly cookie)
scheduleProactiveRefresh(expires_in);
} catch (error) {
// T0176: Gérer les erreurs - supprimer les tokens en cas d'échec
// T0176: Gérer les erreurs - nettoyer les tokens en cas d'échec
TokenStorage.clearTokens();
// INT-016: Annuler le refresh proactif en cas d'erreur
cancelProactiveRefresh();
@ -223,13 +182,10 @@ export async function refreshToken(): Promise<void> {
/**
* INT-016: Programme un refresh proactif avant l'expiration du token
* Action 5.1.1.5: Also sets up periodic refresh every 4 minutes
* @param _accessToken - Token d'accès actuel (non utilisé directement, récupéré depuis storage)
* @param expiresIn - Durée de validité en secondes
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
* @param expiresIn - Durée de validité en secondes (default: 300 for 5 minutes)
*/
function scheduleProactiveRefresh(
_accessToken: string,
expiresIn: number,
): void {
function scheduleProactiveRefresh(expiresIn: number = 300): void {
// Annuler les timers précédents s'ils existent
cancelProactiveRefresh();
@ -252,19 +208,14 @@ function scheduleProactiveRefresh(
}
// Programmer le refresh proactif initial (basé sur expiration)
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh
proactiveRefreshTimer = setTimeout(() => {
const currentToken = TokenStorage.getAccessToken();
if (
currentToken &&
isTokenExpiringSoon(currentToken, PROACTIVE_REFRESH_BUFFER_MS)
) {
refreshToken().catch((error) => {
logger.warn('Proactive token refresh failed', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
refreshToken().catch((error) => {
logger.warn('Proactive token refresh failed', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
});
proactiveRefreshTimer = null;
}, refreshTime);
}
@ -327,47 +278,30 @@ function cancelProactiveRefresh(): void {
/**
* INT-016: Vérifie si le token actuel doit être rafraîchi et le rafraîchit si nécessaire
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
* @returns Promise qui se résout si le token est valide ou a é rafraîchi
*/
export async function ensureValidToken(): Promise<void> {
const accessToken = TokenStorage.getAccessToken();
const refreshTokenValue = TokenStorage.getRefreshToken();
if (!accessToken || !refreshTokenValue) {
throw new Error('No tokens available');
}
// Si le token est expiré ou proche de l'expiration, le rafraîchir
if (isTokenExpiringSoon(accessToken, PROACTIVE_REFRESH_BUFFER_MS)) {
// SECURITY: Action 5.1.1.2 - Can't check tokens from httpOnly cookies
// Just attempt refresh - if it fails, tokens are expired/invalid
// No need to check expiration - periodic refresh handles it
try {
await refreshToken();
} else {
// Programmer le refresh proactif si pas déjà programmé
const payload = decodeJWT(accessToken);
if (payload?.exp) {
const expiresIn = Math.max(
0,
payload.exp - Math.floor(Date.now() / 1000),
);
scheduleProactiveRefresh(accessToken, expiresIn);
}
} catch (error) {
throw new Error('Token refresh failed - tokens may be expired');
}
}
/**
* INT-016: Initialise le système de refresh proactif
* À appeler après un login ou un refresh réussi
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
*/
export function initializeProactiveRefresh(): void {
const accessToken = TokenStorage.getAccessToken();
if (!accessToken) {
return;
}
const payload = decodeJWT(accessToken);
if (payload?.exp) {
const expiresIn = Math.max(0, payload.exp - Math.floor(Date.now() / 1000));
scheduleProactiveRefresh(accessToken, expiresIn);
}
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
// Use default 5 minutes (300 seconds) expiry and set up periodic refresh
// Periodic refresh (every 4 minutes) will handle token refresh
scheduleProactiveRefresh(300); // Default 5 minutes
}
/**

View file

@ -2,159 +2,106 @@
* TokenStorage - Service de gestion du stockage des tokens
* T0169: Service simple pour stocker, récupérer et supprimer les tokens d'authentification
*
* SECURITY: Migration vers cookies httpOnly COMPLÉTÉE
* - Access Token: Stocké en mémoire uniquement (pas de localStorage)
* SECURITY: Migration vers cookies httpOnly COMPLÉTÉE (Action 5.1.1.2)
* - Access Token: Stocké dans un cookie httpOnly par le backend (pas accessible via JavaScript)
* - Refresh Token: Stocké dans un cookie httpOnly par le backend (pas accessible via JavaScript)
*
* IMPORTANT: Le backend sette maintenant des cookies httpOnly pour le refresh token.
* Le système détecte automatiquement les cookies httpOnly et réduit l'utilisation de localStorage.
* IMPORTANT: Les tokens sont maintenant dans des cookies httpOnly set par le backend.
* JavaScript ne peut pas accéder aux cookies httpOnly, donc cette classe est maintenant
* principalement une API de compatibilité (no-op).
*
* NOTE: Mode hybride maintenu pour compatibilité :
* - Le backend sette des cookies httpOnly (priorité)
* - localStorage est utilisé uniquement comme fallback pour compatibilité legacy
* - L'access token reste en mémoire uniquement (protection XSS)
* NOTE: Les cookies httpOnly sont automatiquement envoyés avec les requêtes via withCredentials: true.
* Le backend lit les tokens depuis les cookies, pas depuis localStorage ou les headers Authorization.
*/
const ACCESS_TOKEN_KEY = 'veza_access_token';
const REFRESH_TOKEN_KEY = 'veza_refresh_token';
// SECURITY: Stockage en mémoire pour l'access token (plus sécurisé que localStorage)
let accessTokenMemory: string | null = null;
// CRITIQUE FIX #17: Flag pour indiquer si on utilise les cookies httpOnly (détecté automatiquement)
// Le backend sette maintenant des cookies httpOnly, donc ce flag devrait être true après le premier login
let useHttpOnlyCookies = false;
/**
* CRITIQUE FIX #17: Détecte si le backend utilise des cookies httpOnly
* Vérifie si le refresh token est disponible via cookie (non accessible via JS)
* @returns true si les cookies httpOnly sont utilisés
*/
function detectHttpOnlyCookies(): boolean {
// Si on a un access token mais pas de refresh token dans localStorage,
// c'est qu'on utilise des cookies httpOnly
const hasAccessToken =
!!accessTokenMemory || !!localStorage.getItem(ACCESS_TOKEN_KEY);
const hasRefreshTokenInStorage = !!localStorage.getItem(REFRESH_TOKEN_KEY);
// Si on a un access token mais pas de refresh token dans localStorage,
// on suppose que le backend utilise des cookies httpOnly
return hasAccessToken && !hasRefreshTokenInStorage;
}
/**
* Réinitialise le stockage en mémoire (utile pour les tests)
* Réinitialise le stockage (utile pour les tests)
* @internal
*/
export function _resetTokenMemory(): void {
accessTokenMemory = null;
// No-op: tokens are in httpOnly cookies, not accessible from JS
}
/**
* Classe TokenStorage pour gérer le stockage des tokens
* T0169: Service de gestion du stockage tokens avec localStorage
* T0169: Service de gestion du stockage tokens
*
* SECURITY: Action 5.1.1.2 - Tokens sont maintenant dans des cookies httpOnly
* Cette classe est maintenant principalement une API de compatibilité (no-op)
* car les cookies httpOnly ne sont pas accessibles via JavaScript.
*/
export class TokenStorage {
/**
* Stocke les tokens d'authentification
* SECURITY: Access token stocké en mémoire uniquement (protection XSS)
* Le refresh token est dans un cookie httpOnly set par le backend
* SECURITY: Action 5.1.1.2 - No-op car les tokens sont dans des cookies httpOnly
* Le backend sette les cookies httpOnly lors du login/register/refresh.
*
* CRITIQUE FIX #17: Réduire l'utilisation de localStorage maintenant que la migration est complète
*
* @param accessToken - Token d'accès JWT (stocké en mémoire uniquement)
* @param refreshToken - Token de rafraîchissement (dans cookie httpOnly, localStorage utilisé uniquement comme fallback)
* @param accessToken - Token d'accès JWT (ignoré, dans cookie httpOnly)
* @param refreshToken - Token de rafraîchissement (ignoré, dans cookie httpOnly)
*/
static setTokens(accessToken: string, refreshToken: string): void {
// SECURITY: Stocker l'access token en mémoire uniquement (protection XSS)
accessTokenMemory = accessToken;
// CRITIQUE FIX #17: Le backend sette maintenant des cookies httpOnly
// Si refreshToken === 'cookie-based', c'est qu'on utilise des cookies httpOnly
// Ne pas stocker dans localStorage dans ce cas
if (refreshToken === 'cookie-based') {
useHttpOnlyCookies = true;
// Ne pas stocker le refresh token dans localStorage si on utilise des cookies httpOnly
// Garder seulement l'access token en localStorage comme fallback pour compatibilité
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.removeItem(REFRESH_TOKEN_KEY); // Nettoyer localStorage si présent
return;
static setTokens(_accessToken: string, _refreshToken: string): void {
// No-op: tokens are set in httpOnly cookies by backend, not accessible to JS
// Clean up any legacy localStorage tokens if they exist
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
// Ignore errors (e.g., in SSR environment)
}
// CRITIQUE FIX #17: Mode legacy - garder dans localStorage pour compatibilité
// Ceci est un fallback pour les anciens systèmes qui n'utilisent pas encore les cookies httpOnly
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
useHttpOnlyCookies = false;
}
/**
* Récupère le token d'accès depuis la mémoire ou localStorage
* SECURITY: Priorité à la mémoire (plus sécurisé)
* Récupère le token d'accès
* SECURITY: Action 5.1.1.2 - Retourne null car le token est dans un cookie httpOnly
* Les cookies httpOnly ne sont pas accessibles via JavaScript.
*
* @returns Token d'accès ou null si non trouvé
* @returns null (token est dans cookie httpOnly, non accessible)
*/
static getAccessToken(): string | null {
// SECURITY: Priorité à la mémoire (plus sécurisé)
if (accessTokenMemory) {
return accessTokenMemory;
}
// Fallback: localStorage (pour compatibilité pendant migration)
return localStorage.getItem(ACCESS_TOKEN_KEY);
// Token is in httpOnly cookie, not accessible from JavaScript
return null;
}
/**
* Récupère le token de rafraîchissement depuis localStorage
* T0169: Récupère refresh token
* Récupère le token de rafraîchissement
* SECURITY: Action 5.1.1.2 - Retourne null car le token est dans un cookie httpOnly
* Les cookies httpOnly ne sont pas accessibles via JavaScript.
*
* CRITIQUE FIX #17: Si on utilise des cookies httpOnly, retourner null
* car le refresh token n'est pas accessible via JavaScript
*
* @returns Token de rafraîchissement ou null si non trouvé ou si cookies httpOnly utilisés
* @returns null (token est dans cookie httpOnly, non accessible)
*/
static getRefreshToken(): string | null {
// CRITIQUE FIX #17: Si on utilise des cookies httpOnly, le refresh token n'est pas dans localStorage
if (useHttpOnlyCookies || detectHttpOnlyCookies()) {
return null; // Le refresh token est dans un cookie httpOnly, non accessible via JS
}
return localStorage.getItem(REFRESH_TOKEN_KEY);
// Token is in httpOnly cookie, not accessible from JavaScript
return null;
}
/**
* Supprime tous les tokens (mémoire + localStorage)
* SECURITY: Nettoie aussi la mémoire pour l'access token
*
* NOTE: Les cookies httpOnly seront supprimés par le backend lors du logout
* Supprime tous les tokens
* SECURITY: Action 5.1.1.2 - Nettoie seulement localStorage legacy si présent
* Les cookies httpOnly sont supprimés par le backend lors du logout.
*/
static clearTokens(): void {
// SECURITY: Nettoyer la mémoire
accessTokenMemory = null;
// Nettoyer localStorage (pour compatibilité pendant migration)
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
// Clean up any legacy localStorage tokens if they exist
try {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
// Ignore errors (e.g., in SSR environment)
}
// Cookies httpOnly are cleared by backend on logout
}
/**
* Vérifie si des tokens sont présents
* T0169: Vérifie la présence des tokens
* SECURITY: Action 5.1.1.2 - Retourne false car on ne peut pas vérifier les cookies httpOnly
* Les cookies httpOnly ne sont pas accessibles via JavaScript.
*
* CRITIQUE FIX #17: Si on utilise des cookies httpOnly, vérifier seulement l'access token
* car le refresh token n'est pas accessible via JavaScript
*
* @returns true si l'access token est présent (et refresh token présent si pas de cookies httpOnly), false sinon
* @returns false (ne peut pas vérifier les cookies httpOnly depuis JS)
*/
static hasTokens(): boolean {
const hasAccessToken = !!this.getAccessToken();
// CRITIQUE FIX #17: Si on utilise des cookies httpOnly, le refresh token est dans le cookie
// On considère qu'on a des tokens si on a un access token et qu'on utilise des cookies httpOnly
if (useHttpOnlyCookies || detectHttpOnlyCookies()) {
return hasAccessToken; // Le refresh token est dans un cookie httpOnly
}
// Mode legacy: vérifier les deux tokens
return hasAccessToken && !!this.getRefreshToken();
// Cannot check httpOnly cookies from JavaScript
return false;
}
}

View file

@ -177,6 +177,20 @@ func Login(authService *auth.AuthService, sessionService *services.SessionServic
}
http.SetCookie(c.Writer, refreshTokenCookie)
// SECURITY: Set access token in httpOnly cookie
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
accessTokenCookie := &http.Cookie{
Name: "access_token",
Value: tokens.AccessToken,
Path: cfg.CookiePath,
Domain: cfg.CookieDomain,
MaxAge: int(accessTokenExpires.Seconds()),
HttpOnly: cfg.CookieHttpOnly,
Secure: cfg.ShouldUseSecureCookies(),
SameSite: cfg.GetCookieSameSite(),
}
http.SetCookie(c.Writer, accessTokenCookie)
// Retourner uniquement l'access token dans le body (pas le refresh token)
RespondSuccess(c, http.StatusOK, dto.LoginResponse{
User: dto.UserResponse{
@ -304,6 +318,20 @@ func Register(authService *auth.AuthService, sessionService *services.SessionSer
}
http.SetCookie(c.Writer, refreshTokenCookie)
// SECURITY: Set access token in httpOnly cookie
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
accessTokenCookie := &http.Cookie{
Name: "access_token",
Value: tokens.AccessToken,
Path: cfg.CookiePath,
Domain: cfg.CookieDomain,
MaxAge: int(accessTokenExpires.Seconds()),
HttpOnly: cfg.CookieHttpOnly,
Secure: cfg.ShouldUseSecureCookies(),
SameSite: cfg.GetCookieSameSite(),
}
http.SetCookie(c.Writer, accessTokenCookie)
// Construire la réponse avec uniquement l'access token (pas le refresh token)
response := dto.RegisterResponse{
User: dto.UserResponse{
@ -429,6 +457,20 @@ func Refresh(authService *auth.AuthService, sessionService *services.SessionServ
}
http.SetCookie(c.Writer, refreshTokenCookie)
// SECURITY: Set access token in httpOnly cookie
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
accessTokenCookie := &http.Cookie{
Name: "access_token",
Value: tokens.AccessToken,
Path: cfg.CookiePath,
Domain: cfg.CookieDomain,
MaxAge: int(accessTokenExpires.Seconds()),
HttpOnly: cfg.CookieHttpOnly,
Secure: cfg.ShouldUseSecureCookies(),
SameSite: cfg.GetCookieSameSite(),
}
http.SetCookie(c.Writer, accessTokenCookie)
// Calculate ExpiresIn from tokens if available, otherwise use JWTService config
expiresIn := tokens.ExpiresIn
if expiresIn == 0 && authService.JWTService != nil {

View file

@ -68,30 +68,31 @@ func NewAuthMiddleware(
// authenticate performs the core authentication logic
// Returns userID and true if successful, otherwise handles error response and returns false
func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
am.logger.Warn("Missing Authorization header",
// SECURITY: Try cookie first (new method), fallback to Authorization header (backward compatibility)
tokenString := ""
if cookie, err := c.Cookie("access_token"); err == nil && cookie != "" {
tokenString = cookie
} else {
// Fallback to Authorization header (backward compatibility)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
}
}
}
if tokenString == "" {
am.logger.Warn("Missing access token (cookie or Authorization header)",
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
)
response.Unauthorized(c, "Authorization header required")
response.Unauthorized(c, "Access token required")
c.Abort()
return uuid.Nil, false
}
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
am.logger.Warn("Invalid Authorization header format",
zap.String("ip", c.ClientIP()),
zap.String("header", authHeader),
)
response.Unauthorized(c, "Invalid Authorization header format")
c.Abort()
return uuid.Nil, false
}
tokenString := tokenParts[1]
// T0204: Validate token using JWTService (checks sig, exp, iss, aud, alg)
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {
@ -237,20 +238,26 @@ func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc {
// MIGRATION UUID: Simplifié, utilise UUID directement
func (am *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// SECURITY: Try cookie first (new method), fallback to Authorization header (backward compatibility)
tokenString := ""
if cookie, err := c.Cookie("access_token"); err == nil && cookie != "" {
tokenString = cookie
} else {
// Fallback to Authorization header (backward compatibility)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
}
}
}
if tokenString == "" {
c.Next()
return
}
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.Next()
return
}
tokenString := tokenParts[1]
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {
c.Next()