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:
@@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
||||
import { verify } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isOriginAllowed } from './origin-validation';
|
||||
import { isLockdown } from '../api/auth/distress';
|
||||
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
||||
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
||||
|
||||
// Role permissions: which HTTP methods each role can use
|
||||
@@ -23,7 +23,10 @@ function isMethodAllowed(role: string | null, method: string): boolean {
|
||||
|
||||
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
||||
if (isLockdown()) throw errors.UNAUTHORIZED();
|
||||
if (isLockdown()) {
|
||||
noteBlocked(`${ctx.req.method} ${ctx.req.path}`);
|
||||
throw errors.UNAUTHORIZED();
|
||||
}
|
||||
|
||||
const { authorization } = ctx.req.header();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user