import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; export const ADMIN_COOKIE_NAME = "admin_session"; const SESSION_TTL_S = 12 * 3600; // 12h const LOCKOUT_WINDOW_MIN = 15; // Deux compteurs distincts, volontairement asymetriques. // // L'ancienne requete comptait `username = $2 OR ip = $3` dans un seul total : // cinq echecs avec le nom d'un admin, depuis n'importe quelle adresse, // verrouillaient ce compte un quart d'heure. N'importe qui connaissant le nom // d'utilisateur pouvait donc mettre l'admin dehors, indefiniment et sans cout. // // L'IP reste stricte : c'est elle qui freine une attaque par force brute, et // s'auto-verrouiller n'a aucun interet pour un attaquant. Le compteur par // username subsiste contre une attaque repartie sur plusieurs adresses, mais // avec un seuil bien plus haut : un attaquant devra bruler 4 IP avant de // commencer a genner le titulaire du compte. const LOCKOUT_MAX_PER_IP = 5; const LOCKOUT_MAX_PER_USERNAME = 20; // Hash bcrypt valide mais sans correspondance, utilisé pour égaliser le temps // de réponse quand le username n'existe pas (évite l'énumération de comptes). const DUMMY_HASH = "$2b$12$CwTycUXWue0Thq9StjUM0uJ8vKR1dlT0LYzGsv8ZE8nFI8q9Z5T9."; function jwtSecret() { const secret = (process.env.ADMIN_JWT_SECRET || "").trim(); if (!secret || secret.length < 32) { throw new Error("ADMIN_JWT_SECRET manquant ou trop court (min 32 caractères)"); } return secret; } export async function seedAdminUser(pool) { const username = (process.env.ADMIN_SEED_USERNAME || "").trim(); const passwordHash = (process.env.ADMIN_SEED_PASSWORD_HASH || "").trim(); if (!username || !passwordHash) return; await pool.query( `INSERT INTO admin_users (username, password_hash) VALUES ($1, $2) ON CONFLICT (username) DO NOTHING`, [username, passwordHash] ); } export function signAdminSession(username) { return jwt.sign({ sub: username }, jwtSecret(), { expiresIn: SESSION_TTL_S }); } export function verifyAdminSession(token) { try { const payload = jwt.verify(token, jwtSecret()); return typeof payload.sub === "string" ? payload.sub : null; } catch { return null; } } export async function isLockedOut(pool, username, ip) { const r = await pool.query( `SELECT count(*) FILTER (WHERE ip = $3)::int AS par_ip, count(*) FILTER (WHERE username = $2)::int AS par_username FROM admin_login_attempts WHERE success = false AND created_at > now() - make_interval(mins => $1) AND (username = $2 OR ip = $3)`, [LOCKOUT_WINDOW_MIN, username, ip] ); const { par_ip, par_username } = r.rows[0]; return par_ip >= LOCKOUT_MAX_PER_IP || par_username >= LOCKOUT_MAX_PER_USERNAME; } export async function recordLoginAttempt(pool, username, ip, success) { await pool.query( `INSERT INTO admin_login_attempts (username, ip, success) VALUES ($1, $2, $3)`, [username, ip, success] ); } export async function verifyPassword(pool, username, password) { const r = await pool.query( `SELECT password_hash FROM admin_users WHERE username = $1`, [username] ); const hash = r.rows.length ? r.rows[0].password_hash : DUMMY_HASH; const valid = await bcrypt.compare(password, hash); return valid && r.rows.length > 0; } export async function markLoginSuccess(pool, username) { await pool.query( `UPDATE admin_users SET last_login_at = now() WHERE username = $1`, [username] ); } export function requireAdminSession(req, reply) { const token = req.cookies?.[ADMIN_COOKIE_NAME]; const username = token ? verifyAdminSession(token) : null; if (!username) { reply.code(401).send({ ok: false, error: "unauthorized" }); return null; } return username; } export async function writeAuditLog(pool, adminUsername, action, targetTable, targetId, before, after) { await pool.query( `INSERT INTO admin_audit_log (admin_username, action, target_table, target_id, before_data, after_data) VALUES ($1, $2, $3, $4, $5, $6)`, [adminUsername, action, targetTable, String(targetId), before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null] ); }