feat: implement Stalwart admin authentication and role check across admin routes
This commit is contained in:
+52
-16
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}, {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"contacts": "Contacts",
|
||||
"calendar": "Calendar",
|
||||
"settings": "Settings",
|
||||
"admin": "Admin",
|
||||
"files": "Files",
|
||||
"loading_mailboxes": "Loading mailboxes...",
|
||||
"push_connected": "Real-time updates active",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"contacts": "連絡先",
|
||||
"calendar": "カレンダー",
|
||||
"settings": "設定",
|
||||
"admin": "管理",
|
||||
"files": "ファイル",
|
||||
"loading_mailboxes": "メールボックスを読み込み中...",
|
||||
"push_connected": "リアルタイム更新が有効",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"contacts": "Contacten",
|
||||
"calendar": "Agenda",
|
||||
"settings": "Instellingen",
|
||||
"admin": "Admin",
|
||||
"files": "Bestanden",
|
||||
"loading_mailboxes": "Mappen laden...",
|
||||
"push_connected": "Real-time updates actief",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"contacts": "Контакты",
|
||||
"calendar": "Календарь",
|
||||
"settings": "Настройки",
|
||||
"admin": "Админ",
|
||||
"files": "Файлы",
|
||||
"loading_mailboxes": "Загрузка папок...",
|
||||
"push_connected": "Обновления в реальном времени активны",
|
||||
|
||||
Reference in New Issue
Block a user