add token revoke and panic lockdown endpoints with security logging

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:48:44 +00:00
co-authored by Claude Opus 4.8
parent b96b23d92b
commit fcee091b53
9 changed files with 71 additions and 42 deletions
+5 -5
View File
@@ -19,7 +19,7 @@ import { forgotPasswordHandler } from './forgot-password';
import { resetPasswordHandler } from './reset-password';
import { bootstrapHandler } from './bootstrap';
import { usersMe } from './users-me';
import { lockdownHandler } from './lockdown';
import { panicHandler } from './panic-handler';
import { passkeyRouter } from './passkey-router';
export const authRouter = createRouter();
@@ -33,10 +33,10 @@ authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
authRouter.get('/me', userMiddleware, usersMe);
authRouter.post('/signin', signinRateLimiter, signinHandler);
authRouter.post('/signout', userMiddleware, signoutHandler);
// Explicit "revoke the current token" alias (same effect as signout: blacklists this JWT).
authRouter.post('/blacklist-token', userMiddleware, signoutHandler);
// Trigger the duress lockdown programmatically (needs the distress password in the body).
authRouter.post('/lockdown', signinRateLimiter, lockdownHandler);
// Revoke the current token — blacklists this JWT (same effect as signout). The app's "distress".
authRouter.post('/revoke', userMiddleware, signoutHandler);
// Trigger the panic lockdown — authenticated, no password in the body.
authRouter.post('/panic', userMiddleware, panicHandler);
authRouter.post('/signup', signupRateLimiter, signupHandler);
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
authRouter.post('/verify', verifyHandler);
+9
View File
@@ -0,0 +1,9 @@
import type { Context } from 'hono';
// Best-effort client IP for audit logging. Behind a reverse proxy the real address is in
// x-forwarded-for (first hop); fall back to x-real-ip, then "unknown".
export const clientIp = (ctx: Context): string => {
const xff = ctx.req.header('x-forwarded-for');
if (xff) return xff.split(',')[0]?.trim() || 'unknown';
return ctx.req.header('x-real-ip') || 'unknown';
};
-10
View File
@@ -1,10 +0,0 @@
// Duress lockdown. The distress password (entered at login) trips this in-memory flag; while it is
// set, ALL new logins and ALL existing sessions are refused. It is intentionally in-memory only, so
// the only way to clear it is a manual server restart.
let locked = false;
export const isLockdown = (): boolean => locked;
export const triggerLockdown = (): void => {
locked = true;
};
-15
View File
@@ -1,15 +0,0 @@
import type { Handler } from 'hono';
import * as errors from '@@/custom-errors';
import { triggerLockdown } from './distress';
const { DISTRESS_PASSWORD } = process.env;
// Trigger the duress lockdown programmatically — same effect as entering the distress password at
// login. Requires the distress password in the body so only its holder can trip it (rate-limited at
// the route). Once tripped, all logins and existing sessions are refused until the server restarts.
export const lockdownHandler: Handler = async (ctx) => {
const { password } = ctx.get('body') as { password?: string };
if (!DISTRESS_PASSWORD || password !== DISTRESS_PASSWORD) throw errors.UNAUTHORIZED();
triggerLockdown();
return ctx.json({ ok: true });
};
+12
View File
@@ -0,0 +1,12 @@
import type { Handler } from 'hono';
import { triggerLockdown } from './panic';
import { clientIp } from './client-ip';
// Trigger the panic lockdown. Authenticated (Bearer token) — no password in the body. Once tripped,
// all logins and existing sessions are refused until the server is manually restarted.
export const panicHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { email?: string } | undefined;
const origin = ctx.get('origin') as string | undefined;
triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`);
return ctx.json({ ok: true });
};
+30
View File
@@ -0,0 +1,30 @@
// Panic lockdown. The panic password (entered at login, or via /auth/panic) trips this in-memory flag;
// while it is set, ALL new logins and ALL existing sessions are refused. It is intentionally in-memory
// only, so the only way to clear it is a manual server restart.
let locked = false;
let blockedCount = 0;
let lastBlockedLog = 0;
export const isLockdown = (): boolean => locked;
// Activate the panic lockdown. `source` describes where it was tripped from (login form / endpoint,
// with origin + ip) for the audit log. Logs once — re-triggering while already locked is a no-op.
export const triggerLockdown = (source: string): void => {
if (locked) return;
locked = true;
blockedCount = 0;
lastBlockedLog = 0;
console.warn(`[SECURITY] panic lockdown ACTIVATED — ${source}${new Date().toISOString()}`);
};
// Record a request that was refused because of the lockdown. Logs a throttled running total (at most
// once per 10s) so polling clients hammering the API don't flood the log.
export const noteBlocked = (what: string): void => {
if (!locked) return;
blockedCount++;
const now = Date.now();
if (now - lastBlockedLog >= 10_000) {
lastBlockedLog = now;
console.warn(`[SECURITY] panic lockdown blocked ${blockedCount} request(s) since activation — latest: ${what}`);
}
};
+5 -2
View File
@@ -4,7 +4,7 @@ import { createRouter } from '../../create-router';
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import { isLockdown } from './distress';
import { isLockdown, noteBlocked } from './panic';
import {
getUserByEmail,
getPasskeysByUserIdAndOrigin,
@@ -129,7 +129,10 @@ passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
// Verify passkey authentication and issue token
const passkeyRouterPostVerify: Handler = async (ctx) => {
if (isLockdown()) throw errors.UNAUTHORIZED(); // duress lockdown blocks passkey logins too
if (isLockdown()) {
noteBlocked('passkey login');
throw errors.UNAUTHORIZED();
} // duress lockdown blocks passkey logins too
const { email } = ctx.req.param();
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
+5 -8
View File
@@ -6,23 +6,20 @@ import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { isLockdown, triggerLockdown } from './distress';
import { isLockdown, noteBlocked } from './panic';
const TEST_USERS: number[] = [];
const { DISTRESS_PASSWORD } = process.env;
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
const origin = ctx.get('origin');
// Duress: if lockdown is active, refuse everyone (looks like a normal failed login). If the distress
// password was entered, trip the lockdown now and then fail the same way, giving nothing away.
if (isLockdown()) throw errors.UNAUTHORIZED();
if (DISTRESS_PASSWORD && typeof password === 'string' && password === DISTRESS_PASSWORD) {
triggerLockdown();
// Panic lockdown active refuse all logins (looks like a normal failed login).
if (isLockdown()) {
noteBlocked('login attempt');
throw errors.UNAUTHORIZED();
}
const origin = ctx.get('origin');
const dbUser = await getUserByEmail(email);
if (!dbUser) throw errors.UNAUTHORIZED();