feat: MFA (TOTP) und Self-Service-Profilseite
Backend Image / build-and-push (push) Successful in 2m57s
Frontend Image / build-and-push (push) Successful in 2m30s

Benutzer koennen jetzt selbst Zwei-Faktor-Authentifizierung einrichten
und ihr Profil verwalten (Name, E-Mail, Passwort, Profilbild).

Backend:
- Neues MfaModule (src/auth/mfa/): AES-256-GCM-verschluesselte
  TOTP-Secrets (otplib), QR-Code-Erzeugung, kurzlebige Setup-/
  Challenge-JWTs fuer den 2-Schritt-Login-Flow
- Neues ProfileModule (src/profile/): PATCH /profile,
  POST /profile/password, POST/DELETE/GET /profile/avatar,
  POST /profile/mfa/enroll(/confirm), POST /profile/mfa/disable -
  alle self-scoped ueber actor.id, ohne @RequirePermission (siehe
  ALLOWLIST in authorization-coverage.e2e-spec.ts)
- AuthService.login() zweistufig: bei aktivem MFA erst
  { mfaRequired, mfaToken } statt Tokens, neuer Endpunkt
  POST /auth/mfa/verify schliesst den Flow ab
- Admin-Recovery: POST /users/:id/mfa/reset (users:update) fuer
  verlorene MFA-Geraete, da es keinen E-Mail-Reset-Flow gibt

Frontend:
- Login-Seite mit zweitem Schritt fuer den TOTP-Code
- Neue Profilseite (/profile, ausserhalb des admin-only
  Settings-Bereichs): Profilbild, Name/E-Mail, Passwort, MFA-Ein-/
  Ausrichtung mit QR-Code-Dialog
- Header zeigt jetzt Avatar mit Dropdown (Profil/Abmelden) statt
  nur E-Mail-Text
- Admin-Users-Tabelle zeigt MFA-Status + Reset-Button

Haertung nach Security-Review (Backend/Frontend-Diff, kein CRITICAL,
mehrere MEDIUM behoben):
- Access-Token wird bei Passwortwechsel sofort ungueltig
  (User.passwordChangedAt + JWT-iat-Vergleich in
  PermissionResolverService.isTokenValid, vorher isUserActive) statt
  erst nach bis zu 15 Minuten
- E-Mail-Aenderung verlangt jetzt aktuelles Passwort
- POST /profile/password und /profile/mfa/disable haben jetzt das
  gleiche strikte Throttling wie Login (SENSITIVE_ACTION_THROTTLE)
- Kein stiller leerer Verschluesselungs-Key-Fallback mehr (wirft
  jetzt explizit)

Bewusst zurueckgestellt (dokumentiert, kein aktiver Exploit-Pfad):
dedizierter Verschluesselungs-Key fuer MFA-Secrets statt
JWT_ACCESS_SECRET-Ableitung, dedizierte users:mfa-reset-Permission
statt users:update, separate JWT-Secrets pro Token-Zweck.

Verifiziert: tsc/eslint clean, 297/297 Backend- + 31/31
Frontend-Unit-Tests, Build clean auf beiden Projekten. e2e weiterhin
durch bekanntes WSL2/Postgres-Problem blockiert. Unabhaengiges
Security- und Code-Quality-Review durchgefuehrt (0 CRITICAL, 0 HIGH).
This commit is contained in:
Nick Szaunig committed 2026-08-27 00:25:34 +02:00
1 parent 6776bdfe73
commit 62004483cf
55 files changed
+2876 -129

No files matched your search

@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UserAvatar } from "./user-avatar";
import { fetchAvatarUrl } from "@/lib/profile/api";
vi.mock("@/lib/profile/api", () => ({
fetchAvatarUrl: vi.fn(),
}));
describe("UserAvatar", () => {
beforeEach(() => {
vi.mocked(fetchAvatarUrl).mockReset();
});
it("shows initials and does not fetch when there is no avatar", () => {
render(<UserAvatar name="Anna Berger" hasAvatar={false} />);
expect(screen.getByText("AB")).toBeInTheDocument();
expect(fetchAvatarUrl).not.toHaveBeenCalled();
});
it("fetches the avatar blob when hasAvatar is true", async () => {
// base-ui's AvatarImage only swaps in once the browser fires a real
// image load event, which jsdom never does for a blob: URL - so this
// asserts on our own fetch call, not on what base-ui renders from it.
vi.mocked(fetchAvatarUrl).mockResolvedValueOnce("blob:mock-url");
render(<UserAvatar name="Anna Berger" hasAvatar />);
await vi.waitFor(() => expect(fetchAvatarUrl).toHaveBeenCalledTimes(1));
});
});
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { fetchAvatarUrl } from "@/lib/profile/api";
function initials(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
export function UserAvatar({
name,
hasAvatar,
size = "default",
}: {
name: string;
hasAvatar: boolean;
size?: "default" | "sm" | "lg";
}) {
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
useEffect(() => {
if (!hasAvatar) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
void fetchAvatarUrl().then((url) => {
if (cancelled) {
if (url) {
URL.revokeObjectURL(url);
}
return;
}
objectUrl = url;
setAvatarUrl(url);
});
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [hasAvatar]);
const showImage = hasAvatar && avatarUrl;
return (
<Avatar size={size}>
{showImage ? <AvatarImage src={avatarUrl} alt={name} /> : null}
<AvatarFallback>{initials(name) || "?"}</AvatarFallback>
</Avatar>
);
}