feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens
Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Adressen der Anwendung nach außen.
|
||||
*
|
||||
* Maßgeblich ist APP_URL, nicht die eingehende Anfrage: hinter dem Reverse-Proxy trägt die
|
||||
* Anfrage den internen Host und oft das falsche Schema. Die Herkunft der Anfrage dient nur als
|
||||
* Notnagel, damit die Anwendung ohne APP_URL nicht stehen bleibt.
|
||||
*/
|
||||
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
const basis = process.env["APP_URL"]?.trim();
|
||||
if (basis) {
|
||||
try {
|
||||
return new URL(pfad, basis);
|
||||
} catch {
|
||||
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
|
||||
}
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob die Verbindung des Browsers HTTPS ist. Hinter einem Reverse-Proxy (TRUST_PROXY=true) zählt
|
||||
* X-Forwarded-Proto, sonst das Schema der Anfrage. Davon hängt das `Secure`-Attribut des
|
||||
* Sitzungscookies ab: fest gesetzt, scheitert die Anmeldung über reines HTTP stillschweigend
|
||||
* (in ProxLedger 0.11.1 passiert).
|
||||
*/
|
||||
export function istHttps(kopfzeilen: Headers, anfrageUrl: string): boolean {
|
||||
if (process.env["TRUST_PROXY"] === "true") {
|
||||
const weitergeleitet = kopfzeilen.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
if (weitergeleitet) return weitergeleitet === "https";
|
||||
}
|
||||
return new URL(anfrageUrl).protocol === "https:";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Aufrufers für Protokoll und Anmeldesperre. Ohne vertrauten Proxy sind alle
|
||||
* Kopfzeilen vom Aufrufer frei wählbar, dann bleibt die Adresse lieber leer als erfunden.
|
||||
*/
|
||||
export function aufruferAdresse(kopfzeilen: Headers): string | null {
|
||||
if (process.env["TRUST_PROXY"] !== "true") return null;
|
||||
return kopfzeilen.get("x-forwarded-for")?.split(",")[0]?.trim() || kopfzeilen.get("x-real-ip");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Zugriff auf die Datenbank.
|
||||
*
|
||||
* Der Client wird erst beim ersten Zugriff gebaut. Dadurch laeuft `next build`
|
||||
* ohne gesetzte DATABASE_URL durch, obwohl Module den Import auf oberster Ebene
|
||||
* haben. In der Entwicklung liegt die Instanz an globalThis, damit Hot Reload
|
||||
* nicht bei jedem Speichern eine neue Verbindung aufbaut.
|
||||
*/
|
||||
function clientBauen(): PrismaClient {
|
||||
const url = process.env["DATABASE_URL"];
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||
}
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||
}
|
||||
|
||||
const globalerZwischenspeicher = globalThis as typeof globalThis & {
|
||||
proxdeployPrisma?: PrismaClient;
|
||||
};
|
||||
|
||||
let instanz: PrismaClient | undefined;
|
||||
|
||||
function instanzHolen(): PrismaClient {
|
||||
if (process.env["NODE_ENV"] !== "production") {
|
||||
globalerZwischenspeicher.proxdeployPrisma ??= clientBauen();
|
||||
return globalerZwischenspeicher.proxdeployPrisma;
|
||||
}
|
||||
instanz ??= clientBauen();
|
||||
return instanz;
|
||||
}
|
||||
|
||||
export const prisma = new Proxy({} as PrismaClient, {
|
||||
get(_ziel, eigenschaft, empfaenger) {
|
||||
return Reflect.get(instanzHolen(), eigenschaft, empfaenger);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Verschlüsselt Geheimnisse, die ProxDeploy im Klartext wieder braucht: Proxmox-Token-Secrets,
|
||||
* SSH-Privatschlüssel, das OIDC-Client-Secret. Anmeldepasswörter und CLI-Tokens werden dagegen
|
||||
* gehasht und nie entschlüsselt.
|
||||
*
|
||||
* AES-256-GCM, weil es neben der Vertraulichkeit auch die Unverfälschtheit bezeugt: ein
|
||||
* veränderter Geheimtext wirft beim Entschlüsseln, statt still Unsinn zu liefern.
|
||||
*
|
||||
* Der Schlüssel kommt **direkt** aus `PROXDEPLOY_SCHLUESSEL` (32 Byte, base64) und wird nicht aus
|
||||
* einem Passwort abgeleitet: Er ist ohnehin zufällig erzeugt, und eine Ableitung hätte nur ein
|
||||
* festes Salz im Code gebraucht, ohne etwas zu schützen.
|
||||
*/
|
||||
const ALGORITHMUS = "aes-256-gcm";
|
||||
const IV_LAENGE = 12;
|
||||
const AUTH_TAG_LAENGE = 16;
|
||||
const SCHLUESSEL_LAENGE = 32;
|
||||
|
||||
/** Versionspräfix, damit ein späterer Schlüsseltausch alte Werte noch erkennt. */
|
||||
const FORMAT_VERSION = "v1";
|
||||
|
||||
export class GeheimnisFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "GeheimnisFehler";
|
||||
}
|
||||
}
|
||||
|
||||
let zwischenspeicher: { quelle: string; schluessel: Buffer } | undefined;
|
||||
|
||||
function schluesselHolen(): Buffer {
|
||||
const quelle = process.env["PROXDEPLOY_SCHLUESSEL"]?.trim() ?? "";
|
||||
if (zwischenspeicher?.quelle === quelle) return zwischenspeicher.schluessel;
|
||||
|
||||
const schluessel = Buffer.from(quelle, "base64");
|
||||
// Die tatsächliche Länge steht bewusst nicht in der Meldung – sie könnte in einem Log landen.
|
||||
if (schluessel.length !== SCHLUESSEL_LAENGE) {
|
||||
throw new GeheimnisFehler(
|
||||
"PROXDEPLOY_SCHLUESSEL fehlt oder ist kein base64-kodierter 32-Byte-Schlüssel. " +
|
||||
"Erzeugen mit: openssl rand -base64 32",
|
||||
);
|
||||
}
|
||||
zwischenspeicher = { quelle, schluessel };
|
||||
return schluessel;
|
||||
}
|
||||
|
||||
/** Prüft beim Start, ob der Schlüssel brauchbar ist, statt erst beim ersten Server-Zugriff. */
|
||||
export function schluesselPruefen(): void {
|
||||
schluesselHolen();
|
||||
}
|
||||
|
||||
export function verschluesseln(klartext: string): string {
|
||||
const schluessel = schluesselHolen();
|
||||
// Je Aufruf ein neuer IV. Derselbe IV zweimal mit demselben Schlüssel bricht GCM vollständig.
|
||||
const iv = randomBytes(IV_LAENGE);
|
||||
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
|
||||
// base64 enthält keinen Punkt, er taugt also als Trenner.
|
||||
return [
|
||||
FORMAT_VERSION,
|
||||
iv.toString("base64"),
|
||||
chiffre.getAuthTag().toString("base64"),
|
||||
geheim.toString("base64"),
|
||||
].join(".");
|
||||
}
|
||||
|
||||
export function entschluesseln(geheimtext: string): string {
|
||||
const teile = geheimtext.split(".");
|
||||
const [version, ivTeil, tagTeil, datenTeil] = teile;
|
||||
if (teile.length !== 4 || !version || !ivTeil || !tagTeil || datenTeil === undefined) {
|
||||
throw new GeheimnisFehler("Geheimtext hat ein unbekanntes Format.");
|
||||
}
|
||||
if (version !== FORMAT_VERSION) {
|
||||
throw new GeheimnisFehler(`Geheimtext trägt die unbekannte Version „${version}“.`);
|
||||
}
|
||||
const iv = Buffer.from(ivTeil, "base64");
|
||||
const authTag = Buffer.from(tagTeil, "base64");
|
||||
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
|
||||
throw new GeheimnisFehler("Geheimtext ist unvollständig: IV oder Auth-Tag haben die falsche Länge.");
|
||||
}
|
||||
|
||||
const schluessel = schluesselHolen();
|
||||
try {
|
||||
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||
entschluessler.setAuthTag(authTag);
|
||||
// `final` prüft das Auth-Tag und wirft bei verändertem Text oder falschem Schlüssel.
|
||||
return Buffer.concat([
|
||||
entschluessler.update(Buffer.from(datenTeil, "base64")),
|
||||
entschluessler.final(),
|
||||
]).toString("utf8");
|
||||
} catch (ursache) {
|
||||
throw new GeheimnisFehler(
|
||||
"Geheimtext ließ sich nicht entschlüsseln. Entweder wurde der Wert verändert, oder " +
|
||||
"PROXDEPLOY_SCHLUESSEL ist nicht mehr derselbe wie beim Verschlüsseln.",
|
||||
{ cause: ursache },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import pino from "pino";
|
||||
|
||||
/**
|
||||
* Strukturierte Logs (JSON auf stdout) für den Betrieb. Geheimnisse werden an den bekannten
|
||||
* Stellen geschwärzt – zusätzlich zur Regel, sie gar nicht erst in Log-Objekte zu geben.
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: process.env["LOG_LEVEL"] ?? "info",
|
||||
base: { dienst: process.env["PROXDEPLOY_DIENST"] ?? "app" },
|
||||
messageKey: "nachricht",
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
redact: {
|
||||
paths: [
|
||||
"passwort",
|
||||
"*.passwort",
|
||||
"tokenGeheimnis",
|
||||
"*.tokenGeheimnis",
|
||||
"sshPrivat",
|
||||
"*.sshPrivat",
|
||||
"authorization",
|
||||
"*.authorization",
|
||||
"headers.cookie",
|
||||
],
|
||||
censor: "[geschwärzt]",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Navigation der Seitenleiste. Jeder Eintrag nennt sein Recht als Schlüssel aus RECHTE
|
||||
* (`src/server/auth/rechte.ts`); die Hülle blendet aus, was die Rolle nicht darf.
|
||||
* Hier nur Text und Pfade, damit Client-Komponenten es importieren dürfen.
|
||||
*/
|
||||
export type NavigationsEintrag = {
|
||||
pfad: string;
|
||||
titel: string;
|
||||
symbol:
|
||||
| "uebersicht"
|
||||
| "ausrollen"
|
||||
| "verlauf"
|
||||
| "templates"
|
||||
| "vorlagen"
|
||||
| "katalog"
|
||||
| "server"
|
||||
| "einstellungen";
|
||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
|
||||
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||
ab?: string;
|
||||
};
|
||||
|
||||
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
|
||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
titel: "Einstellungen",
|
||||
symbol: "einstellungen",
|
||||
recht: "eigeneZugaenge",
|
||||
ab: "#015",
|
||||
},
|
||||
];
|
||||
|
||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
||||
export function istAktiv(eintrag: string, pfad: string): boolean {
|
||||
if (eintrag === "/") return pfad === "/";
|
||||
return pfad === eintrag || pfad.startsWith(`${eintrag}/`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
|
||||
/**
|
||||
* Anmeldepasswörter: argon2id mit den Vorgaben der Bibliothek (OWASP-konform).
|
||||
* Wird nie entschlüsselt – das unterscheidet es von `geheimnisse.ts`.
|
||||
*/
|
||||
export const PASSWORT_MINDESTLAENGE = 12;
|
||||
|
||||
export async function passwortHashen(passwort: string): Promise<string> {
|
||||
return await hash(passwort);
|
||||
}
|
||||
|
||||
export async function passwortPruefen(passwortHash: string, passwort: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwortHash, passwort);
|
||||
} catch {
|
||||
// Ein kaputter Hash in der Datenbank ist kein Grund, die Anmeldung abstürzen zu lassen.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fester Hash für den Fall „Benutzer gibt es nicht“: so dauert eine Anmeldung mit
|
||||
* unbekanntem Namen genauso lange wie eine mit falschem Passwort, und die Antwortzeit verrät
|
||||
* nicht, welche Benutzernamen existieren.
|
||||
*/
|
||||
let attrappe: Promise<string> | undefined;
|
||||
export async function attrappenPruefung(passwort: string): Promise<false> {
|
||||
attrappe ??= hash("proxdeploy-attrappe-ohne-bedeutung");
|
||||
await passwortPruefen(await attrappe, passwort);
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Das signierte Sitzungscookie der Web-GUI.
|
||||
*
|
||||
* Framework-frei, damit es ohne Next und ohne Datenbank testbar ist. Im Token steht nur, wer
|
||||
* angemeldet ist und wie. Rolle und Aktiv-Status stehen bewusst **nicht** darin – sie werden je
|
||||
* Anfrage aus der Datenbank gelesen, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
|
||||
*/
|
||||
export const SITZUNG_COOKIE = "pd_sitzung";
|
||||
|
||||
/** Ein Arbeitstag. */
|
||||
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
|
||||
|
||||
export type SitzungsHerkunft = "passwort" | "oidc";
|
||||
|
||||
export type Sitzung = {
|
||||
benutzerId: string;
|
||||
herkunft: SitzungsHerkunft;
|
||||
ausgestelltAm: number;
|
||||
};
|
||||
|
||||
const AUSSTELLER = "proxdeploy";
|
||||
const ZIELGRUPPE = "proxdeploy-web";
|
||||
const ALGORITHMUS = "HS256";
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
export class SitzungsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "SitzungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
let zwischenspeicher: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||
|
||||
function schluesselHolen(): Uint8Array {
|
||||
const quelle = process.env["SITZUNG_GEHEIMNIS"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
throw new SitzungsFehler(
|
||||
`SITZUNG_GEHEIMNIS fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (zwischenspeicher?.quelle !== quelle) {
|
||||
zwischenspeicher = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
||||
}
|
||||
return zwischenspeicher.schluessel;
|
||||
}
|
||||
|
||||
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
||||
return await new SignJWT({ herkunft: daten.herkunft })
|
||||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||
.setSubject(daten.benutzerId)
|
||||
.setIssuer(AUSSTELLER)
|
||||
.setAudience(ZIELGRUPPE)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
||||
.sign(schluesselHolen());
|
||||
}
|
||||
|
||||
/** Gibt undefined zurück, wenn das Token fehlt, abgelaufen, verfälscht oder fremd ist. */
|
||||
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
||||
// Vor dem `try`: ein fehlendes Geheimnis ist ein Konfigurationsfehler, kein „nicht angemeldet“.
|
||||
const schluessel = schluesselHolen();
|
||||
if (!jwt) return undefined;
|
||||
try {
|
||||
const { payload } = await jwtVerify(jwt, schluessel, {
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: AUSSTELLER,
|
||||
audience: ZIELGRUPPE,
|
||||
});
|
||||
const { sub, iat, herkunft } = payload;
|
||||
if (
|
||||
typeof sub !== "string" ||
|
||||
sub.length === 0 ||
|
||||
typeof iat !== "number" ||
|
||||
(herkunft !== "passwort" && herkunft !== "oidc")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { benutzerId: sub, herkunft, ausgestelltAm: iat };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const THEME_COOKIE = "pd_theme";
|
||||
export type Theme = "dunkel" | "hell";
|
||||
|
||||
/** Dunkel ist der Standard; alles Unbekannte im Cookie wird zu dunkel. */
|
||||
export function themeAusCookie(wert: string | undefined): Theme {
|
||||
return wert === "hell" ? "hell" : "dunkel";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod mit deutschen Standardmeldungen.
|
||||
*
|
||||
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
|
||||
* englische Saetze heraus („Invalid input: expected string“), und die landen
|
||||
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
|
||||
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
|
||||
* ab, woran niemand gedacht hat.
|
||||
*
|
||||
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
|
||||
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
|
||||
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
|
||||
*/
|
||||
z.config(z.locales.de());
|
||||
|
||||
export { z };
|
||||
|
||||
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
|
||||
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
|
||||
export { ZodError } from "zod";
|
||||
export type { ZodIssue, ZodType } from "zod";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||
*/
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev";
|
||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||
|
||||
export function versionText(): string {
|
||||
return GIT_SHA ? `v${VERSION} · ${GIT_SHA}` : `v${VERSION}`;
|
||||
}
|
||||
Reference in New Issue
Block a user