From ffbe071bb4a5534dee73aec0a21f0ba3dbf095b0 Mon Sep 17 00:00:00 2001 From: Satanas1275 <107134873+Satanas1275@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:07:22 +0200 Subject: [PATCH] fix: add JWT expiration (24h) and token refresh endpoint - Add expiresIn: '24h' to issueJwt() - tokens now have an exp claim - Export verifyJwt() to allow token verification with options - Add POST /api/auth/refresh endpoint for token renewal - Add frontend refreshAuthToken() utility - Add axios 401 interceptor that auto-refreshes and retries - Register interceptor on default axios and api instances --- API/Mainapi/middleware/auth.js | 10 ++-- API/Mainapi/routes/authRoutes.js | 74 ++++++++++++++++++++++++++- src/main.tsx | 5 ++ src/services/authInterceptor.ts | 85 ++++++++++++++++++++++++++++++++ src/utils/accountAuth.ts | 47 ++++++++++++++++++ 5 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/services/authInterceptor.ts diff --git a/API/Mainapi/middleware/auth.js b/API/Mainapi/middleware/auth.js index 0220fc6..29c54d8 100644 --- a/API/Mainapi/middleware/auth.js +++ b/API/Mainapi/middleware/auth.js @@ -28,12 +28,15 @@ if (!JWT_SECRET) { } function issueJwt(userType, userId, sessionId, authMethod = null) { - // Issue a token without expiration (no exp claim) const payload = { sub: userId, userType, sessionId }; if (AUTH_METHODS.includes(authMethod)) { payload.authMethod = authMethod; } - return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256' }); + return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256', expiresIn: '24h' }); +} + +function verifyJwt(token, options = {}) { + return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], ...options }); } function parseStoredAuth(rawValue) { @@ -128,7 +131,7 @@ async function getAuthIfValid(req) { const authHeader = req.headers['authorization'] || req.headers['Authorization']; if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null; const token = authHeader.split(' ')[1]; - const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); + const payload = verifyJwt(token); const { userType, sub: userId, sessionId } = payload; const authMethod = AUTH_METHODS.includes(payload?.authMethod) ? payload.authMethod @@ -304,6 +307,7 @@ async function isUploaderOrAdmin(req, res, next) { module.exports = { JWT_SECRET, issueJwt, + verifyJwt, isAdmin, isUploaderOrAdmin, getAuthIfValid, diff --git a/API/Mainapi/routes/authRoutes.js b/API/Mainapi/routes/authRoutes.js index 5b1fa9b..29264dc 100644 --- a/API/Mainapi/routes/authRoutes.js +++ b/API/Mainapi/routes/authRoutes.js @@ -13,7 +13,7 @@ const { v4: uuidv4 } = require('uuid'); const rateLimit = require('express-rate-limit'); const { ipKeyGenerator } = require('express-rate-limit'); -const { issueJwt, getAuthIfValid } = require('../middleware/auth'); +const { issueJwt, verifyJwt, getAuthIfValid } = require('../middleware/auth'); const { getPool } = require('../mysqlPool'); const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore'); const { verifyTurnstileFromRequest } = require('../utils/turnstile'); @@ -727,4 +727,76 @@ router.delete('/links/:provider', async (req, res) => { } }); +// Rate limiter pour le refresh token : 5 req/minute +const refreshRateLimit = rateLimit({ + windowMs: 60 * 1000, + max: 5, + keyGenerator: (req) => { + const token = req.body?.token || req.headers['authorization']?.split(' ')[1] || req.ip; + return `refresh_${token}`; + }, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: 'Trop de tentatives de refresh. Réessayez dans une minute.' }, +}); + +/** + * POST /api/auth/refresh + * Émet un nouveau JWT si la session MySQL est toujours valide. + * Accepte le token actuel (même expiré) dans le body ou l'Authorization header. + */ +router.post('/refresh', refreshRateLimit, async (req, res) => { + try { + const token = req.body?.token || req.headers['authorization']?.split(' ')[1]; + if (!token) { + return res.status(401).json({ success: false, error: 'Token requis' }); + } + + // Vérifier la signature du token même s'il est expiré + let payload; + try { + payload = verifyJwt(token, { ignoreExpiration: true }); + } catch (err) { + return res.status(401).json({ success: false, error: 'Token invalide' }); + } + + const { userType, sub: userId, sessionId, authMethod } = payload; + if (!['oauth', 'bip39'].includes(userType) || !userId || !sessionId) { + return res.status(401).json({ success: false, error: 'Payload token invalide' }); + } + + // Vérifier que la session existe toujours en MySQL + const pool = getPool(); + if (!pool) { + return res.status(503).json({ success: false, error: 'Base de données indisponible' }); + } + + const [rows] = await pool.execute( + 'SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?', + [sessionId, userId, userType] + ); + + if (rows.length === 0) { + return res.status(401).json({ success: false, error: 'Session expirée. Veuillez vous reconnecter.' }); + } + + // Mettre à jour le timestamp d'accès + pool.execute( + 'UPDATE user_sessions SET accessed_at = NOW() WHERE id = ?', + [sessionId] + ).catch(() => {}); + + // Émettre un nouveau JWT + const newToken = issueJwt(userType, userId, sessionId, authMethod); + + return res.json({ + success: true, + token: newToken, + }); + } catch (error) { + console.error('[AUTH] Refresh error:', error); + return res.status(500).json({ success: false, error: 'Erreur interne' }); + } +}); + module.exports = router; diff --git a/src/main.tsx b/src/main.tsx index 6ce7cc5..c5fc2b3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import App from './App.tsx' import axios from 'axios' import { api } from './services/api' import { registerBlockDetection } from './services/blockDetection' +import { registerAuthInterceptor } from './services/authInterceptor' import './index.css' import './styles/light-mode.css' @@ -126,6 +127,10 @@ startMovixConsoleSafetyWarning(); registerBlockDetection(axios) registerBlockDetection(api) +// Register auth interceptor (JWT refresh on 401) on both instances +registerAuthInterceptor(axios) +registerAuthInterceptor(api) + createRoot(document.getElementById('root')!).render( diff --git a/src/services/authInterceptor.ts b/src/services/authInterceptor.ts new file mode 100644 index 0000000..7e7fab9 --- /dev/null +++ b/src/services/authInterceptor.ts @@ -0,0 +1,85 @@ +import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { refreshAuthToken } from '../utils/accountAuth'; + +interface FailedRequest { + resolve: (token: string) => void; + reject: (error: unknown) => void; +} + +let isRefreshing = false; +let failedQueue: FailedRequest[] = []; + +function processQueue(error: unknown, token: string | null) { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else if (token) { + resolve(token); + } else { + reject(new Error('Refresh failed')); + } + }); + failedQueue = []; +} + +/** + * Intercepteur réponse axios : sur 401, tente un refresh du JWT. + * Si le refresh réussit, la requête originale est rejouée avec le nouveau token. + * Si le refresh échoue, la requête échoue normalement. + */ +export function registerAuthInterceptor(axiosInstance: AxiosInstance) { + return axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + if ( + !error.response || + error.response.status !== 401 || + !originalRequest || + originalRequest._retry || + originalRequest.url?.includes('/api/auth/refresh') + ) { + return Promise.reject(error); + } + + const currentToken = localStorage.getItem('auth_token'); + if (!currentToken) { + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((newToken) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + } + originalRequest._retry = true; + return axiosInstance(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const newToken = await refreshAuthToken(); + if (newToken) { + processQueue(null, newToken); + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + } + return axiosInstance(originalRequest); + } + processQueue(new Error('Refresh failed'), null); + return Promise.reject(error); + } catch (refreshError) { + processQueue(refreshError, null); + return Promise.reject(error); + } finally { + isRefreshing = false; + } + } + ); +} diff --git a/src/utils/accountAuth.ts b/src/utils/accountAuth.ts index dd3f85a..2797d40 100644 --- a/src/utils/accountAuth.ts +++ b/src/utils/accountAuth.ts @@ -375,6 +375,53 @@ export function clearPendingAuthAction() { clearCookie(PENDING_AUTH_ACTION_COOKIE); } +/** + * Rafraîchit le JWT via l'API /api/auth/refresh + * Retourne le nouveau token ou null si le refresh a échoué. + */ +let refreshPromise: Promise | null = null; + +export async function refreshAuthToken(): Promise { + // Éviter les appels concurrents + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + try { + const currentToken = localStorage.getItem('auth_token'); + if (!currentToken) return null; + + const { MAIN_API } = await import('../config/runtime'); + const response = await fetch(`${MAIN_API}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${currentToken}`, + }, + body: JSON.stringify({ token: currentToken }), + }); + + if (!response.ok) { + // Refresh échoué, forcer la déconnexion + clearStoredAuthSession(); + return null; + } + + const data = await response.json(); + if (data?.token) { + localStorage.setItem('auth_token', data.token); + return data.token; + } + return null; + } catch { + return null; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} + export function clearStoredAuthSession() { AUTH_KEYS.forEach((key) => localStorage.removeItem(key)); }