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:
parent
1f9c5da884
commit
d9b6510802
6 changed files with 204 additions and 368 deletions
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 été 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue