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:
+41
-7
@@ -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
@@ -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
@@ -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.");
|
||||
|
||||
@@ -20,7 +20,6 @@ export function Navbar({ username, role }: NavbarProps) {
|
||||
} catch {
|
||||
// ignore logout errors
|
||||
}
|
||||
localStorage.removeItem("archivmail_token");
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
|
||||
+7
-10
@@ -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
@@ -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() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user