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]>
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
import { getApiUrl } from '../config/runtime-config';
|
|
|
|
export interface AccessTokenResponse {
|
|
accessToken: string;
|
|
}
|
|
|
|
export interface MfaRequiredResponse {
|
|
mfaRequired: true;
|
|
mfaToken: string;
|
|
}
|
|
|
|
export type LoginResponse = AccessTokenResponse | MfaRequiredResponse;
|
|
|
|
export function isMfaRequired(response: LoginResponse): response is MfaRequiredResponse {
|
|
return 'mfaRequired' in response && response.mfaRequired;
|
|
}
|
|
|
|
export async function loginRequest(email: string, password: string): Promise<LoginResponse> {
|
|
const apiUrl = await getApiUrl();
|
|
const response = await fetch(`${apiUrl}/auth/login`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.');
|
|
}
|
|
return response.json() as Promise<LoginResponse>;
|
|
}
|
|
|
|
export async function verifyMfaRequest(mfaToken: string, code: string): Promise<AccessTokenResponse> {
|
|
const apiUrl = await getApiUrl();
|
|
const response = await fetch(`${apiUrl}/auth/mfa/verify`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mfaToken, code }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Code ungültig. Bitte erneut versuchen.');
|
|
}
|
|
return response.json() as Promise<AccessTokenResponse>;
|
|
}
|
|
|
|
// The refresh token is single-use and rotates on every call. If two callers
|
|
// (e.g. React StrictMode's dev double-invoke, or two components hitting a 401
|
|
// at once) both call refresh at the same time, the second one always fails
|
|
// because the first already rotated the cookie. Deduplicating into one
|
|
// shared in-flight request makes concurrent callers share the same result
|
|
// instead of racing each other.
|
|
let inFlightRefresh: Promise<AccessTokenResponse | null> | null = null;
|
|
|
|
async function performRefresh(): Promise<AccessTokenResponse | null> {
|
|
const apiUrl = await getApiUrl();
|
|
const response = await fetch(`${apiUrl}/auth/refresh`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
return response.json() as Promise<AccessTokenResponse>;
|
|
}
|
|
|
|
export function refreshRequest(): Promise<AccessTokenResponse | null> {
|
|
if (!inFlightRefresh) {
|
|
inFlightRefresh = performRefresh().finally(() => {
|
|
inFlightRefresh = null;
|
|
});
|
|
}
|
|
return inFlightRefresh;
|
|
}
|
|
|
|
export async function ssoStatusRequest(): Promise<boolean> {
|
|
const apiUrl = await getApiUrl();
|
|
try {
|
|
const response = await fetch(`${apiUrl}/auth/sso/status`);
|
|
if (!response.ok) {
|
|
return false;
|
|
}
|
|
const data = (await response.json()) as { enabled: boolean };
|
|
return data.enabled;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function ssoLoginUrl(): Promise<string> {
|
|
const apiUrl = await getApiUrl();
|
|
return `${apiUrl}/auth/sso/login`;
|
|
}
|
|
|
|
// 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.
|
|
// 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',
|
|
credentials: 'include',
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
|
}
|
|
return response.json() as Promise<LoginResponse>;
|
|
}
|
|
|
|
export async function logoutRequest(): Promise<void> {
|
|
const apiUrl = await getApiUrl();
|
|
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
|
|
}
|
|
|
|
export interface CurrentUserProfile {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
roleKey: string;
|
|
roleName: string;
|
|
permissions: string[];
|
|
mfaEnabled: boolean;
|
|
hasAvatar: boolean;
|
|
}
|
|
|
|
export async function getCurrentUserRequest(accessToken: string): Promise<CurrentUserProfile> {
|
|
const apiUrl = await getApiUrl();
|
|
const response = await fetch(`${apiUrl}/auth/me`, {
|
|
credentials: 'include',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Profil konnte nicht geladen werden.');
|
|
}
|
|
return response.json() as Promise<CurrentUserProfile>;
|
|
}
|