Implement token-based admin login and remove WebAuthn support
This commit is contained in:
+59
-123
@@ -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 `<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.
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user