Files
proxdeploy/src/server/auth/zugang.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

69 lines
3.0 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 { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Benutzer } from "@/generated/prisma/client";
import type { Quelle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rechte";
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
import { benutzerAusToken } from "@/server/services/tokens";
/**
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
* Token. Jede Seite und jeder Route Handler prüft selbst – das Layout ist nur das zweite Netz,
* weil Next es bei Navigation innerhalb desselben Layouts nicht neu rendert.
*/
export type Aufrufer = { benutzer: Benutzer; quelle: Quelle };
async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | null> {
const sitzung = await sitzungLesen(jwt);
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
return benutzer?.aktiv ? benutzer : null;
}
/** Für Server Components. */
export async function aktuellerBenutzer(): Promise<Benutzer | null> {
const speicher = await cookies();
return await benutzerAusSitzung(speicher.get(SITZUNG_COOKIE)?.value);
}
/** Für Seiten: leitet zur Anmeldung um oder zeigt „kein Recht“. */
export async function seiteSchuetzen(recht: Recht, pfad: string): Promise<Benutzer> {
const benutzer = await aktuellerBenutzer();
if (!benutzer) redirect(`/anmelden?ziel=${encodeURIComponent(pfad)}`);
if (!darf(benutzer.rolle, recht)) redirect("/kein-recht");
return benutzer;
}
function cookieAusKopf(kopf: string | null, name: string): string | undefined {
if (!kopf) return undefined;
for (const teil of kopf.split(";")) {
const [schluessel, ...wert] = teil.trim().split("=");
if (schluessel === name) return decodeURIComponent(wert.join("="));
}
return undefined;
}
/**
* Für Route Handler: Bearer-Token (CLI) oder Sitzungscookie (Web) – hier an einer Stelle, damit
* kein Handler zwei Wege kennen muss. Ein angegebenes, aber ungültiges Token gilt als nicht
* angemeldet, auch wenn zusätzlich ein Cookie mitkommt.
*/
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
const kopf = anfrage.headers.get("authorization");
if (kopf?.startsWith("Bearer ")) {
const ausToken = await benutzerAusToken(kopf.slice("Bearer ".length).trim());
return ausToken ? { benutzer: ausToken, quelle: "CLI" } : null;
}
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
return benutzer ? { benutzer, quelle: "WEB" } : null;
}
export async function rechtErforderlich(anfrage: Request, recht: Recht): Promise<Aufrufer> {
const aufrufer = await anfrageAufrufer(anfrage);
if (!aufrufer) throw nichtAngemeldet();
if (!darf(aufrufer.benutzer.rolle, recht)) throw keinRecht();
return aufrufer;
}