Implement token-based admin login and remove WebAuthn support

This commit is contained in:
2026-03-07 21:19:00 +01:00
parent 88e00e5d41
commit 57c1478cb5
15 changed files with 158 additions and 654 deletions
+9 -5
View File
@@ -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,
-22
View File
@@ -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;
-205
View File
@@ -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 };
}
+39 -135
View File
@@ -3,7 +3,6 @@ export const prerender = false;
import AdminLayout from '../../layouts/AdminLayout.astro';
import { getSession, SESSION_COOKIE } from '../../lib/auth';
import { hasCredentials } from '../../lib/webauthn';
// Redirect if already logged in
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
@@ -12,151 +11,47 @@ if (session) {
return Astro.redirect('/admin');
}
const registrationMode = !hasCredentials();
const tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim());
const tokenError = Astro.url.searchParams.get('tokenError') === '1';
---
<AdminLayout title="Login">
<div class="login-wrap">
<h1>admin access</h1>
{registrationMode ? (
{tokenAuthEnabled ? (
<div class="card">
<h2>Register your security key</h2>
<h2>Token login</h2>
<p class="info-text">
No admin credentials are registered yet. Connect your YubiKey and click the button
below to register it as the admin credential for this site.
Enter your admin token to access moderation.
</p>
<p class="warning-text">
Do this immediately after deployment — once registered, no further registrations
will be accepted without resetting the database.
</p>
<button id="register-btn" class="btn btn-primary" type="button">
Register YubiKey
</button>
<p id="register-status" class="status-text" aria-live="polite"></p>
{tokenError && (
<p class="warning-text">Invalid token. Try again.</p>
)}
<form method="post" action="/api/admin/token-login" class="token-form">
<label for="token-input" class="token-label">Admin token</label>
<input
id="token-input"
name="token"
type="password"
autocomplete="current-password"
required
class="token-input"
/>
<button type="submit" class="btn btn-primary">Sign in with token</button>
</form>
</div>
) : (
<div class="card">
<h2>Login with your security key</h2>
<p class="info-text">
Insert your YubiKey and click the button below to authenticate.
<h2>Token not configured</h2>
<p class="warning-text">
<code>ADMIN_SECRET_TOKEN</code> is not set. Configure it in your environment, then reload this page.
</p>
<button id="login-btn" class="btn btn-primary" type="button">
Authenticate with YubiKey
</button>
<p id="login-status" class="status-text" aria-live="polite"></p>
</div>
)}
</div>
</AdminLayout>
{registrationMode ? (
<script>
import { startRegistration } from '@simplewebauthn/browser';
const btn = document.getElementById('register-btn') as HTMLButtonElement;
const status = document.getElementById('register-status') as HTMLParagraphElement;
btn.addEventListener('click', async () => {
btn.disabled = true;
status.textContent = 'Requesting registration options...';
try {
const optRes = await fetch('/api/admin/webauthn/register-options');
if (!optRes.ok) {
const err = await optRes.json();
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
btn.disabled = false;
return;
}
const options = await optRes.json();
status.textContent = 'Touch your YubiKey when it flashes...';
let credential;
try {
credential = await startRegistration(options);
} catch (e: any) {
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
btn.disabled = false;
return;
}
status.textContent = 'Verifying credential...';
const verifyRes = await fetch('/api/admin/webauthn/register-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
const result = await verifyRes.json();
if (result.verified) {
status.textContent = 'Success! Redirecting to login...';
setTimeout(() => { window.location.href = '/admin/login'; }, 1000);
} else {
status.textContent = `Registration failed: ${result.error ?? 'Unknown error'}`;
btn.disabled = false;
}
} catch (e: any) {
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
btn.disabled = false;
}
});
</script>
) : (
<script>
import { startAuthentication } from '@simplewebauthn/browser';
const btn = document.getElementById('login-btn') as HTMLButtonElement;
const status = document.getElementById('login-status') as HTMLParagraphElement;
btn.addEventListener('click', async () => {
btn.disabled = true;
status.textContent = 'Requesting authentication options...';
try {
const optRes = await fetch('/api/admin/webauthn/login-options');
if (!optRes.ok) {
const err = await optRes.json();
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
btn.disabled = false;
return;
}
const options = await optRes.json();
status.textContent = 'Touch your YubiKey when it flashes...';
let credential;
try {
credential = await startAuthentication(options);
} catch (e: any) {
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
btn.disabled = false;
return;
}
status.textContent = 'Verifying...';
const verifyRes = await fetch('/api/admin/webauthn/login-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
const result = await verifyRes.json();
if (result.verified) {
status.textContent = 'Authenticated! Redirecting...';
window.location.href = '/admin';
} else {
status.textContent = `Authentication failed: ${result.error ?? 'Unknown error'}`;
btn.disabled = false;
}
} catch (e: any) {
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
btn.disabled = false;
}
});
</script>
)}
<style>
<style>
.login-wrap {
max-width: 480px;
margin: 0 auto;
@@ -195,10 +90,19 @@ const registrationMode = !hasCredentials();
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { color: var(--color-accent); }
.status-text {
margin-top: var(--space-sm);
font-size: 0.85rem;
color: var(--color-text-dim);
min-height: 1.4em;
.token-form {
display: grid;
gap: var(--space-xs);
}
</style>
.token-label {
color: var(--color-text-dim);
font-size: 0.82rem;
}
.token-input {
width: 100%;
max-width: 360px;
}
</style>
</AdminLayout>
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { timingSafeEqual } from 'node:crypto';
import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth';
export const prerender = false;
function tokenMatches(input: string, expected: string): boolean {
const a = Buffer.from(input);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export const POST: APIRoute = async ({ request, cookies }) => {
const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim();
if (!expectedToken) {
return new Response(null, { status: 404 });
}
const formData = await request.formData();
const token = String(formData.get('token') ?? '').trim();
if (!tokenMatches(token, expectedToken)) {
return new Response(null, {
status: 303,
headers: { Location: '/admin/login?tokenError=1' },
});
}
const sessionId = createSession('admin');
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
return new Response(null, {
status: 303,
headers: { Location: '/admin' },
});
};
@@ -1,23 +0,0 @@
import type { APIRoute } from 'astro';
import { createAuthenticationOptions, hasCredentials } from '../../../../lib/webauthn';
import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
export const prerender = false;
export const GET: APIRoute = async ({ cookies }) => {
if (!hasCredentials()) {
return new Response(JSON.stringify({ error: 'No credentials registered' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const challengeId = generateId();
const options = await createAuthenticationOptions(challengeId);
cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60));
return new Response(JSON.stringify(options), {
headers: { 'Content-Type': 'application/json' },
});
};
@@ -1,49 +0,0 @@
import type { APIRoute } from 'astro';
import { verifyAuthentication } from '../../../../lib/webauthn';
import {
createSession,
CHALLENGE_COOKIE,
SESSION_COOKIE,
sessionCookieOptions,
} from '../../../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies }) => {
const challengeId = cookies.get(CHALLENGE_COOKIE)?.value;
if (!challengeId) {
return new Response(JSON.stringify({ error: 'No challenge found' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
let credential;
try {
credential = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const result = await verifyAuthentication(credential, challengeId);
// Clear challenge cookie
cookies.delete(CHALLENGE_COOKIE, { path: '/' });
if (result.verified && result.userId) {
const sessionId = createSession(result.userId);
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
return new Response(JSON.stringify({ verified: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Authentication failed' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
};
@@ -1,24 +0,0 @@
import type { APIRoute } from 'astro';
import { createRegistrationOptions, hasCredentials } from '../../../../lib/webauthn';
import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
export const prerender = false;
export const GET: APIRoute = async ({ cookies }) => {
// Only allow registration when no credentials exist yet
if (hasCredentials()) {
return new Response(JSON.stringify({ error: 'Admin credential already registered' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const challengeId = generateId();
const options = await createRegistrationOptions(challengeId);
cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60));
return new Response(JSON.stringify(options), {
headers: { 'Content-Type': 'application/json' },
});
};
@@ -1,48 +0,0 @@
import type { APIRoute } from 'astro';
import { verifyRegistration, hasCredentials } from '../../../../lib/webauthn';
import { CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies }) => {
if (hasCredentials()) {
return new Response(JSON.stringify({ error: 'Admin credential already registered' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const challengeId = cookies.get(CHALLENGE_COOKIE)?.value;
if (!challengeId) {
return new Response(JSON.stringify({ error: 'No challenge found' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
let credential;
try {
credential = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const result = await verifyRegistration(credential, challengeId);
// Clear the challenge cookie
cookies.delete(CHALLENGE_COOKIE, { path: '/' });
if (result.verified) {
return new Response(JSON.stringify({ verified: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Verification failed' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
};