mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
212 lines
7.8 KiB
TypeScript
212 lines
7.8 KiB
TypeScript
import { cookies } from 'next/headers';
|
|
import { secureCookieFlag } from '@/lib/security/request-protocol';
|
|
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;
|
|
const TAG_LENGTH = 16;
|
|
|
|
const MIN_SECRET_LENGTH = 32;
|
|
|
|
/**
|
|
* Bound into every admin token as GCM associated data. The mailbox session
|
|
* cookies and the synced-settings files are sealed with the same key and
|
|
* layout, and a user picks the content of their settings, so without it a
|
|
* settings file holding {"role":"admin",...} would open the dashboard.
|
|
*/
|
|
const ADMIN_SESSION_AAD = Buffer.from('bulwark-admin-session', 'utf8');
|
|
|
|
function getKey(): Buffer {
|
|
const secret = getSessionSecret();
|
|
if (!secret) throw new Error('SESSION_SECRET not configured');
|
|
if (secret.length < MIN_SECRET_LENGTH) {
|
|
throw new Error(
|
|
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
|
|
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
|
);
|
|
}
|
|
return createHash('sha256').update(secret).digest();
|
|
}
|
|
|
|
function getSessionTTL(): number {
|
|
const ttl = parseInt(process.env.ADMIN_SESSION_TTL || '', 10);
|
|
return isNaN(ttl) || ttl <= 0 ? DEFAULT_ADMIN_SESSION_TTL : ttl;
|
|
}
|
|
|
|
/**
|
|
* Create an encrypted admin session token, in session generation
|
|
* `generation` (see getAdminSessionGeneration).
|
|
*/
|
|
export function createAdminSession(generation = 0): string {
|
|
const key = getKey();
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
cipher.setAAD(ADMIN_SESSION_AAD);
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload: AdminSessionPayload = {
|
|
role: 'admin',
|
|
iat: now,
|
|
exp: now + getSessionTTL(),
|
|
sid: randomBytes(16).toString('base64url'),
|
|
gen: generation,
|
|
};
|
|
|
|
const json = JSON.stringify(payload);
|
|
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
|
}
|
|
|
|
/**
|
|
* Verify and decode an admin session token. Returns null if invalid or expired.
|
|
*/
|
|
export function verifyAdminSession(token: string): AdminSessionPayload | null {
|
|
try {
|
|
const key = getKey();
|
|
const data = Buffer.from(token, 'base64');
|
|
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
|
|
|
|
const iv = data.subarray(0, IV_LENGTH);
|
|
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
|
|
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
decipher.setAAD(ADMIN_SESSION_AAD);
|
|
decipher.setAuthTag(tag);
|
|
|
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
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;
|
|
|
|
return payload;
|
|
} catch {
|
|
return 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.
|
|
export { isSameOriginRequest };
|
|
|
|
/**
|
|
* Validate the admin session from cookies. Returns the payload or a 401 response.
|
|
*
|
|
* Also rejects cross-origin state-changing requests with 403 to prevent CSRF
|
|
* against cookie-authenticated admin actions.
|
|
*/
|
|
export async function requireAdminAuth(request: Request): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
|
|
if (!isSameOriginRequest(request)) {
|
|
return { error: NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 }) };
|
|
}
|
|
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
|
|
|
|
if (!token) {
|
|
return { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
|
|
}
|
|
|
|
const payload = await verifyCurrentAdminSession(token);
|
|
if (!payload) {
|
|
cookieStore.delete(ADMIN_SESSION_COOKIE);
|
|
return { error: NextResponse.json({ error: 'Session expired' }, { status: 401 }) };
|
|
}
|
|
|
|
return { payload };
|
|
}
|
|
|
|
/**
|
|
* Set the admin session cookie.
|
|
*
|
|
* `Secure` follows the request the way the setup wizard's cookie does: the
|
|
* browser drops Secure cookies on plain HTTP, so setting it unconditionally in
|
|
* production made admin sign-in fail silently on an HTTP deployment while the
|
|
* wizard right next to it worked. Without a request (older callers) the
|
|
* previous production default stands. An explicit COOKIE_SECURE wins over
|
|
* both, as it does for the mailbox cookies: a TLS proxy that does not send
|
|
* X-Forwarded-Proto would otherwise make the request look like plain HTTP.
|
|
*/
|
|
export async function setAdminSessionCookie(request?: Request): Promise<void> {
|
|
const token = createAdminSession(await getAdminSessionGeneration());
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: secureCookieFlag(request),
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: getSessionTTL(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the client IP from the request headers.
|
|
*
|
|
* Proxies typically *append* to X-Forwarded-For, so the last entry
|
|
* before our trusted proxy is the most reliable client IP. When a
|
|
* single reverse proxy sits in front of the app the rightmost entry
|
|
* is the one added by that proxy. We take the rightmost entry to
|
|
* avoid trusting attacker-controlled values prepended to the header.
|
|
*
|
|
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
|
|
* to the number of trusted proxies (default 1).
|
|
*/
|
|
export function getClientIP(request: Request): string {
|
|
const forwarded = request.headers.get('x-forwarded-for');
|
|
if (forwarded) {
|
|
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
|
|
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
|
|
// Take the entry at position (length - depth), clamped to 0
|
|
const index = Math.max(0, parts.length - depth);
|
|
return parts[index] || '0.0.0.0';
|
|
}
|
|
return request.headers.get('x-real-ip') || '0.0.0.0';
|
|
}
|