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, 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{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": map[string]interface{}{ "user": map[string]interface{}{
"id": user.ID, "id": user.ID,
"username": user.Username, "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) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r) // Read token from cookie first, then Bearer header
if err := s.authMgr.Logout(token); err != nil { token := ""
writeError(w, http.StatusInternalServerError, "logout failed") if c, err := r.Cookie(sessionCookieName); err == nil {
return 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()) sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{ s.audlog.Log(audit.Entry{
@@ -517,11 +542,20 @@ func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
// --- middleware --- // --- middleware ---
const sessionCookieName = "archivmail_session"
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 == "" { if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization header") token = extractBearerToken(r)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization")
return return
} }
+124 -8
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
import { import {
getUsers, getUsers,
createUser, createUser,
updateUser,
deleteUser,
getAuditLog, getAuditLog,
getSMTPStatus, getSMTPStatus,
getHealth, getHealth,
@@ -64,7 +66,7 @@ function formatBytes(bytes: number): string {
} }
export default function AdminPage() { export default function AdminPage() {
const { user, loading: authLoading } = useAuth(true); const { user, loading: authLoading } = useAuth("admin");
// Dashboard state // Dashboard state
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null); const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
@@ -95,6 +97,13 @@ export default function AdminPage() {
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState(""); 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 // Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]); const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0); 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); const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
if (authLoading || !user) { if (authLoading || !user) {
@@ -836,25 +886,54 @@ export default function AdminPage() {
<TableHead>E-Mail</TableHead> <TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead> <TableHead>Rolle</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((u) => ( {users.map((u) => (
<TableRow key={u.username}> <TableRow key={u.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">{u.username}</TableCell>
{u.username}
</TableCell>
<TableCell>{u.email}</TableCell> <TableCell>{u.email}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{u.role}</Badge> <Badge variant="secondary">{u.role}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge variant={u.active ? "default" : "destructive"}>
variant={u.active ? "default" : "destructive"}
>
{u.active ? "Aktiv" : "Inaktiv"} {u.active ? "Aktiv" : "Inaktiv"}
</Badge> </Badge>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -941,6 +1020,43 @@ export default function AdminPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</main> </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> </div>
); );
} }
+5 -6
View File
@@ -16,10 +16,10 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("archivmail_token"); // Check if already logged in via session cookie
if (token) { import("@/lib/api").then(({ getMe }) =>
router.replace("/search"); getMe().then(() => router.replace("/search")).catch(() => {})
} );
}, [router]); }, [router]);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
@@ -28,8 +28,7 @@ export default function LoginPage() {
setLoading(true); setLoading(true);
try { try {
const res = await login(username, password); await login(username, password);
localStorage.setItem("archivmail_token", res.token);
router.push("/search"); router.push("/search");
} catch { } catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen."); setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
-1
View File
@@ -20,7 +20,6 @@ export function Navbar({ username, role }: NavbarProps) {
} catch { } catch {
// ignore logout errors // ignore logout errors
} }
localStorage.removeItem("archivmail_token");
router.push("/"); router.push("/");
} }
+7 -10
View File
@@ -10,7 +10,7 @@ interface AuthState {
error: string | null; error: string | null;
} }
export function useAuth(requireAdmin?: boolean) { export function useAuth(requireRole?: "admin" | "auditor") {
const router = useRouter(); const router = useRouter();
const [state, setState] = useState<AuthState>({ const [state, setState] = useState<AuthState>({
user: null, user: null,
@@ -19,24 +19,21 @@ export function useAuth(requireAdmin?: boolean) {
}); });
const checkAuth = useCallback(async () => { const checkAuth = useCallback(async () => {
const token = localStorage.getItem("archivmail_token");
if (!token) {
router.replace("/");
return;
}
try { try {
const user = await getMe(); 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"); router.replace("/search");
return; return;
} }
setState({ user, loading: false, error: null }); setState({ user, loading: false, error: null });
} catch { } catch {
localStorage.removeItem("archivmail_token");
router.replace("/"); router.replace("/");
} }
}, [router, requireAdmin]); }, [router, requireRole]);
useEffect(() => { useEffect(() => {
checkAuth(); checkAuth();
+34 -28
View File
@@ -1,31 +1,22 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; 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>( async function request<T>(
path: string, path: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<T> { ): Promise<T> {
const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
...options, ...options,
headers, headers,
credentials: "include",
}); });
if (res.status === 401) { if (res.status === 401) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem("archivmail_token");
window.location.href = "/"; window.location.href = "/";
} }
throw new Error("Unauthorized"); throw new Error("Unauthorized");
@@ -44,12 +35,16 @@ async function request<T>(
// Types // Types
export interface LoginResponse { export interface LoginResponse {
token: string; user: {
id: number;
username: string; username: string;
email: string;
role: string; role: string;
};
} }
export interface User { export interface User {
id: number;
username: string; username: string;
email: string; email: string;
role: string; role: string;
@@ -136,6 +131,13 @@ export interface CreateUserRequest {
role: string; role: string;
} }
export interface UpdateUserRequest {
email?: string;
role?: string;
active?: boolean;
password?: string;
}
// API functions // API functions
export async function login( 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 { export interface StorageStats {
total_mails: number; total_mails: number;
total_bytes: number; total_bytes: number;
@@ -212,9 +225,8 @@ export async function downloadMailAttachment(
id: string, id: string,
index: number index: number
): Promise<{ blob: Blob; filename: string }> { ): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, { 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}`); if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
const disposition = res.headers.get("Content-Disposition") || ""; const disposition = res.headers.get("Content-Disposition") || "";
@@ -226,9 +238,8 @@ export async function downloadMailAttachment(
export async function downloadMailRaw( export async function downloadMailRaw(
id: string id: string
): Promise<{ blob: Blob; filename: string }> { ): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, { 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}`); if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
return { blob: await res.blob(), filename: `${id}.eml` }; return { blob: await res.blob(), filename: `${id}.eml` };
@@ -237,11 +248,11 @@ export async function downloadMailRaw(
export interface ServiceStatus { export interface ServiceStatus {
name: string; name: string;
display_name: string; display_name: string;
active: string; // active | inactive | failed | unknown active: string;
sub: string; // running | dead | exited | ... sub: string;
enabled: string; // enabled | disabled | static | unknown enabled: string;
description: string; description: string;
external_blocked?: boolean; // only present for archivmail external_blocked?: boolean;
} }
export async function getServices(): Promise<ServiceStatus[]> { export async function getServices(): Promise<ServiceStatus[]> {
@@ -397,9 +408,8 @@ export async function getSystemStats(): Promise<SystemStats> {
// ── Export ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> { export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, { 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"); if (!res.ok) throw new Error("PDF export failed");
const blob = await res.blob(); 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 }> { export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/zip`, { const res = await fetch(`${API_BASE}/api/export/zip`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", credentials: "include",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ ids, attachments }), body: JSON.stringify({ ids, attachments }),
}); });
if (!res.ok) throw new Error("ZIP export failed"); if (!res.ok) throw new Error("ZIP export failed");
const blob = await res.blob(); return { blob: await res.blob() };
return { blob };
} }