V 2.2.0 Anmeldung per Passkey (WebAuthn)
Zusätzlich zu Benutzername/Passwort ist die Anmeldung per Passkey möglich (Fingerabdruck, Gesichtserkennung, Geräte-PIN). Das Passwort bleibt als Alternative bestehen. - lib/webauthn.ts kapselt @simplewebauthn/server, RP-Config über RP_ID/RP_ORIGIN/RP_NAME, Challenge in kurzlebigem httpOnly-Cookie - lib/passkeys.ts speichert Credentials je Benutzer in der MySQL-Tabelle passkeys und legt sie beim ersten Zugriff an - API unter app/api/passkey/: register (Session nötig), authenticate (öffentlich, in proxy.ts von der Session-Pflicht ausgenommen), GET/DELETE zum Verwalten - Neuer Tab "Einstellungen" zum Registrieren und Entfernen von Passkeys - Button "Mit Passkey anmelden" auf der Login-Seite Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,11 @@ export async function verifyCredentials(username: string, password: string): Pro
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
/** Prüft, ob der Benutzername (noch) in AUTH_USERS hinterlegt ist. */
|
||||
export function isKnownUser(username: string): boolean {
|
||||
return getUsers().some(u => u.username === username);
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
return !!process.env.AUTH_USERS;
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { getPool } from './db';
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
||||
|
||||
const TABLE = 'passkeys';
|
||||
|
||||
/** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */
|
||||
export interface PasskeyRecord {
|
||||
credentialId: string; // base64url
|
||||
username: string;
|
||||
publicKey: Uint8Array<ArrayBuffer>;
|
||||
counter: number;
|
||||
transports: string[];
|
||||
label: string;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
interface PasskeyRow extends RowDataPacket {
|
||||
credential_id: string;
|
||||
username: string;
|
||||
public_key: Buffer;
|
||||
counter: number;
|
||||
transports: string;
|
||||
label: string;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
}
|
||||
|
||||
let schemaReady: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Legt die Passkey-Tabelle beim ersten Zugriff an (einmal pro Prozess).
|
||||
* Das Schema steht zusätzlich in create_table.sql.
|
||||
*/
|
||||
function ensureSchema(): Promise<void> {
|
||||
if (!schemaReady) {
|
||||
schemaReady = getPool()
|
||||
.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
credential_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
public_key VARBINARY(1024) NOT NULL,
|
||||
counter INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
transports VARCHAR(255) NOT NULL DEFAULT '[]',
|
||||
label VARCHAR(80) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
INDEX idx_passkeys_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci`
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
schemaReady = null; // nächster Aufruf darf es erneut versuchen
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return schemaReady;
|
||||
}
|
||||
|
||||
function fromRow(row: PasskeyRow): PasskeyRecord {
|
||||
let transports: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.transports);
|
||||
if (Array.isArray(parsed)) transports = parsed;
|
||||
} catch {
|
||||
/* fehlerhafte transports ignorieren */
|
||||
}
|
||||
return {
|
||||
credentialId: row.credential_id,
|
||||
username: row.username,
|
||||
// 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 eines Benutzers, neueste zuerst. */
|
||||
export async function listPasskeys(username: string): Promise<PasskeyRecord[]> {
|
||||
await ensureSchema();
|
||||
const [rows] = await getPool().execute<PasskeyRow[]>(
|
||||
`SELECT * FROM ${TABLE} WHERE username = ? ORDER BY created_at DESC`,
|
||||
[username]
|
||||
);
|
||||
return rows.map(fromRow);
|
||||
}
|
||||
|
||||
/** Passkey anhand der Credential-ID — benutzerübergreifend, für die Anmeldung. */
|
||||
export async function getPasskey(credentialId: string): Promise<PasskeyRecord | undefined> {
|
||||
await ensureSchema();
|
||||
const [rows] = await getPool().execute<PasskeyRow[]>(
|
||||
`SELECT * FROM ${TABLE} WHERE credential_id = ?`,
|
||||
[credentialId]
|
||||
);
|
||||
return rows.length > 0 ? fromRow(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
export async function insertPasskey(record: {
|
||||
credentialId: string;
|
||||
username: string;
|
||||
publicKey: Uint8Array;
|
||||
counter: number;
|
||||
transports: string[];
|
||||
label: string;
|
||||
}): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().execute(
|
||||
`INSERT INTO ${TABLE} (credential_id, username, public_key, counter, transports, label)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.credentialId,
|
||||
record.username,
|
||||
Buffer.from(record.publicKey),
|
||||
record.counter,
|
||||
JSON.stringify(record.transports ?? []),
|
||||
record.label,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */
|
||||
export async function updatePasskeyUsage(credentialId: string, counter: number): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().execute(
|
||||
`UPDATE ${TABLE} SET counter = ?, last_used_at = NOW() WHERE credential_id = ?`,
|
||||
[counter, credentialId]
|
||||
);
|
||||
}
|
||||
|
||||
/** Löscht einen Passkey — nur wenn er dem angegebenen Benutzer gehört. */
|
||||
export async function deletePasskey(credentialId: string, username: string): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const [result] = await getPool().execute<ResultSetHeader>(
|
||||
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
|
||||
[credentialId, username]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
+176
@@ -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 = 'werte_pk_reg';
|
||||
const AUTH_CHALLENGE_COOKIE = 'werte_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 || 'Werte-Log';
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user