SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
3 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
4.8 KB · 51 lines typescript
Raw Blame History
1import { and, eq, gt, isNull, sql } from "drizzle-orm";2import { cookies, headers } from "next/headers";3import { db } from "@/db/client";4import { sessions, users, userRoles, userProfiles } from "@/db/schema";5import { newSessionToken, sessionDigest, ipHash } from "./crypto";67export const COOKIE = "tv_session";8const TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);9const INACTIVITY_DAYS = Number(process.env.SESSION_INACTIVITY_DAYS ?? 14);1011export interface SessionUser { id: string; email: string; displayName: string | null; roles: string[]; sessionId: string; mfaAt: Date | null; prefs: Record<string, unknown>; publicName: string | null; sellerType: string }1213export async function createSession(userId: string, opts: { ip?: string | null; device?: string | null }): Promise<{ token: string; expiresAt: Date }> {14  const token = newSessionToken(); const expiresAt = new Date(Date.now() + TTL_DAYS * 86400000);15  await db().insert(sessions).values({ userId, tokenDigest: sessionDigest(token), expiresAt, deviceLabel: opts.device?.slice(0, 120) ?? null, ipHash: ipHash(opts.ip) });16  return { token, expiresAt };17}18export function cookieOptions(expiresAt: Date) {19  return { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const, path: "/", expires: expiresAt };20}21export async function currentUser(): Promise<SessionUser | null> {22  const token = (await cookies()).get(COOKIE)?.value; if (!token) return null;23  const d = db(); const digest = sessionDigest(token); const now = new Date();24  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))25    .where(and(eq(sessions.tokenDigest, digest), isNull(sessions.revokedAt), gt(sessions.expiresAt, now))).limit(1);26  if (!row || row.u.status !== "ACTIVE") return null;27  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; }28  if (now.getTime() - row.s.lastSeenAt.getTime() > 5 * 60000) await d.update(sessions).set({ lastSeenAt: now }).where(eq(sessions.id, row.s.id));29  const roles = (await d.select({ role: userRoles.role }).from(userRoles).where(eq(userRoles.userId, row.u.id))).map((r) => r.role);30  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<string, unknown>, publicName: row.p?.publicName ?? null, sellerType: row.p?.sellerType ?? "OWNER" };31}32export async function requireUser(): Promise<SessionUser> { const u = await currentUser(); if (!u) throw new AuthError(401, "Connexion requise"); return u; }33export async function requireRole(role: "ADMIN" | "MODERATOR"): Promise<SessionUser> { const u = await requireUser(); if (!u.roles.includes(role) && !u.roles.includes("ADMIN")) throw new AuthError(403, "Accès refusé"); return u; }34export class AuthError extends Error { constructor(public status: number, msg: string) { super(msg); } }3536export 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))); }37export 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`)); }38export 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`); }3940/** Protection CSRF des mutations : même origine obligatoire (cookie SameSite=Lax + vérification Origin/Referer). */41export async function assertSameOrigin(): Promise<void> {42  const h = await headers(); const origin = h.get("origin") ?? (h.get("referer") ? new URL(h.get("referer")!).origin : null);43  const base = process.env.APP_BASE_URL; const host = h.get("x-forwarded-host") ?? h.get("host");44  if (!origin) throw new AuthError(403, "Origine manquante");45  const o = new URL(origin);46  if ((base && origin === base.replace(/\/$/, "")) || (host && o.host === host)) return;47  throw new AuthError(403, "Origine non autorisée");48}49export async function clientIp(): Promise<string | null> { const h = await headers(); return h.get("x-forwarded-for")?.split(",")[0].trim() ?? h.get("x-real-ip") ?? null; }50export async function userAgent(): Promise<string | null> { return (await headers()).get("user-agent"); }51