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:
Van Leemput Dayron
2026-02-24 08:25:37 +01:00
parent d3e0570214
commit 46e6edcd9c
30 changed files with 3469 additions and 324 deletions

View 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;

View File

@@ -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

View File

@@ -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) => {

View 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 dauthentification. 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();
};
};

View 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;

View 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;