da79543d54
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>
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
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;
|
|
}
|