feat: add profile setup, verification, and authentication context
- Implement ProfileSetup component for user profile creation with username validation. - Create ProtectedRoute component to guard routes based on authentication status. - Add VerifyAuthCode component for email verification with resend functionality. - Establish AuthContext for managing user authentication state and actions. - Enhance CookieContext for consent management with server-side verification. - Set up Firebase configuration for authentication and analytics. - Update styles for authentication components and header layout. - Configure Vite proxy for API requests during development.
This commit is contained in:
18
backend/src/config/authDb.ts
Normal file
18
backend/src/config/authDb.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const authPool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_AUTH_USER,
|
||||
password: process.env.DB_AUTH_PASSWORD,
|
||||
database: process.env.DB_AUTH_NAME,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
export default authPool;
|
||||
@@ -9,6 +9,7 @@ const pool = mysql.createPool({
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
|
||||
@@ -2,20 +2,46 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import pool from './config/db';
|
||||
import {
|
||||
authLimiter,
|
||||
authSlowDown,
|
||||
globalLimiter,
|
||||
requestTimeout,
|
||||
securityHeaders
|
||||
} from './middlewares/security';
|
||||
|
||||
import messagesRouter from './routes/messages';
|
||||
import authCodeRouter from './routes/authCode';
|
||||
import supportRouter from './routes/support';
|
||||
import cookieConsentRouter from './routes/cookieConsent';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const allowedOrigins = String(process.env.CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', 1);
|
||||
app.use(securityHeaders);
|
||||
app.use(globalLimiter);
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigins.length > 0 ? allowedOrigins : true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: false
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '20kb' }));
|
||||
app.use(express.urlencoded({ extended: false, limit: '20kb' }));
|
||||
app.use(requestTimeout(15_000));
|
||||
|
||||
app.use('/api/messages', messagesRouter);
|
||||
import supportRouter from './routes/support';
|
||||
app.use('/api/support', supportRouter);
|
||||
app.use('/api/auth', requestTimeout(8_000), authSlowDown, authLimiter, authCodeRouter);
|
||||
app.use('/api/cookies', requestTimeout(8_000), cookieConsentRouter);
|
||||
|
||||
// Basic health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
||||
70
backend/src/middlewares/security.ts
Normal file
70
backend/src/middlewares/security.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import slowDown from 'express-slow-down';
|
||||
|
||||
export const securityHeaders = helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }
|
||||
});
|
||||
|
||||
export const globalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
status: 'error',
|
||||
message: 'Trop de requêtes. Réessayez plus tard.'
|
||||
}
|
||||
});
|
||||
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
status: 'error',
|
||||
message: 'Trop de tentatives d’authentification. Réessayez plus tard.'
|
||||
}
|
||||
});
|
||||
|
||||
export const verifyCodeLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
status: 'error',
|
||||
message: 'Trop de tentatives de validation de code.'
|
||||
}
|
||||
});
|
||||
|
||||
export const authSlowDown = slowDown({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
delayAfter: 5,
|
||||
delayMs: (hits) => Math.min(1000 * (hits - 5), 10000),
|
||||
validate: { delayMs: false }
|
||||
});
|
||||
|
||||
export const verifyCodeSlowDown = slowDown({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
delayAfter: 3,
|
||||
delayMs: (hits) => Math.min(1500 * (hits - 3), 12000),
|
||||
validate: { delayMs: false }
|
||||
});
|
||||
|
||||
export const requestTimeout = (timeoutMs: number) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
req.setTimeout(timeoutMs);
|
||||
res.setTimeout(timeoutMs, () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
status: 'error',
|
||||
message: 'Délai de requête dépassé.'
|
||||
});
|
||||
}
|
||||
});
|
||||
next();
|
||||
};
|
||||
};
|
||||
404
backend/src/routes/authCode.ts
Normal file
404
backend/src/routes/authCode.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import authPool from '../config/authDb';
|
||||
import { verifyCodeLimiter, verifyCodeSlowDown } from '../middlewares/security';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||
const SESSION_TTL_HOURS = Math.min(Number(process.env.AUTH_SESSION_TTL_HOURS || 12), 24);
|
||||
const SESSION_TTL_MS = SESSION_TTL_HOURS * 60 * 60 * 1000;
|
||||
const RESEND_COOLDOWN_MS = 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const HASH_PEPPER = process.env.AUTH_HASH_PEPPER || 'change-this-pepper';
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const usernameRegex = /^(?=.{3,24}$)[a-zA-Z0-9._-]+$/;
|
||||
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||
|
||||
const hashCode = (email: string, code: string) => {
|
||||
return crypto.createHash('sha256').update(`${email}:${code}:${HASH_PEPPER}`).digest('hex');
|
||||
};
|
||||
|
||||
const normalizeUsername = (username: string) => username.trim().toLowerCase();
|
||||
|
||||
const hashSessionToken = (token: string) => {
|
||||
return crypto.createHash('sha256').update(`${token}:${HASH_PEPPER}`).digest('hex');
|
||||
};
|
||||
|
||||
const resolveAuthDbError = (error: any) => {
|
||||
const code = String(error?.code || '');
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNREFUSED' || code === 'PROTOCOL_CONNECTION_LOST') {
|
||||
return { status: 503, message: 'Base auth indisponible.' };
|
||||
}
|
||||
if (code === 'ER_ACCESS_DENIED_ERROR' || code === 'ER_BAD_DB_ERROR') {
|
||||
return { status: 503, message: 'Configuration DB auth invalide.' };
|
||||
}
|
||||
if (code === 'ER_NO_SUCH_TABLE') {
|
||||
return { status: 503, message: 'Tables auth manquantes.' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cleanupExpiredEntries = async () => {
|
||||
await authPool.query('DELETE FROM auth_code_challenges WHERE expires_at <= NOW() OR consumed_at IS NOT NULL');
|
||||
await authPool.query('DELETE FROM auth_verified_sessions WHERE expires_at <= NOW() OR revoked_at IS NOT NULL');
|
||||
};
|
||||
|
||||
const getTransporter = () => {
|
||||
const host = process.env.MAIL_HOST;
|
||||
const port = Number(process.env.MAIL_PORT || 587);
|
||||
const secure = String(process.env.MAIL_SECURE || 'false') === 'true';
|
||||
const user = process.env.MAIL_USER;
|
||||
const pass = process.env.MAIL_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
connectionTimeout: Number(process.env.MAIL_CONNECTION_TIMEOUT_MS) || 4000,
|
||||
greetingTimeout: Number(process.env.MAIL_GREETING_TIMEOUT_MS) || 4000,
|
||||
socketTimeout: Number(process.env.MAIL_SOCKET_TIMEOUT_MS) || 6000,
|
||||
auth: {
|
||||
user,
|
||||
pass
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
router.post('/send-code', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.body?.email || '');
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
res.status(400).json({ status: 'error', message: 'Email invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const transporter = getTransporter();
|
||||
if (!transporter) {
|
||||
res.status(500).json({ status: 'error', message: 'SMTP non configuré.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupExpiredEntries();
|
||||
|
||||
const [existingRows] = await authPool.query<RowDataPacket[]>(
|
||||
`SELECT id, last_sent_at
|
||||
FROM auth_code_challenges
|
||||
WHERE email = ? AND consumed_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingRows.length > 0) {
|
||||
const lastSent = new Date(existingRows[0].last_sent_at as string | Date).getTime();
|
||||
const remainingMs = RESEND_COOLDOWN_MS - (Date.now() - lastSent);
|
||||
if (remainingMs > 0) {
|
||||
res.status(429).json({
|
||||
status: 'error',
|
||||
message: 'Veuillez patienter avant de demander un nouveau code.',
|
||||
retryAfterSeconds: Math.ceil(remainingMs / 1000)
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const code = String(crypto.randomInt(100000, 1000000));
|
||||
const codeHash = hashCode(email, code);
|
||||
const fromAddress = process.env.MAIL_FROM || process.env.MAIL_USER || 'no-reply@xeewy.be';
|
||||
const subject = process.env.MAIL_AUTH_SUBJECT || 'Votre code de verification';
|
||||
const text = `Votre code de verification est: ${code}\nCe code expire dans 10 minutes.`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: fromAddress,
|
||||
to: email,
|
||||
subject,
|
||||
text
|
||||
});
|
||||
|
||||
await authPool.query(
|
||||
`INSERT INTO auth_code_challenges (email, code_hash, attempts, expires_at, last_sent_at)
|
||||
VALUES (?, ?, 0, DATE_ADD(NOW(), INTERVAL ? SECOND), NOW())`,
|
||||
[email, codeHash, Math.floor(CODE_TTL_MS / 1000)]
|
||||
);
|
||||
|
||||
res.json({ status: 'success', message: 'Code envoyé.' });
|
||||
} catch (error) {
|
||||
console.error('Failed to send auth code email:', error);
|
||||
res.status(500).json({ status: 'error', message: "Impossible d'envoyer le code." });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/username-available', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingUsername = String(req.query.username || '');
|
||||
const username = normalizeUsername(incomingUsername);
|
||||
|
||||
if (!usernameRegex.test(username)) {
|
||||
res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Pseudo invalide (3-24, lettres/chiffres/._-).'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await authPool.query<RowDataPacket[]>(
|
||||
'SELECT id FROM auth_users WHERE username = ? LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
available: rows.length === 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check username availability:', error);
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/profile-status', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.query.email || '');
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
res.status(400).json({ status: 'error', message: 'Email invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await authPool.query<RowDataPacket[]>(
|
||||
'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
res.json({ status: 'success', hasProfile: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
hasProfile: true,
|
||||
username: String(rows[0].username || '')
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get profile status:', error);
|
||||
const dbError = resolveAuthDbError(error);
|
||||
if (dbError) {
|
||||
res.status(dbError.status).json({ status: 'error', message: dbError.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/register-profile', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.body?.email || '');
|
||||
const incomingUid = String(req.body?.firebaseUid || '');
|
||||
const incomingUsername = String(req.body?.username || '');
|
||||
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
const firebaseUid = incomingUid.trim();
|
||||
const username = normalizeUsername(incomingUsername);
|
||||
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
res.status(400).json({ status: 'error', message: 'Email invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firebaseUid || firebaseUid.length < 10) {
|
||||
res.status(400).json({ status: 'error', message: 'UID Firebase invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usernameRegex.test(username)) {
|
||||
res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Pseudo invalide (3-24, lettres/chiffres/._-).'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [existingByEmail] = await authPool.query<RowDataPacket[]>(
|
||||
'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingByEmail.length > 0) {
|
||||
const currentUsername = String(existingByEmail[0].username || '');
|
||||
if (currentUsername === username) {
|
||||
res.json({ status: 'success', message: 'Profil déjà enregistré.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(409).json({
|
||||
status: 'error',
|
||||
message: 'Un profil existe déjà pour cet email avec un autre pseudo.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await authPool.query<ResultSetHeader>(
|
||||
'INSERT INTO auth_users (firebase_uid, email, username) VALUES (?, ?, ?)',
|
||||
[firebaseUid, email, username]
|
||||
);
|
||||
|
||||
res.status(201).json({ status: 'success', message: 'Profil enregistré.' });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
res.status(409).json({ status: 'error', message: 'Ce pseudo est déjà utilisé.' });
|
||||
return;
|
||||
}
|
||||
console.error('Failed to register auth profile:', error);
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/verify-code', verifyCodeSlowDown, verifyCodeLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.body?.email || '');
|
||||
const incomingCode = String(req.body?.code || '').trim();
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
|
||||
if (!email || !emailRegex.test(email) || !/^\d{6}$/.test(incomingCode)) {
|
||||
res.status(400).json({ status: 'error', message: 'Données invalides.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupExpiredEntries();
|
||||
|
||||
const [rows] = await authPool.query<RowDataPacket[]>(
|
||||
`SELECT id, code_hash, attempts, expires_at
|
||||
FROM auth_code_challenges
|
||||
WHERE email = ? AND consumed_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ status: 'error', message: 'Aucun code actif pour cet email.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const challenge = rows[0];
|
||||
const challengeId = Number(challenge.id);
|
||||
const attempts = Number(challenge.attempts || 0);
|
||||
const expiresAt = new Date(challenge.expires_at as string | Date).getTime();
|
||||
const storedHash = String(challenge.code_hash || '');
|
||||
|
||||
if (expiresAt <= Date.now()) {
|
||||
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
|
||||
res.status(410).json({ status: 'error', message: 'Code expiré.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
|
||||
res.status(429).json({ status: 'error', message: 'Trop de tentatives.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const providedHash = hashCode(email, incomingCode);
|
||||
const validCode = crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash));
|
||||
|
||||
if (!validCode) {
|
||||
await authPool.query('UPDATE auth_code_challenges SET attempts = attempts + 1 WHERE id = ?', [challengeId]);
|
||||
res.status(401).json({ status: 'error', message: 'Code invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashSessionToken(token);
|
||||
|
||||
await authPool.query(
|
||||
`INSERT INTO auth_verified_sessions (token_hash, email, expires_at)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`,
|
||||
[tokenHash, email, Math.floor(SESSION_TTL_MS / 1000)]
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
token,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to verify code:', error);
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/check-session', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.body?.email || '');
|
||||
const token = String(req.body?.token || '').trim();
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
|
||||
if (!email || !emailRegex.test(email) || !token) {
|
||||
res.status(400).json({ status: 'error', message: 'Données invalides.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupExpiredEntries();
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await authPool.query<RowDataPacket[]>(
|
||||
`SELECT id
|
||||
FROM auth_verified_sessions
|
||||
WHERE token_hash = ? AND email = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash, email]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
res.status(401).json({ status: 'error', message: 'Session invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ status: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Failed to check session:', error);
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout-session', async (req: Request, res: Response): Promise<void> => {
|
||||
const incomingEmail = String(req.body?.email || '');
|
||||
const token = String(req.body?.token || '').trim();
|
||||
const email = normalizeEmail(incomingEmail);
|
||||
|
||||
if (!email || !emailRegex.test(email) || !token) {
|
||||
res.status(400).json({ status: 'error', message: 'Données invalides.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
await authPool.query(
|
||||
`UPDATE auth_verified_sessions
|
||||
SET revoked_at = NOW()
|
||||
WHERE token_hash = ? AND email = ? AND revoked_at IS NULL`,
|
||||
[tokenHash, email]
|
||||
);
|
||||
res.json({ status: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke session:', error);
|
||||
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
129
backend/src/routes/cookieConsent.ts
Normal file
129
backend/src/routes/cookieConsent.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
const CONSENT_VERSION = 2;
|
||||
const CONSENT_TTL_SECONDS = 180 * 24 * 60 * 60;
|
||||
|
||||
type ConsentPayload = {
|
||||
essential: boolean;
|
||||
preferences: boolean;
|
||||
analytics: boolean;
|
||||
};
|
||||
|
||||
const getSecret = () => {
|
||||
return process.env.CONSENT_COOKIE_SECRET || process.env.AUTH_HASH_PEPPER || '';
|
||||
};
|
||||
|
||||
const base64UrlEncode = (value: string) => {
|
||||
return Buffer.from(value, 'utf-8').toString('base64url');
|
||||
};
|
||||
|
||||
const base64UrlDecode = (value: string) => {
|
||||
return Buffer.from(value, 'base64url').toString('utf-8');
|
||||
};
|
||||
|
||||
const signValue = (payloadB64: string, secret: string) => {
|
||||
return crypto.createHmac('sha512', secret).update(payloadB64).digest('hex');
|
||||
};
|
||||
|
||||
const isValidConsent = (consent: any): consent is ConsentPayload => {
|
||||
return (
|
||||
typeof consent === 'object' &&
|
||||
consent !== null &&
|
||||
typeof consent.essential === 'boolean' &&
|
||||
typeof consent.preferences === 'boolean' &&
|
||||
typeof consent.analytics === 'boolean'
|
||||
);
|
||||
};
|
||||
|
||||
router.post('/sign-consent', (req: Request, res: Response): void => {
|
||||
const secret = getSecret();
|
||||
if (!secret || secret.length < 32) {
|
||||
res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const consent = req.body?.consent;
|
||||
if (!isValidConsent(consent)) {
|
||||
res.status(400).json({ status: 'error', message: 'Consent payload invalide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
consent: {
|
||||
essential: true,
|
||||
preferences: Boolean(consent.preferences),
|
||||
analytics: Boolean(consent.analytics)
|
||||
},
|
||||
version: CONSENT_VERSION,
|
||||
iat: now,
|
||||
exp: now + CONSENT_TTL_SECONDS,
|
||||
nonce: crypto.randomBytes(16).toString('hex')
|
||||
};
|
||||
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
const signature = signValue(payloadB64, secret);
|
||||
const token = `${payloadB64}.${signature}`;
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
token,
|
||||
maxAgeSeconds: CONSENT_TTL_SECONDS
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/verify-consent', (req: Request, res: Response): void => {
|
||||
const secret = getSecret();
|
||||
if (!secret || secret.length < 32) {
|
||||
res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = String(req.body?.token || '');
|
||||
const [payloadB64, providedSignature] = token.split('.');
|
||||
if (!payloadB64 || !providedSignature) {
|
||||
res.json({ status: 'success', valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedSignature = signValue(payloadB64, secret);
|
||||
|
||||
if (
|
||||
expectedSignature.length !== providedSignature.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(providedSignature))
|
||||
) {
|
||||
res.json({ status: 'success', valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(base64UrlDecode(payloadB64)) as {
|
||||
consent?: ConsentPayload;
|
||||
version?: number;
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
if (!payload || payload.version !== CONSENT_VERSION || !isValidConsent(payload.consent)) {
|
||||
res.json({ status: 'success', valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!payload.exp || payload.exp <= now) {
|
||||
res.json({ status: 'success', valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
valid: true,
|
||||
consent: payload.consent
|
||||
});
|
||||
} catch {
|
||||
res.json({ status: 'success', valid: false });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user