Implement token-based admin login and remove WebAuthn support
This commit is contained in:
+9
-5
@@ -50,18 +50,22 @@ 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';
|
||||
|
||||
function shouldUseSecureCookies(): boolean {
|
||||
const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase();
|
||||
if (secureOverride === 'true') return true;
|
||||
if (secureOverride === 'false') return false;
|
||||
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(maxAge: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: shouldUseSecureCookies(),
|
||||
sameSite: 'strict' as const,
|
||||
path: '/',
|
||||
maxAge,
|
||||
|
||||
@@ -26,19 +26,6 @@ db.exec(`
|
||||
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,
|
||||
@@ -46,14 +33,6 @@ db.exec(`
|
||||
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,
|
||||
@@ -73,7 +52,6 @@ db.exec(`
|
||||
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;
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
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