feat: MFA (TOTP) und Self-Service-Profilseite
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user