security: Zufallspasswörter beim Erststart, kryptographisch sichere JTI-Generierung

- seedDefaultUsers: generiert kryptographisch zufällige Passwörter (crypto/rand)
  statt hartkodiertes "archivmailrockz" — Passwörter werden einmalig im Terminal
  angezeigt und können danach nicht wiederhergestellt werden
- generateJTI: verwendet crypto/rand (16 Byte, hex) statt time.UnixNano XOR deadbeef

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 01:19:24 +01:00
parent 7e165c8eed
commit bb963a796f
25 changed files with 471 additions and 111 deletions
+106 -9
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { features, type Feature } from "@/data/features";
import {
getUsers,
createUser,
@@ -276,18 +277,17 @@ export default function AdminPage() {
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-7xl px-4 py-6">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-sm" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
<Tabs defaultValue="dashboard">
@@ -296,6 +296,7 @@ export default function AdminPage() {
<TabsTrigger value="services">Dienste</TabsTrigger>
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
<TabsTrigger value="modules">Module</TabsTrigger>
</TabsList>
{/* ── Dashboard ── */}
@@ -1018,7 +1019,11 @@ export default function AdminPage() {
</>
)}
</TabsContent>
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
</Tabs>
</>)}
</main>
{/* Passwort-Reset Dialog */}
@@ -1060,3 +1065,95 @@ export default function AdminPage() {
</div>
);
}
// ── Module Tab ─────────────────────────────────────────────────────────────
const statusColors: Record<string, string> = {
"Planned": "bg-gray-100 text-gray-700",
"In Progress": "bg-yellow-100 text-yellow-800",
"In Review": "bg-blue-100 text-blue-800",
"Deployed": "bg-green-100 text-green-800",
};
const statusCounts = (list: Feature[]) => ({
total: list.length,
planned: list.filter((f) => f.status === "Planned").length,
inProgress: list.filter((f) => f.status === "In Progress").length,
inReview: list.filter((f) => f.status === "In Review").length,
deployed: list.filter((f) => f.status === "Deployed").length,
});
function ModulesTab() {
const counts = statusCounts(features);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Modulübersicht</h2>
{/* Summary bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
].map((s) => (
<Card key={s.label}>
<CardContent className="p-4 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
{s.value}
</span>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>Feature</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-24 text-center">Frontend</TableHead>
<TableHead className="w-24 text-center">Backend</TableHead>
<TableHead className="w-32">Aktualisiert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{features.map((f) => (
<TableRow key={f.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
{f.id}
</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
{f.status}
</span>
</TableCell>
<TableCell className="text-center">
{f.frontend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-center">
{f.backend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{f.lastUpdated}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
);
}
+8 -9
View File
@@ -229,18 +229,16 @@ export default function ImapPage() {
}
}
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-4xl px-4 py-6">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">IMAP Import</h1>
<Button
@@ -512,6 +510,7 @@ export default function ImapPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</>)}
</main>
</div>
);
+9 -9
View File
@@ -290,18 +290,17 @@ export default function MailViewPage({
}
}
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
{/* Back + Actions */}
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -394,6 +393,7 @@ export default function MailViewPage({
)}
</>
)}
</>)}
</main>
</div>
);
+5 -4
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/lib/api";
import { getCachedUser } from "@/lib/auth-cache";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,10 +17,10 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
useEffect(() => {
// Check if already logged in via session cookie
import("@/lib/api").then(({ getMe }) =>
getMe().then(() => router.replace("/search")).catch(() => {})
);
// Only redirect if we have a cached session — no API call, no loop risk
if (getCachedUser() !== null) {
router.replace("/search");
}
}, [router]);
async function handleSubmit(e: React.FormEvent) {
+64 -10
View File
@@ -17,6 +17,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -31,6 +38,12 @@ import { Switch } from "@/components/ui/switch";
const PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
@@ -40,6 +53,8 @@ export default function SearchPage() {
const [toFilter, setToFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [sort, setSort] = useState("date_desc");
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
const [results, setResults] = useState<SearchHit[]>([]);
const [total, setTotal] = useState(0);
@@ -68,6 +83,8 @@ export default function SearchPage() {
to: toFilter || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
sort: sort !== "date_desc" ? sort : undefined,
has_attachment: hasAttachment,
page: p,
page_size: PAGE_SIZE,
});
@@ -82,7 +99,7 @@ export default function SearchPage() {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo]
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
);
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
@@ -130,18 +147,18 @@ export default function SearchPage() {
const totalPages = Math.ceil(total / PAGE_SIZE);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-7xl px-4 py-6">
{(authLoading || !user) && (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-64 w-full" />
</div>
)}
{!authLoading && user && (<>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex gap-2">
<Input
@@ -206,6 +223,34 @@ export default function SearchPage() {
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum (neu alt)</SelectItem>
<SelectItem value="date_asc">Datum (alt neu)</SelectItem>
<SelectItem value="relevance">Relevanz</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="attach-toggle"
checked={hasAttachment === true}
onCheckedChange={(checked) =>
setHasAttachment(checked ? true : undefined)
}
/>
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
Nur mit Anhang
</Label>
</div>
</div>
</form>
<div className="mt-6">
@@ -257,6 +302,8 @@ export default function SearchPage() {
<TableHead className="w-56">Von</TableHead>
<TableHead>Betreff</TableHead>
<TableHead className="w-48">An</TableHead>
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
<TableHead className="w-20 text-right">Größe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -294,6 +341,12 @@ export default function SearchPage() {
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
<TableCell className="text-center text-sm">
{hit.has_attachments ? "📎" : ""}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
{hit.size ? formatBytes(hit.size) : ""}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -353,6 +406,7 @@ export default function SearchPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</>)}
</main>
</div>
);