mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 00:52:08 +00:00
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
This commit is contained in:
parent
1b41d85795
commit
ffbe071bb4
5 changed files with 217 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
|
|
|||
85
src/services/authInterceptor.ts
Normal file
85
src/services/authInterceptor.ts
Normal file
|
|
@ -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<string>((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;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null> | null = null;
|
||||
|
||||
export async function refreshAuthToken(): Promise<string | null> {
|
||||
// É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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue