diff --git a/src/servers/_middlewares/auth-audit.ts b/src/servers/_middlewares/auth-audit.ts new file mode 100644 index 00000000..88c5a39b --- /dev/null +++ b/src/servers/_middlewares/auth-audit.ts @@ -0,0 +1,170 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { mkdirSync, statSync, renameSync } from 'node:fs'; +import { appendFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DATA_PATH } from '../data-path'; +import { isKnownIdentity, knownIdentityCount } from './known-users'; + +// Every attempt to authenticate as somebody this platform has never heard of, written to a file. +// +// Officer has exactly one account and is reachable from the public internet, so a request naming any +// other identity is never a mistyped login — it is somebody working through a list. That is worth a +// durable record, and a durable record is the point: this is deliberately a FILE, not a table. It has +// to survive the database being unreachable, has to be readable with `tail` from a terminal at 3am, +// and has to be one `grep` away from an IP. JSON lines, appended, rotated once at 5 MB. +// +// WHAT IS NEVER WRITTEN: the password, or anything else credential-shaped. The attempt is the signal; +// the secret adds nothing to it and would turn this file into a thing worth stealing. Bodies are +// recorded as their KEY NAMES plus the identity fields — see safeBody(). Headers are recorded in full +// except authorization/cookie, which become `[redacted:]`, because how long a bearer somebody +// forged is occasionally interesting and its bytes never are. +// +// The middleware only ever observes. It cannot reject, cannot change a response, and swallows its own +// failures — a full disk must not be able to lock the owner out of their own platform. + +const LOG_DIR = join(DATA_PATH, 'logs'); +const LOG_PATH = join(LOG_DIR, 'auth-attempts.log'); +const ROTATED_PATH = join(LOG_DIR, 'auth-attempts.1.log'); +const MAX_BYTES = 5 * 1024 * 1024; + +/** Header names whose VALUE is a credential. Kept as a set of exact lowercase names, not a regex. */ +const SECRET_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization', 'x-api-key']); + +/** Body keys safe to record verbatim. Everything else contributes its name only. */ +const PUBLIC_BODY_KEYS = new Set(['email', 'username', 'name']); + +// Appends are chained rather than fired in parallel: two concurrent probes must not interleave halves +// of two JSON lines into one unparseable one. +let writeQueue: Promise = Promise.resolve(); +let dirReady = false; + +function rotateIfNeeded() { + try { + if (statSync(LOG_PATH).size < MAX_BYTES) return; + renameSync(LOG_PATH, ROTATED_PATH); + } catch { + /* no file yet, or the rename lost a race — either way the append below still works */ + } +} + +function writeRecord(record: Record) { + writeQueue = writeQueue + .then(async () => { + if (!dirReady) { + mkdirSync(LOG_DIR, { recursive: true }); + dirReady = true; + } + rotateIfNeeded(); + await appendFile(LOG_PATH, `${JSON.stringify(record)}\n`, 'utf8'); + }) + .catch((err) => { + console.error('[auth-audit] could not write the attempt log:', String(err)); + }); +} + +const headerSnapshot = (ctx: Context): Record => { + const out: Record = {}; + for (const [name, value] of Object.entries(ctx.req.header())) { + out[name] = SECRET_HEADERS.has(name.toLowerCase()) ? `[redacted:${value.length}]` : value; + } + return out; +}; + +/** Key names always; values only for the fields that identify rather than authenticate. */ +function safeBody(body: Record | undefined): { keys: string[]; values: Record } { + const keys = body ? Object.keys(body) : []; + const values: Record = {}; + for (const key of keys) { + if (!PUBLIC_BODY_KEYS.has(key)) continue; + const value = body?.[key]; + if (typeof value === 'string') values[key] = value.slice(0, 200); + } + return { keys, values }; +} + +/** + * The identity a request is claiming. `POST /signin` and friends carry it in the body; the passkey + * routes put it in the last path segment (`/passkeys/signin/:email`). Read from the path directly + * rather than through `ctx.req.param`, which is not populated for a middleware mounted at the router + * root — the value would silently be undefined for exactly the unauthenticated routes that matter. + */ +function claimedIdentity(ctx: Context, path: string): string | null { + const body = ctx.get('body') as Record | undefined; + for (const field of ['email', 'username'] as const) { + const value = body?.[field]; + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 320); + } + + if (!path.includes('/auth/passkeys/')) return null; + const last = decodeURIComponent(path.split('/').pop() ?? ''); + return last.includes('@') ? last.slice(0, 320) : null; +} + +const clientAddress = (ctx: Context): string => + ctx.req.header('x-forwarded-for')?.split(',')[0]?.trim() || ctx.req.header('x-real-ip') || 'unknown'; + +/** + * Mount on the auth router directly after `bodyParser()` and before everything else, so it wraps origin + * validation and the rate limiter too — a probe rejected at the origin check is precisely the one the + * owner wants a record of, and it never reaches a handler. + */ +export const authAudit: MiddlewareHandler = async (ctx, next) => { + const started = Date.now(); + let failure: unknown = null; + try { + await next(); + } catch (err) { + failure = err; + throw err; + } finally { + const url = new URL(ctx.req.url); + const identity = claimedIdentity(ctx, url.pathname); + + // No name claimed → nothing to classify. Signout, /me and the token routes authenticate with a + // bearer, and their failures are the userMiddleware's business, not this file's. + if (identity) { + // Deliberately after next(): the handler may be slow, and blocking a login on a lookup that only + // decides whether to write a log line would be the wrong trade. The set is in memory anyway. + void isKnownIdentity(identity) + .then((known) => { + if (known) return; + const body = safeBody(ctx.get('body') as Record | undefined); + const record = { + ts: new Date().toISOString(), + event: 'auth.unknown-identity', + identity, + method: ctx.req.method, + path: url.pathname, + query: url.search || null, + status: failure ? 'threw' : ctx.res.status, + ms: Date.now() - started, + ip: clientAddress(ctx), + forwardedFor: ctx.req.header('x-forwarded-for') ?? null, + realIp: ctx.req.header('x-real-ip') ?? null, + host: ctx.req.header('host') ?? null, + origin: ctx.req.header('origin') ?? null, + referer: ctx.req.header('referer') ?? null, + userAgent: ctx.req.header('user-agent') ?? null, + acceptLanguage: ctx.req.header('accept-language') ?? null, + contentType: ctx.req.header('content-type') ?? null, + bodyKeys: body.keys, + bodyValues: body.values, + headers: headerSnapshot(ctx), + knownIdentities: knownIdentityCount(), + error: failure ? String((failure as { message?: unknown })?.message ?? failure).slice(0, 300) : null, + }; + writeRecord(record); + // Also to the process log, one line, so it surfaces in `pm2 logs officer` without anyone + // having to know this file exists. + console.warn( + `[auth-audit] unknown identity "${identity}" from ${record.ip} on ${record.method} ${url.pathname}`, + ); + }) + .catch(() => { + /* classification failed; never let it touch the response */ + }); + } + } +}; + +export const AUTH_AUDIT_LOG_PATH = LOG_PATH; diff --git a/src/servers/_middlewares/index.ts b/src/servers/_middlewares/index.ts index 5c5b07ec..be6dbbc4 100644 --- a/src/servers/_middlewares/index.ts +++ b/src/servers/_middlewares/index.ts @@ -3,3 +3,5 @@ export * from './user-middleware'; export * from './origin-middleware'; export * from './origin-validation'; export * from './rate-limiter'; +export * from './known-users'; +export * from './auth-audit'; diff --git a/src/servers/_middlewares/known-users.ts b/src/servers/_middlewares/known-users.ts new file mode 100644 index 00000000..48f26804 --- /dev/null +++ b/src/servers/_middlewares/known-users.ts @@ -0,0 +1,57 @@ +import { getUsers, getUserByEmail } from 'officerdb'; + +// Who this platform knows about, loaded once at launch. +// +// This is a CLASSIFIER, never a gate. Nothing here decides whether a request is allowed — the signin +// handler still does its own database lookup and is the only thing that authenticates. The snapshot +// exists so the audit middleware can answer one question cheaply, on the hot path of an unauthenticated +// endpoint: "is the name in this request even one of ours?" A wrong answer costs a spurious log line or +// a missing one, never access. +// +// It holds emails AND usernames because both are identities a caller can present, and both are unique +// columns on `users`. Matching is case-insensitive: an attempt on OWNER@example.com is the owner's own +// address typed differently, not a stranger, and logging it as an intrusion would train the owner to +// ignore the file. +// +// Single-user is a hard invariant here, so this set has two entries in practice. It is a Set rather +// than a string because that invariant is the platform's, not this file's, and a Set costs nothing. + +let snapshot: Set | null = null; + +const key = (identity: string) => identity.trim().toLowerCase(); + +const identitiesOf = (user: { email: string; username: string | null }): string[] => + [user.email, user.username].filter((value): value is string => typeof value === 'string' && !!value.trim()); + +/** Read every account into memory. Called once at launch; safe to call again to refresh. */ +export async function loadKnownUsers(): Promise { + const users = await getUsers(); + snapshot = new Set(users.flatMap(identitiesOf).map(key)); + return snapshot.size; +} + +/** + * Fold a newly created account in. Bootstrap is the only path that mints one, and without this the + * owner's very first sign-in after creating the account would be logged as a stranger — the snapshot + * having been taken while the user table was still empty. + */ +export function rememberUser(user: { email: string; username: string | null }): void { + if (!snapshot) return; + for (const identity of identitiesOf(user)) snapshot.add(key(identity)); +} + +/** How many identities are loaded, or null if the launch-time load has not succeeded. */ +export const knownIdentityCount = (): number | null => snapshot?.size ?? null; + +/** + * Whether an identity belongs to an account. Falls back to the database when the launch-time load + * never ran or failed — a Postgres blip at boot must not turn every later login into an alert. + */ +export async function isKnownIdentity(identity: string): Promise { + const wanted = key(identity); + if (!wanted) return false; + if (snapshot) return snapshot.has(wanted); + + const user = await getUserByEmail(identity).catch(() => undefined); + return !!user; +} diff --git a/src/servers/api/auth/auth.ts b/src/servers/api/auth/auth.ts index 1afc8199..3fd141a4 100644 --- a/src/servers/api/auth/auth.ts +++ b/src/servers/api/auth/auth.ts @@ -1,5 +1,6 @@ import { createRouter } from '../../create-router'; import { + authAudit, originMiddleware, originValidationMiddleware, userMiddleware, @@ -23,6 +24,9 @@ import { passkeyRouter } from './passkey-router'; export const authRouter = createRouter(); authRouter.use(bodyParser()); +// After the body parser (it reads the claimed identity out of the body) and before everything else, so +// that a probe rejected by origin validation or the rate limiter is recorded too. Observes only. +authRouter.use(authAudit); authRouter.use(originMiddleware); authRouter.use(originValidationMiddleware); diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index f8604689..cc849d14 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -2,6 +2,7 @@ import type { Handler } from 'hono'; import { getUserCount, createUser } from 'officerdb'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; +import { rememberUser } from '@@/_middlewares'; import { validatePassword } from './validate-password'; import { validateUsername } from './validate-username'; @@ -43,5 +44,9 @@ export const bootstrapHandler: Handler = async function (ctx) { role: 'Super Admin', }); + // The launch-time snapshot was taken while the user table was still empty. Without this the owner's + // very first sign-in would be filed as an unknown identity. + rememberUser(user); + return ctx.json({ ok: true }); }; diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 650f1888..7af6ee76 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -3,6 +3,7 @@ import { DATA_PATH, ensureItemDirs } from './data-path'; import { ensureToolLoader } from './ensure-tool-loader'; // Queue is now owned by the sidecar process import { startChatEventRetention } from './api/chat/retention'; +import { loadKnownUsers } from './_middlewares/known-users'; mkdirSync(DATA_PATH, { recursive: true }); ensureItemDirs(); @@ -13,6 +14,10 @@ ensureItemDirs(); startChatEventRetention(); - - + // Snapshot every account at launch, so the auth audit can tell the owner from a stranger without a + // query on an unauthenticated path. A failure here is not fatal: isKnownIdentity() falls back to the + // database, and the platform must still boot with Postgres briefly unavailable. + await loadKnownUsers() + .then((count) => console.log(`[auth] ${count} known identit${count === 1 ? 'y' : 'ies'} loaded`)) + .catch((err) => console.error('[auth] could not load known users at launch:', String(err))); })();