import { NextRequest, NextResponse } from 'next/server'; import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password'; import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { checkRateLimit } from '@/lib/admin/rate-limit'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * 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(); async function fetchWithTimeout(url: string, init: RequestInit): Promise { 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 { const creds = await getStalwartCredentials(request); if (!creds) return false; const cacheKey = `${creds.serverUrl}${creds.username}`; 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 { 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; }; 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]>; }; 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 { const ip = getClientIP(request); 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(); 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 { const ip = getClientIP(request); 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 }); } }