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 { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
@@ -54,6 +56,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
@@ -67,19 +70,50 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [pathname]);
|
}, [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() {
|
async function checkAuth() {
|
||||||
try {
|
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();
|
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('/');
|
router.replace('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data.authenticated) {
|
|
||||||
router.replace('/admin/login');
|
if (data.authenticated) {
|
||||||
|
setAuthenticated(true);
|
||||||
return;
|
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 {
|
} catch {
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
}
|
}
|
||||||
@@ -153,21 +187,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||||
<Link
|
{!isStalwartAdmin && (
|
||||||
href="/admin/change-password"
|
<Link
|
||||||
className={cn(
|
href="/admin/change-password"
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
className={cn(
|
||||||
pathname === '/admin/change-password'
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
pathname === '/admin/change-password'
|
||||||
: 'hover:bg-muted text-foreground'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
)}
|
: 'hover:bg-muted text-foreground'
|
||||||
>
|
)}
|
||||||
<KeyRound className={cn(
|
>
|
||||||
'w-4 h-4 shrink-0',
|
<KeyRound className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
Change Password
|
Change Password
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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 { checkRateLimit } from '@/lib/admin/rate-limit';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { logger } from '@/lib/logger';
|
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
|
* POST /api/admin/auth — Login
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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();
|
await initAdminPassword();
|
||||||
if (!isAdminEnabled()) {
|
if (!isAdminEnabled()) {
|
||||||
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
|
||||||
|
|
||||||
// Rate limit check
|
// Rate limit check
|
||||||
const limit = checkRateLimit(ip);
|
const limit = checkRateLimit(ip);
|
||||||
if (!limit.allowed) {
|
if (!limit.allowed) {
|
||||||
@@ -28,7 +67,6 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { password } = body;
|
const { password } = body;
|
||||||
|
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
@@ -55,27 +93,37 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/auth — Check session status
|
* 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 {
|
try {
|
||||||
await initAdminPassword();
|
await initAdminPassword();
|
||||||
if (!isAdminEnabled()) {
|
const adminEnabled = isAdminEnabled();
|
||||||
return NextResponse.json({ enabled: false, authenticated: false }, {
|
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' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth();
|
||||||
if ('error' in result) {
|
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' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = getAdminMeta();
|
const meta = getAdminMeta();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
enabled: true,
|
enabled: adminEnabled,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
stalwartAdmin: isStalwartAdmin,
|
||||||
lastLogin: meta?.lastLogin,
|
lastLogin: meta?.lastLogin,
|
||||||
passwordChangedAt: meta?.passwordChangedAt,
|
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",
|
"contacts": "Kontakte",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"loading_mailboxes": "Postfächer werden geladen...",
|
"loading_mailboxes": "Postfächer werden geladen...",
|
||||||
"push_connected": "Echtzeit-Updates aktiv",
|
"push_connected": "Echtzeit-Updates aktiv",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"loading_mailboxes": "Loading mailboxes...",
|
"loading_mailboxes": "Loading mailboxes...",
|
||||||
"push_connected": "Real-time updates active",
|
"push_connected": "Real-time updates active",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Archivos",
|
"files": "Archivos",
|
||||||
"loading_mailboxes": "Cargando buzones...",
|
"loading_mailboxes": "Cargando buzones...",
|
||||||
"push_connected": "Actualizaciones en tiempo real activas",
|
"push_connected": "Actualizaciones en tiempo real activas",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Fichiers",
|
"files": "Fichiers",
|
||||||
"loading_mailboxes": "Chargement des boîtes mail...",
|
"loading_mailboxes": "Chargement des boîtes mail...",
|
||||||
"push_connected": "Mises à jour en temps réel actives",
|
"push_connected": "Mises à jour en temps réel actives",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contatti",
|
"contacts": "Contatti",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "File",
|
"files": "File",
|
||||||
"loading_mailboxes": "Caricamento caselle di posta...",
|
"loading_mailboxes": "Caricamento caselle di posta...",
|
||||||
"push_connected": "Aggiornamenti in tempo reale attivi",
|
"push_connected": "Aggiornamenti in tempo reale attivi",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
|
"admin": "管理",
|
||||||
"files": "ファイル",
|
"files": "ファイル",
|
||||||
"loading_mailboxes": "メールボックスを読み込み中...",
|
"loading_mailboxes": "メールボックスを読み込み中...",
|
||||||
"push_connected": "リアルタイム更新が有効",
|
"push_connected": "リアルタイム更新が有効",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contacten",
|
"contacts": "Contacten",
|
||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Bestanden",
|
"files": "Bestanden",
|
||||||
"loading_mailboxes": "Mappen laden...",
|
"loading_mailboxes": "Mappen laden...",
|
||||||
"push_connected": "Real-time updates actief",
|
"push_connected": "Real-time updates actief",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
|
"admin": "Admin",
|
||||||
"files": "Ficheiros",
|
"files": "Ficheiros",
|
||||||
"loading_mailboxes": "Carregando caixas de entrada...",
|
"loading_mailboxes": "Carregando caixas de entrada...",
|
||||||
"push_connected": "Atualizações em tempo real ativas",
|
"push_connected": "Atualizações em tempo real ativas",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"contacts": "Контакты",
|
"contacts": "Контакты",
|
||||||
"calendar": "Календарь",
|
"calendar": "Календарь",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
|
"admin": "Админ",
|
||||||
"files": "Файлы",
|
"files": "Файлы",
|
||||||
"loading_mailboxes": "Загрузка папок...",
|
"loading_mailboxes": "Загрузка папок...",
|
||||||
"push_connected": "Обновления в реальном времени активны",
|
"push_connected": "Обновления в реальном времени активны",
|
||||||
|
|||||||
Reference in New Issue
Block a user