TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Sessions serveur : jeton aléatoire 256 bits remis en cookie httpOnly,2// haché (SHA-256) avant stockage en base — un vol de BD ne permet pas de rejouer une session.3import { createHash, randomBytes } from "node:crypto";4import { cookies, headers } from "next/headers";5import { get, run } from "../db/index.ts";67export const SESSION_COOKIE = "immbot_session";8const SESSION_DAYS = 14;910export type SessionUser = {11 id: number;12 username: string;13 display_name: string;14 email: string | null;15 role: "student" | "instructor" | "admin";16 must_change_password: number;17 is_initial_admin: number;18 disabled: number;19};2021function hashToken(token: string): string {22 return createHash("sha256").update(token).digest("hex");23}2425export function createSession(userId: number, ip?: string, userAgent?: string): { token: string; expiresAt: Date } {26 const token = randomBytes(32).toString("hex");27 const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400_000);28 run(29 "INSERT INTO sessions (token_hash, user_id, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?)",30 hashToken(token),31 userId,32 expiresAt.toISOString(),33 ip ?? null,34 (userAgent ?? "").slice(0, 300)35 );36 return { token, expiresAt };37}3839export function destroySession(token: string) {40 run("DELETE FROM sessions WHERE token_hash = ?", hashToken(token));41}4243export function destroyAllSessions(userId: number) {44 run("DELETE FROM sessions WHERE user_id = ?", userId);45}4647export function userForToken(token: string | undefined): SessionUser | null {48 if (!token) return null;49 const row = get<SessionUser & { expires_at: string }>(50 `SELECT u.id, u.username, u.display_name, u.email, u.role, u.must_change_password,51 u.is_initial_admin, u.disabled, s.expires_at52 FROM sessions s JOIN users u ON u.id = s.user_id53 WHERE s.token_hash = ?`,54 hashToken(token)55 );56 if (!row) return null;57 if (new Date(row.expires_at) < new Date()) {58 destroySession(token);59 return null;60 }61 if (row.disabled) return null;62 return row;63}6465/** Utilisateur courant (Server Components et Route Handlers). */66export async function currentUser(): Promise<SessionUser | null> {67 const jar = await cookies();68 return userForToken(jar.get(SESSION_COOKIE)?.value);69}7071export async function requireUser(): Promise<SessionUser> {72 const u = await currentUser();73 if (!u) throw new AuthError(401, "Authentification requise.");74 return u;75}7677export async function requireRole(role: "instructor" | "admin"): Promise<SessionUser> {78 const u = await requireUser();79 const ok = u.role === "admin" || (role === "instructor" && u.role === "instructor");80 if (!ok) throw new AuthError(403, "Accès refusé.");81 return u;82}8384export class AuthError extends Error {85 status: number;86 constructor(status: number, message: string) {87 super(message);88 this.status = status;89 }90}9192/** Défense CSRF : les mutations doivent venir de la même origine. */93export async function assertSameOrigin(): Promise<void> {94 const h = await headers();95 const site = h.get("sec-fetch-site");96 if (site && site !== "same-origin" && site !== "none") throw new AuthError(403, "Origine non autorisée.");97 const origin = h.get("origin");98 if (origin) {99 const expected = process.env.APP_URL || "";100 const host = h.get("host") || "";101 try {102 const o = new URL(origin);103 if (o.host !== host && (!expected || origin !== expected)) throw new AuthError(403, "Origine non autorisée.");104 } catch (e) {105 if (e instanceof AuthError) throw e;106 throw new AuthError(403, "Origine invalide.");107 }108 }109}110111export function logAuthEvent(event: string, opts: { userId?: number; username?: string; ip?: string; detail?: string }) {112 run(113 "INSERT INTO auth_events (user_id, username, event, ip, detail) VALUES (?, ?, ?, ?, ?)",114 opts.userId ?? null,115 opts.username ?? null,116 event,117 opts.ip ?? null,118 opts.detail ?? null119 );120}121