fix: SSO-MFA-Umgehung, Datei-Magic-Bytes und oeffentliches Swagger geschlossen
Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als niedrige Prioritaet offen gelassen wurden: - SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung stillschweigend abzuschliessen. - Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen. - Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette API-Schema ungeschuetzt oeffentlich preisgab. npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber, dieses Projekt nutzt Postgres). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
1b16b49913
commit
b7ff92abd3
16 files changed
+225
-28
No files matched your search
@@ -111,11 +111,25 @@ describe("LoginPage", () => {
|
||||
|
||||
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
||||
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||
completeSsoMock.mockResolvedValueOnce(undefined);
|
||||
completeSsoMock.mockResolvedValueOnce({ mfaRequired: false });
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
|
||||
await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard"));
|
||||
expect(completeSsoMock).toHaveBeenCalled();
|
||||
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("shows the MFA code step when SSO completion still requires local MFA", async () => {
|
||||
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||
completeSsoMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "sso-mfa-token" });
|
||||
verifyMfaMock.mockResolvedValueOnce(undefined);
|
||||
render(<LoginPage />);
|
||||
|
||||
const codeInput = await screen.findByLabelText("Code");
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
await userEvent.type(codeInput, "123456");
|
||||
await userEvent.click(screen.getByRole("button", { name: /bestätigen/i }));
|
||||
|
||||
expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456");
|
||||
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,11 @@ function LoginPageContent() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
|
||||
const isCompletingSso = ssoPending;
|
||||
// Its own state rather than deriving straight from the ssoPending query
|
||||
// param: once completeSso() resolves with an MFA challenge, the MFA form
|
||||
// must show immediately (same render pass as setMfaToken), without
|
||||
// waiting on a router.replace() round trip to first clear the param.
|
||||
const [isCompletingSso, setIsCompletingSso] = useState(ssoPending);
|
||||
|
||||
useEffect(() => {
|
||||
ssoStatusRequest().then(setSsoEnabled);
|
||||
@@ -66,8 +70,17 @@ function LoginPageContent() {
|
||||
useEffect(() => {
|
||||
if (ssoPending) {
|
||||
completeSso()
|
||||
.then(() => router.replace("/dashboard"))
|
||||
.then((outcome) => {
|
||||
setIsCompletingSso(false);
|
||||
if (outcome.mfaRequired) {
|
||||
setMfaToken(outcome.mfaToken);
|
||||
router.replace("/login");
|
||||
} else {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsCompletingSso(false);
|
||||
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
||||
router.replace("/login");
|
||||
});
|
||||
|
||||
@@ -93,7 +93,10 @@ export async function ssoLoginUrl(): Promise<string> {
|
||||
|
||||
// The one-time completion code lives only in an httpOnly cookie the callback
|
||||
// set (never in the URL or a request body) - the browser sends it automatically.
|
||||
export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
||||
// Returns the same union as loginRequest: SSO establishes identity, not the
|
||||
// second factor, so a user with local MFA enabled still has to complete the
|
||||
// same challenge as a password login before tokens are issued.
|
||||
export async function ssoCompleteRequest(): Promise<LoginResponse> {
|
||||
const apiUrl = await getApiUrl();
|
||||
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
||||
method: 'POST',
|
||||
@@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
||||
if (!response.ok) {
|
||||
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
||||
}
|
||||
return response.json() as Promise<AccessTokenResponse>;
|
||||
return response.json() as Promise<LoginResponse>;
|
||||
}
|
||||
|
||||
export async function logoutRequest(): Promise<void> {
|
||||
|
||||
@@ -30,7 +30,7 @@ interface AuthContextValue {
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
completeSso: () => Promise<void>;
|
||||
completeSso: () => Promise<LoginOutcome>;
|
||||
logout: () => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
}, []);
|
||||
|
||||
const completeSso = useCallback(async () => {
|
||||
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||
const result = await ssoCompleteRequest();
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
return { mfaRequired: false };
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
|
||||
Reference in new issue
Block a user