auth: distress-password lockdown + explicit token-revoke endpoint
Add a duress password (DISTRESS_PASSWORD env): entering it at login trips an in-memory full lockdown — all new logins (password + passkey) and every existing session are refused until the server is restarted, and the login itself returns a normal "invalid credentials" so it gives nothing away. Also add POST /api/auth/blacklist-token as a clearly-named alias for revoking the current JWT (same effect as signout). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
|||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { isOriginAllowed } from './origin-validation';
|
import { isOriginAllowed } from './origin-validation';
|
||||||
|
import { isLockdown } from '../api/auth/distress';
|
||||||
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
||||||
|
|
||||||
// Role permissions: which HTTP methods each role can use
|
// Role permissions: which HTTP methods each role can use
|
||||||
@@ -21,6 +22,9 @@ function isMethodAllowed(role: string | null, method: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||||
|
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
||||||
|
if (isLockdown()) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
const { authorization } = ctx.req.header();
|
const { authorization } = ctx.req.header();
|
||||||
|
|
||||||
// Support token in query param for media elements (<audio>, <video>, <img>)
|
// Support token in query param for media elements (<audio>, <video>, <img>)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
|
|||||||
authRouter.get('/me', userMiddleware, usersMe);
|
authRouter.get('/me', userMiddleware, usersMe);
|
||||||
authRouter.post('/signin', signinRateLimiter, signinHandler);
|
authRouter.post('/signin', signinRateLimiter, signinHandler);
|
||||||
authRouter.post('/signout', userMiddleware, signoutHandler);
|
authRouter.post('/signout', userMiddleware, signoutHandler);
|
||||||
|
// Explicit "revoke the current token" alias (same effect as signout: blacklists this JWT).
|
||||||
|
authRouter.post('/blacklist-token', userMiddleware, signoutHandler);
|
||||||
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
||||||
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
|
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
|
||||||
authRouter.post('/verify', verifyHandler);
|
authRouter.post('/verify', verifyHandler);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 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;
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { createRouter } from '../../create-router';
|
|||||||
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
||||||
import { sign } from '../../jwt';
|
import { sign } from '../../jwt';
|
||||||
import * as errors from '../../custom-errors';
|
import * as errors from '../../custom-errors';
|
||||||
|
import { isLockdown } from './distress';
|
||||||
import {
|
import {
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getPasskeysByUserIdAndOrigin,
|
getPasskeysByUserIdAndOrigin,
|
||||||
@@ -128,6 +129,7 @@ passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
|||||||
|
|
||||||
// Verify passkey authentication and issue token
|
// Verify passkey authentication and issue token
|
||||||
const passkeyRouterPostVerify: Handler = async (ctx) => {
|
const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||||
|
if (isLockdown()) throw errors.UNAUTHORIZED(); // duress lockdown blocks passkey logins too
|
||||||
const { email } = ctx.req.param();
|
const { email } = ctx.req.param();
|
||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
|
|||||||
@@ -6,11 +6,22 @@ import { sign } from '@@/jwt';
|
|||||||
import { getClaudeDir } from '@@/data-path';
|
import { getClaudeDir } from '@@/data-path';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { isLockdown, triggerLockdown } from './distress';
|
||||||
|
|
||||||
const TEST_USERS: number[] = [];
|
const TEST_USERS: number[] = [];
|
||||||
|
const { DISTRESS_PASSWORD } = process.env;
|
||||||
|
|
||||||
export const signinHandler: Handler = async function (ctx) {
|
export const signinHandler: Handler = async function (ctx) {
|
||||||
const { email, password } = ctx.get('body');
|
const { email, password } = ctx.get('body');
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
throw errors.UNAUTHORIZED();
|
||||||
|
}
|
||||||
|
|
||||||
const origin = ctx.get('origin');
|
const origin = ctx.get('origin');
|
||||||
const dbUser = await getUserByEmail(email);
|
const dbUser = await getUserByEmail(email);
|
||||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||||
|
|||||||
Reference in New Issue
Block a user