Implement token-based admin login and remove WebAuthn support
This commit is contained in:
+6
-9
@@ -1,14 +1,12 @@
|
|||||||
# Required: random secret used to salt IP hashes and sign sessions
|
# Required: random secret used to salt IP hashes and sign sessions
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
SECRET_KEY=replace_me_with_a_random_secret
|
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
|
# Optional: force cookie secure behavior (`true` or `false`)
|
||||||
# rpID must match the domain your site is served from (no scheme, no port)
|
# Leave unset for automatic behavior based on NODE_ENV
|
||||||
WEBAUTHN_RP_ID=hiddenden.cafe
|
# COOKIE_SECURE=
|
||||||
# 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
|
|
||||||
|
|
||||||
# Database path (Docker mounts /data as a named volume)
|
# Database path (Docker mounts /data as a named volume)
|
||||||
DB_PATH=/data/guestbook.db
|
DB_PATH=/data/guestbook.db
|
||||||
@@ -19,6 +17,5 @@ PORT=3000
|
|||||||
|
|
||||||
# --- Development overrides ---
|
# --- Development overrides ---
|
||||||
# For local dev (npm run dev), override with:
|
# For local dev (npm run dev), override with:
|
||||||
# WEBAUTHN_RP_ID=localhost
|
# COOKIE_SECURE=false
|
||||||
# WEBAUTHN_ORIGIN=http://localhost:4321
|
|
||||||
# DB_PATH=./data/guestbook.db
|
# DB_PATH=./data/guestbook.db
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ cozy-den/
|
|||||||
Astro **hybrid SSR** site — most pages are statically pre-rendered, but guestbook and admin pages are server-rendered:
|
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
|
- Layouts in `src/layouts/` for reusable page templates
|
||||||
- Pages in `src/pages/` (routes automatically based on filename)
|
- Pages in `src/pages/` (routes automatically based on filename)
|
||||||
- Server-side lib code in `src/lib/` (db, auth, guestbook, webauthn, spam)
|
- Server-side lib code in `src/lib/` (db, auth, guestbook, spam)
|
||||||
- API routes in `src/pages/api/` for form handling, WebAuthn, and admin actions
|
- API routes in `src/pages/api/` for form handling and admin actions
|
||||||
- CSS custom properties centralized in `BaseLayout.astro` for theming
|
- CSS custom properties centralized in `BaseLayout.astro` for theming
|
||||||
- `output: 'hybrid'` + `@astrojs/node` adapter — Node.js standalone server in production
|
- `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
|
- 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
|
## Commands
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -20,7 +20,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Stage 3: Runtime image
|
# Stage 3: Runtime image
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
|
|||||||
+2
-3
@@ -11,9 +11,8 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
- DB_PATH=/data/guestbook.db
|
- DB_PATH=/data/guestbook.db
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-hiddenden.cafe}
|
- ADMIN_SECRET_TOKEN=${ADMIN_SECRET_TOKEN}
|
||||||
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-Cozy Den}
|
- COOKIE_SECURE=${COOKIE_SECURE:-true}
|
||||||
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://hiddenden.cafe}
|
|
||||||
volumes:
|
volumes:
|
||||||
- guestbook_data:/data
|
- guestbook_data:/data
|
||||||
|
|
||||||
|
|||||||
+57
-121
@@ -2,176 +2,112 @@
|
|||||||
|
|
||||||
## Architecture summary
|
## 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).
|
- Existing content pages remain static.
|
||||||
- New guestbook and admin pages are server-rendered on request (`export const prerender = false`).
|
- Guestbook and admin pages are server-rendered (`export const prerender = false`).
|
||||||
- API routes handle form submissions, WebAuthn flows, and moderation actions.
|
- API routes handle submissions, moderation, and token-based admin login.
|
||||||
- SQLite (via `better-sqlite3`) provides zero-dependency persistent storage.
|
- SQLite (`better-sqlite3`) stores entries, sessions, rate-limit data, and audit logs.
|
||||||
- The Docker image now runs a Node.js process instead of Nginx serving static files.
|
|
||||||
|
|
||||||
## New files
|
## Relevant files
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
lib/
|
lib/
|
||||||
db.ts — SQLite singleton, schema initialization
|
db.ts — SQLite singleton + schema
|
||||||
guestbook.ts — Entry CRUD, rate limiting, pagination
|
guestbook.ts — Entry CRUD, pagination, moderation reads
|
||||||
auth.ts — Session management, IP hashing
|
auth.ts — Session management + cookie policy
|
||||||
webauthn.ts — WebAuthn registration + authentication (server)
|
spam.ts — Validation + heuristic spam scoring
|
||||||
spam.ts — Input sanitization, validation, heuristic spam scoring
|
|
||||||
pages/
|
pages/
|
||||||
guestbook.astro — Public guestbook page (SSR)
|
guestbook.astro — Public guestbook page
|
||||||
admin/
|
admin/
|
||||||
index.astro — Moderation portal (SSR, session-gated)
|
index.astro — Moderation portal (session-gated)
|
||||||
login.astro — YubiKey login / first-time registration
|
login.astro — Token login form
|
||||||
api/
|
pages/api/
|
||||||
guestbook/
|
guestbook/submit.ts — POST: public guestbook submission
|
||||||
submit.ts — POST: public submission handler
|
admin/token-login.ts — POST: token authentication + session creation
|
||||||
admin/
|
admin/moderate.ts — POST: approve / reject / spam
|
||||||
webauthn/
|
admin/logout.ts — POST: end admin session
|
||||||
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
|
|
||||||
layouts/
|
layouts/
|
||||||
AdminLayout.astro — Minimal admin UI layout
|
AdminLayout.astro — Minimal admin UI layout
|
||||||
.env.example
|
|
||||||
docs/guestbook.md — This file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill in:
|
Copy `.env.example` to `.env` and set:
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SECRET_KEY` | **Yes** | 32+ char random secret for IP hash salting and sessions |
|
| `SECRET_KEY` | **Yes** | Random secret for IP-hash salting and session-related values |
|
||||||
| `WEBAUTHN_RP_ID` | **Yes** | Domain without scheme (e.g. `hiddenden.cafe`) |
|
| `ADMIN_SECRET_TOKEN` | **Yes** | Shared secret token for `/admin/login` |
|
||||||
| `WEBAUTHN_ORIGIN` | **Yes** | Full origin (e.g. `https://hiddenden.cafe`) |
|
| `COOKIE_SECURE` | No | Force secure cookies (`true`/`false`). If unset, `NODE_ENV=production` => secure cookies |
|
||||||
| `WEBAUTHN_RP_NAME` | No | Display name in YubiKey prompt (default: `Cozy Den`) |
|
| `DB_PATH` | No | SQLite path (default: `./data/guestbook.db`) |
|
||||||
| `DB_PATH` | No | Path to SQLite file (default: `./data/guestbook.db`) |
|
|
||||||
| `PORT` | No | Server port (default: `3000`) |
|
| `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`:
|
Generate secrets:
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Access the database inside Docker
|
openssl rand -hex 32 # SECRET_KEY
|
||||||
docker exec -it cozy-den sh
|
openssl rand -hex 32 # ADMIN_SECRET_TOKEN
|
||||||
sqlite3 /data/guestbook.db "DELETE FROM webauthn_credentials;"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Create a local .env file
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env and set:
|
# set at minimum:
|
||||||
# SECRET_KEY=any-random-string-here
|
# SECRET_KEY=...
|
||||||
# WEBAUTHN_RP_ID=localhost
|
# ADMIN_SECRET_TOKEN=...
|
||||||
# WEBAUTHN_ORIGIN=http://localhost:4321
|
|
||||||
# DB_PATH=./data/guestbook.db
|
# DB_PATH=./data/guestbook.db
|
||||||
|
# COOKIE_SECURE=false # for local http
|
||||||
|
|
||||||
# Run dev server
|
|
||||||
npm run dev
|
npm run dev
|
||||||
# Visit http://localhost:4321/guestbook
|
# guestbook: http://localhost:4321/guestbook
|
||||||
# Admin portal: http://localhost:4321/admin/login
|
# 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
|
## Docker deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run
|
|
||||||
docker compose up -d --build
|
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
|
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
|
## Moderation flow
|
||||||
|
|
||||||
1. Visitor submits a message at `/guestbook` → stored as `status = 'pending'`
|
1. Visitor submits message at `/guestbook`.
|
||||||
2. Admin logs in at `/admin/login` with YubiKey
|
2. Entry is saved as `pending`.
|
||||||
3. Admin sees pending messages at `/admin`
|
3. Admin logs in at `/admin/login` with token.
|
||||||
4. Admin clicks **approve**, **reject**, or **spam**
|
4. Admin approves/rejects/marks spam in `/admin`.
|
||||||
5. Optional: add an internal note before moderating
|
5. Approved entries are shown publicly.
|
||||||
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 `<input name="address">` — 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.
|
|
||||||
|
|
||||||
## Privacy decisions
|
## Privacy decisions
|
||||||
|
|
||||||
- IP addresses are **never stored**. A salted SHA-256 hash (truncated to 16 hex chars) is stored for rate limiting only.
|
- IP addresses are never stored directly.
|
||||||
- The `SECRET_KEY` env var is the salt — changing it invalidates all stored hashes (rate limits reset; this is acceptable).
|
- A truncated salted hash is stored only for rate limiting.
|
||||||
- No user accounts, no tracking, no cookies for public visitors.
|
- No tracking scripts or third-party analytics.
|
||||||
- Admin sessions use `httpOnly`, `SameSite=Strict` cookies.
|
- Admin session cookie is `httpOnly` and `SameSite=Strict`.
|
||||||
- Guestbook entries are plain text only; HTML is stripped before storage.
|
- User content is stored as plain text (HTML stripped server-side).
|
||||||
- The admin portal is `noindex, nofollow` — not discoverable by search engines.
|
|
||||||
|
|
||||||
## Database schema overview
|
## Database tables
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `guestbook_entries` | All submissions (pending/approved/rejected/spam) |
|
| `guestbook_entries` | Submissions + moderation status |
|
||||||
| `webauthn_credentials` | Registered YubiKey credentials |
|
| `admin_sessions` | Active admin sessions |
|
||||||
| `admin_sessions` | Active admin login sessions (expire after 24h) |
|
| `rate_limit` | Submission throttling by IP hash |
|
||||||
| `webauthn_challenges` | Temporary WebAuthn challenges (expire after 5 min) |
|
| `audit_log` | Moderation actions |
|
||||||
| `rate_limit` | Per-IP-hash submission timestamps |
|
|
||||||
| `audit_log` | Record of all 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).
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
"@astrojs/sitemap": "^3.2.2",
|
"@astrojs/sitemap": "^3.2.2",
|
||||||
"@astrojs/node": "^8.3.4",
|
"@astrojs/node": "^8.3.4",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+9
-5
@@ -50,18 +50,22 @@ export function cleanExpiredSessions(): void {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
|
||||||
).run();
|
).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 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) {
|
export function sessionCookieOptions(maxAge: number) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: shouldUseSecureCookies(),
|
||||||
sameSite: 'strict' as const,
|
sameSite: 'strict' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge,
|
maxAge,
|
||||||
|
|||||||
@@ -26,19 +26,6 @@ db.exec(`
|
|||||||
moderation_note 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 (
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -46,14 +33,6 @@ db.exec(`
|
|||||||
expires_at TEXT NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ip_hash TEXT NOT NULL,
|
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_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_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_sessions_expires ON admin_sessions(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_challenges_expires ON webauthn_challenges(expires_at);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export default db;
|
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 };
|
|
||||||
}
|
|
||||||
+39
-135
@@ -3,7 +3,6 @@ export const prerender = false;
|
|||||||
|
|
||||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||||
import { getSession, SESSION_COOKIE } from '../../lib/auth';
|
import { getSession, SESSION_COOKIE } from '../../lib/auth';
|
||||||
import { hasCredentials } from '../../lib/webauthn';
|
|
||||||
|
|
||||||
// Redirect if already logged in
|
// Redirect if already logged in
|
||||||
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
|
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||||
@@ -12,151 +11,47 @@ if (session) {
|
|||||||
return Astro.redirect('/admin');
|
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">
|
<AdminLayout title="Login">
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<h1>admin access</h1>
|
<h1>admin access</h1>
|
||||||
|
|
||||||
{registrationMode ? (
|
{tokenAuthEnabled ? (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Register your security key</h2>
|
<h2>Token login</h2>
|
||||||
<p class="info-text">
|
<p class="info-text">
|
||||||
No admin credentials are registered yet. Connect your YubiKey and click the button
|
Enter your admin token to access moderation.
|
||||||
below to register it as the admin credential for this site.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="warning-text">
|
{tokenError && (
|
||||||
Do this immediately after deployment — once registered, no further registrations
|
<p class="warning-text">Invalid token. Try again.</p>
|
||||||
will be accepted without resetting the database.
|
)}
|
||||||
</p>
|
<form method="post" action="/api/admin/token-login" class="token-form">
|
||||||
<button id="register-btn" class="btn btn-primary" type="button">
|
<label for="token-input" class="token-label">Admin token</label>
|
||||||
Register YubiKey
|
<input
|
||||||
</button>
|
id="token-input"
|
||||||
<p id="register-status" class="status-text" aria-live="polite"></p>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Login with your security key</h2>
|
<h2>Token not configured</h2>
|
||||||
<p class="info-text">
|
<p class="warning-text">
|
||||||
Insert your YubiKey and click the button below to authenticate.
|
<code>ADMIN_SECRET_TOKEN</code> is not set. Configure it in your environment, then reload this page.
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
|
||||||
|
|
||||||
{registrationMode ? (
|
<style>
|
||||||
<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>
|
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -195,10 +90,19 @@ const registrationMode = !hasCredentials();
|
|||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary { color: var(--color-accent); }
|
.btn-primary { color: var(--color-accent); }
|
||||||
|
|
||||||
.status-text {
|
.token-form {
|
||||||
margin-top: var(--space-sm);
|
display: grid;
|
||||||
font-size: 0.85rem;
|
gap: var(--space-xs);
|
||||||
color: var(--color-text-dim);
|
|
||||||
min-height: 1.4em;
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.token-label {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</AdminLayout>
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user