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