- ApiToken (nur SHA-256), Bearer-Anmeldung, POST /auth/token für proxdeploy login, Verwaltung in den Einstellungen - anfrageErgaenzen: Storage, Bridge, Node, Benutzer und Schlüssel aus Server-Standards, Verbindungstest und Anmeldung – auch beim Template-Bau - Abschlussbox serverseitig (abschluss), Web und CLI zeigen dieselben Zeilen - Antwortschemas als zod, daraus /api/v1/openapi.json und cli/api/openapi.json (pnpm openapi) Refs #013 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
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<TokenSicht[]> {
|
||
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<TokenSicht & { token: string }> {
|
||
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<void> {
|
||
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<Benutzer | null> {
|
||
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;
|
||
}
|