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>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
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;
|
||
}
|