feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf sshkv angepasst. Abweichungen mit Absicht: - Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen Lesezugriff: schon die Hostliste verraet, wer wohin darf. - Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt einmal, fuenf Fehlversuche sperren 15 Minuten. - Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis. - Sitzungen laufen acht statt zwoelf Stunden. Die Seiten folgen, sobald das Design-System (#002) steht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { sitzungBeenden } from "@/server/auth/anmeldung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abmelden. Nur per POST, damit ein fremder Link oder ein vorgeladenes Bild
|
||||||
|
* niemanden abmelden kann.
|
||||||
|
*/
|
||||||
|
export async function POST(anfrage: NextRequest): Promise<Response> {
|
||||||
|
await sitzungBeenden();
|
||||||
|
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?abgemeldet=1", anfrage.nextUrl.origin), {
|
||||||
|
status: 303,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
|
||||||
|
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
|
||||||
|
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
AnmeldeFehler,
|
||||||
|
oidcBenutzerUebernehmen,
|
||||||
|
oidcBestaetigungVermerken,
|
||||||
|
} from "@/server/services/anmeldung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rueckruf des OIDC-Anbieters: entweder Anmeldung (Sitzung setzen) oder erneute
|
||||||
|
* Bestaetigung des bereits angemeldeten Kontos (#004).
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: NextRequest): Promise<Response> {
|
||||||
|
const flussToken = anfrage.cookies.get(OIDC_FLUSS_COOKIE)?.value;
|
||||||
|
const ursprung = anfrage.nextUrl.origin;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gepruefte Adresse muss die oeffentliche sein – dagegen hat der Anbieter signiert.
|
||||||
|
const aktuelleAdresse = oeffentlicheAdresse(anfrage.nextUrl.pathname + anfrage.nextUrl.search, ursprung);
|
||||||
|
const anspruecke = await anmeldungAbschliessen(aktuelleAdresse, flussToken);
|
||||||
|
|
||||||
|
if (anspruecke.erneut) {
|
||||||
|
const vorhanden = await angemeldet();
|
||||||
|
if (!vorhanden) throw new AnmeldeFehler("Für eine Bestätigung muss eine Sitzung bestehen.");
|
||||||
|
await oidcBestaetigungVermerken(vorhanden.benutzer.id, anspruecke);
|
||||||
|
} else {
|
||||||
|
const benutzer = await oidcBenutzerUebernehmen(anspruecke);
|
||||||
|
await sitzungSetzen(benutzer, "oidc");
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = NextResponse.redirect(oeffentlicheAdresse(sicheresZiel(anspruecke.ziel), ursprung));
|
||||||
|
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
|
||||||
|
return antwort;
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(
|
||||||
|
"OIDC-Rueckruf fehlgeschlagen:",
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
|
||||||
|
);
|
||||||
|
// Nur ein Code geht in die Adresse, nie der Meldungstext.
|
||||||
|
const code = fehler instanceof AnmeldeFehler ? fehler.grund : "oidc-rueckruf";
|
||||||
|
const antwort = NextResponse.redirect(oeffentlicheAdresse(`/anmelden?fehler=${code}`, ursprung));
|
||||||
|
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { sicheresZiel, ueberHttps } from "@/server/auth/anmeldung";
|
||||||
|
import { OIDC_FLUSS_COOKIE, anmeldungStarten, oidcAktiv } from "@/server/auth/oidc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beginnt die Anmeldung beim OIDC-Anbieter. Mit `?erneut=1` wird daraus die erneute
|
||||||
|
* Bestaetigung vor einer kritischen Aktion (prompt=login, max_age=0) – siehe oidc.ts.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: NextRequest): Promise<Response> {
|
||||||
|
if (!oidcAktiv()) {
|
||||||
|
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", anfrage.nextUrl.origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ziel = sicheresZiel(anfrage.nextUrl.searchParams.get("ziel"));
|
||||||
|
const erneut = anfrage.nextUrl.searchParams.get("erneut") === "1";
|
||||||
|
try {
|
||||||
|
const { adresse, flussToken } = await anmeldungStarten(ziel, erneut);
|
||||||
|
const antwort = NextResponse.redirect(adresse);
|
||||||
|
antwort.cookies.set(OIDC_FLUSS_COOKIE, flussToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
// Der Rueckruf kommt als Navigation vom Anbieter zurueck, also lax.
|
||||||
|
sameSite: "lax",
|
||||||
|
// Massgeblich ist das Schema von sshkv, nicht das des Anbieters (sonst kaeme ein
|
||||||
|
// Secure-Cookie ueber http nie zurueck).
|
||||||
|
secure: ueberHttps(),
|
||||||
|
path: "/api/auth/oidc",
|
||||||
|
maxAge: 600,
|
||||||
|
});
|
||||||
|
return antwort;
|
||||||
|
} catch (fehler) {
|
||||||
|
// Nur Name und Meldung: die cause-Kette von openid-client kann Antworten des Anbieters
|
||||||
|
// mitfuehren.
|
||||||
|
console.error(
|
||||||
|
"OIDC-Anmeldung konnte nicht begonnen werden:",
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
|
||||||
|
);
|
||||||
|
return NextResponse.redirect(oeffentlicheAdresse("/anmelden?fehler=oidc-start", anfrage.nextUrl.origin));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Adressen der Anwendung nach aussen (#004, aus Laufzettel uebernommen).
|
||||||
|
*
|
||||||
|
* Massgeblich ist SSHKV_APP_URL, nicht die eingehende Anfrage: hinter einem Reverse
|
||||||
|
* Proxy traegt die Anfrage den internen Host, und eine daraus gebaute Umleitung oder
|
||||||
|
* OIDC-Rueckruf-Adresse passt nicht zu der, die der Benutzer erreicht.
|
||||||
|
*/
|
||||||
|
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||||
|
const basis = process.env["SSHKV_APP_URL"]?.trim();
|
||||||
|
if (basis) {
|
||||||
|
try {
|
||||||
|
return new URL(pfad, basis);
|
||||||
|
} catch {
|
||||||
|
// Eine unbrauchbare SSHKV_APP_URL soll die Anmeldung nicht blockieren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new URL(pfad, ersatzHerkunft);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#004):
|
||||||
|
* heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer
|
||||||
|
* Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht
|
||||||
|
* (argon2) und nie entschluesselt.
|
||||||
|
*
|
||||||
|
* AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit
|
||||||
|
* bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still
|
||||||
|
* Unsinn zu liefern.
|
||||||
|
*/
|
||||||
|
const ALGORITHMUS = "aes-256-gcm";
|
||||||
|
|
||||||
|
/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */
|
||||||
|
const IV_LAENGE = 12;
|
||||||
|
|
||||||
|
const AUTH_TAG_LAENGE = 16;
|
||||||
|
|
||||||
|
const SCHLUESSEL_LAENGE = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Festes Salz, absichtlich hier im Code.
|
||||||
|
*
|
||||||
|
* Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache
|
||||||
|
* Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `SSHKV_APP_SECRET`
|
||||||
|
* ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine
|
||||||
|
* Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel
|
||||||
|
* ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind
|
||||||
|
* alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz
|
||||||
|
* muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts
|
||||||
|
* zusaetzlich.
|
||||||
|
*/
|
||||||
|
const SALZ = "sshkv.geheimnisse.v1";
|
||||||
|
|
||||||
|
/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */
|
||||||
|
const FORMAT_VERSION = "v1";
|
||||||
|
|
||||||
|
const MINDESTLAENGE = 32;
|
||||||
|
|
||||||
|
const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}.<iv>.<authTag>.<geheimtext>' mit base64-kodierten Teilen.`;
|
||||||
|
|
||||||
|
export class GeheimnisFehler extends Error {
|
||||||
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
super(nachricht, optionen);
|
||||||
|
this.name = "GeheimnisFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import:
|
||||||
|
* `next build` laeuft ohne gesetztes `SSHKV_APP_SECRET`, und `scryptSync` ist
|
||||||
|
* absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes
|
||||||
|
* `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
||||||
|
* vorbeilaeuft.
|
||||||
|
*/
|
||||||
|
let abgeleitet: { quelle: string; schluessel: Buffer } | undefined;
|
||||||
|
|
||||||
|
function schluesselHolen(): Buffer {
|
||||||
|
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||||
|
if (quelle.length < MINDESTLAENGE) {
|
||||||
|
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in
|
||||||
|
// einem Log oder einer Fehlerseite landen und verriete etwas ueber das
|
||||||
|
// Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben.
|
||||||
|
throw new GeheimnisFehler(
|
||||||
|
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||||
|
"Erzeugen mit: openssl rand -base64 48",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abgeleitet?.quelle !== quelle) {
|
||||||
|
abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) };
|
||||||
|
}
|
||||||
|
return abgeleitet.schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verschluesseln(klartext: string): string {
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
// Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben
|
||||||
|
// Schluessel zu benutzen bricht GCM vollstaendig.
|
||||||
|
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 verwendet keinen Punkt, der Punkt 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 === undefined ||
|
||||||
|
ivTeil === undefined ||
|
||||||
|
tagTeil === undefined ||
|
||||||
|
datenTeil === undefined
|
||||||
|
) {
|
||||||
|
throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`);
|
||||||
|
}
|
||||||
|
if (version !== FORMAT_VERSION) {
|
||||||
|
throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
try {
|
||||||
|
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, {
|
||||||
|
authTagLength: AUTH_TAG_LAENGE,
|
||||||
|
});
|
||||||
|
entschluessler.setAuthTag(authTag);
|
||||||
|
const klar = Buffer.concat([
|
||||||
|
entschluessler.update(Buffer.from(datenTeil, "base64")),
|
||||||
|
// `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag
|
||||||
|
// veraendert wurden oder der Schluessel nicht passt.
|
||||||
|
entschluessler.final(),
|
||||||
|
]);
|
||||||
|
return klar.toString("utf8");
|
||||||
|
} catch (ursache) {
|
||||||
|
throw new GeheimnisFehler(
|
||||||
|
"Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " +
|
||||||
|
"Wert veraendert, oder SSHKV_APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.",
|
||||||
|
{ cause: ursache },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { scryptSync } from "node:crypto";
|
||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen
|
||||||
|
* ueberleben muessen (#004) – heute der OIDC-Anmeldefluss mit `state`, `nonce`,
|
||||||
|
* PKCE-Pruefwert und dem urspruenglich angeforderten Ziel.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten
|
||||||
|
* statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber
|
||||||
|
* `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen
|
||||||
|
* Stelle durchrutschen.
|
||||||
|
*/
|
||||||
|
const ALGORITHMUS = "HS256";
|
||||||
|
|
||||||
|
const HERAUSGEBER = "sshkv";
|
||||||
|
|
||||||
|
const SALZ = "sshkv.kurzzeit.v1";
|
||||||
|
|
||||||
|
const MINDESTLAENGE = 32;
|
||||||
|
|
||||||
|
export class KurzzeitFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "KurzzeitFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne
|
||||||
|
* gesetztes SSHKV_APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes
|
||||||
|
* Geheimnis nicht am Zwischenspeicher vorbeilaeuft.
|
||||||
|
*/
|
||||||
|
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||||
|
|
||||||
|
function schluesselHolen(): Uint8Array {
|
||||||
|
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||||
|
if (quelle.length < MINDESTLAENGE) {
|
||||||
|
throw new KurzzeitFehler(
|
||||||
|
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||||
|
"Erzeugen mit: openssl rand -base64 48",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abgeleitet?.quelle !== quelle) {
|
||||||
|
abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) };
|
||||||
|
}
|
||||||
|
return abgeleitet.schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */
|
||||||
|
export async function kurzzeitTokenErzeugen(
|
||||||
|
zweck: string,
|
||||||
|
nutzlast: Record<string, string>,
|
||||||
|
gueltigkeitSekunden: number,
|
||||||
|
): Promise<string> {
|
||||||
|
return new SignJWT(nutzlast)
|
||||||
|
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer(HERAUSGEBER)
|
||||||
|
.setAudience(zweck)
|
||||||
|
.setExpirationTime(`${gueltigkeitSekunden}s`)
|
||||||
|
.sign(schluesselHolen());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt,
|
||||||
|
* abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der
|
||||||
|
* Aufrufer behandelt all das gleich.
|
||||||
|
*/
|
||||||
|
export async function kurzzeitTokenLesen(
|
||||||
|
zweck: string,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<Record<string, string> | undefined> {
|
||||||
|
if (!token) return undefined;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, schluesselHolen(), {
|
||||||
|
algorithms: [ALGORITHMUS],
|
||||||
|
issuer: HERAUSGEBER,
|
||||||
|
audience: zweck,
|
||||||
|
});
|
||||||
|
const werte: Record<string, string> = {};
|
||||||
|
for (const [schluessel, wert] of Object.entries(payload)) {
|
||||||
|
if (typeof wert === "string") werte[schluessel] = wert;
|
||||||
|
}
|
||||||
|
return werte;
|
||||||
|
} catch (fehler) {
|
||||||
|
// Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token.
|
||||||
|
if (fehler instanceof KurzzeitFehler) throw fehler;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das signierte Sitzungscookie (#004).
|
||||||
|
*
|
||||||
|
* Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und
|
||||||
|
* Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status
|
||||||
|
* in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird --
|
||||||
|
* so laesst sich beides ohne Next und ohne Datenbank testen.
|
||||||
|
*
|
||||||
|
* Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung
|
||||||
|
* aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen
|
||||||
|
* bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
|
||||||
|
*/
|
||||||
|
export const SITZUNG_COOKIE = "sshkv_sitzung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acht Stunden statt eines vollen Tages: sshkv hat root auf allen Hosts, eine offen
|
||||||
|
* gelassene Sitzung ist entsprechend mehr wert als in anderen Anwendungen. Kritische
|
||||||
|
* Aktionen verlangen ohnehin eine frische Bestaetigung (5 Minuten).
|
||||||
|
*/
|
||||||
|
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
||||||
|
|
||||||
|
export type SitzungsHerkunft = "notfall" | "oidc";
|
||||||
|
|
||||||
|
export type Sitzung = {
|
||||||
|
benutzerId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
herkunft: SitzungsHerkunft;
|
||||||
|
/** Sekunden seit Epoche, aus dem iat-Anspruch. */
|
||||||
|
ausgestelltAm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis
|
||||||
|
* signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung
|
||||||
|
* durchrutscht.
|
||||||
|
*/
|
||||||
|
const AUSSTELLER = "sshkv";
|
||||||
|
const ZIELGRUPPE = "sshkv";
|
||||||
|
|
||||||
|
/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */
|
||||||
|
const ALGORITHMUS = "HS256";
|
||||||
|
|
||||||
|
const MINDESTLAENGE = 32;
|
||||||
|
|
||||||
|
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"];
|
||||||
|
|
||||||
|
export class SitzungsFehler extends Error {
|
||||||
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
super(nachricht, optionen);
|
||||||
|
this.name = "SitzungsFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build`
|
||||||
|
* laeuft ohne gesetztes `SSHKV_APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit,
|
||||||
|
* damit ein geaendertes `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
||||||
|
* vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`.
|
||||||
|
*/
|
||||||
|
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||||
|
|
||||||
|
function schluesselHolen(): Uint8Array {
|
||||||
|
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||||
|
if (quelle.length < MINDESTLAENGE) {
|
||||||
|
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log
|
||||||
|
// landen und verriete etwas ueber das Geheimnis.
|
||||||
|
throw new SitzungsFehler(
|
||||||
|
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||||
|
"Erzeugen mit: openssl rand -base64 48",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abgeleitet?.quelle !== quelle) {
|
||||||
|
abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
||||||
|
}
|
||||||
|
return abgeleitet.schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
return await new SignJWT({
|
||||||
|
email: daten.email,
|
||||||
|
name: daten.name,
|
||||||
|
herkunft: daten.herkunft,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||||
|
// Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das
|
||||||
|
// Token auch mit fremdem Werkzeug lesbar.
|
||||||
|
.setSubject(daten.benutzerId)
|
||||||
|
.setIssuer(AUSSTELLER)
|
||||||
|
.setAudience(ZIELGRUPPE)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
||||||
|
.sign(schluessel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */
|
||||||
|
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
||||||
|
// Absichtlich vor dem `try`: ein zu kurzes `SSHKV_APP_SECRET` ist ein Konfigurationsfehler und
|
||||||
|
// darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache.
|
||||||
|
const schluessel = schluesselHolen();
|
||||||
|
if (jwt === undefined || jwt.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(jwt, schluessel, {
|
||||||
|
// Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen
|
||||||
|
// Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen.
|
||||||
|
algorithms: [ALGORITHMUS],
|
||||||
|
issuer: AUSSTELLER,
|
||||||
|
audience: ZIELGRUPPE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sub, iat, email, name, herkunft } = payload;
|
||||||
|
// Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von
|
||||||
|
// Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere
|
||||||
|
// schlimmer als keines.
|
||||||
|
if (
|
||||||
|
typeof sub !== "string" ||
|
||||||
|
sub.length === 0 ||
|
||||||
|
typeof iat !== "number" ||
|
||||||
|
typeof email !== "string" ||
|
||||||
|
typeof name !== "string" ||
|
||||||
|
typeof herkunft !== "string" ||
|
||||||
|
!ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
benutzerId: sub,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
herkunft: herkunft as SitzungsHerkunft,
|
||||||
|
ausgestelltAm: iat,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 --
|
||||||
|
// fuer den Aufrufer ist das alles dasselbe: nicht angemeldet.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP nach RFC 6238 fuer den Notfall-Admin (#004).
|
||||||
|
*
|
||||||
|
* Selbst geschrieben statt als Abhaengigkeit: es sind zwanzig Zeilen, die Testvektoren
|
||||||
|
* stehen im RFC, und der Notfallzugang soll von moeglichst wenig fremdem Code abhaengen.
|
||||||
|
* SHA-1, 6 Stellen, 30 Sekunden – das, was jede Authenticator-App ohne Rueckfrage kann.
|
||||||
|
*/
|
||||||
|
export const TOTP_SCHRITT_SEKUNDEN = 30;
|
||||||
|
export const TOTP_STELLEN = 6;
|
||||||
|
/** Ein Schritt Toleranz je Richtung faengt Uhrenabweichung und Tippzeit ab. */
|
||||||
|
const TOLERANZ_SCHRITTE = 1;
|
||||||
|
|
||||||
|
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
export function base32Kodieren(daten: Buffer): string {
|
||||||
|
let bits = 0;
|
||||||
|
let wert = 0;
|
||||||
|
let ausgabe = "";
|
||||||
|
for (const byte of daten) {
|
||||||
|
wert = (wert << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
ausgabe += BASE32[(wert >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) ausgabe += BASE32[(wert << (5 - bits)) & 31];
|
||||||
|
return ausgabe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base32Dekodieren(text: string): Buffer {
|
||||||
|
const sauber = text.replace(/[\s=]/g, "").toUpperCase();
|
||||||
|
let bits = 0;
|
||||||
|
let wert = 0;
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (const zeichen of sauber) {
|
||||||
|
const index = BASE32.indexOf(zeichen);
|
||||||
|
if (index < 0) throw new Error("Ungültiges Base32-Zeichen im TOTP-Geheimnis.");
|
||||||
|
wert = (wert << 5) | index;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bytes.push((wert >>> (bits - 8)) & 255);
|
||||||
|
bits -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 160 Bit, wie von RFC 4226 fuer SHA-1 empfohlen. */
|
||||||
|
export function totpGeheimnisErzeugen(): string {
|
||||||
|
return base32Kodieren(randomBytes(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totpSchritt(zeitpunktMs: number): number {
|
||||||
|
return Math.floor(zeitpunktMs / 1000 / TOTP_SCHRITT_SEKUNDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totpCode(geheimnis: Buffer, schritt: number, stellen = TOTP_STELLEN): string {
|
||||||
|
const zaehler = Buffer.alloc(8);
|
||||||
|
zaehler.writeBigUInt64BE(BigInt(schritt));
|
||||||
|
const hmac = createHmac("sha1", geheimnis).update(zaehler).digest();
|
||||||
|
const versatz = (hmac[hmac.length - 1] as number) & 0x0f;
|
||||||
|
const zahl = hmac.readUInt32BE(versatz) & 0x7fffffff;
|
||||||
|
return String(zahl % 10 ** stellen).padStart(stellen, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft einen Code und liefert den getroffenen Zeitschritt – oder undefined.
|
||||||
|
*
|
||||||
|
* `letzterSchritt` verhindert, dass ein mitgelesener Code innerhalb seines Fensters ein
|
||||||
|
* zweites Mal gilt: angenommen wird nur ein Schritt **nach** dem zuletzt benutzten.
|
||||||
|
*/
|
||||||
|
export function totpPruefen(
|
||||||
|
geheimnisBase32: string,
|
||||||
|
code: string,
|
||||||
|
jetztMs: number,
|
||||||
|
letzterSchritt?: number | null,
|
||||||
|
): number | undefined {
|
||||||
|
const eingabe = code.replace(/\s/g, "");
|
||||||
|
if (!/^\d{6}$/.test(eingabe)) return undefined;
|
||||||
|
const geheimnis = base32Dekodieren(geheimnisBase32);
|
||||||
|
const aktuell = totpSchritt(jetztMs);
|
||||||
|
for (let abstand = -TOLERANZ_SCHRITTE; abstand <= TOLERANZ_SCHRITTE; abstand += 1) {
|
||||||
|
const schritt = aktuell + abstand;
|
||||||
|
if (letzterSchritt !== undefined && letzterSchritt !== null && schritt <= letzterSchritt) continue;
|
||||||
|
const erwartet = Buffer.from(totpCode(geheimnis, schritt));
|
||||||
|
if (timingSafeEqual(erwartet, Buffer.from(eingabe))) return schritt;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse fuer den QR-Code der Authenticator-App. */
|
||||||
|
export function totpAdresse(geheimnisBase32: string, konto: string): string {
|
||||||
|
const aussteller = "SSHKeyVault";
|
||||||
|
const etikett = encodeURIComponent(`${aussteller}:${konto}`);
|
||||||
|
const parameter = new URLSearchParams({
|
||||||
|
secret: geheimnisBase32,
|
||||||
|
issuer: aussteller,
|
||||||
|
algorithm: "SHA1",
|
||||||
|
digits: String(TOTP_STELLEN),
|
||||||
|
period: String(TOTP_SCHRITT_SEKUNDEN),
|
||||||
|
});
|
||||||
|
return `otpauth://totp/${etikett}?${parameter.toString()}`;
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { SITZUNG_COOKIE } from "@/lib/sitzung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft
|
||||||
|
* in der Node-Laufzeit.
|
||||||
|
*
|
||||||
|
* Sie tut zweierlei:
|
||||||
|
*
|
||||||
|
* 1. Content-Security-Policy mit einer Nonce je Anfrage. Die muss dynamisch
|
||||||
|
* sein, deshalb steht sie nicht bei den festen Kopfzeilen in next.config.ts.
|
||||||
|
* 2. Ein billiges Tor: ohne Sitzungscookie geht es gar nicht erst weiter, und
|
||||||
|
* der Benutzer landet auf der Anmeldeseite – mit dem urspruenglichen Ziel im
|
||||||
|
* Gepaeck. Geprueft wird das Cookie hier NICHT; das macht die Huelle unter
|
||||||
|
* src/app/(app)/layout.tsx gegen die Datenbank. Diese Trennung spart bei
|
||||||
|
* jeder Anfrage ohne Cookie eine Datenbankabfrage, ohne die Sicherheit an
|
||||||
|
* den Proxy zu haengen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfade, die ohne Anmeldung erreichbar sein muessen.
|
||||||
|
*
|
||||||
|
* Exportiert, damit tests/zugang.test.ts die Liste pruefen kann: ein neuer oeffentlicher
|
||||||
|
* Pfad ist eine Sicherheitsentscheidung und soll jemanden zwingen, sie aufzuschreiben.
|
||||||
|
*/
|
||||||
|
export const OEFFENTLICH: readonly string[] = [
|
||||||
|
"/anmelden",
|
||||||
|
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
|
||||||
|
"/api/auth",
|
||||||
|
// Healthcheck fuer Docker und den Reverse Proxy; verraet nur die Version.
|
||||||
|
"/api/gesundheit",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/brand",
|
||||||
|
"/icons",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function istOeffentlich(pfad: string): boolean {
|
||||||
|
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst.
|
||||||
|
*
|
||||||
|
* Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort:
|
||||||
|
*
|
||||||
|
* - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur
|
||||||
|
* Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg).
|
||||||
|
* Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig
|
||||||
|
* aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein
|
||||||
|
* Screenshot sieht aus wie immer, und erst ein Klick faellt auf.
|
||||||
|
* - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung.
|
||||||
|
*
|
||||||
|
* Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung
|
||||||
|
* bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest,
|
||||||
|
* damit die Lockerung nicht eines Tages mitgeliefert wird.
|
||||||
|
*/
|
||||||
|
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
|
||||||
|
return [
|
||||||
|
`default-src 'self'`,
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||||
|
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||||
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
|
`img-src 'self' data:`,
|
||||||
|
`font-src 'self'`,
|
||||||
|
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
|
||||||
|
`frame-ancestors 'none'`,
|
||||||
|
`base-uri 'self'`,
|
||||||
|
`form-action 'self'`,
|
||||||
|
`object-src 'none'`,
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(anfrage: NextRequest): NextResponse {
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
|
const pfad = anfrage.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) {
|
||||||
|
/*
|
||||||
|
* Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#004).
|
||||||
|
* Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die
|
||||||
|
* Anmeldeseite als HTML mit Status 200 – fuer den Client saehe das wie eine
|
||||||
|
* gueltige Antwort aus.
|
||||||
|
*/
|
||||||
|
if (pfad.startsWith("/api/")) {
|
||||||
|
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const ziel = pfad + anfrage.nextUrl.search;
|
||||||
|
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
|
||||||
|
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
|
||||||
|
return NextResponse.redirect(anmelden);
|
||||||
|
}
|
||||||
|
|
||||||
|
const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
|
||||||
|
|
||||||
|
const kopfzeilen = new Headers(anfrage.headers);
|
||||||
|
kopfzeilen.set("x-nonce", nonce);
|
||||||
|
// Server Components kennen den Pfad nicht von sich aus; die Huelle braucht ihn
|
||||||
|
// fuer den aktiven Eintrag in der Navigation und fuer den Ruecksprung nach der
|
||||||
|
// Anmeldung.
|
||||||
|
kopfzeilen.set("x-pfad", pfad);
|
||||||
|
kopfzeilen.set("x-abfrage", anfrage.nextUrl.search);
|
||||||
|
|
||||||
|
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
|
||||||
|
antwort.headers.set("Content-Security-Policy", richtlinie);
|
||||||
|
return antwort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
/*
|
||||||
|
* Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`.
|
||||||
|
*
|
||||||
|
* Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor
|
||||||
|
* schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf
|
||||||
|
* `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein
|
||||||
|
* WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen
|
||||||
|
* macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im
|
||||||
|
* Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber
|
||||||
|
* kein Knopf tat etwas.
|
||||||
|
*/
|
||||||
|
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
|
import {
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
SITZUNG_LAUFZEIT_STUNDEN,
|
||||||
|
sitzungErzeugen,
|
||||||
|
type SitzungsHerkunft,
|
||||||
|
} from "@/lib/sitzung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzen und Loeschen des Sitzungscookies (#004).
|
||||||
|
*
|
||||||
|
* Getrennt von src/lib/sitzung.ts, weil dort nichts aus Next stehen soll – so
|
||||||
|
* bleibt das Signieren und Pruefen ohne Framework testbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Secure-Cookie ueber http kommt nie beim Server an, und die Anmeldung
|
||||||
|
* laeuft dann endlos im Kreis. Massgeblich ist das Schema von SSHKV_APP_URL, nicht das
|
||||||
|
* der eingehenden Anfrage – hinter einem Reverse Proxy ist die intern oft http,
|
||||||
|
* obwohl der Benutzer https spricht.
|
||||||
|
*/
|
||||||
|
let fehlendeAppUrlGemeldet = false;
|
||||||
|
|
||||||
|
export function ueberHttps(): boolean {
|
||||||
|
const basis = process.env["SSHKV_APP_URL"];
|
||||||
|
if (!basis) {
|
||||||
|
// Ohne SSHKV_APP_URL faellt das Secure-Flag weg. In der Entwicklung ueber http ist
|
||||||
|
// das richtig; in Produktion waere es ein Sitzungscookie im Klartext, und das
|
||||||
|
// soll nicht unbemerkt bleiben.
|
||||||
|
if (process.env["NODE_ENV"] === "production" && !fehlendeAppUrlGemeldet) {
|
||||||
|
fehlendeAppUrlGemeldet = true;
|
||||||
|
console.warn(
|
||||||
|
"SSHKV_APP_URL ist nicht gesetzt. Das Sitzungscookie wird ohne Secure-Flag gesetzt. " +
|
||||||
|
"Hinter HTTPS muss SSHKV_APP_URL auf die oeffentliche Adresse zeigen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(basis).protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sitzungSetzen(benutzer: Benutzer, herkunft: SitzungsHerkunft): Promise<void> {
|
||||||
|
const token = await sitzungErzeugen({
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
email: benutzer.email ?? benutzer.anmeldename ?? "",
|
||||||
|
name: benutzer.name,
|
||||||
|
herkunft,
|
||||||
|
});
|
||||||
|
const laden = await cookies();
|
||||||
|
laden.set(SITZUNG_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: ueberHttps(),
|
||||||
|
path: "/",
|
||||||
|
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 60 * 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sitzungBeenden(): Promise<void> {
|
||||||
|
const laden = await cookies();
|
||||||
|
laden.delete(SITZUNG_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basis, die es nicht gibt – dient nur dazu, das Ziel aufzuloesen. */
|
||||||
|
const WEGWERF_BASIS = "http://ziel.invalid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt nur Ziele innerhalb der eigenen Anwendung an.
|
||||||
|
*
|
||||||
|
* Ohne diese Pruefung waere `?ziel=https://fremd.example` eine offene
|
||||||
|
* Weiterleitung: ein Angreifer koennte einen Anmeldelink verschicken, der nach
|
||||||
|
* erfolgreicher Anmeldung auf seiner Seite landet.
|
||||||
|
*
|
||||||
|
* Geprueft wird ueber das Aufloesen gegen eine Wegwerf-Basis, nicht ueber
|
||||||
|
* Praefixe. Eine Praefix-Pruefung laesst sich austricksen: `/<TAB>/fremd.example`
|
||||||
|
* faengt mit einem Schraegstrich an und ist kein `//`, aber Browser entfernen
|
||||||
|
* Tabulator, Zeilenumbruch und Wagenruecklauf beim Verarbeiten einer URL – uebrig
|
||||||
|
* bleibt `//fremd.example`, also ein fremder Host. Dasselbe gilt fuer kodierte
|
||||||
|
* Varianten, Backslashes und `..`. Das Aufloesen macht genau das, was der Browser
|
||||||
|
* auch tut, und der Vergleich der Herkunft erschlaegt damit alle Formen auf
|
||||||
|
* einmal.
|
||||||
|
*/
|
||||||
|
export function sicheresZiel(ziel: string | null | undefined): string {
|
||||||
|
if (!ziel || !ziel.startsWith("/")) return "/";
|
||||||
|
try {
|
||||||
|
const aufgeloest = new URL(ziel, WEGWERF_BASIS);
|
||||||
|
if (aufgeloest.origin !== WEGWERF_BASIS) return "/";
|
||||||
|
|
||||||
|
const fertig = aufgeloest.pathname + aufgeloest.search + aufgeloest.hash;
|
||||||
|
// Zweite Runde: aus `/..//fremd.example` macht das Aufloesen `//fremd.example`,
|
||||||
|
// und genau das waere als Location-Kopfzeile wieder ein fremder Host. Ein
|
||||||
|
// Ergebnis, das selbst nicht mehr eindeutig zur eigenen Anwendung gehoert,
|
||||||
|
// wird verworfen.
|
||||||
|
if (!fertig.startsWith("/") || fertig.startsWith("//")) return "/";
|
||||||
|
return fertig;
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import * as oidc from "openid-client";
|
||||||
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
|
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||||
|
import { staerksteRolle } from "@/server/auth/rechte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung ueber einen generischen OIDC-Anbieter (#004, Muster aus Laufzettel). Ziel ist
|
||||||
|
* authentik, aber es wird nichts Anbieter-Eigenes benutzt: alles kommt aus der Discovery.
|
||||||
|
* In der Entwicklung steht ein Mock-Anbieter in docker-compose.test.yml (#011).
|
||||||
|
*
|
||||||
|
* Kein Auth.js – v5 steht dauerhaft auf Beta (docs/PLAN.md).
|
||||||
|
*
|
||||||
|
* Dieselbe Strecke dient der erneuten Bestaetigung vor kritischen Aktionen: mit
|
||||||
|
* `prompt=login` und `max_age=0` muss sich der Benutzer beim Anbieter wirklich neu
|
||||||
|
* anmelden, und `auth_time` im ID-Token beweist, dass das nach dem Start geschah.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zweck des Kurzzeit-Tokens, das den Fluss ueber die Weiterleitung traegt. */
|
||||||
|
const FLUSS_ZWECK = "oidc-fluss";
|
||||||
|
|
||||||
|
/** Der Fluss ist eine Weiterleitung und zurueck – zehn Minuten sind reichlich. */
|
||||||
|
const FLUSS_GUELTIGKEIT_SEKUNDEN = 600;
|
||||||
|
|
||||||
|
export const OIDC_FLUSS_COOKIE = "sshkv_oidc";
|
||||||
|
|
||||||
|
export class OidcFehler extends Error {
|
||||||
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
super(nachricht, optionen);
|
||||||
|
this.name = "OidcFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function umgebung(name: string): string | undefined {
|
||||||
|
const wert = process.env[name]?.trim();
|
||||||
|
return wert ? wert : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OIDC ist abgeschaltet, solange nicht alle drei Angaben vorliegen. */
|
||||||
|
export function oidcAktiv(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
umgebung("SSHKV_OIDC_ISSUER") && umgebung("SSHKV_OIDC_CLIENT_ID") && umgebung("SSHKV_OIDC_CLIENT_SECRET"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oidcName(): string {
|
||||||
|
return umgebung("SSHKV_OIDC_NAME") ?? "Single Sign-on";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rueckruf-Adresse wird aus SSHKV_APP_URL abgeleitet und nicht aus der eingehenden
|
||||||
|
* Anfrage. Hinter einem Reverse Proxy ist der Host der Anfrage der interne –
|
||||||
|
* genau dort geht die Anmeldung sonst schief.
|
||||||
|
*/
|
||||||
|
export function rueckrufAdresse(): string {
|
||||||
|
const basis = umgebung("SSHKV_APP_URL");
|
||||||
|
if (!basis) {
|
||||||
|
throw new OidcFehler("SSHKV_APP_URL ist nicht gesetzt. Sie steckt in der OIDC-Rueckruf-Adresse.");
|
||||||
|
}
|
||||||
|
return new URL("/api/auth/oidc/rueckruf", basis).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Discovery wird einmal je Aussteller geholt und behalten. Sie kostet einen
|
||||||
|
* Netzaufruf, den nicht jede Anmeldung bezahlen soll.
|
||||||
|
*/
|
||||||
|
let entdeckt:
|
||||||
|
{ aussteller: string; kundennummer: string; konfiguration: Promise<oidc.Configuration> } | undefined;
|
||||||
|
|
||||||
|
function konfigurationHolen(): Promise<oidc.Configuration> {
|
||||||
|
const aussteller = umgebung("SSHKV_OIDC_ISSUER");
|
||||||
|
const kundennummer = umgebung("SSHKV_OIDC_CLIENT_ID");
|
||||||
|
const geheimnis = umgebung("SSHKV_OIDC_CLIENT_SECRET");
|
||||||
|
if (!aussteller || !kundennummer || !geheimnis) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"OIDC ist nicht vollstaendig konfiguriert. Erwartet werden OIDC_ISSUER, " +
|
||||||
|
"SSHKV_OIDC_CLIENT_ID und OIDC_CLIENT_SECRET.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entdeckt?.aussteller !== aussteller || entdeckt.kundennummer !== kundennummer) {
|
||||||
|
entdeckt = {
|
||||||
|
aussteller,
|
||||||
|
kundennummer,
|
||||||
|
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
|
||||||
|
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
|
||||||
|
entdeckt = undefined;
|
||||||
|
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return entdeckt.konfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur fuer Tests: den gemerkten Discovery-Stand verwerfen. */
|
||||||
|
export function konfigurationVergessen(): void {
|
||||||
|
entdeckt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlussStart = {
|
||||||
|
/** Adresse, zu der der Browser weitergeleitet wird. */
|
||||||
|
readonly adresse: string;
|
||||||
|
/** Inhalt des Fluss-Cookies, das den Rueckruf wiedererkennbar macht. */
|
||||||
|
readonly flussToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beginnt die Anmeldung. `ziel` ist die Seite, die der Benutzer eigentlich
|
||||||
|
* wollte – nach dem Rueckruf landet er dort und nicht stumpf auf der Uebersicht.
|
||||||
|
* Mit `erneut` wird daraus die Bestaetigung vor einer kritischen Aktion.
|
||||||
|
*/
|
||||||
|
export async function anmeldungStarten(ziel: string, erneut = false): Promise<FlussStart> {
|
||||||
|
const konfiguration = await konfigurationHolen();
|
||||||
|
|
||||||
|
const pruefwert = oidc.randomPKCECodeVerifier();
|
||||||
|
const herausforderung = await oidc.calculatePKCECodeChallenge(pruefwert);
|
||||||
|
const zustand = oidc.randomState();
|
||||||
|
const einmalwert = oidc.randomNonce();
|
||||||
|
|
||||||
|
const adresse = oidc.buildAuthorizationUrl(konfiguration, {
|
||||||
|
redirect_uri: rueckrufAdresse(),
|
||||||
|
scope: umgebung("SSHKV_OIDC_SCOPE") ?? "openid profile email groups",
|
||||||
|
code_challenge: herausforderung,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: zustand,
|
||||||
|
nonce: einmalwert,
|
||||||
|
...(erneut ? { prompt: "login", max_age: "0" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flussToken = await kurzzeitTokenErzeugen(
|
||||||
|
FLUSS_ZWECK,
|
||||||
|
{
|
||||||
|
pruefwert,
|
||||||
|
zustand,
|
||||||
|
einmalwert,
|
||||||
|
ziel,
|
||||||
|
erneut: erneut ? "ja" : "nein",
|
||||||
|
gestartetAm: String(Math.floor(Date.now() / 1000)),
|
||||||
|
},
|
||||||
|
FLUSS_GUELTIGKEIT_SEKUNDEN,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { adresse: adresse.toString(), flussToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OidcAnspruecke = {
|
||||||
|
readonly sub: string;
|
||||||
|
readonly email: string;
|
||||||
|
/**
|
||||||
|
* Hat der Anbieter die Adresse bestaetigt? Entscheidend beim Verknuepfen mit
|
||||||
|
* einem bestehenden Konto: laesst ein Anbieter selbst gewaehlte, unbestaetigte
|
||||||
|
* Adressen zu, waere das sonst die Uebernahme eines fremden Kontos.
|
||||||
|
*/
|
||||||
|
readonly emailBestaetigt: boolean;
|
||||||
|
readonly name: string;
|
||||||
|
readonly gruppen: readonly string[];
|
||||||
|
/** Ziel, das vor der Anmeldung angefordert wurde. */
|
||||||
|
readonly ziel: string;
|
||||||
|
/** War das eine erneute Bestaetigung (Re-Auth) und keine gewoehnliche Anmeldung? */
|
||||||
|
readonly erneut: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Spielraum fuer Uhrenabweichung zwischen sshkv und Anbieter. */
|
||||||
|
const UHR_TOLERANZ_SEKUNDEN = 30;
|
||||||
|
|
||||||
|
function textAus(wert: unknown): string | undefined {
|
||||||
|
return typeof wert === "string" && wert.trim() ? wert.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gruppenAus(wert: unknown): readonly string[] {
|
||||||
|
if (!Array.isArray(wert)) return [];
|
||||||
|
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst die Anmeldung ab. Prueft `state` und `nonce` gegen das Fluss-Cookie –
|
||||||
|
* ohne das waere der Rueckruf faelschbar.
|
||||||
|
*/
|
||||||
|
export async function anmeldungAbschliessen(
|
||||||
|
aktuelleAdresse: URL,
|
||||||
|
flussToken: string | undefined,
|
||||||
|
): Promise<OidcAnspruecke> {
|
||||||
|
const fluss = await kurzzeitTokenLesen(FLUSS_ZWECK, flussToken);
|
||||||
|
if (!fluss?.["pruefwert"] || !fluss["zustand"] || !fluss["einmalwert"]) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"Der Anmeldevorgang ist abgelaufen oder wurde nicht von hier begonnen. Bitte erneut anmelden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const konfiguration = await konfigurationHolen();
|
||||||
|
let antwort;
|
||||||
|
try {
|
||||||
|
antwort = await oidc.authorizationCodeGrant(konfiguration, aktuelleAdresse, {
|
||||||
|
pkceCodeVerifier: fluss["pruefwert"],
|
||||||
|
expectedState: fluss["zustand"],
|
||||||
|
expectedNonce: fluss["einmalwert"],
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
throw new OidcFehler("Der Anbieter hat die Anmeldung abgelehnt.", { cause: fehler });
|
||||||
|
}
|
||||||
|
|
||||||
|
const anspruecke = antwort.claims();
|
||||||
|
if (!anspruecke) {
|
||||||
|
throw new OidcFehler("Der Anbieter hat kein ID-Token geliefert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = textAus(anspruecke.sub);
|
||||||
|
const email = textAus(anspruecke["email"]);
|
||||||
|
if (!sub) throw new OidcFehler("Das ID-Token enthaelt keine Kennung (sub).");
|
||||||
|
if (!email) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"Das ID-Token enthaelt keine E-Mail-Adresse. Der Scope 'email' muss beim Anbieter freigegeben sein.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const erneut = fluss["erneut"] === "ja";
|
||||||
|
if (erneut) {
|
||||||
|
// Ohne auth_time laesst sich nicht beweisen, dass wirklich neu angemeldet wurde – ein
|
||||||
|
// Anbieter, der die laufende SSO-Sitzung einfach wiederverwendet, zaehlt nicht.
|
||||||
|
const anmeldezeit = anspruecke["auth_time"];
|
||||||
|
const gestartet = Number(fluss["gestartetAm"] ?? "0");
|
||||||
|
if (typeof anmeldezeit !== "number" || anmeldezeit + UHR_TOLERANZ_SEKUNDEN < gestartet) {
|
||||||
|
throw new OidcFehler(
|
||||||
|
"Der Anbieter hat keine erneute Anmeldung bestätigt (auth_time fehlt oder ist zu alt).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub,
|
||||||
|
erneut,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
emailBestaetigt: anspruecke["email_verified"] === true,
|
||||||
|
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||||
|
gruppen: gruppenAus(anspruecke["groups"]),
|
||||||
|
ziel: fluss["ziel"] ?? "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet die Gruppen des Anbieters auf eine Rolle ab – bei **jeder** Anmeldung neu, damit
|
||||||
|
* eine Aenderung in authentik sofort greift. Wer in mehreren Gruppen steht, bekommt die
|
||||||
|
* staerkste Rolle.
|
||||||
|
*
|
||||||
|
* Anders als in Laufzettel gibt es **keinen** Standard: ohne passende Gruppe kein Zugang.
|
||||||
|
* sshkv verwaltet root-Zugaenge; „jeder im Verzeichnis darf lesen“ verriete bereits, welche
|
||||||
|
* Hosts es gibt und wer wohin darf.
|
||||||
|
*/
|
||||||
|
export function rolleAusGruppen(gruppen: readonly string[]): Rolle | undefined {
|
||||||
|
const zuordnung: readonly (readonly [string, Rolle])[] = [
|
||||||
|
[umgebung("SSHKV_OIDC_GROUP_ADMIN") ?? "sshkv-admins", "ADMIN"],
|
||||||
|
[umgebung("SSHKV_OIDC_GROUP_OPERATOR") ?? "sshkv-operators", "OPERATOR"],
|
||||||
|
[umgebung("SSHKV_OIDC_GROUP_READER") ?? "sshkv-readers", "LESER"],
|
||||||
|
];
|
||||||
|
const vorhanden = new Set(gruppen);
|
||||||
|
return staerksteRolle(zuordnung.filter(([gruppe]) => vorhanden.has(gruppe)).map(([, rolle]) => rolle));
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechte je Rolle (#004, Plan Abschnitt 4).
|
||||||
|
*
|
||||||
|
* Eine Tabelle statt verstreuter `rolle === "ADMIN"`-Abfragen: wer wissen will, was ein
|
||||||
|
* Operator darf, liest es hier – und `tests/rechte.test.ts` haelt die heiklen Zeilen fest.
|
||||||
|
* „Bestaetigung“ heisst: zusaetzlich frische Re-Auth (siehe bestaetigung.ts).
|
||||||
|
*/
|
||||||
|
export type Recht =
|
||||||
|
| "lesen"
|
||||||
|
| "eigeneSchluesselHinterlegen"
|
||||||
|
| "hostsVerwalten"
|
||||||
|
| "hostKeyEntscheiden"
|
||||||
|
| "schluesselVerwalten"
|
||||||
|
| "publicKeyAusrollen"
|
||||||
|
| "privateKeyAusrollen"
|
||||||
|
| "privateKeyAnzeigen"
|
||||||
|
| "deploymentKeyVerwalten"
|
||||||
|
| "massenwiderruf"
|
||||||
|
| "zielkontenVerwalten"
|
||||||
|
| "einstellungenVerwalten";
|
||||||
|
|
||||||
|
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||||
|
lesen: ["ADMIN", "OPERATOR", "LESER"],
|
||||||
|
// Freigabe Frage 4: jeder darf eigene Public Keys hinterlegen; wirksam erst nach Zuweisung.
|
||||||
|
eigeneSchluesselHinterlegen: ["ADMIN", "OPERATOR", "LESER"],
|
||||||
|
hostsVerwalten: ["ADMIN", "OPERATOR"],
|
||||||
|
hostKeyEntscheiden: ["ADMIN", "OPERATOR"],
|
||||||
|
schluesselVerwalten: ["ADMIN", "OPERATOR"],
|
||||||
|
publicKeyAusrollen: ["ADMIN", "OPERATOR"],
|
||||||
|
// Freigabe Frage 3: wer einen Private Key auf einen Host legen darf, auf den er selbst
|
||||||
|
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
|
||||||
|
privateKeyAusrollen: ["ADMIN"],
|
||||||
|
privateKeyAnzeigen: ["ADMIN"],
|
||||||
|
deploymentKeyVerwalten: ["ADMIN"],
|
||||||
|
massenwiderruf: ["ADMIN"],
|
||||||
|
zielkontenVerwalten: ["ADMIN"],
|
||||||
|
einstellungenVerwalten: ["ADMIN"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||||
|
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||||
|
"privateKeyAnzeigen",
|
||||||
|
"deploymentKeyVerwalten",
|
||||||
|
"massenwiderruf",
|
||||||
|
"zielkontenVerwalten",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||||
|
return RECHTE[recht].includes(rolle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAERKE: Readonly<Record<Rolle, number>> = { LESER: 0, OPERATOR: 1, ADMIN: 2 };
|
||||||
|
|
||||||
|
export function staerksteRolle(rollen: readonly Rolle[]): Rolle | undefined {
|
||||||
|
return [...rollen].sort((a, b) => STAERKE[b] - STAERKE[a])[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||||
|
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
|
||||||
|
*
|
||||||
|
* Rolle und Aktiv-Status kommen bei **jeder** Anfrage aus der Datenbank, nicht aus dem
|
||||||
|
* Cookie: eine entzogene Rolle greift sofort, und ein deaktiviertes Konto kommt nicht mit
|
||||||
|
* einem gueltigen Cookie bis zum Ablauf weiter.
|
||||||
|
*/
|
||||||
|
export class RechteFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "RechteFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kritische Aktion ohne frische Bestaetigung – die Oberflaeche leitet dann zu /bestaetigen. */
|
||||||
|
export class BestaetigungFehler extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Diese Aktion verlangt eine erneute Bestätigung Ihrer Anmeldung.");
|
||||||
|
this.name = "BestaetigungFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie lange eine erneute Bestaetigung fuer kritische Aktionen gilt. */
|
||||||
|
export const BESTAETIGUNG_GUELTIG_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export type Urteil =
|
||||||
|
| { readonly gueltig: true }
|
||||||
|
| { readonly gueltig: false; readonly grund: "unbekannt" | "deaktiviert" | "widerrufen" };
|
||||||
|
|
||||||
|
/** Reine Entscheidung, ob eine Sitzung noch traegt – ohne Datenbank und ohne Next testbar. */
|
||||||
|
export function sitzungBewerten(
|
||||||
|
sitzung: Sitzung,
|
||||||
|
benutzer: Pick<Benutzer, "id" | "aktiv" | "sitzungenGueltigAb"> | null,
|
||||||
|
): Urteil {
|
||||||
|
if (!benutzer || benutzer.id !== sitzung.benutzerId) {
|
||||||
|
return { gueltig: false, grund: "unbekannt" };
|
||||||
|
}
|
||||||
|
if (!benutzer.aktiv) {
|
||||||
|
return { gueltig: false, grund: "deaktiviert" };
|
||||||
|
}
|
||||||
|
// iat steht in Sekunden und ist abgerundet; eine Sekunde Spiel, damit die Sitzung, die
|
||||||
|
// im selben Augenblick wie `sitzungenGueltigAb` entsteht, nicht sofort verfaellt.
|
||||||
|
if (sitzung.ausgestelltAm * 1000 + 1000 < benutzer.sitzungenGueltigAb.getTime()) {
|
||||||
|
return { gueltig: false, grund: "widerrufen" };
|
||||||
|
}
|
||||||
|
return { gueltig: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist die letzte Bestaetigung frisch genug? Rein, damit testbar. */
|
||||||
|
export function bestaetigungFrisch(bestaetigtAm: Date | null, jetzt: Date = new Date()): boolean {
|
||||||
|
if (!bestaetigtAm) return false;
|
||||||
|
const alter = jetzt.getTime() - bestaetigtAm.getTime();
|
||||||
|
return alter >= 0 && alter <= BESTAETIGUNG_GUELTIG_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Angemeldet = {
|
||||||
|
readonly benutzer: Benutzer;
|
||||||
|
readonly sitzung: Sitzung;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Der angemeldete Benutzer oder `undefined`; `cache` teilt die Abfrage innerhalb einer Anfrage. */
|
||||||
|
export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
||||||
|
const laden = await cookies();
|
||||||
|
const sitzung = await sitzungLesen(laden.get(SITZUNG_COOKIE)?.value);
|
||||||
|
if (!sitzung) return undefined;
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||||
|
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||||
|
if (!urteil.gueltig || !benutzer) return undefined;
|
||||||
|
// Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht
|
||||||
|
// ueber die Einrichtung hinaus – siehe /anmelden/totp.
|
||||||
|
if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined;
|
||||||
|
return { benutzer, sitzung };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuer Seiten: verlangt eine Anmeldung und optional ein Recht. Ohne Anmeldung geht es zur
|
||||||
|
* Anmeldeseite (mit Ruecksprungziel), ohne Recht auf /kein-zugriff.
|
||||||
|
*/
|
||||||
|
export async function sitzungErforderlich(recht: Recht = "lesen", ziel?: string): Promise<Angemeldet> {
|
||||||
|
const vorhanden = await angemeldet();
|
||||||
|
if (!vorhanden) {
|
||||||
|
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
||||||
|
}
|
||||||
|
if (!darf(vorhanden.benutzer.rolle, recht)) {
|
||||||
|
redirect("/kein-zugriff");
|
||||||
|
}
|
||||||
|
return vorhanden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuer Server Actions und Route Handler: wirft statt weiterzuleiten. Rechte aus
|
||||||
|
* `BESTAETIGUNG_NOETIG` verlangen zusaetzlich eine frische Bestaetigung.
|
||||||
|
*/
|
||||||
|
export async function rechtErforderlich(recht: Recht): Promise<Angemeldet> {
|
||||||
|
const vorhanden = await angemeldet();
|
||||||
|
if (!vorhanden) {
|
||||||
|
throw new RechteFehler("Nicht angemeldet.");
|
||||||
|
}
|
||||||
|
if (!darf(vorhanden.benutzer.rolle, recht)) {
|
||||||
|
throw new RechteFehler("Dafür fehlt Ihrer Rolle die Berechtigung.");
|
||||||
|
}
|
||||||
|
if (BESTAETIGUNG_NOETIG.has(recht) && !bestaetigungFrisch(vorhanden.benutzer.bestaetigtAm)) {
|
||||||
|
throw new BestaetigungFehler();
|
||||||
|
}
|
||||||
|
return vorhanden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { passwortPruefen } from "@/lib/passwort-hash";
|
||||||
|
import { totpPruefen } from "@/lib/totp";
|
||||||
|
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||||
|
import { rolleAusGruppen } from "@/server/auth/oidc";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
|
||||||
|
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `grund` landet als Code in der Adresse der Anmeldeseite – die Meldung selbst nie. */
|
||||||
|
export type AnmeldeGrund = "allgemein" | "keine-gruppe" | "konto-konflikt" | "deaktiviert" | "fremdes-konto";
|
||||||
|
|
||||||
|
export class AnmeldeFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
nachricht: string,
|
||||||
|
readonly grund: AnmeldeGrund = "allgemein",
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "AnmeldeFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
|
||||||
|
const NOTFALL_VERSUCHE = 5;
|
||||||
|
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
|
||||||
|
|
||||||
|
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
|
||||||
|
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- OIDC
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt das Konto beim ersten Login an oder aktualisiert es. Die Rolle kommt bei jeder
|
||||||
|
* Anmeldung neu aus den Gruppen – wer in authentik herausfaellt, verliert sofort den Zugang.
|
||||||
|
*/
|
||||||
|
export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
|
||||||
|
const rolle = rolleAusGruppen(anspruecke.gruppen);
|
||||||
|
if (!rolle) {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "anmeldung.verweigert",
|
||||||
|
ergebnis: "VERWEIGERT",
|
||||||
|
details: { email: anspruecke.email, grund: "keine sshkv-Gruppe" },
|
||||||
|
});
|
||||||
|
throw new AnmeldeFehler(
|
||||||
|
"Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.",
|
||||||
|
"keine-gruppe",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let benutzer = await prisma.benutzer.findUnique({ where: { oidcSub: anspruecke.sub } });
|
||||||
|
if (!benutzer) {
|
||||||
|
const perEmail = await prisma.benutzer.findUnique({ where: { email: anspruecke.email } });
|
||||||
|
if (perEmail) {
|
||||||
|
// Ein bestehendes Konto nur ueber eine vom Anbieter bestaetigte Adresse verknuepfen –
|
||||||
|
// sonst koennte jemand mit selbst gewaehlter Adresse ein fremdes Konto uebernehmen.
|
||||||
|
if (!anspruecke.emailBestaetigt || perEmail.oidcSub || perEmail.istNotfall) {
|
||||||
|
throw new AnmeldeFehler(
|
||||||
|
"Für diese E-Mail-Adresse besteht bereits ein anderes Konto.",
|
||||||
|
"konto-konflikt",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
benutzer = perEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const daten = {
|
||||||
|
oidcSub: anspruecke.sub,
|
||||||
|
email: anspruecke.email,
|
||||||
|
name: anspruecke.name,
|
||||||
|
rolle,
|
||||||
|
letzteAnmeldungAm: new Date(),
|
||||||
|
};
|
||||||
|
const ergebnis = benutzer
|
||||||
|
? await prisma.benutzer.update({ where: { id: benutzer.id }, data: daten })
|
||||||
|
: await prisma.benutzer.create({ data: daten });
|
||||||
|
|
||||||
|
if (!ergebnis.aktiv) {
|
||||||
|
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.", "deaktiviert");
|
||||||
|
}
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: ergebnis.id, name: ergebnis.name },
|
||||||
|
aktion: benutzer ? "anmeldung.oidc" : "anmeldung.oidc.erstmals",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { rolle },
|
||||||
|
});
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erneute Bestaetigung per OIDC: nur fuer das Konto, das schon angemeldet ist. */
|
||||||
|
export async function oidcBestaetigungVermerken(
|
||||||
|
benutzerId: string,
|
||||||
|
anspruecke: OidcAnspruecke,
|
||||||
|
): Promise<void> {
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||||
|
if (!benutzer || benutzer.oidcSub !== anspruecke.sub) {
|
||||||
|
throw new AnmeldeFehler(
|
||||||
|
"Die Bestätigung kam von einem anderen Konto als dem angemeldeten.",
|
||||||
|
"fremdes-konto",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.benutzer.update({ where: { id: benutzerId }, data: { bestaetigtAm: new Date() } });
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "bestaetigung.oidc",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Notfall-Admin
|
||||||
|
|
||||||
|
export type NotfallErgebnis =
|
||||||
|
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
|
||||||
|
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
|
||||||
|
*
|
||||||
|
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt
|
||||||
|
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||||
|
*/
|
||||||
|
export async function notfallAnmelden(
|
||||||
|
anmeldename: string,
|
||||||
|
passwort: string,
|
||||||
|
code: string,
|
||||||
|
jetzt = new Date(),
|
||||||
|
): Promise<NotfallErgebnis> {
|
||||||
|
const zaehler = `notfall:${anmeldename.toLowerCase()}`;
|
||||||
|
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN);
|
||||||
|
if (!versuch.erlaubt) {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "anmeldung.notfall.gesperrt",
|
||||||
|
ergebnis: "VERWEIGERT",
|
||||||
|
details: { anmeldename },
|
||||||
|
});
|
||||||
|
throw new AnmeldeFehler(
|
||||||
|
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
|
||||||
|
const passwortStimmt =
|
||||||
|
benutzer?.istNotfall && benutzer.passwortHash
|
||||||
|
? await passwortPruefen(benutzer.passwortHash, passwort)
|
||||||
|
: false;
|
||||||
|
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "anmeldung.notfall.fehlgeschlagen",
|
||||||
|
ergebnis: "VERWEIGERT",
|
||||||
|
details: { anmeldename },
|
||||||
|
});
|
||||||
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
|
||||||
|
return { art: "totpEinrichten", benutzer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schritt = totpPruefen(
|
||||||
|
entschluesseln(Buffer.from(benutzer.totpGeheimnis).toString("utf8")),
|
||||||
|
code,
|
||||||
|
jetzt.getTime(),
|
||||||
|
benutzer.totpLetzterSchritt === null ? null : Number(benutzer.totpLetzterSchritt),
|
||||||
|
);
|
||||||
|
if (schritt === undefined) {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "anmeldung.notfall.totp_falsch",
|
||||||
|
ergebnis: "VERWEIGERT",
|
||||||
|
});
|
||||||
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
await zaehlerLoeschen(zaehler);
|
||||||
|
const aktualisiert = await prisma.benutzer.update({
|
||||||
|
where: { id: benutzer.id },
|
||||||
|
data: { totpLetzterSchritt: BigInt(schritt), letzteAnmeldungAm: jetzt, bestaetigtAm: jetzt },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "anmeldung.notfall",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
});
|
||||||
|
return { art: "angemeldet", benutzer: aktualisiert };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
|
||||||
|
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
|
||||||
|
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht.
|
||||||
|
*/
|
||||||
|
export async function totpEinrichtungAbschliessen(
|
||||||
|
benutzerId: string,
|
||||||
|
geheimnisBase32: string,
|
||||||
|
code: string,
|
||||||
|
jetzt = new Date(),
|
||||||
|
): Promise<Benutzer> {
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||||
|
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
|
||||||
|
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
|
||||||
|
}
|
||||||
|
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
|
||||||
|
if (schritt === undefined) {
|
||||||
|
throw new AnmeldeFehler(
|
||||||
|
"Der Code passt nicht. Bitte die Uhrzeit des Geräts prüfen und erneut versuchen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const aktualisiert = await prisma.benutzer.update({
|
||||||
|
where: { id: benutzerId },
|
||||||
|
data: {
|
||||||
|
totpGeheimnis: Buffer.from(verschluesseln(geheimnisBase32), "utf8"),
|
||||||
|
totpEingerichtetAm: jetzt,
|
||||||
|
totpLetzterSchritt: BigInt(schritt),
|
||||||
|
letzteAnmeldungAm: jetzt,
|
||||||
|
bestaetigtAm: jetzt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "notfall.totp_eingerichtet",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
});
|
||||||
|
return aktualisiert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */
|
||||||
|
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||||
|
if (!benutzer.istNotfall || !benutzer.anmeldename) {
|
||||||
|
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
|
||||||
|
}
|
||||||
|
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
|
||||||
|
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
|
||||||
|
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
||||||
|
*
|
||||||
|
* Hash und Verkettung berechnet die Datenbank (Trigger), hier wird nur eingefuegt.
|
||||||
|
* `details` laeuft vor dem Schreiben durch `schwaerzen()` (#006), damit kein Private Key
|
||||||
|
* und kein Passwort hineingeraet – auch nicht aus Versehen ueber ein Fehlerobjekt.
|
||||||
|
*/
|
||||||
|
export type ProtokollEintrag = {
|
||||||
|
readonly akteur: { readonly id: string; readonly name: string } | "System";
|
||||||
|
readonly aktion: string;
|
||||||
|
readonly ergebnis: ProtokollErgebnis;
|
||||||
|
readonly hostName?: string | null;
|
||||||
|
readonly zielbenutzer?: string | null;
|
||||||
|
readonly fingerprint?: string | null;
|
||||||
|
readonly diff?: string | null;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function protokollieren(
|
||||||
|
eintrag: ProtokollEintrag,
|
||||||
|
client: Prisma.TransactionClient = prisma,
|
||||||
|
): Promise<void> {
|
||||||
|
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
||||||
|
await client.protokoll.create({
|
||||||
|
data: {
|
||||||
|
akteurId: akteur.id,
|
||||||
|
akteurName: akteur.name,
|
||||||
|
aktion: eintrag.aktion,
|
||||||
|
ergebnis: eintrag.ergebnis,
|
||||||
|
hostName: eintrag.hostName ?? null,
|
||||||
|
zielbenutzer: eintrag.zielbenutzer ?? null,
|
||||||
|
fingerprint: eintrag.fingerprint ?? null,
|
||||||
|
diff: eintrag.diff ?? null,
|
||||||
|
details: (eintrag.details as Prisma.InputJsonValue | undefined) ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-Limit mit festen Zeitfenstern in der Datenbank (#004).
|
||||||
|
*
|
||||||
|
* In der Datenbank statt im Speicher, weil Web-Prozesse neu starten und es spaeter mehr
|
||||||
|
* als einen geben kann – ein Zaehler im Speicher waere nach jedem Deploy wieder bei null.
|
||||||
|
* Ein einziges UPSERT haelt das auch bei gleichzeitigen Anfragen konsistent.
|
||||||
|
*/
|
||||||
|
export async function versuchZaehlen(
|
||||||
|
schluessel: string,
|
||||||
|
hoechstens: number,
|
||||||
|
fensterSekunden: number,
|
||||||
|
): Promise<{ erlaubt: boolean; wiederInSekunden: number }> {
|
||||||
|
const zeilen = await prisma.$queryRaw<{ anzahl: number; fensterStart: Date }[]>`
|
||||||
|
INSERT INTO "Ratenbegrenzung" ("schluessel", "fensterStart", "anzahl")
|
||||||
|
VALUES (${schluessel}, now(), 1)
|
||||||
|
ON CONFLICT ("schluessel") DO UPDATE SET
|
||||||
|
"anzahl" = CASE
|
||||||
|
WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN 1
|
||||||
|
ELSE "Ratenbegrenzung"."anzahl" + 1 END,
|
||||||
|
"fensterStart" = CASE
|
||||||
|
WHEN "Ratenbegrenzung"."fensterStart" < now() - make_interval(secs => ${fensterSekunden}) THEN now()
|
||||||
|
ELSE "Ratenbegrenzung"."fensterStart" END
|
||||||
|
RETURNING "anzahl", "fensterStart"`;
|
||||||
|
const zeile = zeilen[0];
|
||||||
|
if (!zeile) return { erlaubt: true, wiederInSekunden: 0 };
|
||||||
|
const ende = zeile.fensterStart.getTime() + fensterSekunden * 1000;
|
||||||
|
return {
|
||||||
|
erlaubt: zeile.anzahl <= hoechstens,
|
||||||
|
wiederInSekunden: Math.max(0, Math.ceil((ende - Date.now()) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zaehlerLoeschen(schluessel: string): Promise<void> {
|
||||||
|
await prisma.ratenbegrenzung.deleteMany({ where: { schluessel } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { Sitzung } from "@/lib/sitzung";
|
||||||
|
import { base32Dekodieren, base32Kodieren, totpCode, totpPruefen, totpSchritt } from "@/lib/totp";
|
||||||
|
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||||
|
import { rolleAusGruppen } from "@/server/auth/oidc";
|
||||||
|
import { BESTAETIGUNG_NOETIG, RECHTE, darf } from "@/server/auth/rechte";
|
||||||
|
import { BESTAETIGUNG_GUELTIG_MS, bestaetigungFrisch, sitzungBewerten } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { OEFFENTLICH, istOeffentlich, sicherheitsrichtlinie } from "@/proxy";
|
||||||
|
|
||||||
|
/** Entscheidungen rund um Anmeldung und Rechte, die ohne Datenbank pruefbar sind (#004). */
|
||||||
|
|
||||||
|
const UMGEBUNG = { ...process.env };
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...UMGEBUNG };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TOTP nach RFC 6238", () => {
|
||||||
|
// Testvektoren aus RFC 6238, Anhang B (SHA-1, Geheimnis "12345678901234567890").
|
||||||
|
const geheimnis = Buffer.from("12345678901234567890");
|
||||||
|
it.each([
|
||||||
|
[59, "94287082"],
|
||||||
|
[1111111109, "07081804"],
|
||||||
|
[1111111111, "14050471"],
|
||||||
|
[1234567890, "89005924"],
|
||||||
|
[2000000000, "69279037"],
|
||||||
|
])("Zeitpunkt %i ergibt %s", (sekunden, erwartet) => {
|
||||||
|
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000), 8)).toBe(erwartet);
|
||||||
|
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000))).toBe(erwartet.slice(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kodiert Base32 verlustfrei", () => {
|
||||||
|
expect(base32Kodieren(geheimnis)).toBe("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
|
||||||
|
expect(base32Dekodieren("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")).toEqual(geheimnis);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt den Code des Nachbarschritts an, aber keinen zweimal", () => {
|
||||||
|
const base32 = base32Kodieren(geheimnis);
|
||||||
|
const jetzt = 1_111_111_111_000;
|
||||||
|
const vorher = totpCode(geheimnis, totpSchritt(jetzt) - 1);
|
||||||
|
const schritt = totpPruefen(base32, vorher, jetzt);
|
||||||
|
expect(schritt).toBe(totpSchritt(jetzt) - 1);
|
||||||
|
// Derselbe Code nach erfolgreicher Anmeldung: abgelehnt (Wiederholung).
|
||||||
|
expect(totpPruefen(base32, vorher, jetzt, schritt)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt alte, kaputte und fremde Codes ab", () => {
|
||||||
|
const base32 = base32Kodieren(geheimnis);
|
||||||
|
const jetzt = 1_111_111_111_000;
|
||||||
|
expect(totpPruefen(base32, totpCode(geheimnis, totpSchritt(jetzt) - 3), jetzt)).toBeUndefined();
|
||||||
|
expect(totpPruefen(base32, "12345", jetzt)).toBeUndefined();
|
||||||
|
expect(totpPruefen(base32, "abcdef", jetzt)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rechte", () => {
|
||||||
|
it("laesst Private Keys nur Admins ausrollen und anzeigen (Freigabe Frage 3)", () => {
|
||||||
|
expect(darf("ADMIN", "privateKeyAusrollen")).toBe(true);
|
||||||
|
expect(darf("OPERATOR", "privateKeyAusrollen")).toBe(false);
|
||||||
|
expect(darf("OPERATOR", "privateKeyAnzeigen")).toBe(false);
|
||||||
|
expect(darf("LESER", "privateKeyAnzeigen")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst Operatoren Public Keys ausrollen, Leser nicht", () => {
|
||||||
|
expect(darf("OPERATOR", "publicKeyAusrollen")).toBe(true);
|
||||||
|
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst jeden eigene Public Keys hinterlegen (Freigabe Frage 4)", () => {
|
||||||
|
for (const rolle of ["ADMIN", "OPERATOR", "LESER"] as const) {
|
||||||
|
expect(darf(rolle, "eigeneSchluesselHinterlegen")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
|
||||||
|
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
|
||||||
|
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
|
||||||
|
);
|
||||||
|
for (const recht of BESTAETIGUNG_NOETIG) {
|
||||||
|
expect(RECHTE[recht]).toEqual(["ADMIN"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rollen aus OIDC-Gruppen", () => {
|
||||||
|
it("bildet die Standardgruppen ab und nimmt die staerkste", () => {
|
||||||
|
expect(rolleAusGruppen(["sshkv-readers"])).toBe("LESER");
|
||||||
|
expect(rolleAusGruppen(["sshkv-operators"])).toBe("OPERATOR");
|
||||||
|
expect(rolleAusGruppen(["sshkv-readers", "sshkv-admins"])).toBe("ADMIN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt ohne passende Gruppe gar keine Rolle – kein stiller Lesezugriff", () => {
|
||||||
|
expect(rolleAusGruppen([])).toBeUndefined();
|
||||||
|
expect(rolleAusGruppen(["andere", "SSHKV-ADMINS"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folgt umbenannten Gruppen aus der Umgebung", () => {
|
||||||
|
process.env["SSHKV_OIDC_GROUP_ADMIN"] = "it-root";
|
||||||
|
expect(rolleAusGruppen(["it-root"])).toBe("ADMIN");
|
||||||
|
expect(rolleAusGruppen(["sshkv-admins"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sitzung bewerten", () => {
|
||||||
|
const sitzung: Sitzung = {
|
||||||
|
benutzerId: "b1",
|
||||||
|
email: "a@b.de",
|
||||||
|
name: "A",
|
||||||
|
herkunft: "oidc",
|
||||||
|
ausgestelltAm: 1_700_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("traegt fuer ein aktives Konto", () => {
|
||||||
|
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: new Date(0) })).toEqual({
|
||||||
|
gueltig: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verfaellt fuer fremde, deaktivierte und widerrufene Konten", () => {
|
||||||
|
expect(sitzungBewerten(sitzung, null).gueltig).toBe(false);
|
||||||
|
expect(sitzungBewerten(sitzung, { id: "b2", aktiv: true, sitzungenGueltigAb: new Date(0) }).gueltig).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sitzungBewerten(sitzung, { id: "b1", aktiv: false, sitzungenGueltigAb: new Date(0) }).gueltig,
|
||||||
|
).toBe(false);
|
||||||
|
const spaeter = new Date((sitzung.ausgestelltAm + 60) * 1000);
|
||||||
|
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: spaeter })).toEqual({
|
||||||
|
gueltig: false,
|
||||||
|
grund: "widerrufen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt eine Bestaetigung genau fuenf Minuten fuer frisch", () => {
|
||||||
|
const jetzt = new Date("2026-10-12T10:00:00Z");
|
||||||
|
expect(bestaetigungFrisch(null, jetzt)).toBe(false);
|
||||||
|
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS), jetzt)).toBe(true);
|
||||||
|
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS - 1), jetzt)).toBe(false);
|
||||||
|
// Aus der Zukunft zaehlt nicht (Uhr verstellt oder Wert manipuliert).
|
||||||
|
expect(bestaetigungFrisch(new Date(jetzt.getTime() + 60_000), jetzt)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ruecksprungziel", () => {
|
||||||
|
it("nimmt Pfade der Anwendung an und weist alles andere ab", () => {
|
||||||
|
expect(sicheresZiel("/schluessel/abc?tab=2")).toBe("/schluessel/abc?tab=2");
|
||||||
|
for (const boesartig of [
|
||||||
|
"https://fremd.example",
|
||||||
|
"//fremd.example",
|
||||||
|
"/\\fremd.example",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
expect(sicheresZiel(boesartig)).toBe("/");
|
||||||
|
}
|
||||||
|
expect(sicheresZiel(null)).toBe("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Proxy", () => {
|
||||||
|
it("haelt die oeffentlichen Pfade klein und benannt", () => {
|
||||||
|
expect([...OEFFENTLICH].sort()).toEqual(
|
||||||
|
["/anmelden", "/api/auth", "/api/gesundheit", "/brand", "/icons", "/manifest.webmanifest"].sort(),
|
||||||
|
);
|
||||||
|
expect(istOeffentlich("/api/auth/oidc/start")).toBe(true);
|
||||||
|
expect(istOeffentlich("/anmeldenX")).toBe(false);
|
||||||
|
expect(istOeffentlich("/schluessel")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert im Betrieb eine strenge CSP ohne unsafe-eval", () => {
|
||||||
|
const richtlinie = sicherheitsrichtlinie("abc", false);
|
||||||
|
expect(richtlinie).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
|
||||||
|
expect(richtlinie).not.toContain("unsafe-eval");
|
||||||
|
expect(richtlinie).toContain("frame-ancestors 'none'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
|
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen
|
||||||
|
// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert.
|
||||||
|
const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"];
|
||||||
|
|
||||||
|
function appSecretSetzen(wert: string): void {
|
||||||
|
process.env["SSHKV_APP_SECRET"] = wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (APP_SECRET_VORHER === undefined) {
|
||||||
|
delete process.env["SSHKV_APP_SECRET"];
|
||||||
|
} else {
|
||||||
|
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */
|
||||||
|
function teilVerfaelschen(geheimtext: string, index: number): string {
|
||||||
|
const teile = geheimtext.split(".");
|
||||||
|
const teil = teile[index];
|
||||||
|
if (teil === undefined || teil.length === 0) {
|
||||||
|
throw new Error(`Teil ${index} fehlt im Geheimtext.`);
|
||||||
|
}
|
||||||
|
// Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine
|
||||||
|
// Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende.
|
||||||
|
const ersetzt = teil.startsWith("A") ? "B" : "A";
|
||||||
|
teile[index] = ersetzt + teil.slice(1);
|
||||||
|
return teile.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Geheimnisse verschluesseln und entschluesseln", () => {
|
||||||
|
it("ist verlustfrei fuer einfachen Text", () => {
|
||||||
|
const klartext = "bind-passwort-123";
|
||||||
|
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"],
|
||||||
|
["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"],
|
||||||
|
["leerer Text", ""],
|
||||||
|
["nur Leerzeichen", " "],
|
||||||
|
["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"],
|
||||||
|
["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"],
|
||||||
|
["base64-artiger Text", "YWJjZGVmZ2g="],
|
||||||
|
])("ist verlustfrei fuer %s", (_fall, klartext) => {
|
||||||
|
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist verlustfrei fuer lange Texte", () => {
|
||||||
|
const lang = "Zertifikat-".repeat(10_000);
|
||||||
|
const geheim = verschluesseln(lang);
|
||||||
|
|
||||||
|
expect(geheim.length).toBeGreaterThan(lang.length / 2);
|
||||||
|
expect(entschluesseln(geheim)).toBe(lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => {
|
||||||
|
const teile = verschluesseln("etwas").split(".");
|
||||||
|
|
||||||
|
expect(teile).toHaveLength(4);
|
||||||
|
expect(teile[0]).toBe("v1");
|
||||||
|
// 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert.
|
||||||
|
expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12);
|
||||||
|
expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => {
|
||||||
|
// Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten
|
||||||
|
// ablesbar, welche Konten dasselbe Passwort benutzen.
|
||||||
|
const klartext = "immer-dasselbe-passwort";
|
||||||
|
const erster = verschluesseln(klartext);
|
||||||
|
const zweiter = verschluesseln(klartext);
|
||||||
|
|
||||||
|
expect(erster).not.toBe(zweiter);
|
||||||
|
expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]);
|
||||||
|
expect(entschluesseln(erster)).toBe(klartext);
|
||||||
|
expect(entschluesseln(zweiter)).toBe(klartext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthaelt den Klartext nicht im Ergebnis", () => {
|
||||||
|
const klartext = "geheimes-bind-passwort";
|
||||||
|
expect(verschluesseln(klartext)).not.toContain(klartext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Geheimnisse gegen Verfaelschung", () => {
|
||||||
|
it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3);
|
||||||
|
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn das Auth-Tag veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn der IV veraendert wurde", () => {
|
||||||
|
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1);
|
||||||
|
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn der Geheimtext abgeschnitten wurde", () => {
|
||||||
|
const geheim = verschluesseln("bind-passwort-123");
|
||||||
|
const ohneLetztenTeil = geheim.split(".").slice(0, 3).join(".");
|
||||||
|
|
||||||
|
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["leerer Wert", ""],
|
||||||
|
["Klartext ohne Trenner", "einfach-nur-text"],
|
||||||
|
["zu wenige Teile", "v1.abc"],
|
||||||
|
["zu viele Teile", "v1.a.b.c.d"],
|
||||||
|
["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
|
||||||
|
["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
|
||||||
|
["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"],
|
||||||
|
])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => {
|
||||||
|
let gefangen: unknown;
|
||||||
|
try {
|
||||||
|
entschluesseln(geheimtext);
|
||||||
|
} catch (fehler) {
|
||||||
|
gefangen = fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gefangen).toBeInstanceOf(GeheimnisFehler);
|
||||||
|
expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.<iv>\.<authTag>\.<geheimtext>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft, wenn SSHKV_APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => {
|
||||||
|
const geheim = verschluesseln("bind-passwort-123");
|
||||||
|
appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen");
|
||||||
|
|
||||||
|
expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSHKV_APP_SECRET pruefen", () => {
|
||||||
|
it.each([
|
||||||
|
["fehlendes", ""],
|
||||||
|
["viel zu kurzes", "kurz"],
|
||||||
|
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
||||||
|
])("wirft bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", (_fall, wert) => {
|
||||||
|
appSecretSetzen(wert);
|
||||||
|
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler);
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/);
|
||||||
|
expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt genau 32 Zeichen an", () => {
|
||||||
|
appSecretSetzen("b".repeat(32));
|
||||||
|
|
||||||
|
expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stellt den Standardwert aus setup-env wieder her", () => {
|
||||||
|
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die
|
||||||
|
// Tests je nach Reihenfolge unterschiedlich ausgehen.
|
||||||
|
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
||||||
|
expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { passwortHashen } from "@/lib/passwort-hash";
|
||||||
|
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
|
||||||
|
import {
|
||||||
|
AnmeldeFehler,
|
||||||
|
notfallAnmelden,
|
||||||
|
oidcBenutzerUebernehmen,
|
||||||
|
totpEinrichtungAbschliessen,
|
||||||
|
} from "@/server/services/anmeldung";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Notfall-Admin mit Pflicht-TOTP und OIDC-Kontouebernahme gegen die Datenbank (#004). */
|
||||||
|
|
||||||
|
const PASSWORT = "notfall-test-passwort-123";
|
||||||
|
|
||||||
|
async function notfallAnlegen() {
|
||||||
|
return prisma.benutzer.create({
|
||||||
|
data: {
|
||||||
|
anmeldename: "notfall",
|
||||||
|
name: "Notfall-Admin",
|
||||||
|
rolle: "ADMIN",
|
||||||
|
istNotfall: true,
|
||||||
|
passwortHash: await passwortHashen(PASSWORT),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeZu(geheimnis: string, zeit: Date, versatz = 0) {
|
||||||
|
return totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()) + versatz);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Notfall-Admin", () => {
|
||||||
|
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
|
||||||
|
await notfallAnlegen();
|
||||||
|
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
|
||||||
|
expect(ergebnis.art).toBe("totpEinrichten");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("richtet TOTP ein und meldet danach nur mit gueltigem Code an", async () => {
|
||||||
|
const benutzer = await notfallAnlegen();
|
||||||
|
const geheimnis = totpGeheimnisErzeugen();
|
||||||
|
const t0 = new Date("2026-10-12T10:00:00Z");
|
||||||
|
|
||||||
|
await expect(totpEinrichtungAbschliessen(benutzer.id, geheimnis, "000000", t0)).rejects.toThrow(
|
||||||
|
AnmeldeFehler,
|
||||||
|
);
|
||||||
|
await totpEinrichtungAbschliessen(benutzer.id, geheimnis, codeZu(geheimnis, t0), t0);
|
||||||
|
|
||||||
|
const gespeichert = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
|
||||||
|
// Das Geheimnis liegt verschluesselt, nicht im Klartext.
|
||||||
|
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
|
||||||
|
|
||||||
|
const t1 = new Date(t0.getTime() + 60_000);
|
||||||
|
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
|
||||||
|
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
|
||||||
|
expect(ergebnis.art).toBe("angemeldet");
|
||||||
|
// Derselbe Code ein zweites Mal: abgelehnt.
|
||||||
|
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
|
||||||
|
AnmeldeFehler,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
|
||||||
|
await notfallAnlegen();
|
||||||
|
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
|
||||||
|
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
|
||||||
|
await notfallAnlegen();
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("protokolliert Fehlversuche ohne Passwort", async () => {
|
||||||
|
await notfallAnlegen();
|
||||||
|
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
|
||||||
|
const eintraege = await prisma.protokoll.findMany();
|
||||||
|
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
|
||||||
|
expect(
|
||||||
|
JSON.stringify(eintraege, (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert)),
|
||||||
|
).not.toContain("geheimes-falsches-pw");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OIDC-Konto", () => {
|
||||||
|
const basis = {
|
||||||
|
sub: "sub-1",
|
||||||
|
email: "florian@example.de",
|
||||||
|
emailBestaetigt: true,
|
||||||
|
name: "Florian",
|
||||||
|
ziel: "/",
|
||||||
|
erneut: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("legt das Konto an und nimmt die Rolle aus den Gruppen", async () => {
|
||||||
|
const benutzer = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-operators"] });
|
||||||
|
expect(benutzer.rolle).toBe("OPERATOR");
|
||||||
|
const wieder = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-readers"] });
|
||||||
|
expect(wieder.id).toBe(benutzer.id);
|
||||||
|
expect(wieder.rolle).toBe("LESER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert ohne sshkv-Gruppe", async () => {
|
||||||
|
await expect(oidcBenutzerUebernehmen({ ...basis, gruppen: [] })).rejects.toMatchObject({
|
||||||
|
grund: "keine-gruppe",
|
||||||
|
});
|
||||||
|
expect(await prisma.benutzer.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verknuepft nur ueber eine bestaetigte Adresse mit einem bestehenden Konto", async () => {
|
||||||
|
await prisma.benutzer.create({ data: { email: basis.email, name: "Alt", rolle: "LESER" } });
|
||||||
|
await expect(
|
||||||
|
oidcBenutzerUebernehmen({ ...basis, emailBestaetigt: false, gruppen: ["sshkv-admins"] }),
|
||||||
|
).rejects.toMatchObject({ grund: "konto-konflikt" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { SignJWT } from "jose";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
SITZUNG_COOKIE,
|
||||||
|
SITZUNG_LAUFZEIT_STUNDEN,
|
||||||
|
SitzungsFehler,
|
||||||
|
sitzungErzeugen,
|
||||||
|
sitzungLesen,
|
||||||
|
type Sitzung,
|
||||||
|
} from "@/lib/sitzung";
|
||||||
|
|
||||||
|
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
|
||||||
|
// wieder her, damit die Reihenfolge der Tests nichts aendert.
|
||||||
|
const APP_SECRET_VORHER = process.env["SSHKV_APP_SECRET"];
|
||||||
|
|
||||||
|
function appSecretSetzen(wert: string): void {
|
||||||
|
process.env["SSHKV_APP_SECRET"] = wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (APP_SECRET_VORHER === undefined) {
|
||||||
|
delete process.env["SSHKV_APP_SECRET"];
|
||||||
|
} else {
|
||||||
|
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
|
||||||
|
benutzerId: "b-0815",
|
||||||
|
email: "anna.mueller@example.org",
|
||||||
|
name: "Anna Müller",
|
||||||
|
herkunft: "notfall",
|
||||||
|
};
|
||||||
|
|
||||||
|
function schluessel(quelle: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(quelle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
|
||||||
|
async function tokenBauen(optionen: {
|
||||||
|
aussteller?: string;
|
||||||
|
zielgruppe?: string;
|
||||||
|
ausgestelltAm?: number;
|
||||||
|
laeuftAbAm?: number;
|
||||||
|
geheimnis?: string;
|
||||||
|
inhalt?: Record<string, unknown>;
|
||||||
|
}): Promise<string> {
|
||||||
|
const jetzt = Math.floor(Date.now() / 1000);
|
||||||
|
return await new SignJWT({
|
||||||
|
email: DATEN.email,
|
||||||
|
name: DATEN.name,
|
||||||
|
herkunft: DATEN.herkunft,
|
||||||
|
...optionen.inhalt,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(DATEN.benutzerId)
|
||||||
|
.setIssuer(optionen.aussteller ?? "sshkv")
|
||||||
|
.setAudience(optionen.zielgruppe ?? "sshkv")
|
||||||
|
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
|
||||||
|
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
|
||||||
|
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
|
||||||
|
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
|
||||||
|
const teile = jwt.split(".");
|
||||||
|
const teil = teile[abschnitt];
|
||||||
|
if (teil === undefined || teil.length === 0) {
|
||||||
|
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
|
||||||
|
}
|
||||||
|
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
|
||||||
|
return teile.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Sitzung erzeugen und lesen", () => {
|
||||||
|
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
const sitzung = await sitzungLesen(jwt);
|
||||||
|
|
||||||
|
expect(sitzung).toBeDefined();
|
||||||
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||||
|
expect(sitzung?.email).toBe(DATEN.email);
|
||||||
|
expect(sitzung?.name).toBe("Anna Müller");
|
||||||
|
expect(sitzung?.herkunft).toBe("notfall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
|
||||||
|
["franzoesische Akzente", "Zoé Lefèvre"],
|
||||||
|
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
|
||||||
|
])("behaelt %s im Namen", async (_fall, name) => {
|
||||||
|
const jwt = await sitzungErzeugen({ ...DATEN, name });
|
||||||
|
expect((await sitzungLesen(jwt))?.name).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
|
||||||
|
const vorher = Math.floor(Date.now() / 1000);
|
||||||
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||||
|
const nachher = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
|
||||||
|
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uebernimmt beide Herkuenfte", async () => {
|
||||||
|
for (const herkunft of ["notfall", "oidc"] as const) {
|
||||||
|
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
|
||||||
|
expect(sitzung?.herkunft).toBe(herkunft);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
const [kopfTeil, inhaltTeil] = jwt.split(".");
|
||||||
|
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
|
||||||
|
alg: string;
|
||||||
|
};
|
||||||
|
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
aud: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(kopf.alg).toBe("HS256");
|
||||||
|
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
|
||||||
|
expect(inhalt.iss).toBe("sshkv");
|
||||||
|
expect(inhalt.aud).toBe("sshkv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
|
||||||
|
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
|
||||||
|
expect(SITZUNG_COOKIE).toBe("sshkv_sitzung");
|
||||||
|
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
|
||||||
|
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
|
||||||
|
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
expect(jwt.split(".")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sitzung lesen bei kaputtem Token", () => {
|
||||||
|
it("gibt bei undefined undefined zurueck", async () => {
|
||||||
|
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["leerer Wert", ""],
|
||||||
|
["nur Leerzeichen", " "],
|
||||||
|
["Klartext ohne Punkte", "keinTokenSondernText"],
|
||||||
|
["zu wenige Abschnitte", "aaa.bbb"],
|
||||||
|
["kein base64", "!!!.???.###"],
|
||||||
|
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
|
||||||
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
|
||||||
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
|
||||||
|
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
|
||||||
|
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
|
||||||
|
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
|
||||||
|
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
|
||||||
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei einem Token undefined zurueck, dessen SSHKV_APP_SECRET inzwischen rotiert wurde", async () => {
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
|
||||||
|
|
||||||
|
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
|
||||||
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
|
||||||
|
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
|
||||||
|
const jetzt = Math.floor(Date.now() / 1000);
|
||||||
|
const abgelaufen = await tokenBauen({
|
||||||
|
ausgestelltAm: jetzt - 13 * 3600,
|
||||||
|
laeuftAbAm: jetzt - 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt ein Token an, das gerade noch laeuft", async () => {
|
||||||
|
const jetzt = Math.floor(Date.now() / 1000);
|
||||||
|
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
|
||||||
|
|
||||||
|
expect(await sitzungLesen(knapp)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei falschem Aussteller undefined zurueck", async () => {
|
||||||
|
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
|
||||||
|
// Sitzung durchrutschen.
|
||||||
|
const fremd = await tokenBauen({ aussteller: "proxledger" });
|
||||||
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
|
||||||
|
const fremd = await tokenBauen({ zielgruppe: "einladung" });
|
||||||
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["fehlende E-Mail", { email: undefined }],
|
||||||
|
["fehlender Name", { name: undefined }],
|
||||||
|
["fehlende Herkunft", { herkunft: undefined }],
|
||||||
|
["unbekannte Herkunft", { herkunft: "ldap" }],
|
||||||
|
["Name als Zahl", { name: 42 }],
|
||||||
|
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
|
||||||
|
const luecke = await tokenBauen({ inhalt });
|
||||||
|
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSHKV_APP_SECRET pruefen", () => {
|
||||||
|
it.each([
|
||||||
|
["fehlendes", ""],
|
||||||
|
["viel zu kurzes", "kurz"],
|
||||||
|
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
||||||
|
])("wirft beim Erzeugen bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
|
||||||
|
appSecretSetzen(wert);
|
||||||
|
|
||||||
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
|
||||||
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
|
||||||
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft auch beim Lesen bei zu kurzem SSHKV_APP_SECRET", async () => {
|
||||||
|
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
|
||||||
|
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
|
||||||
|
const jwt = await sitzungErzeugen(DATEN);
|
||||||
|
appSecretSetzen("kurz");
|
||||||
|
|
||||||
|
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
|
||||||
|
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt genau 32 Zeichen an", async () => {
|
||||||
|
appSecretSetzen("b".repeat(32));
|
||||||
|
|
||||||
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
||||||
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stellt den Standardwert aus setup-env wieder her", async () => {
|
||||||
|
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
|
||||||
|
// Reihenfolge unterschiedlich ausgehen.
|
||||||
|
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
||||||
|
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user