Add guestbook with WebAuthn admin and SQLite
Introduce server-rendered guestbook and moderation portal. Persist data in SQLite (better-sqlite3); add WebAuthn YubiKey admin auth, rate-limiting, spam heuristics, and sanitization. Switch Docker image to run Node/standalone Astro (remove nginx), update docker-compose, Dockerfile, astro.config, and package.json. Add .env.example, docs/guestbook.md, gitignore updates, layouts, API routes, and supporting lib/components/pages for the feature.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import db from './db';
|
||||
|
||||
function getSecretKey(): string {
|
||||
const key = process.env.SECRET_KEY;
|
||||
if (!key) throw new Error('SECRET_KEY environment variable is required');
|
||||
return key;
|
||||
}
|
||||
|
||||
export function hashIP(ip: string): string {
|
||||
return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export interface AdminSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export function createSession(userId: string): string {
|
||||
const sessionId = generateId();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)`
|
||||
).run(sessionId, userId, expiresAt);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): AdminSession | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM admin_sessions
|
||||
WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
||||
)
|
||||
.get(sessionId) as AdminSession | undefined;
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string): void {
|
||||
db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId);
|
||||
}
|
||||
|
||||
export function cleanExpiredSessions(): void {
|
||||
db.prepare(
|
||||
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
||||
).run();
|
||||
db.prepare(
|
||||
`DELETE FROM webauthn_challenges WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
||||
).run();
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE = 'admin_session';
|
||||
export const CHALLENGE_COOKIE = 'webauthn_challenge';
|
||||
|
||||
export function sessionCookieOptions(maxAge: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict' as const,
|
||||
path: '/',
|
||||
maxAge,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db');
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// WAL mode improves concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS guestbook_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
website TEXT,
|
||||
ip_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
moderated_at TEXT,
|
||||
moderation_note TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
challenge TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
entry_id INTEGER,
|
||||
admin_session TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_challenges_expires ON webauthn_challenges(expires_at);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,125 @@
|
||||
import db from './db';
|
||||
|
||||
export interface GuestbookEntry {
|
||||
id: number;
|
||||
display_name: string;
|
||||
message: string;
|
||||
website: string | null;
|
||||
ip_hash: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'spam';
|
||||
created_at: string;
|
||||
moderated_at: string | null;
|
||||
moderation_note: string | null;
|
||||
}
|
||||
|
||||
export interface SubmitData {
|
||||
display_name: string;
|
||||
message: string;
|
||||
website: string | null;
|
||||
ip_hash: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } {
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const entries = db
|
||||
.prepare(
|
||||
`SELECT id, display_name, message, website, created_at
|
||||
FROM guestbook_entries
|
||||
WHERE status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(PAGE_SIZE, offset) as GuestbookEntry[];
|
||||
|
||||
const { total } = db
|
||||
.prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`)
|
||||
.get() as { total: number };
|
||||
|
||||
return { entries, total, hasMore: offset + PAGE_SIZE < total };
|
||||
}
|
||||
|
||||
export function getPendingEntries(): GuestbookEntry[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC`
|
||||
)
|
||||
.all() as GuestbookEntry[];
|
||||
}
|
||||
|
||||
export function getRecentModerated(limit = 30): GuestbookEntry[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM guestbook_entries
|
||||
WHERE status IN ('approved', 'rejected', 'spam')
|
||||
ORDER BY moderated_at DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as GuestbookEntry[];
|
||||
}
|
||||
|
||||
export function submitEntry(data: SubmitData): number {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO guestbook_entries (display_name, message, website, ip_hash)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(data.display_name, data.message, data.website, data.ip_hash);
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
export function moderateEntry(
|
||||
id: number,
|
||||
status: 'approved' | 'rejected' | 'spam',
|
||||
note: string | null,
|
||||
sessionId: string
|
||||
): boolean {
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE guestbook_entries
|
||||
SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ?
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(status, note, id);
|
||||
|
||||
if (result.changes > 0) {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)`
|
||||
).run(`moderate:${status}`, id, sessionId, note);
|
||||
}
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function checkRateLimit(ipHash: string): boolean {
|
||||
// Clean up old entries (>1 hour)
|
||||
db.prepare(
|
||||
`DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')`
|
||||
).run();
|
||||
|
||||
const { count } = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`)
|
||||
.get(ipHash) as { count: number };
|
||||
|
||||
// Allow max 3 submissions per hour per IP hash
|
||||
return count < 3;
|
||||
}
|
||||
|
||||
export function recordSubmission(ipHash: string): void {
|
||||
db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash);
|
||||
}
|
||||
|
||||
export function isDuplicateSubmission(displayName: string, message: string): boolean {
|
||||
// Check for exact duplicate in last 24 hours (regardless of status)
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id FROM guestbook_entries
|
||||
WHERE display_name = ? AND message = ?
|
||||
AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')`
|
||||
)
|
||||
.get(displayName, message);
|
||||
|
||||
return row !== undefined;
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// Lightweight spam detection and input sanitization
|
||||
|
||||
const MAX_NAME_LENGTH = 60;
|
||||
const MAX_MESSAGE_LENGTH = 1000;
|
||||
const MAX_WEBSITE_LENGTH = 200;
|
||||
|
||||
// Patterns that strongly suggest spam
|
||||
const SPAM_PATTERNS = [
|
||||
/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i,
|
||||
/\b(click here|buy now|free money|earn \$|make money)\b/i,
|
||||
/(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message
|
||||
];
|
||||
|
||||
// Basic URL validation for the optional website field
|
||||
const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i;
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function sanitizeText(input: string): string {
|
||||
// Normalize whitespace: collapse runs of spaces/tabs, normalize line endings
|
||||
return input
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function stripHtml(input: string): string {
|
||||
// Remove anything that looks like an HTML tag
|
||||
return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
};
|
||||
return entities[match] ?? match;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateWebsite(url: string): string | null {
|
||||
if (!url || url.trim() === '') return null;
|
||||
|
||||
const cleaned = url.trim();
|
||||
|
||||
if (cleaned.length > MAX_WEBSITE_LENGTH) return null;
|
||||
if (!SAFE_URL_PATTERN.test(cleaned)) return null;
|
||||
|
||||
// Block localhost and private ranges in URLs
|
||||
if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null;
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function validateEntry(data: {
|
||||
display_name: string;
|
||||
message: string;
|
||||
website: string;
|
||||
consent: string;
|
||||
honeypot: string;
|
||||
}): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Honeypot check: must be empty (bots fill it in)
|
||||
if (data.honeypot && data.honeypot.trim() !== '') {
|
||||
errors.push({ field: 'honeypot', message: 'Spam detected' });
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Consent required
|
||||
if (!data.consent || data.consent !== 'yes') {
|
||||
errors.push({ field: 'consent', message: 'You must consent to public posting' });
|
||||
}
|
||||
|
||||
// Display name validation
|
||||
const name = stripHtml(sanitizeText(data.display_name));
|
||||
if (!name || name.length < 1) {
|
||||
errors.push({ field: 'display_name', message: 'Please enter a display name' });
|
||||
} else if (name.length > MAX_NAME_LENGTH) {
|
||||
errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` });
|
||||
}
|
||||
|
||||
// Message validation
|
||||
const message = stripHtml(sanitizeText(data.message));
|
||||
if (!message || message.length < 1) {
|
||||
errors.push({ field: 'message', message: 'Please enter a message' });
|
||||
} else if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number {
|
||||
let score = 0;
|
||||
const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`;
|
||||
|
||||
for (const pattern of SPAM_PATTERNS) {
|
||||
if (pattern.test(combined)) score += 30;
|
||||
}
|
||||
|
||||
// Lots of URLs in message is suspicious
|
||||
const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length;
|
||||
if (urlCount >= 2) score += 20 * urlCount;
|
||||
|
||||
// All-caps message is a mild spam signal
|
||||
const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1);
|
||||
if (upperRatio > 0.6 && data.message.length > 10) score += 15;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean {
|
||||
return scoreSpam(data) >= 50;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
isoBase64URL,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
} from '@simplewebauthn/server';
|
||||
import db from './db';
|
||||
import { generateId } from './auth';
|
||||
|
||||
function getRpConfig() {
|
||||
return {
|
||||
rpName: process.env.WEBAUTHN_RP_NAME ?? 'Cozy Den',
|
||||
rpID: process.env.WEBAUTHN_RP_ID ?? 'localhost',
|
||||
origin: process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4321',
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredCredential {
|
||||
id: number;
|
||||
credential_id: string;
|
||||
public_key: Buffer;
|
||||
counter: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
backed_up: number;
|
||||
transports: string | null;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export function hasCredentials(): boolean {
|
||||
const row = db.prepare(`SELECT id FROM webauthn_credentials LIMIT 1`).get();
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
export function getStoredCredentials(): StoredCredential[] {
|
||||
return db.prepare(`SELECT * FROM webauthn_credentials`).all() as StoredCredential[];
|
||||
}
|
||||
|
||||
export function saveChallenge(challengeId: string, challenge: string, type: 'registration' | 'authentication'): void {
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 min
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO webauthn_challenges (id, challenge, type, expires_at) VALUES (?, ?, ?, ?)`
|
||||
).run(challengeId, challenge, type, expiresAt);
|
||||
}
|
||||
|
||||
export function consumeChallenge(challengeId: string, type: 'registration' | 'authentication'): string | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT challenge FROM webauthn_challenges
|
||||
WHERE id = ? AND type = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
||||
)
|
||||
.get(challengeId, type) as { challenge: string } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
db.prepare(`DELETE FROM webauthn_challenges WHERE id = ?`).run(challengeId);
|
||||
return row.challenge;
|
||||
}
|
||||
|
||||
export async function createRegistrationOptions(challengeId: string) {
|
||||
const { rpName, rpID } = getRpConfig();
|
||||
const userId = 'admin';
|
||||
|
||||
const existingCredentials = getStoredCredentials().map((c) => ({
|
||||
id: isoBase64URL.toBuffer(c.credential_id),
|
||||
type: 'public-key' as const,
|
||||
transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined,
|
||||
}));
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: Buffer.from(userId),
|
||||
userName: 'admin',
|
||||
userDisplayName: 'Cozy Den Admin',
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingCredentials,
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'cross-platform', // security key (YubiKey)
|
||||
residentKey: 'discouraged',
|
||||
userVerification: 'discouraged',
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
saveChallenge(challengeId, options.challenge, 'registration');
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
credential: RegistrationResponseJSON,
|
||||
challengeId: string
|
||||
): Promise<{ verified: boolean; error?: string }> {
|
||||
const { rpID, origin } = getRpConfig();
|
||||
const expectedChallenge = consumeChallenge(challengeId, 'registration');
|
||||
|
||||
if (!expectedChallenge) {
|
||||
return { verified: false, error: 'Challenge expired or not found' };
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
|
||||
const transports = credential.response.transports ?? [];
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO webauthn_credentials
|
||||
(credential_id, public_key, counter, user_id, user_name, backed_up, transports)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
isoBase64URL.fromBuffer(credentialID),
|
||||
Buffer.from(credentialPublicKey),
|
||||
counter,
|
||||
'admin',
|
||||
'admin',
|
||||
credentialBackedUp ? 1 : 0,
|
||||
transports.length > 0 ? JSON.stringify(transports) : null
|
||||
);
|
||||
}
|
||||
|
||||
return { verified: verification.verified };
|
||||
}
|
||||
|
||||
export async function createAuthenticationOptions(challengeId: string) {
|
||||
const { rpID } = getRpConfig();
|
||||
|
||||
const allowCredentials = getStoredCredentials().map((c) => ({
|
||||
id: isoBase64URL.toBuffer(c.credential_id),
|
||||
type: 'public-key' as const,
|
||||
transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined,
|
||||
}));
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'discouraged',
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
saveChallenge(challengeId, options.challenge, 'authentication');
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyAuthentication(
|
||||
credential: AuthenticationResponseJSON,
|
||||
challengeId: string
|
||||
): Promise<{ verified: boolean; userId?: string; error?: string }> {
|
||||
const { rpID, origin } = getRpConfig();
|
||||
const expectedChallenge = consumeChallenge(challengeId, 'authentication');
|
||||
|
||||
if (!expectedChallenge) {
|
||||
return { verified: false, error: 'Challenge expired or not found' };
|
||||
}
|
||||
|
||||
const credentialId = credential.id;
|
||||
const stored = db
|
||||
.prepare(`SELECT * FROM webauthn_credentials WHERE credential_id = ?`)
|
||||
.get(credentialId) as StoredCredential | undefined;
|
||||
|
||||
if (!stored) {
|
||||
return { verified: false, error: 'Credential not found' };
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: isoBase64URL.toBuffer(stored.credential_id),
|
||||
credentialPublicKey: Uint8Array.from(stored.public_key),
|
||||
counter: stored.counter,
|
||||
transports: stored.transports
|
||||
? (JSON.parse(stored.transports) as AuthenticatorTransportFuture[])
|
||||
: undefined,
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
|
||||
if (verification.verified && verification.authenticationInfo) {
|
||||
db.prepare(
|
||||
`UPDATE webauthn_credentials
|
||||
SET counter = ?, last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE credential_id = ?`
|
||||
).run(verification.authenticationInfo.newCounter, credentialId);
|
||||
}
|
||||
|
||||
return { verified: verification.verified, userId: stored.user_id };
|
||||
}
|
||||
Reference in New Issue
Block a user