fix: end admin sessions on sign-out and when the admin password changes

This commit is contained in:
Linus Rath
2026-10-26 14:52:04 +01:00
parent 121d2d5e31
commit eb2787bcc9
7 changed files with 200 additions and 12 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { changeAdminPassword } from '@/lib/admin/password';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { requireAdminAuth, getClientIP, setAdminSessionCookie } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
@@ -29,6 +29,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
// The new password signed every admin session out, this one included;
// the admin who changed it stays signed in.
await setAdminSessionCookie(request);
await auditLog('admin.change-password', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
+105
View File
@@ -0,0 +1,105 @@
/**
* @vitest-environment node
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Admin tokens are stateless, so signing out used to delete only the
// browser's cookie: a copy of it (stolen, or left in another browser) kept
// the dashboard open until it expired, and changing the admin password
// didn't end it either.
const jar = new Map<string, string>();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (name: string) => { jar.delete(name); },
}),
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
const COOKIE = 'admin_session';
let dir: string;
beforeEach(() => {
vi.resetModules();
jar.clear();
dir = mkdtempSync(path.join(tmpdir(), 'bw-admin-signout-'));
vi.stubEnv('SESSION_SECRET', 'a'.repeat(64));
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state'));
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
const post = (url: string, body?: unknown) => new Request(`http://localhost${url}`, {
method: 'POST',
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
/** Signs a browser in and returns its cookie. */
async function signIn(): Promise<string> {
const { setAdminSessionCookie } = await import('@/lib/admin/session');
await setAdminSessionCookie(post('/api/admin/auth'));
return jar.get(COOKIE)!;
}
/** Whether the dashboard opens for this cookie. */
async function opens(token: string): Promise<boolean> {
const { requireAdminAuth } = await import('@/lib/admin/session');
jar.set(COOKIE, token);
return !('error' in await requireAdminAuth(post('/api/admin/config')));
}
describe('admin sign-out', () => {
it('refuses a copy of the cookie after its browser signed out, and keeps other sessions', async () => {
const stolen = await signIn();
const other = await signIn();
expect(await opens(stolen)).toBe(true);
jar.set(COOKIE, stolen);
const { clearAdminSessionCookie } = await import('@/lib/admin/session');
await clearAdminSessionCookie();
expect(jar.has(COOKIE)).toBe(false);
expect(await opens(stolen)).toBe(false);
expect(await opens(other)).toBe(true);
// Kept on disk: a restart does not bring it back.
vi.resetModules();
expect(await opens(stolen)).toBe(false);
});
it('signs every session out on a password change and keeps the changer signed in', async () => {
const { setInitialAdminPassword } = await import('@/lib/admin/password');
await setInitialAdminPassword('old-password-1');
const elsewhere = await signIn();
const changer = await signIn();
jar.set(COOKIE, changer);
const { POST } = await import('@/app/api/admin/change-password/route');
const res = await POST(post('/api/admin/change-password', { currentPassword: 'old-password-1', newPassword: 'new-password-2' }) as never);
expect(res.status).toBe(200);
const reissued = jar.get(COOKIE)!;
expect(reissued).not.toBe(changer);
expect(await opens(elsewhere)).toBe(false);
expect(await opens(changer)).toBe(false);
expect(await opens(reissued)).toBe(true);
vi.resetModules();
expect(await opens(elsewhere)).toBe(false);
expect(await opens(reissued)).toBe(true);
});
it('keeps sessions without an admin password (Stalwart admin sign-in) working', async () => {
const token = await signIn();
expect(await opens(token)).toBe(true);
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ vi.mock('next/headers', () => ({
cookies: async () => ({ get: (name: string) => (state.jar.has(name) ? { value: state.jar.get(name) } : undefined) }),
}));
vi.mock('@/lib/auth/crypto', () => ({ decryptSession: (token: string) => (token === 'valid' ? {} : null) }));
vi.mock('@/lib/admin/session', () => ({ verifyAdminSession: (token: string) => (token === 'admin' ? {} : null) }));
vi.mock('@/lib/admin/session', () => ({ verifyCurrentAdminSession: async (token: string) => (token === 'admin' ? {} : null) }));
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
+44 -3
View File
@@ -114,6 +114,20 @@ function freshState(): AdminStateData {
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
}
async function loadState(): Promise<AdminStateData | null> {
if (!cachedState) cachedState = await readStateData();
return cachedState;
}
/**
* Fresh state for a new admin password, one session generation on: every
* admin session of the old password is signed out (a stolen cookie with it).
*/
async function stateForNewPassword(): Promise<AdminStateData> {
const previous = await loadState();
return { ...freshState(), sessionGeneration: (previous?.sessionGeneration ?? 0) + 1 };
}
/**
* Initialize admin password on startup.
* - If admin.json exists, use it (state file may or may not exist; created on first need).
@@ -149,7 +163,7 @@ export async function initAdminPassword(): Promise<boolean> {
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
cachedState = freshState();
cachedState = await stateForNewPassword();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
@@ -193,9 +207,11 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
await writeConfigData(next);
cachedConfig = next;
const previous = await loadState();
cachedState = {
...(cachedState ?? freshState()),
...(previous ?? freshState()),
passwordChangedAt: new Date().toISOString(),
sessionGeneration: (previous?.sessionGeneration ?? 0) + 1,
};
await writeStateData(cachedState);
return true;
@@ -220,7 +236,7 @@ export async function setInitialAdminPassword(
if (existing && !options.allowOverwrite) return false;
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
cachedState = freshState();
cachedState = await stateForNewPassword();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
@@ -259,3 +275,28 @@ export function getAdminMeta(): AdminStateData | null {
if (!cachedConfig) return null;
return cachedState ?? freshState();
}
// ─── Admin session sign-out ─────────────────────────────────────────────────
/** The session generation new admin tokens are issued in, and the only one accepted. */
export async function getAdminSessionGeneration(): Promise<number> {
return (await loadState())?.sessionGeneration ?? 0;
}
export async function isAdminSessionRevoked(sid: string): Promise<boolean> {
const revoked = (await loadState())?.revokedSessions;
return !!revoked && Object.prototype.hasOwnProperty.call(revoked, sid);
}
/**
* Sign one admin token out (logout) until its exp, after which it is refused
* as expired anyway; expired entries are dropped on the way.
*/
export async function revokeAdminSession(sid: string, exp: number): Promise<void> {
const now = Math.floor(Date.now() / 1000);
const previous = await loadState();
const revoked = Object.fromEntries(Object.entries(previous?.revokedSessions ?? {}).filter(([, until]) => until >= now));
revoked[sid] = exp;
cachedState = { ...(previous ?? freshState()), revokedSessions: revoked };
await writeStateData(cachedState);
}
+36 -5
View File
@@ -4,8 +4,10 @@ import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { getSessionSecret } from '@/lib/auth/session-secret';
import { isSameOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
import { getAdminSessionGeneration, isAdminSessionRevoked, revokeAdminSession } from './password';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -39,9 +41,10 @@ function getSessionTTL(): number {
}
/**
* Create an encrypted admin session token.
* Create an encrypted admin session token, in session generation
* `generation` (see getAdminSessionGeneration).
*/
export function createAdminSession(): string {
export function createAdminSession(generation = 0): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
@@ -52,6 +55,8 @@ export function createAdminSession(): string {
role: 'admin',
iat: now,
exp: now + getSessionTTL(),
sid: randomBytes(16).toString('base64url'),
gen: generation,
};
const json = JSON.stringify(payload);
@@ -82,6 +87,9 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
const payload = JSON.parse(decrypted.toString('utf8')) as AdminSessionPayload;
if (payload.role !== 'admin') return null;
// Every token this version issues has both; ADMIN_SESSION_AAD already
// signed the ones from before out.
if (typeof payload.sid !== 'string' || typeof payload.gen !== 'number') return null;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
@@ -92,6 +100,18 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
}
}
/**
* verifyAdminSession, and not signed out since it was issued: by a logout
* of this token, or by a new admin password (which moves the generation on).
*/
export async function verifyCurrentAdminSession(token: string): Promise<AdminSessionPayload | null> {
const payload = verifyAdminSession(token);
if (!payload) return null;
if (payload.gen !== await getAdminSessionGeneration()) return null;
if (await isAdminSessionRevoked(payload.sid)) return null;
return payload;
}
// The CSRF gate lives in lib/security/same-origin so the unauthenticated
// /api/auth/* routes share it (GHSA-qvr9-m8cq-7wvg). Re-exported here for
// the admin routes and their tests.
@@ -115,7 +135,7 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm
return { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
}
const payload = verifyAdminSession(token);
const payload = await verifyCurrentAdminSession(token);
if (!payload) {
cookieStore.delete(ADMIN_SESSION_COOKIE);
return { error: NextResponse.json({ error: 'Session expired' }, { status: 401 }) };
@@ -136,7 +156,7 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm
* X-Forwarded-Proto would otherwise make the request look like plain HTTP.
*/
export async function setAdminSessionCookie(request?: Request): Promise<void> {
const token = createAdminSession();
const token = createAdminSession(await getAdminSessionGeneration());
const cookieStore = await cookies();
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true,
@@ -148,11 +168,22 @@ export async function setAdminSessionCookie(request?: Request): Promise<void> {
}
/**
* Clear the admin session cookie.
* Clear the admin session cookie and sign its token out, so a copy of the
* cookie stops working too.
*/
export async function clearAdminSessionCookie(): Promise<void> {
const cookieStore = await cookies();
const payload = verifyAdminSession(cookieStore.get(ADMIN_SESSION_COOKIE)?.value ?? '');
cookieStore.delete(ADMIN_SESSION_COOKIE);
if (!payload) return;
try {
await revokeAdminSession(payload.sid, payload.exp);
} catch (error) {
// Kept in memory regardless; only a restart forgets it.
logger.warn('Failed to save the admin session sign-out', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
+8
View File
@@ -18,12 +18,20 @@ export interface AdminStateData {
createdAt: string;
lastLogin: string | null;
passwordChangedAt: string;
/** Moves on with every new admin password; tokens of an older one are refused. */
sessionGeneration?: number;
/** Tokens signed out by a logout, by id, with the exp they would have run until. */
revokedSessions?: Record<string, number>;
}
export interface AdminSessionPayload {
role: 'admin';
iat: number;
exp: number;
/** Token id, for signing this one token out. */
sid: string;
/** AdminStateData.sessionGeneration it was issued in. */
gen: number;
}
export interface SettingRestriction {
+2 -2
View File
@@ -4,7 +4,7 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { decryptSession } from '@/lib/auth/crypto';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { verifyAdminSession } from '@/lib/admin/session';
import { verifyCurrentAdminSession } from '@/lib/admin/session';
import { ADMIN_SESSION_COOKIE } from '@/lib/admin/types';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
@@ -29,5 +29,5 @@ export async function canSeeInstanceDetails(request: NextRequest): Promise<boole
if (session && decryptSession(session)) return true;
}
const admin = jar.get(ADMIN_SESSION_COOKIE)?.value;
return !!admin && verifyAdminSession(admin) !== null;
return !!admin && (await verifyCurrentAdminSession(admin)) !== null;
}