feat: implement Stalwart admin authentication and role check across admin routes

This commit is contained in:
Linus Rath
2026-03-26 20:21:12 +01:00
parent 37bc88dbad
commit 6696636df8
12 changed files with 166 additions and 24 deletions
+52 -16
View File
@@ -19,6 +19,8 @@ import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
const NAV_GROUPS = [
{
label: 'Overview',
@@ -54,6 +56,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
@@ -67,19 +70,50 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client;
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
}
async function checkAuth() {
try {
const res = await fetch('/api/admin/auth');
const jmapHeaders = getJmapHeaders();
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (!data.enabled) {
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (!data.authenticated) {
router.replace('/admin/login');
if (data.authenticated) {
setAuthenticated(true);
return;
}
setAuthenticated(true);
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await fetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (loginRes.ok) {
setAuthenticated(true);
return;
}
}
router.replace('/admin/login');
} catch {
router.replace('/admin/login');
}
@@ -153,21 +187,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
+56 -8
View File
@@ -4,19 +4,58 @@ import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getCl
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 via principal roles.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
const response = await fetch(
`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`,
{ method: 'GET', headers: { 'Authorization': creds.authHeader } }
);
if (!response.ok) return false;
const data = await response.json();
const principal = data.data ?? data;
const roles: string[] = Array.isArray(principal?.roles) ? principal.roles : [];
return roles.includes('admin');
} catch {
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 });
}
const ip = getClientIP(request);
// Rate limit check
const limit = checkRateLimit(ip);
if (!limit.allowed) {
@@ -28,7 +67,6 @@ export async function POST(request: NextRequest) {
);
}
const body = await request.json();
const { password } = body;
if (!password || typeof password !== 'string') {
@@ -55,27 +93,37 @@ export async function POST(request: NextRequest) {
/**
* 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() {
export async function GET(request: NextRequest) {
try {
await initAdminPassword();
if (!isAdminEnabled()) {
return NextResponse.json({ enabled: false, authenticated: false }, {
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: true, authenticated: false }, {
return NextResponse.json({
enabled: adminEnabled,
authenticated: false,
stalwartAdmin: isStalwartAdmin,
}, {
headers: { 'Cache-Control': 'no-store' },
});
}
const meta = getAdminMeta();
return NextResponse.json({
enabled: true,
enabled: adminEnabled,
authenticated: true,
stalwartAdmin: isStalwartAdmin,
lastLogin: meta?.lastLogin,
passwordChangedAt: meta?.passwordChangedAt,
}, {
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user has the 'admin' role in Stalwart.
* Uses the user's JMAP session credentials.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
const response = await fetch(
`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`,
{
method: 'GET',
headers: { 'Authorization': creds.authHeader },
}
);
if (!response.ok) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
const data = await response.json();
const principal = data.data ?? data;
const roles: string[] = Array.isArray(principal?.roles) ? principal.roles : [];
const isStalwartAdmin = roles.includes('admin');
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Kontakte",
"calendar": "Kalender",
"settings": "Einstellungen",
"admin": "Admin",
"files": "Dateien",
"loading_mailboxes": "Postfächer werden geladen...",
"push_connected": "Echtzeit-Updates aktiv",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contacts",
"calendar": "Calendar",
"settings": "Settings",
"admin": "Admin",
"files": "Files",
"loading_mailboxes": "Loading mailboxes...",
"push_connected": "Real-time updates active",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contactos",
"calendar": "Calendario",
"settings": "Configuración",
"admin": "Admin",
"files": "Archivos",
"loading_mailboxes": "Cargando buzones...",
"push_connected": "Actualizaciones en tiempo real activas",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contacts",
"calendar": "Calendrier",
"settings": "Paramètres",
"admin": "Admin",
"files": "Fichiers",
"loading_mailboxes": "Chargement des boîtes mail...",
"push_connected": "Mises à jour en temps réel actives",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contatti",
"calendar": "Calendario",
"settings": "Impostazioni",
"admin": "Admin",
"files": "File",
"loading_mailboxes": "Caricamento caselle di posta...",
"push_connected": "Aggiornamenti in tempo reale attivi",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "連絡先",
"calendar": "カレンダー",
"settings": "設定",
"admin": "管理",
"files": "ファイル",
"loading_mailboxes": "メールボックスを読み込み中...",
"push_connected": "リアルタイム更新が有効",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contacten",
"calendar": "Agenda",
"settings": "Instellingen",
"admin": "Admin",
"files": "Bestanden",
"loading_mailboxes": "Mappen laden...",
"push_connected": "Real-time updates actief",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Contatos",
"calendar": "Calendário",
"settings": "Configurações",
"admin": "Admin",
"files": "Ficheiros",
"loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas",
+1
View File
@@ -75,6 +75,7 @@
"contacts": "Контакты",
"calendar": "Календарь",
"settings": "Настройки",
"admin": "Админ",
"files": "Файлы",
"loading_mailboxes": "Загрузка папок...",
"push_connected": "Обновления в реальном времени активны",