import { createHash, randomBytes } from "node:crypto"; import type { Benutzer } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { z } from "@/lib/validierung"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler"; import { protokollieren } from "@/server/services/protokoll"; /** * API-Tokens für die CLI. Das Token selbst gibt es genau einmal – in der Antwort beim Anlegen. * Gespeichert wird nur sein SHA-256. */ export const TOKEN_PRAEFIX = "pd_"; export type TokenSicht = { id: string; name: string; praefix: string; erstelltAm: string; zuletztGenutzt: string | null; ablaufAm: string | null; }; export const TokenEingabe = z.object({ name: z.string().trim().min(1, "Name fehlt.").max(80), /** Leer = unbefristet. */ tage: z.coerce.number().int().min(1).max(3650).optional(), }); function hashen(token: string): string { return createHash("sha256").update(token).digest("hex"); } function sicht(t: { id: string; name: string; praefix: string; erstelltAm: Date; zuletztGenutzt: Date | null; ablaufAm: Date | null; }): TokenSicht { return { id: t.id, name: t.name, praefix: t.praefix, erstelltAm: t.erstelltAm.toISOString(), zuletztGenutzt: t.zuletztGenutzt?.toISOString() ?? null, ablaufAm: t.ablaufAm?.toISOString() ?? null, }; } export async function tokensVon(benutzerId: string): Promise { const liste = await prisma.apiToken.findMany({ where: { benutzerId }, orderBy: { erstelltAm: "asc" } }); return liste.map(sicht); } export async function tokenAnlegen( roh: unknown, aufrufer: Aufrufer, ip: string | null, ): Promise { const eingabe = TokenEingabe.parse(roh); const token = TOKEN_PRAEFIX + randomBytes(32).toString("base64url"); const ablaufAm = eingabe.tage ? new Date(Date.now() + eingabe.tage * 86_400_000) : null; try { const zeile = await prisma.$transaction(async (tx) => { const neu = await tx.apiToken.create({ data: { benutzerId: aufrufer.benutzer.id, name: eingabe.name, praefix: token.slice(0, TOKEN_PRAEFIX.length + 6), hash: hashen(token), ablaufAm, }, }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: "API-Token angelegt", ziel: `token:${neu.id}`, details: { name: eingabe.name, ablaufAm: ablaufAm?.toISOString() ?? null }, ip, }, tx, ); return neu; }); return { ...sicht(zeile), token }; } catch (fehler) { if ((fehler as { code?: string }).code === "P2002") { throw new AnwendungsFehler(409, "vergeben", `Ein Token „${eingabe.name}“ gibt es schon.`, undefined, { name: "Name schon vergeben.", }); } throw fehler; } } export async function tokenLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise { const zeile = await prisma.apiToken.findUnique({ where: { id } }); if (!zeile) throw nichtGefunden("Das Token"); if (zeile.benutzerId !== aufrufer.benutzer.id) throw keinRecht(); await prisma.$transaction(async (tx) => { await tx.apiToken.delete({ where: { id } }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: "API-Token widerrufen", ziel: `token:${id}`, details: { name: zeile.name }, ip, }, tx, ); }); } /** „Zuletzt genutzt“ höchstens einmal je Minute schreiben – nicht bei jedem SSE-Wiederverbinden. */ const NUTZUNG_TAKT_MS = 60_000; /** Zum Bearer-Token den aktiven Benutzer – oder null, wenn unbekannt, abgelaufen oder gesperrt. */ export async function benutzerAusToken(token: string): Promise { if (!token.startsWith(TOKEN_PRAEFIX)) return null; const zeile = await prisma.apiToken.findUnique({ where: { hash: hashen(token) }, include: { benutzer: true }, }); if (!zeile || !zeile.benutzer.aktiv) return null; const jetzt = new Date(); if (zeile.ablaufAm && zeile.ablaufAm <= jetzt) return null; if (!zeile.zuletztGenutzt || jetzt.getTime() - zeile.zuletztGenutzt.getTime() > NUTZUNG_TAKT_MS) { await prisma.apiToken.update({ where: { id: zeile.id }, data: { zuletztGenutzt: jetzt } }); } return zeile.benutzer; }