SPB Git

Repository Public MIT

auth.ts 13.8 KB 318 lines typescript

1/**2 * Service d'authentification : mots de passe (bcrypt), codes à usage unique envoyés par courriel,3 * limites de débit, appareils de confiance et journal de sécurité.4 */5import { createHash, randomBytes, randomInt, timingSafeEqual } from "crypto";6import bcrypt from "bcryptjs";7import { and, desc, eq, gt, gte, isNull, sql } from "drizzle-orm";8import type { Request, Response } from "express";9import { db, schema } from "../db";10import { logger } from "../utils/logger";1112const { users, authCodes, trustedDevices, authEvents } = schema;13// eslint-disable-next-line @typescript-eslint/no-explicit-any -- même approche que storage.ts (schéma sqlite/pg unifié)14const q = db as any;1516export type CodePurpose = "verify_email" | "login_mfa" | "reset_password";1718export const CODE_TTL_MIN: Record<CodePurpose, number> = {19  verify_email: 15,20  login_mfa: 10,21  reset_password: 15,22};23export const CODE_MAX_ATTEMPTS = 5;24export const TRUSTED_DEVICE_DAYS = 30;25export const DEVICE_COOKIE = "vq_device";26const PEPPER = process.env.SESSION_SECRET || "dev-only-secret-do-not-use-in-prod";27const DEBUG_CODES = process.env.AUTH_DEBUG_CODES === "1";2829export class AuthError extends Error {30  constructor(public status: number, message: string, public code?: string, public retryAfterSec?: number) {31    super(message);32    this.name = "AuthError";33  }34}3536/* ─── Helpers ────────────────────────────────────────────────────────────── */3738export function normalizeEmail(email: string): string {39  return email.trim().toLowerCase();40}4142export function clientIp(req: Request): string {43  const xf = (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim();44  return xf || req.ip || req.socket.remoteAddress || "";45}4647export function userAgent(req: Request): string {48  return (req.headers["user-agent"] as string | undefined)?.slice(0, 300) || "";49}5051export function sha256(value: string): string {52  return createHash("sha256").update(value).digest("hex");53}5455function hashCode(email: string, purpose: string, code: string): string {56  return sha256(`${PEPPER}|${email}|${purpose}|${code}`);57}5859export async function hashPassword(password: string): Promise<string> {60  return bcrypt.hash(password, 12);61}6263export async function verifyPassword(password: string, hash: string | null | undefined): Promise<boolean> {64  if (!hash) {65    // temps constant approximatif pour ne pas révéler l'absence de mot de passe66    await bcrypt.compare(password, "$2a$12$CwTycUXWue0Thq9StjUM0uJ8hYtEwWq0eXnE2G8nR0Z0QW5b7HkI2");67    return false;68  }69  return bcrypt.compare(password, hash);70}7172export function generateCode(): string {73  return String(randomInt(0, 1_000_000)).padStart(6, "0");74}7576export function generateLegacyToken(): string {77  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";78  const bytes = randomBytes(10);79  let out = "";80  for (let i = 0; i < 10; i++) out += chars[bytes[i] % chars.length];81  return `vquant-${out}`;82}8384/** Vérifie la robustesse d'un mot de passe. Retourne un message d'erreur ou null. */85export function passwordProblem(pw: string): string | null {86  if (pw.length < 8) return "Le mot de passe doit contenir au moins 8 caractères.";87  if (pw.length > 128) return "Le mot de passe est trop long (128 caractères max).";88  if (!/[a-zA-Z]/.test(pw) || !/\d/.test(pw)) return "Le mot de passe doit combiner lettres et chiffres.";89  const common = ["password", "motdepasse", "12345678", "qwertyui", "azertyui", "vquant123", "abcd1234", "11111111", "password1", "iloveyou"];90  if (common.includes(pw.toLowerCase())) return "Ce mot de passe est trop courant.";91  return null;92}9394/* ─── Journal + limites de débit ─────────────────────────────────────────── */9596export async function logEvent(type: string, opts: { userId?: string | null; email?: string | null; req?: Request; meta?: Record<string, unknown> }) {97  try {98    await q.insert(authEvents).values({99      id: crypto.randomUUID(),100      userId: opts.userId ?? null,101      email: opts.email ?? null,102      type,103      ip: opts.req ? clientIp(opts.req) : null,104      userAgent: opts.req ? userAgent(opts.req) : null,105      meta: opts.meta ? JSON.stringify(opts.meta) : null,106      createdAt: new Date(),107    });108  } catch (e) {109    logger.error(`auth event log failed: ${(e as Error).message}`);110  }111}112113async function countEvents(where: ReturnType<typeof and>): Promise<number> {114  const [row] = await q.select({ n: sql<number>`count(*)` }).from(authEvents).where(where);115  return Number(row?.n ?? 0);116}117118/**119 * Limite de débit : `max` événements `type` dans la fenêtre pour le courriel ET pour l'IP.120 * Lève AuthError 429 si dépassé.121 */122export async function assertRate(type: string, email: string | null, req: Request, max: number, windowMin: number) {123  const since = new Date(Date.now() - windowMin * 60_000);124  const ip = clientIp(req);125  if (email) {126    const n = await countEvents(and(eq(authEvents.type, type), eq(authEvents.email, email), gte(authEvents.createdAt, since)));127    if (n >= max) throw new AuthError(429, `Trop de tentatives. Réessayez dans ${windowMin} minutes.`, "rate_limited", windowMin * 60);128  }129  if (ip) {130    const n = await countEvents(and(eq(authEvents.type, type), eq(authEvents.ip, ip), gte(authEvents.createdAt, since)));131    if (n >= max * 4) throw new AuthError(429, `Trop de tentatives depuis cette connexion. Réessayez dans ${windowMin} minutes.`, "rate_limited", windowMin * 60);132  }133}134135/* ─── Utilisateurs ───────────────────────────────────────────────────────── */136137export type DbUser = typeof schema.users.$inferSelect;138139export async function findUserByEmail(email: string): Promise<DbUser | undefined> {140  const [u] = await q.select().from(users).where(eq(users.email, normalizeEmail(email)));141  return u ?? undefined;142}143144export async function findUserById(id: string): Promise<DbUser | undefined> {145  const [u] = await q.select().from(users).where(eq(users.id, id));146  return u ?? undefined;147}148149export async function updateUser(id: string, patch: Partial<DbUser>): Promise<DbUser | undefined> {150  const [u] = await q.update(users).set(patch).where(eq(users.id, id)).returning();151  return u ?? undefined;152}153154export function publicUser(u: DbUser) {155  return {156    id: u.id,157    displayName: u.displayName,158    email: u.email ?? null,159    emailVerified: !!u.emailVerifiedAt,160    createdAt: u.createdAt,161    lastLoginAt: u.lastLoginAt ?? null,162    legacy: !u.email,163  };164}165166/* ─── Codes à usage unique ───────────────────────────────────────────────── */167168export async function issueCode(purpose: CodePurpose, email: string, userId: string | null, req: Request): Promise<{ code: string; expiresMin: number }> {169  const em = normalizeEmail(email);170  // 5 envois / 15 min par courriel (et 20 par IP)171  await assertRate(`code_sent:${purpose}`, em, req, 5, 15);172  // invalide les codes précédents du même usage173  await q.update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.email, em), eq(authCodes.purpose, purpose), isNull(authCodes.consumedAt)));174  const code = generateCode();175  const expiresMin = CODE_TTL_MIN[purpose];176  await q.insert(authCodes).values({177    id: crypto.randomUUID(),178    userId,179    email: em,180    purpose,181    codeHash: hashCode(em, purpose, code),182    attempts: 0,183    expiresAt: new Date(Date.now() + expiresMin * 60_000),184    consumedAt: null,185    ip: clientIp(req),186    createdAt: new Date(),187  });188  await logEvent(`code_sent:${purpose}`, { userId, email: em, req });189  if (DEBUG_CODES) logger.warn(`[AUTH_DEBUG_CODES] ${purpose} ${em} → ${code}`);190  return { code, expiresMin };191}192193/**194 * Vérifie un code. Consomme le code si valide ; incrémente les tentatives sinon.195 * Lève AuthError 400/410/429 avec un message utilisateur.196 */197export async function consumeCode(purpose: CodePurpose, email: string, code: string, req: Request): Promise<{ userId: string | null }> {198  const em = normalizeEmail(email);199  const clean = code.replace(/\D/g, "");200  await assertRate(`code_failed:${purpose}`, em, req, 10, 15);201202  const [rec] = await q.select().from(authCodes)203    .where(and(eq(authCodes.email, em), eq(authCodes.purpose, purpose), isNull(authCodes.consumedAt)))204    .orderBy(desc(authCodes.createdAt)).limit(1);205206  if (!rec) throw new AuthError(410, "Aucun code actif. Demandez un nouveau code.", "no_code");207  if (new Date(rec.expiresAt).getTime() < Date.now()) throw new AuthError(410, "Ce code a expiré. Demandez un nouveau code.", "expired");208  if (rec.attempts >= CODE_MAX_ATTEMPTS) throw new AuthError(429, "Trop d'essais pour ce code. Demandez un nouveau code.", "too_many_attempts");209210  const expected = Buffer.from(rec.codeHash, "hex");211  const given = Buffer.from(hashCode(em, purpose, clean), "hex");212  const ok = clean.length === 6 && expected.length === given.length && timingSafeEqual(expected, given);213214  if (!ok) {215    await q.update(authCodes).set({ attempts: rec.attempts + 1 }).where(eq(authCodes.id, rec.id));216    await logEvent(`code_failed:${purpose}`, { userId: rec.userId, email: em, req });217    const left = CODE_MAX_ATTEMPTS - rec.attempts - 1;218    throw new AuthError(400, left > 0 ? `Code incorrect. ${left} essai${left > 1 ? "s" : ""} restant${left > 1 ? "s" : ""}.` : "Code incorrect. Demandez un nouveau code.", "invalid_code");219  }220  await q.update(authCodes).set({ consumedAt: new Date() }).where(eq(authCodes.id, rec.id));221  return { userId: rec.userId ?? null };222}223224/* ─── Appareils de confiance ─────────────────────────────────────────────── */225226function parseCookies(req: Request): Record<string, string> {227  const out: Record<string, string> = {};228  const raw = req.headers.cookie;229  if (!raw) return out;230  for (const part of raw.split(";")) {231    const i = part.indexOf("=");232    if (i < 0) continue;233    out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());234  }235  return out;236}237238export function deviceTokenFromRequest(req: Request): string | null {239  return parseCookies(req)[DEVICE_COOKIE] || null;240}241242function setDeviceCookie(res: Response, token: string | null) {243  const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";244  const value = token245    ? `${DEVICE_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TRUSTED_DEVICE_DAYS * 86400}${secure}`246    : `${DEVICE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;247  const prev = res.getHeader("Set-Cookie");248  const list = Array.isArray(prev) ? prev : prev ? [String(prev)] : [];249  res.setHeader("Set-Cookie", [...list, value]);250}251252export async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {253  const token = deviceTokenFromRequest(req);254  if (!token) return false;255  const [dev] = await q.select().from(trustedDevices)256    .where(and(eq(trustedDevices.tokenHash, sha256(token)), eq(trustedDevices.userId, userId), gt(trustedDevices.expiresAt, new Date())));257  if (!dev) return false;258  await q.update(trustedDevices).set({ lastUsedAt: new Date(), ip: clientIp(req) }).where(eq(trustedDevices.id, dev.id));259  return true;260}261262export async function trustDevice(req: Request, res: Response, userId: string): Promise<void> {263  const token = randomBytes(32).toString("base64url");264  await q.insert(trustedDevices).values({265    id: crypto.randomUUID(),266    userId,267    tokenHash: sha256(token),268    label: null,269    userAgent: userAgent(req),270    ip: clientIp(req),271    createdAt: new Date(),272    lastUsedAt: new Date(),273    expiresAt: new Date(Date.now() + TRUSTED_DEVICE_DAYS * 86_400_000),274  });275  setDeviceCookie(res, token);276  await logEvent("device_trusted", { userId, req });277}278279export async function listDevices(userId: string) {280  const rows = await q.select().from(trustedDevices).where(and(eq(trustedDevices.userId, userId), gt(trustedDevices.expiresAt, new Date()))).orderBy(desc(trustedDevices.lastUsedAt));281  return rows as (typeof schema.trustedDevices.$inferSelect)[];282}283284export async function revokeDevice(userId: string, deviceId: string) {285  await q.delete(trustedDevices).where(and(eq(trustedDevices.userId, userId), eq(trustedDevices.id, deviceId)));286}287288export async function revokeAllDevices(userId: string, res?: Response) {289  await q.delete(trustedDevices).where(eq(trustedDevices.userId, userId));290  if (res) setDeviceCookie(res, null);291}292293export async function currentDeviceId(req: Request, userId: string): Promise<string | null> {294  const token = deviceTokenFromRequest(req);295  if (!token) return null;296  const [dev] = await q.select({ id: trustedDevices.id }).from(trustedDevices).where(and(eq(trustedDevices.tokenHash, sha256(token)), eq(trustedDevices.userId, userId)));297  return dev?.id ?? null;298}299300export async function recentEvents(userId: string, limit = 20) {301  const rows = await q.select().from(authEvents).where(eq(authEvents.userId, userId)).orderBy(desc(authEvents.createdAt)).limit(limit);302  return rows as (typeof schema.authEvents.$inferSelect)[];303}304305/* ─── Session ────────────────────────────────────────────────────────────── */306307/** Régénère la session (anti-fixation) puis y place l'utilisateur. */308export function establishSession(req: Request, userId: string): Promise<void> {309  return new Promise((resolve, reject) => {310    req.session.regenerate((err) => {311      if (err) return reject(err);312      // eslint-disable-next-line @typescript-eslint/no-explicit-any313      (req.session as any).userId = userId;314      req.session.save((e) => (e ? reject(e) : resolve()));315    });316  });317}318
Files main
Menu

Theme