import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { cookies, headers } from "next/headers"; import { db } from "@/db/client"; import { sessions, users, userRoles, userProfiles } from "@/db/schema"; import { newSessionToken, sessionDigest, ipHash } from "./crypto"; export const COOKIE = "tv_session"; const TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30); const INACTIVITY_DAYS = Number(process.env.SESSION_INACTIVITY_DAYS ?? 14); export interface SessionUser { id: string; email: string; displayName: string | null; roles: string[]; sessionId: string; mfaAt: Date | null; prefs: Record; publicName: string | null; sellerType: string } export async function createSession(userId: string, opts: { ip?: string | null; device?: string | null }): Promise<{ token: string; expiresAt: Date }> { const token = newSessionToken(); const expiresAt = new Date(Date.now() + TTL_DAYS * 86400000); await db().insert(sessions).values({ userId, tokenDigest: sessionDigest(token), expiresAt, deviceLabel: opts.device?.slice(0, 120) ?? null, ipHash: ipHash(opts.ip) }); return { token, expiresAt }; } export function cookieOptions(expiresAt: Date) { return { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const, path: "/", expires: expiresAt }; } export async function currentUser(): Promise { const token = (await cookies()).get(COOKIE)?.value; if (!token) return null; const d = db(); const digest = sessionDigest(token); const now = new Date(); const [row] = await d.select({ s: sessions, u: users, p: userProfiles }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).leftJoin(userProfiles, eq(userProfiles.userId, users.id)) .where(and(eq(sessions.tokenDigest, digest), isNull(sessions.revokedAt), gt(sessions.expiresAt, now))).limit(1); if (!row || row.u.status !== "ACTIVE") return null; if (now.getTime() - row.s.lastSeenAt.getTime() > INACTIVITY_DAYS * 86400000) { await d.update(sessions).set({ revokedAt: now }).where(eq(sessions.id, row.s.id)); return null; } if (now.getTime() - row.s.lastSeenAt.getTime() > 5 * 60000) await d.update(sessions).set({ lastSeenAt: now }).where(eq(sessions.id, row.s.id)); const roles = (await d.select({ role: userRoles.role }).from(userRoles).where(eq(userRoles.userId, row.u.id))).map((r) => r.role); return { id: row.u.id, email: row.u.email, displayName: row.u.displayName, roles, sessionId: row.s.id, mfaAt: row.s.mfaAt, prefs: (row.u.prefs ?? {}) as Record, publicName: row.p?.publicName ?? null, sellerType: row.p?.sellerType ?? "OWNER" }; } export async function requireUser(): Promise { const u = await currentUser(); if (!u) throw new AuthError(401, "Connexion requise"); return u; } export async function requireRole(role: "ADMIN" | "MODERATOR"): Promise { const u = await requireUser(); if (!u.roles.includes(role) && !u.roles.includes("ADMIN")) throw new AuthError(403, "Accès refusé"); return u; } export class AuthError extends Error { constructor(public status: number, msg: string) { super(msg); } } export async function revokeSession(sessionId: string, userId: string) { await db().update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))); } export async function revokeAllSessions(userId: string, exceptSessionId?: string) { await db().update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt), exceptSessionId ? sql`${sessions.id} <> ${exceptSessionId}` : sql`true`)); } export async function listSessions(userId: string) { return db().select({ id: sessions.id, issuedAt: sessions.issuedAt, lastSeenAt: sessions.lastSeenAt, expiresAt: sessions.expiresAt, deviceLabel: sessions.deviceLabel, revokedAt: sessions.revokedAt }).from(sessions).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date()))).orderBy(sql`${sessions.lastSeenAt} desc`); } /** Protection CSRF des mutations : même origine obligatoire (cookie SameSite=Lax + vérification Origin/Referer). */ export async function assertSameOrigin(): Promise { const h = await headers(); const origin = h.get("origin") ?? (h.get("referer") ? new URL(h.get("referer")!).origin : null); const base = process.env.APP_BASE_URL; const host = h.get("x-forwarded-host") ?? h.get("host"); if (!origin) throw new AuthError(403, "Origine manquante"); const o = new URL(origin); if ((base && origin === base.replace(/\/$/, "")) || (host && o.host === host)) return; throw new AuthError(403, "Origine non autorisée"); } export async function clientIp(): Promise { const h = await headers(); return h.get("x-forwarded-for")?.split(",")[0].trim() ?? h.get("x-real-ip") ?? null; } export async function userAgent(): Promise { return (await headers()).get("user-agent"); }