Files
SRCmail/app/api/admin/auth/route.ts
T

236 lines
9.0 KiB
TypeScript

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';
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;
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<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 {
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 });
}
}