V 1.3.0 Anmeldung per Passkey (WebAuthn)

Passkey-Login wie in ausgaben-next/werte-next: auf der Login-Seite "Mit
Passkey anmelden", Verwaltung der eigenen Passkeys unter /einstellungen
(Button oben rechts). Das Passwort bleibt als Alternative bestehen.

- lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey
- proxy.ts: /api/passkey/authenticate ohne Session erreichbar
- Styles als eigene CSS-Klassen in globals.css (kein Tailwind in diesem
  Projekt), passend zum vorhandenen login-*/app-*-Schema
- Tabelle tabletten_passkeys in der DB medizin: app-spezifischer Name,
  damit sich Apps desselben Stacks nicht gegenseitig Passkeys anzeigen
  oder loeschen
- RP-Konfiguration ueber TABLETTEN_RP_*, da sich die .env auf dem Server
  mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 20:17:11 +02:00
parent 325c07b469
commit 96043efe46
17 changed files with 1094 additions and 5 deletions
+176
View File
@@ -0,0 +1,176 @@
import { cookies } from 'next/headers';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { isKnownUser } from './auth';
import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
const REG_CHALLENGE_COOKIE = 'tabletten_pk_reg';
const AUTH_CHALLENGE_COOKIE = 'tabletten_pk_auth';
const CHALLENGE_TTL_S = 300; // 5 Minuten
/**
* Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt
* (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung.
*/
export function getRpConfig() {
const rpID = process.env.RP_ID || 'localhost';
const rpName = process.env.RP_NAME || 'Tabletten-Check';
const origin = process.env.RP_ORIGIN || 'http://localhost:3000';
return { rpID, rpName, origin };
}
// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------
async function setChallengeCookie(name: string, challenge: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(name, challenge, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: CHALLENGE_TTL_S,
});
}
async function takeChallengeCookie(name: string): Promise<string | null> {
const cookieStore = await cookies();
const value = cookieStore.get(name)?.value ?? null;
cookieStore.delete(name);
return value;
}
// --- Registrierung (Enrollment, Session erforderlich) --------------------
export async function buildRegistrationOptions(username: string) {
const { rpID, rpName } = getRpConfig();
const existing = await listPasskeys(username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
userDisplayName: username,
userID: new TextEncoder().encode(username),
attestationType: 'none',
excludeCredentials: existing.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge);
return options;
}
/** Verifiziert die Registrierungs-Antwort und speichert den Passkey für den Benutzer. */
export async function finishRegistration(
username: string,
response: RegistrationResponseJSON,
label: string
): Promise<{ ok: true } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch {
return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' };
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' };
}
const { credential } = verification.registrationInfo;
if (await getPasskey(credential.id)) {
return { ok: false, error: 'Dieser Passkey ist bereits registriert.' };
}
await insertPasskey({
credentialId: credential.id,
username,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports ?? [],
label: label.trim().slice(0, 80) || 'Passkey',
});
return { ok: true };
}
// --- Anmeldung (öffentlich, keine Session) -------------------------------
export async function buildAuthenticationOptions() {
const { rpID } = getRpConfig();
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl.
});
await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge);
return options;
}
/**
* Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey und liefert
* den zugehörigen Benutzer zurück.
*/
export async function finishAuthentication(
response: AuthenticationResponseJSON
): Promise<{ ok: true; username: string } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const passkey = await getPasskey(response.id);
if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' };
// Benutzer könnte inzwischen aus AUTH_USERS entfernt worden sein.
if (!isKnownUser(passkey.username)) {
return { ok: false, error: 'Benutzer existiert nicht mehr.' };
}
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch {
return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
}
if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
await updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter);
return { ok: true, username: passkey.username };
}