Files
ProxLedger/src/server/auth/passkeys.ts
T
florian.wildeandClaude Opus 5 2f45e33e20 feat(auth): Rollen, Benutzerverwaltung und Passkeys (#027, #028)
Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das
Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten
haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen -
ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen
selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests
pruefen das.

Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des
Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der
Vorgabe Techniker.

Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort
und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der
oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:46:29 +02:00

144 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
import { prisma } from "@/lib/db";
import { oeffentlicheBasis } from "@/lib/adresse";
import { einstellungen } from "@/server/einstellungen";
/**
* Passkeys (WebAuthn) als gleichwertiger Anmeldeweg neben Passwort und OIDC (#028).
*
* Ein Passkey ist an eine Domäne gebunden – deshalb entscheidet die **öffentliche** Adresse über
* `rpID` und `origin`. Steht dort die falsche Adresse, lehnt der Browser die Anmeldung wortlos ab.
*
* Voraussetzung ist ein sicherer Kontext: HTTPS, oder `localhost` zum Ausprobieren.
*/
export interface Herkunft {
rpID: string;
origin: string;
}
export async function herkunft(request: Request): Promise<Herkunft> {
const { dashboardUrl } = await einstellungen();
const basis = oeffentlicheBasis(request, dashboardUrl);
return { rpID: basis.hostname, origin: basis.origin };
}
/** Ist die Adresse für Passkeys geeignet? Ohne HTTPS geht es nur auf localhost. */
export function passkeysMoeglich(origin: string): boolean {
try {
const adresse = new URL(origin);
return (
adresse.protocol === "https:" || adresse.hostname === "localhost" || adresse.hostname === "127.0.0.1"
);
} catch {
return false;
}
}
export async function registrierungVorbereiten(benutzerId: string, herkunft: Herkunft) {
const benutzer = await prisma.benutzer.findUniqueOrThrow({
where: { id: benutzerId },
include: { passkeys: { select: { credentialId: true, transports: true } } },
});
return generateRegistrationOptions({
rpName: "ProxLedger",
rpID: herkunft.rpID,
userName: benutzer.email,
userDisplayName: benutzer.name,
// Der Schlüssel soll im Gerät bleiben und ohne Eingabe des Benutzernamens funktionieren.
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
// Bereits registrierte Schlüssel ausschließen, damit niemand denselben zweimal anlegt.
excludeCredentials: benutzer.passkeys.map((passkey) => ({
id: passkey.credentialId,
transports: passkey.transports as never,
})),
});
}
export async function registrierungAbschliessen(
benutzerId: string,
antwort: RegistrationResponseJSON,
erwarteteChallenge: string,
herkunft: Herkunft,
bezeichnung: string,
) {
const ergebnis = await verifyRegistrationResponse({
response: antwort,
expectedChallenge: erwarteteChallenge,
expectedOrigin: herkunft.origin,
expectedRPID: herkunft.rpID,
});
if (!ergebnis.verified || !ergebnis.registrationInfo) {
throw new Error("Der Passkey ließ sich nicht überprüfen.");
}
const { credential } = ergebnis.registrationInfo;
return prisma.passkey.create({
data: {
benutzerId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
zaehler: BigInt(credential.counter),
transports: credential.transports ?? [],
bezeichnung: bezeichnung.trim() || "Passkey",
},
});
}
export async function anmeldungVorbereiten(herkunft: Herkunft) {
return generateAuthenticationOptions({
rpID: herkunft.rpID,
// Keine Liste erlaubter Schlüssel: Der Browser bietet selbst an, was zu dieser Seite passt.
userVerification: "preferred",
});
}
export async function anmeldungPruefen(
antwort: AuthenticationResponseJSON,
erwarteteChallenge: string,
herkunft: Herkunft,
) {
const passkey = await prisma.passkey.findUnique({
where: { credentialId: antwort.id },
include: { benutzer: true },
});
if (!passkey) throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
if (!passkey.benutzer.aktiv) throw new Error("Dieses Konto ist gesperrt.");
const ergebnis = await verifyAuthenticationResponse({
response: antwort,
expectedChallenge: erwarteteChallenge,
expectedOrigin: herkunft.origin,
expectedRPID: herkunft.rpID,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.zaehler),
transports: passkey.transports as never,
},
});
if (!ergebnis.verified) throw new Error("Die Anmeldung ließ sich nicht überprüfen.");
// Der Zähler steigt bei jeder Nutzung; ein Rückschritt wäre ein Hinweis auf einen kopierten
// Schlüssel. simplewebauthn prüft das bereits – hier wird der neue Stand nur festgehalten.
await prisma.passkey.update({
where: { id: passkey.id },
data: {
zaehler: BigInt(ergebnis.authenticationInfo.newCounter),
zuletztAm: new Date(),
},
});
return passkey.benutzer;
}