Files
proxdeploy/src/server/services/tokens.ts
T
florian.wildeandClaude Opus 5.5 9fdced62db feat(api): API-Tokens, kurze Anfragen und OpenAPI für die CLI
- 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>
2026-10-12 15:08:27 +02:00

138 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}