# Security: audit log and two-factor login Two features for the developer portal and the admin API, implemented in `internal/audit`, `internal/auth/totp.go` and `internal/api/ext_security.go`. ## Audit log Every request that *changes* something on the admin or account surface is recorded, as is every failed authentication on those routes. **What is recorded** | Routes | Recorded when | |--------|---------------| | `/v1/admin/*` | method is POST, PUT, PATCH or DELETE, or the response is 401/403 | | `/v1/account`, `/v1/account/*` | same | | `/v1/signup` | same | | `/v1/login`, `/v1/login/2fa` | failed attempts (4xx) by the request observer; successful logins by the login handlers themselves, so the entry carries the account id | Successful `GET`s are never recorded; application-key routes (`/v1/rooms`, `/v1/tokens`, …) are out of scope of the audit log (call records are in the analytics service). **Entry fields** ```json { "id": "aud_3f9c…", "ts": "2026-10-27T10:12:03.511Z", "actor_type": "admin | owner | app | anonymous", "actor_id": "admin | acct_… | ck_test_ab… (16-char key prefix)", "method": "PATCH", "path": "/v1/admin/accounts/acct_1a2b", "status": 200, "ip": "203.0.113.9", "target": "account:acct_1a2b", "summary": "updated account (approval/status)" } ``` - `actor_type` / `actor_id` come from the credentials on the request: a valid `X-Admin-Key` → `admin`; a bearer that is a valid owner session JWT → `owner` with the account id; a bearer that starts with `ck_` → `app` with the key prefix (never the key itself); anything else (including a wrong admin key or an expired session) → `anonymous`. - `target` is derived from the path: the segment after `accounts`, `apps`, `rooms`, `consultants` or `consultations` (`account:acct_…`, `app:app_…`). Owner actions without an object in the path target the owner's own account. - `summary` is a short sentence for known routes (`rotated API key`, `enabled 2FA`, …) or `METHOD /path` otherwise; failures get ` failed (status)`. - `ip` honours `X-Forwarded-For` / `X-Real-IP` (set by the nginx front). **Retention** - Appended as one JSON object per line to `DATA_DIR/audit.log` (default `./data/audit.log`, mode 0600). The service never rewrites or truncates the file; rotate it with logrotate (`copytruncate`) or ship it to your SIEM. - The last **5000** entries are kept in memory and served by the API; at start-up the tail of the file is reloaded so a restart does not blank the admin view. Older entries remain in the file only. - With `DATA_DIR` empty (tests) the log is memory-only. **Reading it** ```bash # admin: newest first; limit ≤ 5000, actor = type (admin|owner|app|anonymous) or an actor id, path = substring curl -H "X-Admin-Key: $ADMIN_API_KEY" "$API/v1/admin/audit?limit=100&actor=owner&path=/rotate-key" # owner: their own actions plus anything that targeted their account (e.g. an admin approval or 2FA reset) curl -H "Authorization: Bearer $SESSION" "$API/v1/account/audit?limit=50" ``` Both return `{"entries":[…]}`; the admin response adds `in_memory`, the number of entries currently held. The admin dashboard has an **Audit log** card with a filter box (path substring, or an actor id such as `acct_…` / `ck_test_…`) and an actor-type selector. The portal shows **Recent activity** in its Security card. ## Two-factor login (TOTP) RFC 6238 time-based one-time passwords: HMAC-SHA1, 30-second step, 6 digits, one step of tolerance on each side (codes stay valid for roughly ±30 s of clock drift). Secrets are 160 random bits, base32 encoded, compatible with Google Authenticator, Authy, 1Password, Bitwarden, etc. **Enable (portal → Security → "Enable two-factor login")** ``` POST /v1/account/2fa/setup (owner session) → {secret, otpauth_url, enabled:false} POST /v1/account/2fa/enable {"code":"123456"} → {enabled:true, recovery_codes:[8 codes]} GET /v1/account/2fa → {enabled, pending_setup, recovery_codes_remaining} POST /v1/account/2fa/disable {"code":"123456"} → {enabled:false} (a recovery code is accepted too) ``` `setup` stores a new secret on the account but does not enable anything; the owner scans the QR code (rendered in the browser from `otpauth_url`, no third-party service) or types the secret, then confirms with a live code. `enable` verifies that code, turns 2FA on and returns **8 recovery codes** shown exactly once. Calling `setup` again before `enable` replaces the pending secret; `disable` with any body clears an abandoned setup. Wrong codes on `enable`/`disable` are limited to 5 per account per 15 minutes. **Login flow with 2FA on** ``` POST /v1/login {"email","password"} → 202 {"two_factor_required":true, "pending_token":"", "pending_expires_at":…} POST /v1/login/2fa {"pending_token":"…", "code":"123456"} → 200 {"account", "session_token", "session_expires_at", "recovery_code_used":false} ``` - The pending token proves the password step only: it is rejected by every `/v1/account/*` route and is **single-use**. - At most **5 wrong codes per pending token**; after that `/v1/login/2fa` answers `429` and the user has to start again with the password. - `code` may be a recovery code (`xxxxx-xxxxx`) instead of a TOTP code. Each recovery code works once; the response says `recovery_code_used: true` and the audit entry notes how many are left. Generate a fresh set by disabling and re-enabling 2FA. - Accounts without 2FA keep the old one-step `200` login; clients that ignore `two_factor_required` simply see no `session_token` on a `202`. The portal handles the `202` by showing a code field under the password (Enter submits); `GET /v1/account` reports `"totp_enabled": true` on the account. **Storage**: the TOTP secret and the SHA-256 hashes of the recovery codes live on the account record (`totp_secret`, `recovery_codes` in `store.json`, like the password hash) and are never returned by the API after setup. **Locked-out customer (lost phone and recovery codes)** Verify the person out of band, then reset from the admin API: ```bash curl -X POST -H "X-Admin-Key: $ADMIN_API_KEY" "$API/v1/admin/accounts/acct_…/2fa/reset" ``` This clears the secret and recovery codes; the owner logs in with the password alone and can enrol a new device from the Security card. The reset is itself an audit entry (`reset 2FA for account`, actor `admin`), and shows up in the owner's Recent activity. ## Trying it locally ```bash # after restarting the API (the extension registers its routes at start-up) curl -s -X POST localhost:8080/v1/signup -d '{"email":"me@example.com","password":"hunter2hunter2"}' | tee /tmp/su.json S=$(jq -r .session_token /tmp/su.json) curl -s -X POST -H "Authorization: Bearer $S" localhost:8080/v1/account/2fa/setup # → secret; add it to an authenticator app curl -s -X POST -H "Authorization: Bearer $S" localhost:8080/v1/account/2fa/enable -d '{"code":""}' curl -s -X POST localhost:8080/v1/login -d '{"email":"me@example.com","password":"hunter2hunter2"}' # → 202 + pending_token curl -s -X POST localhost:8080/v1/login/2fa -d '{"pending_token":"…","code":""}' # → session curl -s -H "X-Admin-Key: $ADMIN_API_KEY" localhost:8080/v1/admin/audit | jq . tail -f data/audit.log ``` Or use the portal at (Security card) and the admin page at (Audit log card).