From 57c1478cb5335a727f7a4ab13c4cc9b7f610c12b Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 7 Mar 2026 21:19:00 +0100 Subject: [PATCH] Implement token-based admin login and remove WebAuthn support --- .env.example | 15 +- CLAUDE.md | 8 +- Dockerfile | 4 +- docker-compose.yml | 5 +- docs/guestbook.md | 182 +++++----------- package.json | 2 - src/lib/auth.ts | 14 +- src/lib/db.ts | 22 -- src/lib/webauthn.ts | 205 ------------------ src/pages/admin/login.astro | 174 ++++----------- src/pages/api/admin/token-login.ts | 37 ++++ src/pages/api/admin/webauthn/login-options.ts | 23 -- src/pages/api/admin/webauthn/login-verify.ts | 49 ----- .../api/admin/webauthn/register-options.ts | 24 -- .../api/admin/webauthn/register-verify.ts | 48 ---- 15 files changed, 158 insertions(+), 654 deletions(-) delete mode 100644 src/lib/webauthn.ts create mode 100644 src/pages/api/admin/token-login.ts delete mode 100644 src/pages/api/admin/webauthn/login-options.ts delete mode 100644 src/pages/api/admin/webauthn/login-verify.ts delete mode 100644 src/pages/api/admin/webauthn/register-options.ts delete mode 100644 src/pages/api/admin/webauthn/register-verify.ts diff --git a/.env.example b/.env.example index 733373d..ee072c2 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ # Required: random secret used to salt IP hashes and sign sessions # Generate with: openssl rand -hex 32 SECRET_KEY=replace_me_with_a_random_secret +# Required: admin login token for /admin/login +ADMIN_SECRET_TOKEN=replace_me_with_a_long_random_token -# WebAuthn / YubiKey configuration -# rpID must match the domain your site is served from (no scheme, no port) -WEBAUTHN_RP_ID=hiddenden.cafe -# Full origin including scheme (and port if non-standard) -WEBAUTHN_ORIGIN=https://hiddenden.cafe -# Human-readable name shown in the YubiKey prompt -WEBAUTHN_RP_NAME=Cozy Den +# Optional: force cookie secure behavior (`true` or `false`) +# Leave unset for automatic behavior based on NODE_ENV +# COOKIE_SECURE= # Database path (Docker mounts /data as a named volume) DB_PATH=/data/guestbook.db @@ -19,6 +17,5 @@ PORT=3000 # --- Development overrides --- # For local dev (npm run dev), override with: -# WEBAUTHN_RP_ID=localhost -# WEBAUTHN_ORIGIN=http://localhost:4321 +# COOKIE_SECURE=false # DB_PATH=./data/guestbook.db diff --git a/CLAUDE.md b/CLAUDE.md index c8e5d8d..7bab2c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,14 +43,14 @@ cozy-den/ Astro **hybrid SSR** site — most pages are statically pre-rendered, but guestbook and admin pages are server-rendered: - Layouts in `src/layouts/` for reusable page templates - Pages in `src/pages/` (routes automatically based on filename) -- Server-side lib code in `src/lib/` (db, auth, guestbook, webauthn, spam) -- API routes in `src/pages/api/` for form handling, WebAuthn, and admin actions +- Server-side lib code in `src/lib/` (db, auth, guestbook, spam) +- API routes in `src/pages/api/` for form handling and admin actions - CSS custom properties centralized in `BaseLayout.astro` for theming - `output: 'hybrid'` + `@astrojs/node` adapter — Node.js standalone server in production -- SQLite database (better-sqlite3) for guestbook entries, sessions, and credentials +- SQLite database (better-sqlite3) for guestbook entries and admin sessions - Docker runtime is now Node.js (not Nginx); see `docs/guestbook.md` for setup -**Guestbook:** See `docs/guestbook.md` for full setup, YubiKey registration, and deployment notes. +**Guestbook:** See `docs/guestbook.md` for full setup, token login, and deployment notes. ## Commands diff --git a/Dockerfile b/Dockerfile index 57465fa..31407f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN apk add --no-cache python3 make g++ COPY package*.json ./ -RUN npm ci +RUN npm install COPY . . RUN npm run build @@ -20,7 +20,7 @@ WORKDIR /app RUN apk add --no-cache python3 make g++ COPY package*.json ./ -RUN npm ci --omit=dev +RUN npm install --omit=dev # Stage 3: Runtime image FROM node:20-alpine AS runtime diff --git a/docker-compose.yml b/docker-compose.yml index 3c04222..d20523d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,8 @@ services: - PORT=3000 - DB_PATH=/data/guestbook.db - SECRET_KEY=${SECRET_KEY} - - WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-hiddenden.cafe} - - WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-Cozy Den} - - WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://hiddenden.cafe} + - ADMIN_SECRET_TOKEN=${ADMIN_SECRET_TOKEN} + - COOKIE_SECURE=${COOKIE_SECURE:-true} volumes: - guestbook_data:/data diff --git a/docs/guestbook.md b/docs/guestbook.md index 20e09dd..e6db9fb 100644 --- a/docs/guestbook.md +++ b/docs/guestbook.md @@ -2,176 +2,112 @@ ## Architecture summary -The guestbook extends the existing Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter. +The guestbook extends the Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter. -- All existing pages remain statically pre-rendered (no behavior change). -- New guestbook and admin pages are server-rendered on request (`export const prerender = false`). -- API routes handle form submissions, WebAuthn flows, and moderation actions. -- SQLite (via `better-sqlite3`) provides zero-dependency persistent storage. -- The Docker image now runs a Node.js process instead of Nginx serving static files. +- Existing content pages remain static. +- Guestbook and admin pages are server-rendered (`export const prerender = false`). +- API routes handle submissions, moderation, and token-based admin login. +- SQLite (`better-sqlite3`) stores entries, sessions, rate-limit data, and audit logs. -## New files +## Relevant files ``` src/ lib/ - db.ts — SQLite singleton, schema initialization - guestbook.ts — Entry CRUD, rate limiting, pagination - auth.ts — Session management, IP hashing - webauthn.ts — WebAuthn registration + authentication (server) - spam.ts — Input sanitization, validation, heuristic spam scoring + db.ts — SQLite singleton + schema + guestbook.ts — Entry CRUD, pagination, moderation reads + auth.ts — Session management + cookie policy + spam.ts — Validation + heuristic spam scoring pages/ - guestbook.astro — Public guestbook page (SSR) + guestbook.astro — Public guestbook page admin/ - index.astro — Moderation portal (SSR, session-gated) - login.astro — YubiKey login / first-time registration - api/ - guestbook/ - submit.ts — POST: public submission handler - admin/ - webauthn/ - register-options.ts — GET: generate registration options - register-verify.ts — POST: verify and store YubiKey credential - login-options.ts — GET: generate authentication challenge - login-verify.ts — POST: verify authentication, create session - moderate.ts — POST: approve / reject / mark spam - logout.ts — POST: end admin session + index.astro — Moderation portal (session-gated) + login.astro — Token login form + pages/api/ + guestbook/submit.ts — POST: public guestbook submission + admin/token-login.ts — POST: token authentication + session creation + admin/moderate.ts — POST: approve / reject / spam + admin/logout.ts — POST: end admin session layouts/ - AdminLayout.astro — Minimal admin UI layout -.env.example -docs/guestbook.md — This file + AdminLayout.astro — Minimal admin UI layout ``` ## Environment variables -Copy `.env.example` to `.env` and fill in: +Copy `.env.example` to `.env` and set: | Variable | Required | Description | |---|---|---| -| `SECRET_KEY` | **Yes** | 32+ char random secret for IP hash salting and sessions | -| `WEBAUTHN_RP_ID` | **Yes** | Domain without scheme (e.g. `hiddenden.cafe`) | -| `WEBAUTHN_ORIGIN` | **Yes** | Full origin (e.g. `https://hiddenden.cafe`) | -| `WEBAUTHN_RP_NAME` | No | Display name in YubiKey prompt (default: `Cozy Den`) | -| `DB_PATH` | No | Path to SQLite file (default: `./data/guestbook.db`) | +| `SECRET_KEY` | **Yes** | Random secret for IP-hash salting and session-related values | +| `ADMIN_SECRET_TOKEN` | **Yes** | Shared secret token for `/admin/login` | +| `COOKIE_SECURE` | No | Force secure cookies (`true`/`false`). If unset, `NODE_ENV=production` => secure cookies | +| `DB_PATH` | No | SQLite path (default: `./data/guestbook.db`) | | `PORT` | No | Server port (default: `3000`) | -| `HOST` | No | Bind address (default: `0.0.0.0`) | +| `HOST` | No | Bind host (default: `0.0.0.0`) | -Generate a `SECRET_KEY`: -```bash -openssl rand -hex 32 -``` - -## First-time admin setup (YubiKey registration) - -1. Deploy the site with correct `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` env vars. -2. Visit `https://hiddenden.cafe/admin/login` in your browser. -3. Since no credentials are registered yet, you will see **"Register your security key"**. -4. Insert your YubiKey, click the button, touch the key when it flashes. -5. After successful registration, the page reloads to login mode. -6. Use the same key to log in — touch when prompted. -7. You are now in the moderation portal at `/admin`. - -**Security note:** Registration is only possible when no credentials exist in the database. -Once registered, the registration endpoint returns `403 Forbidden`. To re-register (e.g. after a lost key), you must clear the `webauthn_credentials` table in the SQLite database: +Generate secrets: ```bash -# Access the database inside Docker -docker exec -it cozy-den sh -sqlite3 /data/guestbook.db "DELETE FROM webauthn_credentials;" +openssl rand -hex 32 # SECRET_KEY +openssl rand -hex 32 # ADMIN_SECRET_TOKEN ``` -Then visit `/admin/login` again to register a new key. +## Admin setup + +1. Set `ADMIN_SECRET_TOKEN` in your environment. +2. Open `/admin/login`. +3. Enter token. +4. After success, you are redirected to `/admin`. + +If token is missing, `/admin/login` shows a configuration warning and login is disabled. ## Local development ```bash -# Install dependencies npm install - -# Create a local .env file cp .env.example .env -# Edit .env and set: -# SECRET_KEY=any-random-string-here -# WEBAUTHN_RP_ID=localhost -# WEBAUTHN_ORIGIN=http://localhost:4321 -# DB_PATH=./data/guestbook.db +# set at minimum: +# SECRET_KEY=... +# ADMIN_SECRET_TOKEN=... +# DB_PATH=./data/guestbook.db +# COOKIE_SECURE=false # for local http -# Run dev server npm run dev -# Visit http://localhost:4321/guestbook -# Admin portal: http://localhost:4321/admin/login +# guestbook: http://localhost:4321/guestbook +# admin: http://localhost:4321/admin/login ``` -**WebAuthn in local dev:** Most browsers require HTTPS for WebAuthn, with one exception: -`localhost` (not `127.0.0.1`) is treated as a secure origin. Make sure `WEBAUTHN_RP_ID=localhost` -and `WEBAUTHN_ORIGIN=http://localhost:4321` when developing locally. - ## Docker deployment ```bash -# Build and run docker compose up -d --build - -# The app is available on port 3000 -# Point your reverse proxy (Nginx, Caddy) to http://localhost:3000 -# Make sure to set X-Forwarded-For and X-Real-IP headers for rate limiting - -# View logs docker compose logs -f cozy-den - -# Backup the database -docker run --rm -v cozy_den_guestbook_data:/data -v $(pwd):/backup alpine \ - tar czf /backup/guestbook-backup.tar.gz /data ``` -The `guestbook_data` Docker volume persists the SQLite database across container restarts and updates. +The `guestbook_data` Docker volume persists the SQLite database. ## Moderation flow -1. Visitor submits a message at `/guestbook` → stored as `status = 'pending'` -2. Admin logs in at `/admin/login` with YubiKey -3. Admin sees pending messages at `/admin` -4. Admin clicks **approve**, **reject**, or **spam** -5. Optional: add an internal note before moderating -6. Approved messages appear on `/guestbook` - -Messages auto-marked as spam (by heuristic scoring) skip the pending queue and are silently rejected. - -## Spam protection - -- **Honeypot field:** Hidden `` — bots fill it, humans don't. -- **Rate limiting:** Max 3 submissions per IP hash per hour (stored in SQLite). -- **Duplicate detection:** Exact name+message matches within 24 hours are silently deduped. -- **Heuristic scoring:** Pattern matching for known spam keywords, URL counts, all-caps messages. -- **Server-side validation:** All fields sanitized, HTML stripped, length limits enforced. -- **Moderation gate:** No message appears publicly without admin approval. +1. Visitor submits message at `/guestbook`. +2. Entry is saved as `pending`. +3. Admin logs in at `/admin/login` with token. +4. Admin approves/rejects/marks spam in `/admin`. +5. Approved entries are shown publicly. ## Privacy decisions -- IP addresses are **never stored**. A salted SHA-256 hash (truncated to 16 hex chars) is stored for rate limiting only. -- The `SECRET_KEY` env var is the salt — changing it invalidates all stored hashes (rate limits reset; this is acceptable). -- No user accounts, no tracking, no cookies for public visitors. -- Admin sessions use `httpOnly`, `SameSite=Strict` cookies. -- Guestbook entries are plain text only; HTML is stripped before storage. -- The admin portal is `noindex, nofollow` — not discoverable by search engines. +- IP addresses are never stored directly. +- A truncated salted hash is stored only for rate limiting. +- No tracking scripts or third-party analytics. +- Admin session cookie is `httpOnly` and `SameSite=Strict`. +- User content is stored as plain text (HTML stripped server-side). -## Database schema overview +## Database tables | Table | Purpose | |---|---| -| `guestbook_entries` | All submissions (pending/approved/rejected/spam) | -| `webauthn_credentials` | Registered YubiKey credentials | -| `admin_sessions` | Active admin login sessions (expire after 24h) | -| `webauthn_challenges` | Temporary WebAuthn challenges (expire after 5 min) | -| `rate_limit` | Per-IP-hash submission timestamps | -| `audit_log` | Record of all moderation actions | +| `guestbook_entries` | Submissions + moderation status | +| `admin_sessions` | Active admin sessions | +| `rate_limit` | Submission throttling by IP hash | +| `audit_log` | Moderation actions | -## Deployment note: nginx and CSP - -The original `nginx.conf` served static files directly. Now that the app is a Node.js server, -the nginx config has been updated to a **reverse proxy** pattern. - -If you run your own Nginx/Caddy in front: -- Pass `X-Forwarded-For` and `X-Real-IP` headers so rate limiting works correctly. -- WebAuthn requires that the `Origin` header matches `WEBAUTHN_ORIGIN` exactly. -- The Content-Security-Policy should allow `script-src 'self'` for the WebAuthn client JS (it's bundled by Astro, served from self). diff --git a/package.json b/package.json index d3e92b0..53b9d37 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,6 @@ "@astrojs/sitemap": "^3.2.2", "@astrojs/node": "^8.3.4", "better-sqlite3": "^9.4.3", - "@simplewebauthn/server": "^9.0.3", - "@simplewebauthn/browser": "^9.0.1", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 75e78ab..0cafdc1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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, diff --git a/src/lib/db.ts b/src/lib/db.ts index f330b58..e3ae8bf 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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; diff --git a/src/lib/webauthn.ts b/src/lib/webauthn.ts deleted file mode 100644 index 98ad91b..0000000 --- a/src/lib/webauthn.ts +++ /dev/null @@ -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 }; -} diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro index 31fc027..db12b49 100644 --- a/src/pages/admin/login.astro +++ b/src/pages/admin/login.astro @@ -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'; --- - -{registrationMode ? ( - -) : ( - -)} - - + + .token-label { + color: var(--color-text-dim); + font-size: 0.82rem; + } + + .token-input { + width: 100%; + max-width: 360px; + } + + diff --git a/src/pages/api/admin/token-login.ts b/src/pages/api/admin/token-login.ts new file mode 100644 index 0000000..5eb71e6 --- /dev/null +++ b/src/pages/api/admin/token-login.ts @@ -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' }, + }); +}; diff --git a/src/pages/api/admin/webauthn/login-options.ts b/src/pages/api/admin/webauthn/login-options.ts deleted file mode 100644 index b189aa7..0000000 --- a/src/pages/api/admin/webauthn/login-options.ts +++ /dev/null @@ -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' }, - }); -}; diff --git a/src/pages/api/admin/webauthn/login-verify.ts b/src/pages/api/admin/webauthn/login-verify.ts deleted file mode 100644 index 1997177..0000000 --- a/src/pages/api/admin/webauthn/login-verify.ts +++ /dev/null @@ -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' }, - }); -}; diff --git a/src/pages/api/admin/webauthn/register-options.ts b/src/pages/api/admin/webauthn/register-options.ts deleted file mode 100644 index c27b180..0000000 --- a/src/pages/api/admin/webauthn/register-options.ts +++ /dev/null @@ -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' }, - }); -}; diff --git a/src/pages/api/admin/webauthn/register-verify.ts b/src/pages/api/admin/webauthn/register-verify.ts deleted file mode 100644 index c64ad77..0000000 --- a/src/pages/api/admin/webauthn/register-verify.ts +++ /dev/null @@ -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' }, - }); -};