SPB Git

Repository Public

auth.ts 13.4 KB 210 lines typescript

1// UQO Éval — Métré : authentification par code d'accès + session signée, autorisations serveur, isolation par projet. SERVEUR seulement.2/**3 * PLATEFORME OUVERTE : aucun mot de passe. À la première visite, une session4 * anonyme est créée automatiquement (rôle « student », nom « Visiteur-XXXX »5 * modifiable). Tous les projets sont visibles et modifiables par tout le monde6 * (laboratoire partagé, comme un wiki) ; chaque action reste attribuée à une7 * session dans le journal d'audit. Seuls les gabarits (exemples) ne sont8 * modifiables que par le personnel, et seul le créateur / le personnel peut9 * supprimer un projet.10 *  - Codes facultatifs : METRE_INSTRUCTOR_CODE / METRE_ADMIN_CODE (env) et codes11 *    générés (hachés) donnent les rôles enseignant/admin pour l'administration12 *    (sources, devoirs/corrigés, codes) — jamais requis pour utiliser le module.13 *  - Session : jeton aléatoire 256 bits, stocké haché, cookie HttpOnly SameSite=Lax,14 *    expiration 30 jours glissants.15 */16import { createHash, randomBytes, randomUUID, timingSafeEqual } from "crypto";17import type Database from "better-sqlite3";18import { NextResponse, type NextRequest } from "next/server";19import { audit, getMetreDb, metreEnabled, nowIso } from "./db";20import type { MetreUser, Role } from "./types";21import { clientKey, rateLimit } from "../cost/cache";2223export const SESSION_COOKIE = "metre_session";24const SESSION_DAYS = 30;2526export class HttpError extends Error {27  constructor(public readonly status: number, message: string) { super(message); }28}2930const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");3132function safeEqual(a: string, b: string): boolean {33  const x = Buffer.from(sha256(a)), y = Buffer.from(sha256(b));34  return x.length === y.length && timingSafeEqual(x, y);35}3637/* ---------------------------------------------------------------- codes */3839export function createAccessCode(d: Database.Database, p: { role: Role; label: string | null; course: string | null; maxUses: number | null; expiresAt: string | null; createdBy: string }): { id: string; code: string } {40  const code = randomBytes(6).toString("base64url").replace(/[-_]/g, "x").toUpperCase().slice(0, 8);41  const id = randomUUID();42  d.prepare("INSERT INTO metre_access_codes(id,code_hash,code_hint,role,label,course,max_uses,expires_at,created_by) VALUES(?,?,?,?,?,?,?,?,?)")43    .run(id, sha256(code), `${code.slice(0, 2)}••••${code.slice(-2)}`, p.role, p.label, p.course, p.maxUses, p.expiresAt, p.createdBy);44  return { id, code };45}4647export function listAccessCodes(d: Database.Database, createdBy: string | null): Record<string, unknown>[] {48  return d.prepare(`SELECT id, code_hint hint, role, label, course, max_uses maxUses, uses, expires_at expiresAt, active, created_at createdAt FROM metre_access_codes ${createdBy ? "WHERE created_by=?" : ""} ORDER BY created_at DESC`).all(...(createdBy ? [createdBy] : [])) as Record<string, unknown>[];49}5051export function deactivateAccessCode(d: Database.Database, id: string): void {52  d.prepare("UPDATE metre_access_codes SET active=0 WHERE id=?").run(id);53}5455interface Resolved { role: Role; accessCodeId: string | null; course: string | null }5657function resolveCode(d: Database.Database, code: string): Resolved | null {58  const env = (k: string) => (process.env[k] ?? "").trim();59  if (env("METRE_ADMIN_CODE") && safeEqual(code, env("METRE_ADMIN_CODE"))) return { role: "admin", accessCodeId: null, course: null };60  if (env("METRE_INSTRUCTOR_CODE") && safeEqual(code, env("METRE_INSTRUCTOR_CODE"))) return { role: "instructor", accessCodeId: null, course: null };61  const r = d.prepare("SELECT id, role, course, max_uses maxUses, uses, expires_at expiresAt, active FROM metre_access_codes WHERE code_hash=?").get(sha256(code)) as { id: string; role: Role; course: string | null; maxUses: number | null; uses: number; expiresAt: string | null; active: number } | undefined;62  if (!r || !r.active) return null;63  if (r.expiresAt && r.expiresAt < nowIso()) return null;64  if (r.maxUses != null && r.uses >= r.maxUses) return null;65  return { role: r.role, accessCodeId: r.id, course: r.course };66}6768/* -------------------------------------------------------------- sessions */6970export function login(d: Database.Database, p: { code: string; displayName: string; ip: string | null; userAgent: string | null }): { user: MetreUser; token: string; expiresAt: string } {71  const code = p.code.trim();72  const name = p.displayName.trim().slice(0, 80);73  if (code.length < 4) throw new HttpError(400, "Code d'accès invalide.");74  if (name.length < 2) throw new HttpError(400, "Nom d'affichage requis (2 caractères minimum).");75  const res = resolveCode(d, code);76  if (!res) throw new HttpError(401, "Code d'accès inconnu, expiré ou épuisé.");77  // un même code + même nom = même utilisateur (retour sans mot de passe) ; codes env : un utilisateur par nom78  const existing = d.prepare("SELECT id, display_name, role, created_at FROM metre_users WHERE display_name=? AND role=? AND COALESCE(access_code_id,'')=COALESCE(?, '')").get(name, res.role, res.accessCodeId) as { id: string; display_name: string; role: Role; created_at: string } | undefined;79  let user: MetreUser;80  if (existing) {81    user = { id: existing.id, displayName: existing.display_name, role: existing.role, createdAt: existing.created_at };82    d.prepare("UPDATE metre_users SET last_seen_at=? WHERE id=?").run(nowIso(), user.id);83  } else {84    const id = randomUUID();85    d.prepare("INSERT INTO metre_users(id,display_name,role,access_code_id,course,last_seen_at) VALUES(?,?,?,?,?,?)").run(id, name, res.role, res.accessCodeId, res.course, nowIso());86    if (res.accessCodeId) d.prepare("UPDATE metre_access_codes SET uses=uses+1 WHERE id=?").run(res.accessCodeId);87    user = { id, displayName: name, role: res.role, createdAt: nowIso() };88  }89  const token = randomBytes(32).toString("base64url");90  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000).toISOString();91  d.prepare("INSERT INTO metre_sessions(id,token_hash,user_id,expires_at,ip,user_agent) VALUES(?,?,?,?,?,?)").run(randomUUID(), sha256(token), user.id, expiresAt, p.ip, p.userAgent?.slice(0, 200) ?? null);92  audit(d, { userId: user.id, role: user.role, action: "login", ip: p.ip });93  return { user, token, expiresAt };94}9596/** Session anonyme (plateforme ouverte) : créée automatiquement, sans code. */97export function anonymousLogin(d: Database.Database, p: { ip: string | null; userAgent: string | null; displayName?: string | null }): { user: MetreUser; token: string; expiresAt: string } {98  const id = randomUUID();99  const name = (p.displayName ?? "").trim().slice(0, 80) || `Visiteur-${id.slice(0, 4).toUpperCase()}`;100  d.prepare("INSERT INTO metre_users(id,display_name,role,access_code_id,course,last_seen_at) VALUES(?,?,'student',NULL,NULL,?)").run(id, name, nowIso());101  const token = randomBytes(32).toString("base64url");102  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000).toISOString();103  d.prepare("INSERT INTO metre_sessions(id,token_hash,user_id,expires_at,ip,user_agent) VALUES(?,?,?,?,?,?)").run(randomUUID(), sha256(token), id, expiresAt, p.ip, p.userAgent?.slice(0, 200) ?? null);104  audit(d, { userId: id, role: "student", action: "session.anonymous", ip: p.ip });105  return { user: { id, displayName: name, role: "student", createdAt: nowIso() }, token, expiresAt };106}107108/** Renommer sa session (nom d'affichage libre, utilisé pour l'attribution). */109export function renameUser(d: Database.Database, userId: string, displayName: string): string {110  const name = displayName.trim().slice(0, 80);111  if (name.length < 2) throw new HttpError(400, "Nom d'affichage : 2 caractères minimum.");112  d.prepare("UPDATE metre_users SET display_name=? WHERE id=?").run(name, userId);113  return name;114}115116/** Élever une session existante à un rôle via un code (enseignant/admin) sans perdre ses projets. */117export function elevateWithCode(d: Database.Database, userId: string, code: string): MetreUser {118  const res = resolveCode(d, code.trim());119  if (!res) throw new HttpError(401, "Code d'accès inconnu, expiré ou épuisé.");120  d.prepare("UPDATE metre_users SET role=?, access_code_id=COALESCE(?, access_code_id), course=COALESCE(?, course), last_seen_at=? WHERE id=?").run(res.role, res.accessCodeId, res.course, nowIso(), userId);121  if (res.accessCodeId) d.prepare("UPDATE metre_access_codes SET uses=uses+1 WHERE id=?").run(res.accessCodeId);122  const r = d.prepare("SELECT id, display_name, role, created_at FROM metre_users WHERE id=?").get(userId) as { id: string; display_name: string; role: Role; created_at: string };123  audit(d, { userId, role: r.role, action: "session.elevate", details: { role: r.role } });124  return { id: r.id, displayName: r.display_name, role: r.role, createdAt: r.created_at };125}126127export function logout(d: Database.Database, token: string | null): void {128  if (token) d.prepare("DELETE FROM metre_sessions WHERE token_hash=?").run(sha256(token));129}130131export function userFromToken(d: Database.Database, token: string | null | undefined): MetreUser | null {132  if (!token) return null;133  const r = d.prepare("SELECT u.id, u.display_name, u.role, u.created_at, s.expires_at FROM metre_sessions s JOIN metre_users u ON u.id=s.user_id WHERE s.token_hash=?").get(sha256(token)) as { id: string; display_name: string; role: Role; created_at: string; expires_at: string } | undefined;134  if (!r || r.expires_at < nowIso()) return null;135  return { id: r.id, displayName: r.display_name, role: r.role, createdAt: r.created_at };136}137138export function tokenFromRequest(req: Request): string | null {139  const cookie = req.headers.get("cookie") ?? "";140  const m = cookie.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`));141  return m ? decodeURIComponent(m[1]) : null;142}143144export function sessionCookie(token: string | null, expiresAt?: string): string {145  const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";146  if (!token) return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;147  return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt ?? Date.now() + SESSION_DAYS * 86400000).toUTCString()}${secure}`;148}149150/* ------------------------------------------------------------ autorisation */151152export interface Ctx { db: Database.Database; user: MetreUser; ip: string | null }153154/** Contexte authentifié ou erreur HTTP (401/404 si module désactivé). */155export function requireUser(req: Request, opts: { roles?: Role[]; limit?: { key: string; max: number; windowMs: number } } = {}): Ctx {156  if (!metreEnabled()) throw new HttpError(404, "Module Métré désactivé.");157  const ip = clientKey(req);158  if (opts.limit && !rateLimit(`metre:${opts.limit.key}:${ip}`, opts.limit.max, opts.limit.windowMs)) throw new HttpError(429, "Trop de requêtes.");159  const d = getMetreDb();160  const user = userFromToken(d, tokenFromRequest(req));161  if (!user) throw new HttpError(401, "Connexion requise.");162  if (opts.roles && !opts.roles.includes(user.role)) throw new HttpError(403, "Rôle insuffisant.");163  return { db: d, user, ip };164}165166export type Access = "read" | "write" | "owner";167168/**169 * Droit effectif sur un projet (plateforme OUVERTE) :170 *  - projet ordinaire : « owner » pour son créateur, le personnel et les membres éditeurs ; « write » pour tout le monde ;171 *  - gabarit (exemple) : « owner » pour le personnel, « read » pour les autres (dupliquer pour travailler dessus) ;172 *  - projet verrouillé (`locked`, créateur/personnel) : « read » pour les autres.173 */174export function projectAccess(d: Database.Database, user: MetreUser, projectId: string): Access | null {175  const p = d.prepare("SELECT owner_id, is_template, locked FROM metre_projects WHERE id=? AND deleted_at IS NULL").get(projectId) as { owner_id: string; is_template: number; locked: number } | undefined;176  if (!p) return null;177  const staff = user.role === "admin" || user.role === "instructor";178  if (staff || p.owner_id === user.id) return "owner";179  const m = d.prepare("SELECT role FROM metre_project_members WHERE project_id=? AND user_id=?").get(projectId, user.id) as { role: "viewer" | "editor" } | undefined;180  if (m?.role === "editor") return "owner";181  if (p.is_template || p.locked) return "read";182  return "write";183}184185const RANK: Record<Access, number> = { read: 1, write: 2, owner: 3 };186187export function requireProject(ctx: Ctx, projectId: string, need: Access): Access {188  if (!/^[0-9a-f-]{36}$/i.test(projectId)) throw new HttpError(400, "Identifiant de projet invalide.");189  const a = projectAccess(ctx.db, ctx.user, projectId);190  if (!a) throw new HttpError(404, "Projet introuvable."); // 404 plutôt que 403 : ne pas révéler l'existence191  if (RANK[a] < RANK[need]) throw new HttpError(403, "Droits insuffisants sur ce projet.");192  return a;193}194195/** Enveloppe de route : transforme HttpError en réponse JSON, journalise les autres. */196export async function handle(fn: () => Promise<Response> | Response): Promise<Response> {197  try { return await fn(); }198  catch (e) {199    if (e instanceof HttpError) return NextResponse.json({ error: e.message }, { status: e.status });200    console.error("[metre]", e);201    return NextResponse.json({ error: (e as Error).message ?? "Erreur interne" }, { status: 500 });202  }203}204205export async function readJson<T = Record<string, unknown>>(req: NextRequest): Promise<T> {206  try { return (await req.json()) as T; } catch { throw new HttpError(400, "JSON invalide."); }207}208209export const isUuid = (s: unknown): s is string => typeof s === "string" && /^[0-9a-f-]{36}$/i.test(s);210
Files main
Menu

Theme