Files
ausgaben-next/lib/passkeys.ts
T
admin d3718da1f8 V 2.2.1 Anmeldung per Passkey (WebAuthn)
Passkey-Login analog zu werte-next: auf der Login-Seite "Mit Passkey
anmelden", Verwaltung der eigenen Passkeys im neuen Tab Einstellungen.
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
- TabLayout: dritter Tab Einstellungen (Index-Tabs statt Route wie werte)
- RP-Konfiguration ueber AUSGABEN_RP_*, da sich die .env auf dem Server
  mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen
- Eigene Tabelle ausgaben_passkeys aus demselben Grund: werte-next nutzt
  in der gemeinsamen Datenbank RXF bereits die Tabelle passkeys
- eslint.config.mjs auf die Flat-Config von eslint-config-next umgestellt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:35:35 +02:00

147 lines
4.7 KiB
TypeScript

import { getDbPool } from './db';
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
// Eigene Tabelle je Anwendung: Auf dem Server teilen sich mehrere Apps die
// Datenbank RXF. werte-next verwendet `passkeys` — würde diese App denselben
// Namen nutzen, sähe sie fremde Passkeys, könnte sie löschen und über
// excludeCredentials die eigene Registrierung blockieren.
const TABLE = 'ausgaben_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 = getDbPool()
.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_ausgaben_passkeys_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_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 getDbPool().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 getDbPool().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 getDbPool().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 getDbPool().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 getDbPool().execute<ResultSetHeader>(
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
[credentialId, username]
);
return result.affectedRows > 0;
}