mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: refuse the old admin password everywhere right after it was changed
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
// Next bundles lib/admin/password.ts once per entry: the sign-in route and
|
||||
// the change-password route each have their own copy in one process. Each
|
||||
// copy used to keep its own cache of admin.json, so after a password change
|
||||
// the sign-in route went on accepting the old password until a restart.
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bw-admin-copies-'));
|
||||
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
|
||||
vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state'));
|
||||
// Every copy made below starts from nothing, as after a restart.
|
||||
delete (globalThis as Record<symbol, unknown>)[Symbol.for('bulwark.admin.password')];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A module copy of its own, as another bundle entry has. */
|
||||
async function moduleCopy() {
|
||||
vi.resetModules();
|
||||
return import('@/lib/admin/password');
|
||||
}
|
||||
|
||||
describe('admin password across module copies', () => {
|
||||
it('refuses the old password in every copy after a change in one', async () => {
|
||||
const settings = await moduleCopy();
|
||||
expect(await settings.setInitialAdminPassword('old-password-1')).toBe(true);
|
||||
const signIn = await moduleCopy();
|
||||
expect(await signIn.verifyAdminPassword('old-password-1')).toBe(true);
|
||||
const generation = await signIn.getAdminSessionGeneration();
|
||||
|
||||
expect(await settings.changeAdminPassword('old-password-1', 'new-password-2')).toBe(true);
|
||||
|
||||
expect(await signIn.verifyAdminPassword('old-password-1')).toBe(false);
|
||||
expect(await signIn.verifyAdminPassword('new-password-2')).toBe(true);
|
||||
expect(await signIn.getAdminSessionGeneration()).toBe(generation + 1);
|
||||
});
|
||||
|
||||
it('sees a sign-out made through another copy', async () => {
|
||||
const signIn = await moduleCopy();
|
||||
expect(await signIn.isAdminSessionRevoked('sid-1')).toBe(false);
|
||||
const signOut = await moduleCopy();
|
||||
await signOut.revokeAdminSession('sid-1', Math.floor(Date.now() / 1000) + 3600);
|
||||
expect(await signIn.isAdminSessionRevoked('sid-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
+62
-39
@@ -105,9 +105,32 @@ async function writeStateData(data: AdminStateData): Promise<void> {
|
||||
|
||||
// ─── Cache & init ───────────────────────────────────────────────────────────
|
||||
|
||||
let cachedConfig: AdminConfigData | null = null;
|
||||
let cachedState: AdminStateData | null = null;
|
||||
let initialized = false;
|
||||
/**
|
||||
* What this process knows of admin.json and admin-state.json. On globalThis:
|
||||
* Next bundles this module once per entry (each route bundle, the
|
||||
* instrumentation), and a copy with its own cache kept accepting the old
|
||||
* password after a password change made through another one, until a
|
||||
* restart.
|
||||
*/
|
||||
interface AdminCache {
|
||||
config: AdminConfigData | null;
|
||||
state: AdminStateData | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
const CACHE_KEY = Symbol.for('bulwark.admin.password');
|
||||
const cache: AdminCache = ((globalThis as typeof globalThis & { [CACHE_KEY]?: AdminCache })[CACHE_KEY] ??= {
|
||||
config: null,
|
||||
state: null,
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
/** Forget what this process read, as a restart would (tests). */
|
||||
export function resetAdminCacheForTests(): void {
|
||||
cache.config = null;
|
||||
cache.state = null;
|
||||
cache.initialized = false;
|
||||
}
|
||||
|
||||
function freshState(): AdminStateData {
|
||||
const now = new Date().toISOString();
|
||||
@@ -115,8 +138,8 @@ function freshState(): AdminStateData {
|
||||
}
|
||||
|
||||
async function loadState(): Promise<AdminStateData | null> {
|
||||
if (!cachedState) cachedState = await readStateData();
|
||||
return cachedState;
|
||||
if (!cache.state) cache.state = await readStateData();
|
||||
return cache.state;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,38 +158,38 @@ async function stateForNewPassword(): Promise<AdminStateData> {
|
||||
* - Otherwise, admin dashboard stays disabled.
|
||||
*/
|
||||
export async function initAdminPassword(): Promise<boolean> {
|
||||
if (initialized) return cachedConfig !== null;
|
||||
if (cache.initialized) return cache.config !== null;
|
||||
|
||||
const existingConfig = await readConfigData();
|
||||
if (existingConfig) {
|
||||
cachedConfig = existingConfig;
|
||||
cachedState = (await readStateData()) ?? freshState();
|
||||
cache.config = existingConfig;
|
||||
cache.state = (await readStateData()) ?? freshState();
|
||||
if (!(await readStateData())) {
|
||||
// No state file yet (fresh install or migration); create it.
|
||||
try {
|
||||
await writeStateData(cachedState);
|
||||
await writeStateData(cache.state);
|
||||
} catch {
|
||||
/* state dir may not be writable yet during early boot probes */
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
cache.initialized = true;
|
||||
logger.info('Admin dashboard enabled (password loaded from admin.json)');
|
||||
return true;
|
||||
}
|
||||
|
||||
const envPassword = process.env.ADMIN_PASSWORD;
|
||||
if (!envPassword) {
|
||||
initialized = true;
|
||||
cache.initialized = true;
|
||||
logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
|
||||
return false;
|
||||
}
|
||||
|
||||
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cachedState = await stateForNewPassword();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
initialized = true;
|
||||
cache.config = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cache.state = await stateForNewPassword();
|
||||
await writeConfigData(cache.config);
|
||||
await writeStateData(cache.state);
|
||||
cache.initialized = true;
|
||||
if (isHashed(envPassword)) {
|
||||
logger.info('Admin password hash saved to admin.json from environment variable');
|
||||
} else {
|
||||
@@ -179,12 +202,12 @@ export async function initAdminPassword(): Promise<boolean> {
|
||||
* Verify a password against the stored admin hash.
|
||||
*/
|
||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||
if (!cachedConfig) cachedConfig = await readConfigData();
|
||||
if (!cachedConfig) return false;
|
||||
if (cachedConfig.passwordHash) {
|
||||
return verifyPassword(password, cachedConfig.passwordHash);
|
||||
} else if (cachedConfig.passwordHashFile) {
|
||||
let passwordHash = readFileEnv(cachedConfig.passwordHashFile);
|
||||
if (!cache.config) cache.config = await readConfigData();
|
||||
if (!cache.config) return false;
|
||||
if (cache.config.passwordHash) {
|
||||
return verifyPassword(password, cache.config.passwordHash);
|
||||
} else if (cache.config.passwordHashFile) {
|
||||
let passwordHash = readFileEnv(cache.config.passwordHashFile);
|
||||
if (passwordHash) {
|
||||
return verifyPassword(password, passwordHash);
|
||||
}
|
||||
@@ -205,15 +228,15 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
||||
const hash = await hashPassword(newPassword);
|
||||
const next: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined };
|
||||
await writeConfigData(next);
|
||||
cachedConfig = next;
|
||||
cache.config = next;
|
||||
|
||||
const previous = await loadState();
|
||||
cachedState = {
|
||||
cache.state = {
|
||||
...(previous ?? freshState()),
|
||||
passwordChangedAt: new Date().toISOString(),
|
||||
sessionGeneration: (previous?.sessionGeneration ?? 0) + 1,
|
||||
};
|
||||
await writeStateData(cachedState);
|
||||
await writeStateData(cache.state);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -235,11 +258,11 @@ export async function setInitialAdminPassword(
|
||||
const existing = await readConfigData();
|
||||
if (existing && !options.allowOverwrite) return false;
|
||||
const hash = await hashPassword(newPassword);
|
||||
cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cachedState = await stateForNewPassword();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
initialized = true;
|
||||
cache.config = { passwordHash: hash, passwordHashFile: undefined };
|
||||
cache.state = await stateForNewPassword();
|
||||
await writeConfigData(cache.config);
|
||||
await writeStateData(cache.state);
|
||||
cache.initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -247,13 +270,13 @@ export async function setInitialAdminPassword(
|
||||
* Update the last login timestamp.
|
||||
*/
|
||||
export async function updateLastLogin(): Promise<void> {
|
||||
if (!cachedConfig) return;
|
||||
cachedState = {
|
||||
...(cachedState ?? freshState()),
|
||||
if (!cache.config) return;
|
||||
cache.state = {
|
||||
...(cache.state ?? freshState()),
|
||||
lastLogin: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
await writeStateData(cachedState);
|
||||
await writeStateData(cache.state);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update admin last-login state', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
@@ -265,15 +288,15 @@ export async function updateLastLogin(): Promise<void> {
|
||||
* Check if admin dashboard is enabled (has a password configured).
|
||||
*/
|
||||
export function isAdminEnabled(): boolean {
|
||||
return cachedConfig !== null;
|
||||
return cache.config !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin metadata (without the hash).
|
||||
*/
|
||||
export function getAdminMeta(): AdminStateData | null {
|
||||
if (!cachedConfig) return null;
|
||||
return cachedState ?? freshState();
|
||||
if (!cache.config) return null;
|
||||
return cache.state ?? freshState();
|
||||
}
|
||||
|
||||
// ─── Admin session sign-out ─────────────────────────────────────────────────
|
||||
@@ -297,6 +320,6 @@ export async function revokeAdminSession(sid: string, exp: number): Promise<void
|
||||
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);
|
||||
cache.state = { ...(previous ?? freshState()), revokedSessions: revoked };
|
||||
await writeStateData(cache.state);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user