mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: end admin sessions on sign-out and when the admin password changes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user