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>
146 lines
6.1 KiB
JavaScript
146 lines
6.1 KiB
JavaScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const {
|
|
signAdminSession,
|
|
verifyAdminSession,
|
|
isLockedOut,
|
|
verifyPassword,
|
|
requireAdminSession,
|
|
ADMIN_COOKIE_NAME,
|
|
} = await import("../src/adminAuth.js");
|
|
const { makeFakePool, rows } = await import("./helpers/fakePool.js");
|
|
|
|
describe("sessions JWT", () => {
|
|
it("aller-retour signature / vérification", () => {
|
|
expect(verifyAdminSession(signAdminSession("nico"))).toBe("nico");
|
|
});
|
|
|
|
it("rejette un jeton signé avec un autre secret", async () => {
|
|
const jwt = (await import("jsonwebtoken")).default;
|
|
const forged = jwt.sign({ sub: "nico" }, "un-autre-secret-de-32-caracteres-ok");
|
|
expect(verifyAdminSession(forged)).toBeNull();
|
|
});
|
|
|
|
it("rejette un jeton `alg: none` (attaque classique)", async () => {
|
|
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
|
const payload = Buffer.from(JSON.stringify({ sub: "nico" })).toString("base64url");
|
|
expect(verifyAdminSession(`${header}.${payload}.`)).toBeNull();
|
|
});
|
|
|
|
it("rejette un jeton expiré", async () => {
|
|
const jwt = (await import("jsonwebtoken")).default;
|
|
const expired = jwt.sign({ sub: "nico" }, process.env.ADMIN_JWT_SECRET, { expiresIn: -10 });
|
|
expect(verifyAdminSession(expired)).toBeNull();
|
|
});
|
|
|
|
it("rejette un jeton sans sub exploitable", async () => {
|
|
const jwt = (await import("jsonwebtoken")).default;
|
|
const noSub = jwt.sign({ sub: { admin: true } }, process.env.ADMIN_JWT_SECRET);
|
|
expect(verifyAdminSession(noSub)).toBeNull();
|
|
});
|
|
|
|
it("rejette une chaîne quelconque sans lever d'exception", () => {
|
|
expect(verifyAdminSession("")).toBeNull();
|
|
expect(verifyAdminSession("aaa.bbb.ccc")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("requireAdminSession", () => {
|
|
function fakeReply() {
|
|
const reply = {
|
|
statusCode: null,
|
|
body: null,
|
|
code(c) { reply.statusCode = c; return reply; },
|
|
send(b) { reply.body = b; return reply; },
|
|
};
|
|
return reply;
|
|
}
|
|
|
|
it("renvoie le username et ne répond pas quand le cookie est valide", () => {
|
|
const reply = fakeReply();
|
|
const req = { cookies: { [ADMIN_COOKIE_NAME]: signAdminSession("nico") } };
|
|
expect(requireAdminSession(req, reply)).toBe("nico");
|
|
expect(reply.statusCode).toBeNull();
|
|
});
|
|
|
|
it("répond 401 et renvoie null sans cookie", () => {
|
|
const reply = fakeReply();
|
|
expect(requireAdminSession({ cookies: {} }, reply)).toBeNull();
|
|
expect(reply.statusCode).toBe(401);
|
|
});
|
|
|
|
it("ne plante pas quand `cookies` est absent", () => {
|
|
const reply = fakeReply();
|
|
expect(requireAdminSession({}, reply)).toBeNull();
|
|
expect(reply.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("verifyPassword", () => {
|
|
it("renvoie false pour un utilisateur inexistant", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
// Le DUMMY_HASH de production est à coût 12 (~330 ms par comparaison).
|
|
// On neutralise le calcul : ce test porte sur la valeur de retour, et le
|
|
// test suivant vérifie séparément que la comparaison a bien lieu.
|
|
const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(true);
|
|
const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
|
|
// Même si bcrypt dit « vrai », l'absence d'utilisateur doit l'emporter.
|
|
expect(await verifyPassword(pool, "inconnu", "peu-importe")).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
// Anti-énumération : le hash factice doit être comparé même quand l'utilisateur
|
|
// n'existe pas, sinon le temps de réponse révèle les comptes valides.
|
|
it("compare quand même un hash pour un utilisateur inexistant", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(false);
|
|
const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
|
|
await verifyPassword(pool, "inconnu", "x");
|
|
expect(spy).toHaveBeenCalled();
|
|
// La comparaison doit porter sur un hash bcrypt valide, sinon le temps de
|
|
// réponse trahirait l'absence de compte.
|
|
expect(spy.mock.calls[0][1]).toMatch(/^\$2[aby]\$\d\d\$/);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("renvoie true pour le bon mot de passe", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
const hash = bcrypt.hashSync("bon-mot-de-passe", 4); // coût faible : test rapide
|
|
const pool = makeFakePool([{ match: "FROM admin_users", result: rows({ password_hash: hash }) }]);
|
|
expect(await verifyPassword(pool, "nico", "bon-mot-de-passe")).toBe(true);
|
|
expect(await verifyPassword(pool, "nico", "mauvais")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isLockedOut", () => {
|
|
const compteurs = (par_ip, par_username) =>
|
|
makeFakePool([{ match: "admin_login_attempts", result: rows({ par_ip, par_username }) }]);
|
|
|
|
it("verrouille à partir de 5 échecs venant de la même IP", async () => {
|
|
expect(await isLockedOut(compteurs(4, 4), "nico", "1.2.3.4")).toBe(false);
|
|
expect(await isLockedOut(compteurs(5, 5), "nico", "1.2.3.4")).toBe(true);
|
|
});
|
|
|
|
it("un tiers ne peut pas verrouiller un compte en échouant depuis ailleurs", async () => {
|
|
// Le coeur du correctif : les deux compteurs etaient additionnes dans un
|
|
// seul total. Cinq echecs avec le nom d'un admin, depuis n'importe quelle
|
|
// adresse, le mettaient dehors un quart d'heure — a repeter indefiniment.
|
|
// L'admin legitime, lui, n'a aucun echec a son IP : il doit passer.
|
|
expect(await isLockedOut(compteurs(0, 19), "nico", "1.2.3.4")).toBe(false);
|
|
});
|
|
|
|
it("un seuil par username subsiste contre une attaque répartie", async () => {
|
|
expect(await isLockedOut(compteurs(0, 20), "nico", "1.2.3.4")).toBe(true);
|
|
});
|
|
|
|
it("compte sur une fenêtre de 15 minutes", async () => {
|
|
const pool = compteurs(0, 0);
|
|
await isLockedOut(pool, "nico", "1.2.3.4");
|
|
const call = pool.find("admin_login_attempts");
|
|
expect(call.sql).toMatch(/FILTER \(WHERE ip = \$3\)/);
|
|
expect(call.sql).toMatch(/FILTER \(WHERE username = \$2\)/);
|
|
expect(call.values).toEqual([15, "nico", "1.2.3.4"]);
|
|
});
|
|
});
|