// Sessions serveur : jeton aléatoire 256 bits remis en cookie httpOnly, // haché (SHA-256) avant stockage en base — un vol de BD ne permet pas de rejouer une session. import { createHash, randomBytes } from "node:crypto"; import { cookies, headers } from "next/headers"; import { get, run } from "../db/index.ts"; export const SESSION_COOKIE = "immbot_session"; const SESSION_DAYS = 14; export type SessionUser = { id: number; username: string; display_name: string; email: string | null; role: "student" | "instructor" | "admin"; must_change_password: number; is_initial_admin: number; disabled: number; }; function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } export function createSession(userId: number, ip?: string, userAgent?: string): { token: string; expiresAt: Date } { const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400_000); run( "INSERT INTO sessions (token_hash, user_id, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?)", hashToken(token), userId, expiresAt.toISOString(), ip ?? null, (userAgent ?? "").slice(0, 300) ); return { token, expiresAt }; } export function destroySession(token: string) { run("DELETE FROM sessions WHERE token_hash = ?", hashToken(token)); } export function destroyAllSessions(userId: number) { run("DELETE FROM sessions WHERE user_id = ?", userId); } export function userForToken(token: string | undefined): SessionUser | null { if (!token) return null; const row = get( `SELECT u.id, u.username, u.display_name, u.email, u.role, u.must_change_password, u.is_initial_admin, u.disabled, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`, hashToken(token) ); if (!row) return null; if (new Date(row.expires_at) < new Date()) { destroySession(token); return null; } if (row.disabled) return null; return row; } /** Utilisateur courant (Server Components et Route Handlers). */ export async function currentUser(): Promise { const jar = await cookies(); return userForToken(jar.get(SESSION_COOKIE)?.value); } export async function requireUser(): Promise { const u = await currentUser(); if (!u) throw new AuthError(401, "Authentification requise."); return u; } export async function requireRole(role: "instructor" | "admin"): Promise { const u = await requireUser(); const ok = u.role === "admin" || (role === "instructor" && u.role === "instructor"); if (!ok) throw new AuthError(403, "Accès refusé."); return u; } export class AuthError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } /** Défense CSRF : les mutations doivent venir de la même origine. */ export async function assertSameOrigin(): Promise { const h = await headers(); const site = h.get("sec-fetch-site"); if (site && site !== "same-origin" && site !== "none") throw new AuthError(403, "Origine non autorisée."); const origin = h.get("origin"); if (origin) { const expected = process.env.APP_URL || ""; const host = h.get("host") || ""; try { const o = new URL(origin); if (o.host !== host && (!expected || origin !== expected)) throw new AuthError(403, "Origine non autorisée."); } catch (e) { if (e instanceof AuthError) throw e; throw new AuthError(403, "Origine invalide."); } } } export function logAuthEvent(event: string, opts: { userId?: number; username?: string; ip?: string; detail?: string }) { run( "INSERT INTO auth_events (user_id, username, event, ip, detail) VALUES (?, ?, ?, ?, ?)", opts.userId ?? null, opts.username ?? null, event, opts.ip ?? null, opts.detail ?? null ); }