Files
SRCmail/app/api/admin/auth/route.ts
T
Bernd Rodler 47b9ab4398 fix: Phase 1 critical+high fixes (17/18 items)
CRITICAL fixes:
- C1: Error swallowing - throw TransportError on network failure in getEmails/searchEmails
- C2: Recurrence expansion ID delimiter changed from ':' to '::occurrence::'
- C3: Cross-account calendar event UID dedup after multi-account aggregation
- C4: Admin session token revocation via JTI blacklist on logout
- C6: FTS5 schema-drop - add warning log for automatic reindex trigger
- C7: Settings lock - gate updateSetting() with isSettingLocked() check
- C8: Offline push pause - add offline event handler that closes push transports

HIGH fixes:
- H1: Push handler - add ContactCard and FileNode branches
- H2: WS fallback - await state snapshot before reconcileAfterWebSocketFallback
- H3: Auth rate limiting - add checkUserAuthRateLimit to session and token routes
- H4: OAuth logs - strip access_token from error log context
- H7: Template XSS - apply DOMPurify to HTML template body on import
- H8: Secure cookie - derive from x-forwarded-proto, not NODE_ENV
- H9: bcrypt fix - remove bcrypt prefixes from isHashed() so scrypt-only
- H13: calendarTasksEnabled - apply admin gate at runtime in calendar page
- H14: Task mutations - add try/catch error handling to update/delete/toggle
- H18: autoSelectReplyIdentity default changed from false to true

Deferred: P1.3 (C5 auth localStorage encryption) - requires custom Zustand persist adapter.
2026-08-07 12:40:32 +02:00

