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
+217
-20
No files matched your search
@@ -28,3 +28,8 @@ OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
|
|||||||
OIDC_CLIENT_ID="change-me-entra-client-id"
|
OIDC_CLIENT_ID="change-me-entra-client-id"
|
||||||
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||||
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
||||||
|
|
||||||
|
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
|
||||||
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
|
ENABLE_SWAGGER=false
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
setRefreshTokenCookie,
|
setRefreshTokenCookie,
|
||||||
} from './util/refresh-token-cookie';
|
} from './util/refresh-token-cookie';
|
||||||
|
|
||||||
interface MfaRequiredResponse {
|
export interface MfaRequiredResponse {
|
||||||
mfaRequired: true;
|
mfaRequired: true;
|
||||||
mfaToken: string;
|
mfaToken: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,9 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
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(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
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 () => {
|
it('throws when the user is deactivated', async () => {
|
||||||
usersService.findById.mockResolvedValue({
|
usersService.findById.mockResolvedValue({
|
||||||
...activeUser,
|
...activeUser,
|
||||||
|
|||||||
@@ -144,12 +144,24 @@ export class AuthService {
|
|||||||
return { mfaRequired: false, accessToken, refreshToken };
|
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);
|
const user = await this.usersService.findById(userId);
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
return {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaToken: this.mfaService.signChallengeToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
const accessToken = this.signAccessToken({
|
const accessToken = this.signAccessToken({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -161,7 +173,7 @@ export class AuthService {
|
|||||||
action: 'LOGIN',
|
action: 'LOGIN',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
return { accessToken, refreshToken };
|
return { mfaRequired: false, accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
|
import { MfaRequiredResponse } from '../auth.controller';
|
||||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||||
@@ -121,7 +122,7 @@ export class SsoController {
|
|||||||
async complete(
|
async complete(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto | MfaRequiredResponse> {
|
||||||
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
SSO_CODE_COOKIE
|
SSO_CODE_COOKIE
|
||||||
];
|
];
|
||||||
@@ -132,10 +133,12 @@ export class SsoController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||||
const { accessToken, refreshToken } =
|
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||||
await this.authService.issueTokensForSsoUser(userId);
|
if (result.mfaRequired) {
|
||||||
setRefreshTokenCookie(res, refreshToken);
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
return { accessToken };
|
}
|
||||||
|
setRefreshTokenCookie(res, result.refreshToken);
|
||||||
|
return { accessToken: result.accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { matchesFileSignature } from './file-signature';
|
||||||
|
|
||||||
|
describe('matchesFileSignature', () => {
|
||||||
|
it('accepts a buffer starting with the PNG signature', () => {
|
||||||
|
const png = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
|
||||||
|
]);
|
||||||
|
expect(matchesFileSignature(png, 'image/png')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a buffer starting with the JPEG signature', () => {
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||||
|
expect(matchesFileSignature(jpeg, 'image/jpeg')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a RIFF/WEBP container', () => {
|
||||||
|
const webp = Buffer.concat([
|
||||||
|
Buffer.from('RIFF', 'ascii'),
|
||||||
|
Buffer.from([0, 0, 0, 0]),
|
||||||
|
Buffer.from('WEBP', 'ascii'),
|
||||||
|
]);
|
||||||
|
expect(matchesFileSignature(webp, 'image/webp')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a PDF header', () => {
|
||||||
|
const pdf = Buffer.from('%PDF-1.7\n', 'ascii');
|
||||||
|
expect(matchesFileSignature(pdf, 'application/pdf')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a buffer whose bytes do not match the declared type', () => {
|
||||||
|
const plainText = Buffer.from('this is not an image', 'ascii');
|
||||||
|
expect(matchesFileSignature(plainText, 'image/png')).toBe(false);
|
||||||
|
expect(matchesFileSignature(plainText, 'image/jpeg')).toBe(false);
|
||||||
|
expect(matchesFileSignature(plainText, 'image/webp')).toBe(false);
|
||||||
|
expect(matchesFileSignature(plainText, 'application/pdf')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a buffer shorter than the signature it claims to have', () => {
|
||||||
|
expect(matchesFileSignature(Buffer.from([0xff, 0xd8]), 'image/jpeg')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows any bytes through for a mime type with no known signature', () => {
|
||||||
|
expect(matchesFileSignature(Buffer.from('anything'), 'text/plain')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const SIGNATURE_CHECKS: Record<string, (buffer: Buffer) => boolean> = {
|
||||||
|
'image/png': (buffer) =>
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
|
||||||
|
'image/jpeg': (buffer) =>
|
||||||
|
buffer.length >= 3 &&
|
||||||
|
buffer[0] === 0xff &&
|
||||||
|
buffer[1] === 0xd8 &&
|
||||||
|
buffer[2] === 0xff,
|
||||||
|
'image/webp': (buffer) =>
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP',
|
||||||
|
'application/pdf': (buffer) =>
|
||||||
|
buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a file's actual bytes match its declared MIME type instead of
|
||||||
|
* trusting the client-supplied value alone (a multipart Content-Type header
|
||||||
|
* or a data: URL prefix are both attacker-controlled). Used at every upload
|
||||||
|
* path that persists the bytes to disk under a server-chosen name/extension,
|
||||||
|
* so a mislabeled file can't be dropped onto the filesystem as a type it
|
||||||
|
* isn't. Types with no signature defined here are not checked (returns
|
||||||
|
* true) - callers still gate on their own MIME allowlist first.
|
||||||
|
*/
|
||||||
|
export function matchesFileSignature(
|
||||||
|
buffer: Buffer,
|
||||||
|
declaredMimeType: string,
|
||||||
|
): boolean {
|
||||||
|
const check = SIGNATURE_CHECKS[declaredMimeType];
|
||||||
|
return check ? check(buffer) : true;
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ describe('DocumentsService', () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
const file = {
|
const file = {
|
||||||
buffer: Buffer.from('binary'),
|
buffer: Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]),
|
||||||
originalname: 'bild.jpg',
|
originalname: 'bild.jpg',
|
||||||
mimetype: 'image/jpeg',
|
mimetype: 'image/jpeg',
|
||||||
size: 1234,
|
size: 1234,
|
||||||
@@ -95,6 +95,14 @@ describe('DocumentsService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a file whose bytes do not match the declared mime type', async () => {
|
||||||
|
const mislabeled = { ...file, buffer: Buffer.from('not-a-jpeg') };
|
||||||
|
await expect(
|
||||||
|
service.create(mislabeled, 'COLONY', 'colony-1', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when the referenced entity does not exist', async () => {
|
it('throws NotFoundException when the referenced entity does not exist', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(null);
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { Document, DocumentEntityType } from '../../generated/prisma';
|
import { Document, DocumentEntityType } from '../../generated/prisma';
|
||||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
||||||
|
|
||||||
@@ -86,6 +87,11 @@ export class DocumentsService {
|
|||||||
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
||||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||||
}
|
}
|
||||||
|
if (!matchesFileSignature(file.buffer, file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Datei entspricht nicht dem angegebenen Format',
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.assertEntityExists(entityType, entityId);
|
await this.assertEntityExists(entityType, entityId);
|
||||||
|
|
||||||
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
|
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ async function bootstrap() {
|
|||||||
|
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
|
|
||||||
|
// Swagger is mounted as raw Express middleware outside Nest's routing, so
|
||||||
|
// it is never covered by the global JwtAuthGuard - anyone reaching the
|
||||||
|
// host could otherwise read the full API schema (every endpoint and DTO
|
||||||
|
// shape) with no authentication at all. Off by default; an operator who
|
||||||
|
// wants it in a given environment opts in explicitly.
|
||||||
|
if (process.env.ENABLE_SWAGGER === 'true') {
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('ApiCore API')
|
.setTitle('ApiCore API')
|
||||||
.setDescription('Digitale Imkereiverwaltung – API-Dokumentation')
|
.setDescription('Digitale Imkereiverwaltung – API-Dokumentation')
|
||||||
@@ -31,6 +37,7 @@ async function bootstrap() {
|
|||||||
.build();
|
.build();
|
||||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
|
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3001);
|
await app.listen(process.env.PORT ?? 3001);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ describe('ProfileService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('uploadAvatar', () => {
|
describe('uploadAvatar', () => {
|
||||||
const dataUrl = `data:image/jpeg;base64,${Buffer.from('binary').toString('base64')}`;
|
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]);
|
||||||
|
const dataUrl = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`;
|
||||||
|
|
||||||
it('rejects a value that is not a data: URL', async () => {
|
it('rejects a value that is not a data: URL', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -200,6 +201,14 @@ describe('ProfileService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects bytes that do not match the declared mime type', async () => {
|
||||||
|
const mislabeled = `data:image/jpeg;base64,${Buffer.from('not-a-jpeg').toString('base64')}`;
|
||||||
|
await expect(
|
||||||
|
service.uploadAvatar('user-1', mislabeled),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('writes the file, updates avatarPath, and deletes the old file if present', async () => {
|
it('writes the file, updates avatarPath, and deletes the old file if present', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
...baseUser,
|
...baseUser,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as fs from 'node:fs/promises';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
@@ -155,6 +156,11 @@ export class ProfileService {
|
|||||||
if (buffer.length > MAX_AVATAR_SIZE_BYTES) {
|
if (buffer.length > MAX_AVATAR_SIZE_BYTES) {
|
||||||
throw new BadRequestException('Datei zu groß (max. 2 MB)');
|
throw new BadRequestException('Datei zu groß (max. 2 MB)');
|
||||||
}
|
}
|
||||||
|
if (!matchesFileSignature(buffer, mimeType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Datei entspricht nicht dem angegebenen Format',
|
||||||
|
);
|
||||||
|
}
|
||||||
const user = await this.requireUser(userId);
|
const user = await this.requireUser(userId);
|
||||||
const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType];
|
const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType];
|
||||||
const directory = avatarStorageRoot();
|
const directory = avatarStorageRoot();
|
||||||
|
|||||||
@@ -111,11 +111,25 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
||||||
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||||
completeSsoMock.mockResolvedValueOnce(undefined);
|
completeSsoMock.mockResolvedValueOnce({ mfaRequired: false });
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
|
await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard"));
|
||||||
expect(completeSsoMock).toHaveBeenCalled();
|
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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(false);
|
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||||
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
ssoStatusRequest().then(setSsoEnabled);
|
ssoStatusRequest().then(setSsoEnabled);
|
||||||
@@ -66,8 +70,17 @@ function LoginPageContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ssoPending) {
|
if (ssoPending) {
|
||||||
completeSso()
|
completeSso()
|
||||||
.then(() => router.replace("/dashboard"))
|
.then((outcome) => {
|
||||||
|
setIsCompletingSso(false);
|
||||||
|
if (outcome.mfaRequired) {
|
||||||
|
setMfaToken(outcome.mfaToken);
|
||||||
|
router.replace("/login");
|
||||||
|
} else {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
setIsCompletingSso(false);
|
||||||
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
||||||
router.replace("/login");
|
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
|
// 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.
|
// 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 apiUrl = await getApiUrl();
|
||||||
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
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> {
|
export async function logoutRequest(): Promise<void> {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface AuthContextValue {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||||
completeSso: () => Promise<void>;
|
completeSso: () => Promise<LoginOutcome>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hasPermission: (permission: string) => boolean;
|
hasPermission: (permission: string) => boolean;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(await applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const completeSso = useCallback(async () => {
|
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||||
const result = await ssoCompleteRequest();
|
const result = await ssoCompleteRequest();
|
||||||
|
if (isMfaRequired(result)) {
|
||||||
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
|
}
|
||||||
setUser(await applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
|
return { mfaRequired: false };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user