feat(PROJ-1): httpOnly Cookie, Auditor-Guard, Nutzer-Aktionen (C)

Backend:
- Login setzt httpOnly SameSite=Strict Cookie (archivmail_session)
- Logout löscht Cookie + blacklistet Token
- authMiddleware: Cookie first, Bearer als Fallback (CLI kompatibel)

Frontend:
- api.ts: credentials: include statt localStorage/Bearer Token
- updateUser(), deleteUser() hinzugefügt
- useAuth: kein localStorage mehr, nur /api/auth/me
  requireRole: "admin" | "auditor" | undefined
- Login-Seite: kein localStorage
- Navbar: kein localStorage
- Admin: Nutzer-Aktionen (Sperren/Freischalten, Löschen, Passwort-Reset)
  Löschen verhindert wenn letzter Admin (HTTP 409)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-15 19:57:13 +01:00
parent a94b1d3e52
commit 7e165c8eed
6 changed files with 213 additions and 62 deletions
+41 -7
View File
@@ -178,8 +178,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Success: true,
})
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: 8 * 3600,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
// Secure: true — enable when TLS is terminated at this server
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
@@ -206,11 +215,27 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if err := s.authMgr.Logout(token); err != nil {
writeError(w, http.StatusInternalServerError, "logout failed")
return
// Read token from cookie first, then Bearer header
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
token = extractBearerToken(r)
}
if token != "" {
_ = s.authMgr.Logout(token)
}
// Clear the session cookie
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
@@ -517,11 +542,20 @@ func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
// --- middleware ---
const sessionCookieName = "archivmail_session"
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
// Prefer httpOnly cookie; fall back to Bearer token for CLI/API clients.
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization header")
token = extractBearerToken(r)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization")
return
}
+124 -8
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
import {
getUsers,
createUser,
updateUser,
deleteUser,
getAuditLog,
getSMTPStatus,
getHealth,
@@ -64,7 +66,7 @@ function formatBytes(bytes: number): string {
}
export default function AdminPage() {
const { user, loading: authLoading } = useAuth(true);
const { user, loading: authLoading } = useAuth("admin");
// Dashboard state
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
@@ -95,6 +97,13 @@ export default function AdminPage() {
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
// User action state
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
const [resetPasswordValue, setResetPasswordValue] = useState("");
const [resetPasswordError, setResetPasswordError] = useState("");
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
// Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
@@ -224,6 +233,47 @@ export default function AdminPage() {
}
}
async function handleToggleActive(u: User) {
setUserActionLoading(u.id);
try {
await updateUser(u.id, { active: !u.active });
loadUsers();
} catch {
// ignore
} finally {
setUserActionLoading(null);
}
}
async function handleDeleteUser(u: User) {
if (!confirm(`Benutzer "${u.username}" wirklich löschen?`)) return;
setUserActionLoading(u.id);
try {
await deleteUser(u.id);
loadUsers();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setUserActionLoading(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUserId) return;
setResetPasswordLoading(true);
setResetPasswordError("");
try {
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
setResetPasswordUserId(null);
setResetPasswordValue("");
} catch {
setResetPasswordError("Passwort konnte nicht geändert werden.");
} finally {
setResetPasswordLoading(false);
}
}
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
if (authLoading || !user) {
@@ -836,25 +886,54 @@ export default function AdminPage() {
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.username}>
<TableCell className="font-medium">
{u.username}
</TableCell>
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={u.active ? "default" : "destructive"}
>
<Badge variant={u.active ? "default" : "destructive"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => {
setResetPasswordUserId(u.id);
setResetPasswordValue("");
setResetPasswordError("");
}}
>
Passwort
</Button>
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => handleToggleActive(u)}
>
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={userActionLoading === u.id}
onClick={() => handleDeleteUser(u)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -941,6 +1020,43 @@ export default function AdminPage() {
</TabsContent>
</Tabs>
</main>
{/* Passwort-Reset Dialog */}
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passwort zurücksetzen</DialogTitle>
<DialogDescription>
Neues Passwort für den Benutzer festlegen.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleResetPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={resetPasswordValue}
onChange={(e) => setResetPasswordValue(e.target.value)}
required
minLength={8}
placeholder="Mindestens 8 Zeichen"
/>
</div>
{resetPasswordError && (
<p className="text-sm text-destructive">{resetPasswordError}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
Abbrechen
</Button>
<Button type="submit" disabled={resetPasswordLoading}>
{resetPasswordLoading ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+5 -6
View File
@@ -16,10 +16,10 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
useEffect(() => {
const token = localStorage.getItem("archivmail_token");
if (token) {
router.replace("/search");
}
// Check if already logged in via session cookie
import("@/lib/api").then(({ getMe }) =>
getMe().then(() => router.replace("/search")).catch(() => {})
);
}, [router]);
async function handleSubmit(e: React.FormEvent) {
@@ -28,8 +28,7 @@ export default function LoginPage() {
setLoading(true);
try {
const res = await login(username, password);
localStorage.setItem("archivmail_token", res.token);
await login(username, password);
router.push("/search");
} catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
-1
View File
@@ -20,7 +20,6 @@ export function Navbar({ username, role }: NavbarProps) {
} catch {
// ignore logout errors
}
localStorage.removeItem("archivmail_token");
router.push("/");
}
+7 -10
View File
@@ -10,7 +10,7 @@ interface AuthState {
error: string | null;
}
export function useAuth(requireAdmin?: boolean) {
export function useAuth(requireRole?: "admin" | "auditor") {
const router = useRouter();
const [state, setState] = useState<AuthState>({
user: null,
@@ -19,24 +19,21 @@ export function useAuth(requireAdmin?: boolean) {
});
const checkAuth = useCallback(async () => {
const token = localStorage.getItem("archivmail_token");
if (!token) {
router.replace("/");
return;
}
try {
const user = await getMe();
if (requireAdmin && user.role !== "admin") {
if (requireRole === "admin" && user.role !== "admin") {
router.replace("/search");
return;
}
if (requireRole === "auditor" && user.role !== "auditor" && user.role !== "admin") {
router.replace("/search");
return;
}
setState({ user, loading: false, error: null });
} catch {
localStorage.removeItem("archivmail_token");
router.replace("/");
}
}, [router, requireAdmin]);
}, [router, requireRole]);
useEffect(() => {
checkAuth();
+34 -28
View File
@@ -1,31 +1,22 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("archivmail_token");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: "include",
});
if (res.status === 401) {
if (typeof window !== "undefined") {
localStorage.removeItem("archivmail_token");
window.location.href = "/";
}
throw new Error("Unauthorized");
@@ -44,12 +35,16 @@ async function request<T>(
// Types
export interface LoginResponse {
token: string;
user: {
id: number;
username: string;
email: string;
role: string;
};
}
export interface User {
id: number;
username: string;
email: string;
role: string;
@@ -136,6 +131,13 @@ export interface CreateUserRequest {
role: string;
}
export interface UpdateUserRequest {
email?: string;
role?: string;
active?: boolean;
password?: string;
}
// API functions
export async function login(
@@ -187,6 +189,17 @@ export async function createUser(data: CreateUserRequest): Promise<User> {
});
}
export async function updateUser(id: number, data: UpdateUserRequest): Promise<User> {
return request<User>(`/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export async function deleteUser(id: number): Promise<void> {
await request<void>(`/api/users/${id}`, { method: "DELETE" });
}
export interface StorageStats {
total_mails: number;
total_bytes: number;
@@ -212,9 +225,8 @@ export async function downloadMailAttachment(
id: string,
index: number
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "include",
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
const disposition = res.headers.get("Content-Disposition") || "";
@@ -226,9 +238,8 @@ export async function downloadMailAttachment(
export async function downloadMailRaw(
id: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "include",
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
return { blob: await res.blob(), filename: `${id}.eml` };
@@ -237,11 +248,11 @@ export async function downloadMailRaw(
export interface ServiceStatus {
name: string;
display_name: string;
active: string; // active | inactive | failed | unknown
sub: string; // running | dead | exited | ...
enabled: string; // enabled | disabled | static | unknown
active: string;
sub: string;
enabled: string;
description: string;
external_blocked?: boolean; // only present for archivmail
external_blocked?: boolean;
}
export async function getServices(): Promise<ServiceStatus[]> {
@@ -397,9 +408,8 @@ export async function getSystemStats(): Promise<SystemStats> {
// ── Export ────────────────────────────────────────────────────────────────
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "include",
});
if (!res.ok) throw new Error("PDF export failed");
const blob = await res.blob();
@@ -409,16 +419,12 @@ export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename:
}
export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/zip`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ids, attachments }),
});
if (!res.ok) throw new Error("ZIP export failed");
const blob = await res.blob();
return { blob };
return { blob: await res.blob() };
}