metalfrom.eu/apps/api/src/adminAuth.js
Nicolas FRYDER a21bd55a90 fix(sécurité): verrouillage de compte détournable, et Vary manquant sur le CORS
isLockedOut comptait `username = $2 OR ip = $3` dans un seul total. Cinq échecs
avec le nom d'un admin, depuis n'importe quelle adresse, verrouillaient donc ce
compte un quart d'heure — à répéter indéfiniment, sans coût et sans connaître
le mot de passe. Il suffisait de connaître le nom d'utilisateur pour mettre
l'admin dehors en permanence.

Les deux compteurs deviennent distincts et volontairement asymétriques. L'IP
reste stricte à 5 : c'est elle qui freine la force brute, et s'auto-verrouiller
n'a aucun intérêt pour un attaquant. Le compteur par username subsiste contre
une attaque répartie, mais à 20 — il faut brûler 4 IP avant de commencer à
gêner le titulaire du compte. Vérifié contre PostgreSQL : l'admin légitime a
bien par_ip=0 quand un tiers martèle depuis ailleurs.

Ajoute par ailleurs `Vary: Origin` aux réponses CORS. L'en-tête Allow-Origin
dépend de l'origine demandée : sans Vary, un cache intermédiaire peut servir à
une origine la réponse mise en cache pour une autre — ce qui revient à
autoriser une origine qui ne l'est pas, ou à faire refuser une origine
légitime. Posé aussi quand l'origine est refusée, pour la même raison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:24:33 +02:00

114 lines
4.1 KiB
JavaScript

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]
);
}