255 lines
9.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
import { setAdminSessionCookie, clearAdminSessionCookie, revokeAdminSession, requireAdminAuth, getClientIP, isSameOriginRequest } from '@/lib/admin/session';
import { checkRateLimit } from '@/lib/admin/rate-limit';
import { auditLog } from '@/lib/admin/audit';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
/**
* Check if the current user is a Stalwart admin by attempting an
* admin-only JMAP method call. Stalwart v0.16 removed the REST management
* API (including the /api/account permissions endpoint) and exposes all
* administration through JMAP under the `urn:stalwart:jmap` capability.
*
* `x:Account/query` requires the `sysAccountQuery` permission, which is
* granted to admins and tenant admins. If the method returns a result,
* the caller is an admin; a method error (typically `forbidden`) means
* the caller is a regular user.
*
* Results are cached briefly per (server, user) — the admin panel's
* layout and dashboard page between them trigger this three times on
* every navigation, and admin status does not change on that timescale.
*/
const ADMIN_CHECK_CACHE_MS = 60_000;
const ADMIN_CHECK_TIMEOUT_MS = 10_000;
const adminCheckCache = new Map<string, { value: boolean; expires: number }>();
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ADMIN_CHECK_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
// When users may connect to arbitrary JMAP servers, the cookie-stored
// serverUrl is attacker-controllable — trusting an admin-status response
// from such a server would let anyone mint an admin session by standing
// up a JMAP-shaped endpoint. Require the password login in that mode.
await configManager.ensureLoaded();
if (configManager.get<boolean>('allowCustomJmapEndpoint', false)) return false;
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Key on a hash of the actual authHeader, not the cookie-claimed username.
// The username field is caller-supplied in /api/auth/stalwart-context and
// can be spoofed independently of the credential; keying on it would let
// any user ride an admin's recent cache entry by sending a cookie with
// username="admin@host" plus their own authHeader.
const credHash = createHash('sha256').update(creds.authHeader).digest('base64url');
const cacheKey = `${creds.serverUrl}${credHash}`;
const cached = adminCheckCache.get(cacheKey);
if (cached && cached.expires > Date.now()) return cached.value;
const isAdmin = await runStalwartAdminCheck(creds);
adminCheckCache.set(cacheKey, { value: isAdmin, expires: Date.now() + ADMIN_CHECK_CACHE_MS });
return isAdmin;
}
async function runStalwartAdminCheck(
creds: { serverUrl: string; authHeader: string; username: string },
): Promise<boolean> {
try {
const sessionRes = await fetchWithTimeout(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!sessionRes.ok) {
logger.info('Stalwart admin check (session)', { username: creds.username, status: sessionRes.status, isAdmin: false });
return false;
}
const session = await sessionRes.json() as {
primaryAccounts?: Record<string, string>;
};
const accountId = session.primaryAccounts?.['urn:stalwart:jmap']
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
?? (session.primaryAccounts ? Object.values(session.primaryAccounts)[0] : undefined);
if (!accountId) {
logger.info('Stalwart admin check (no account)', { username: creds.username, isAdmin: false });
return false;
}
// Hit the externally-reachable URL, not session.apiUrl — Stalwart
// advertises its internal bind address (e.g. http://host:8080/jmap/)
// which may not be routable from this process.
const jmapRes = await fetchWithTimeout(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:Account/query', { accountId, limit: 1 }, '0']],
}),
});
if (!jmapRes.ok) {
logger.info('Stalwart admin check (jmap)', { username: creds.username, status: jmapRes.status, isAdmin: false });
return false;
}
const data = await jmapRes.json() as {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
};
const first = data.methodResponses?.[0];
const isAdmin = !!first && first[0] === 'x:Account/query';
logger.info('Stalwart admin check', { username: creds.username, isAdmin });
return isAdmin;
} catch (error) {
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
return false;
}
}
/**
* POST /api/admin/auth - Login
*/
export async function POST(request: NextRequest) {
try {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
}
const ip = getClientIP(request);
const cookieStore = await import('next/headers').then(m => m.cookies());
const token = (await cookieStore).get('admin_session')?.value;
if (token) revokeAdminSession(token);
const body = await request.json();
// Stalwart-based admin authentication
if (body.stalwartAuth === true) {
const isStalwartAdmin = await checkStalwartAdmin(request);
if (!isStalwartAdmin) {
await auditLog('admin.login_failed', { method: 'stalwart' }, ip);
return NextResponse.json({ error: 'Not a Stalwart admin' }, { status: 403 });
}
await setAdminSessionCookie();
await auditLog('admin.login', { method: 'stalwart' }, ip);
return NextResponse.json({ ok: true });
}
// Password-based admin authentication
await initAdminPassword();
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
}
// Rate limit check
const limit = checkRateLimit(ip);
if (!limit.allowed) {
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
return NextResponse.json(
{ error: 'Too many login attempts. Try again later.' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
);
}
const { password } = body;
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
}
const valid = await verifyAdminPassword(password);
if (!valid) {
await auditLog('admin.login_failed', {}, ip);
logger.warn('Admin login failed', { ip });
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
await setAdminSessionCookie();
await updateLastLogin();
await auditLog('admin.login', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* GET /api/admin/auth - Check session status
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
*/
export async function GET(request: NextRequest) {
try {
await initAdminPassword();
const adminEnabled = isAdminEnabled();
const isStalwartAdmin = await checkStalwartAdmin(request);
// If neither password-based admin nor Stalwart admin, admin is disabled
if (!adminEnabled && !isStalwartAdmin) {
return NextResponse.json({ enabled: false, authenticated: false, stalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
const result = await requireAdminAuth(request);
if ('error' in result) {
return NextResponse.json({
enabled: adminEnabled,
authenticated: false,
stalwartAdmin: isStalwartAdmin,
}, {
headers: { 'Cache-Control': 'no-store' },
});
}
const meta = getAdminMeta();
return NextResponse.json({
enabled: adminEnabled,
authenticated: true,
stalwartAdmin: isStalwartAdmin,
lastLogin: meta?.lastLogin,
passwordChangedAt: meta?.passwordChangedAt,
}, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/auth - Logout
*/
export async function DELETE(request: NextRequest) {
try {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
}
const ip = getClientIP(request);
const sessionToken = (await cookies()).get('admin_session')?.value;
if (sessionToken) revokeAdminSession(sessionToken);
await clearAdminSessionCookie();
await auditLog('admin.logout', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}