diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts index 2cc06b3db..c18bdabe7 100644 --- a/app/api/admin/change-password/route.ts +++ b/app/api/admin/change-password/route.ts @@ -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) { diff --git a/lib/__tests__/admin-session-signout.test.ts b/lib/__tests__/admin-session-signout.test.ts new file mode 100644 index 000000000..b4827b153 --- /dev/null +++ b/lib/__tests__/admin-session-signout.test.ts @@ -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(); +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 { + 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 { + 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); + }); +}); diff --git a/lib/__tests__/instance-details.test.ts b/lib/__tests__/instance-details.test.ts index a360c3eb7..11cb7d1a2 100644 --- a/lib/__tests__/instance-details.test.ts +++ b/lib/__tests__/instance-details.test.ts @@ -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'; diff --git a/lib/admin/password.ts b/lib/admin/password.ts index d5c6677e9..e0dd8baa6 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -114,6 +114,20 @@ function freshState(): AdminStateData { return { createdAt: now, lastLogin: null, passwordChangedAt: now }; } +async function loadState(): Promise { + 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 { + 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 { 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 { + return (await loadState())?.sessionGeneration ?? 0; +} + +export async function isAdminSessionRevoked(sid: string): Promise { + 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 { + 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); +} diff --git a/lib/admin/session.ts b/lib/admin/session.ts index 8f437ba99..29f9c64c7 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -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 { + 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 { - 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 { } /** - * 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 { 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', + }); + } } /** diff --git a/lib/admin/types.ts b/lib/admin/types.ts index b7ad4b6fe..3a1bdaae8 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -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; } 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 { diff --git a/lib/auth/instance-details.ts b/lib/auth/instance-details.ts index e5e3f47d0..85ff22f2e 100644 --- a/lib/auth/instance-details.ts +++ b/lib/auth/instance-details.ts @@ -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