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:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 09:09:58 +02:00
1 parent 1b16b49913
commit b7ff92abd3
16 files changed
+225 -28

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ import {
setRefreshTokenCookie,
} from './util/refresh-token-cookie';
interface MfaRequiredResponse {
export interface MfaRequiredResponse {
mfaRequired: true;
mfaToken: string;
}
@@ -240,6 +240,9 @@ describe('AuthService', () => {
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (result.mfaRequired) {
throw new Error('expected mfaRequired to be false');
}
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.create).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
@@ -247,6 +250,26 @@ describe('AuthService', () => {
);
});
it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
mfaEnabled: true,
mfaSecret: 'encrypted-secret',
});
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (!result.mfaRequired) {
throw new Error('expected mfaRequired to be true');
}
expect(result.mfaToken).toBe('mfa-challenge-token');
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN' }),
);
});
it('throws when the user is deactivated', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
+15 -3
View File
@@ -144,12 +144,24 @@ export class AuthService {
return { mfaRequired: false, accessToken, refreshToken };
}
/** Issues our own access/refresh token pair for a user already authenticated via SSO. */
async issueTokensForSsoUser(userId: string): Promise<AuthTokens> {
/**
* Issues our own access/refresh token pair for a user already
* authenticated via SSO. Local MFA is still enforced here, same as
* password login: SSO establishes identity, not the second factor, so a
* user who has explicitly enabled TOTP must complete it here too rather
* than getting it silently skipped by signing in via Microsoft instead.
*/
async issueTokensForSsoUser(userId: string): Promise<LoginResult> {
const user = await this.usersService.findById(userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const accessToken = this.signAccessToken({
id: user.id,
email: user.email,
@@ -161,7 +173,7 @@ export class AuthService {
action: 'LOGIN',
userId: user.id,
});
return { accessToken, refreshToken };
return { mfaRequired: false, accessToken, refreshToken };
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
@@ -13,6 +13,7 @@ import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
@@ -121,7 +122,7 @@ export class SsoController {
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
): Promise<AccessTokenDto | MfaRequiredResponse> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
@@ -132,10 +133,12 @@ export class SsoController {
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const { accessToken, refreshToken } =
await this.authService.issueTokensForSsoUser(userId);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {