Repository Public
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