Passkey-Anmeldung (WebAuthn) zusätzlich zum Passwort

- Tabelle passkeys + lib/passkeys.ts (CRUD, Counter-Replay-Schutz)
- lib/webauthn.ts: RP-Config via RP_ID/RP_ORIGIN/RP_NAME, Challenge im
  httpOnly-Cookie, Wrapper um @simplewebauthn/server
- API: app/api/passkey/{register,authenticate,route}; authenticate ist
  öffentlich (proxy.ts ausgenommen) und setzt bei Erfolg die Session
- Login-Button "Mit Passkey anmelden", Verwaltung im Einstellungen-Tab
- Passwort bleibt als Fallback; Version 0.1.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 13:30:12 +02:00
parent 4dc64bfb5c
commit 4a3dfa5821
13 changed files with 856 additions and 26 deletions
+10
View File
@@ -37,6 +37,16 @@ export function getDb(): Database.Database {
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS passkeys (
credential_id TEXT PRIMARY KEY,
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '[]',
label TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT
);
`);
// Standard-Pause beim ersten Start setzen (0,5 h)
+94
View File
@@ -0,0 +1,94 @@
import { getDb } from './db';
/** Ein gespeicherter Passkey (WebAuthn-Credential) für den Single-User. */
export interface PasskeyRecord {
credentialId: string; // base64url
publicKey: Uint8Array<ArrayBuffer>;
counter: number;
transports: string[];
label: string;
createdAt: string;
lastUsedAt: string | null;
}
interface PasskeyRow {
credential_id: string;
public_key: Buffer;
counter: number;
transports: string;
label: string;
created_at: string;
last_used_at: string | null;
}
function fromRow(row: PasskeyRow): PasskeyRecord {
let transports: string[] = [];
try {
const parsed = JSON.parse(row.transports);
if (Array.isArray(parsed)) transports = parsed;
} catch {
/* ignore malformed transports */
}
return {
credentialId: row.credential_id,
// Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von
// @simplewebauthn erwarteten Uint8Array<ArrayBuffer>).
publicKey: Uint8Array.from(row.public_key),
counter: row.counter,
transports,
label: row.label,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
};
}
/** Alle Passkeys, neueste zuerst. */
export function listPasskeys(): PasskeyRecord[] {
return (
getDb().prepare('SELECT * FROM passkeys ORDER BY created_at DESC').all() as PasskeyRow[]
).map(fromRow);
}
export function getPasskey(credentialId: string): PasskeyRecord | undefined {
const row = getDb()
.prepare('SELECT * FROM passkeys WHERE credential_id = ?')
.get(credentialId) as PasskeyRow | undefined;
return row ? fromRow(row) : undefined;
}
export function insertPasskey(record: {
credentialId: string;
publicKey: Uint8Array;
counter: number;
transports: string[];
label: string;
}): void {
getDb()
.prepare(
`INSERT INTO passkeys (credential_id, public_key, counter, transports, label)
VALUES (@credentialId, @publicKey, @counter, @transports, @label)`
)
.run({
credentialId: record.credentialId,
publicKey: Buffer.from(record.publicKey),
counter: record.counter,
transports: JSON.stringify(record.transports ?? []),
label: record.label,
});
}
/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */
export function updatePasskeyUsage(credentialId: string, counter: number): void {
getDb()
.prepare(
`UPDATE passkeys SET counter = @counter, last_used_at = datetime('now')
WHERE credential_id = @credentialId`
)
.run({ credentialId, counter });
}
export function deletePasskey(credentialId: string): boolean {
return (
getDb().prepare('DELETE FROM passkeys WHERE credential_id = ?').run(credentialId).changes > 0
);
}
+164
View File
@@ -0,0 +1,164 @@
import { cookies } from 'next/headers';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { getConfiguredUsername } from './auth';
import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
const REG_CHALLENGE_COOKIE = 'arbeitszeit_pk_reg';
const AUTH_CHALLENGE_COOKIE = 'arbeitszeit_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 || 'Arbeitszeiterfassung';
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() {
const { rpID, rpName } = getRpConfig();
const existing = listPasskeys();
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: getConfiguredUsername(),
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. */
export async function finishRegistration(
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 (getPasskey(credential.id)) {
return { ok: false, error: 'Dieser Passkey ist bereits registriert.' };
}
insertPasskey({
credentialId: credential.id,
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. */
export async function finishAuthentication(
response: AuthenticationResponseJSON
): Promise<{ ok: true } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const passkey = getPasskey(response.id);
if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' };
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.' };
updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter);
return { ok: true };
}