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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user