log every auth attempt that names an identity the platform does not know
This commit is contained in:
@@ -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:<length>]`, 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<void> = 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<string, unknown>) {
|
||||||
|
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<string, string> => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, unknown> | undefined): { keys: string[]; values: Record<string, string> } {
|
||||||
|
const keys = body ? Object.keys(body) : [];
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
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<string, unknown> | 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<string, unknown> | 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;
|
||||||
@@ -3,3 +3,5 @@ export * from './user-middleware';
|
|||||||
export * from './origin-middleware';
|
export * from './origin-middleware';
|
||||||
export * from './origin-validation';
|
export * from './origin-validation';
|
||||||
export * from './rate-limiter';
|
export * from './rate-limiter';
|
||||||
|
export * from './known-users';
|
||||||
|
export * from './auth-audit';
|
||||||
|
|||||||
@@ -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<string> | 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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
const wanted = key(identity);
|
||||||
|
if (!wanted) return false;
|
||||||
|
if (snapshot) return snapshot.has(wanted);
|
||||||
|
|
||||||
|
const user = await getUserByEmail(identity).catch(() => undefined);
|
||||||
|
return !!user;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import {
|
import {
|
||||||
|
authAudit,
|
||||||
originMiddleware,
|
originMiddleware,
|
||||||
originValidationMiddleware,
|
originValidationMiddleware,
|
||||||
userMiddleware,
|
userMiddleware,
|
||||||
@@ -23,6 +24,9 @@ import { passkeyRouter } from './passkey-router';
|
|||||||
export const authRouter = createRouter();
|
export const authRouter = createRouter();
|
||||||
|
|
||||||
authRouter.use(bodyParser());
|
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(originMiddleware);
|
||||||
authRouter.use(originValidationMiddleware);
|
authRouter.use(originValidationMiddleware);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Handler } from 'hono';
|
|||||||
import { getUserCount, createUser } from 'officerdb';
|
import { getUserCount, createUser } from 'officerdb';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { rememberUser } from '@@/_middlewares';
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
import { validateUsername } from './validate-username';
|
import { validateUsername } from './validate-username';
|
||||||
|
|
||||||
@@ -43,5 +44,9 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
role: 'Super Admin',
|
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 });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DATA_PATH, ensureItemDirs } from './data-path';
|
|||||||
import { ensureToolLoader } from './ensure-tool-loader';
|
import { ensureToolLoader } from './ensure-tool-loader';
|
||||||
// Queue is now owned by the sidecar process
|
// Queue is now owned by the sidecar process
|
||||||
import { startChatEventRetention } from './api/chat/retention';
|
import { startChatEventRetention } from './api/chat/retention';
|
||||||
|
import { loadKnownUsers } from './_middlewares/known-users';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
ensureItemDirs();
|
ensureItemDirs();
|
||||||
@@ -13,6 +14,10 @@ ensureItemDirs();
|
|||||||
|
|
||||||
startChatEventRetention();
|
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)));
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user