4a3dfa5821
- 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>
165 lines
5.3 KiB
TypeScript
165 lines
5.3 KiB
TypeScript
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 };
|
|
}
|