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,17 @@
|
||||
export const ALLOWED_AVATAR_MIME_TYPES: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
};
|
||||
|
||||
export function isAllowedAvatarMimeType(mimeType: string): boolean {
|
||||
return mimeType in ALLOWED_AVATAR_MIME_TYPES;
|
||||
}
|
||||
|
||||
const MIME_TYPE_BY_EXTENSION: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(ALLOWED_AVATAR_MIME_TYPES).map(([mime, ext]) => [ext, mime]),
|
||||
);
|
||||
|
||||
export function mimeTypeForExtension(extension: string): string {
|
||||
return MIME_TYPE_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DisableMfaDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class MfaConfirmDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
setupToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required when changing email; ignored otherwise',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentPassword?: string;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
||||
import { isAllowedAvatarMimeType } from './avatar-mime-types';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
||||
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deliberately its own top-level path, not nested under /users/:id -
|
||||
* "/users/me" would collide with UsersController's PATCH/GET ":id" route
|
||||
* (id="me") depending on module registration order. Every route here is
|
||||
* self-scoped (no @RequirePermission): the resource acted on is always the
|
||||
* caller's own account (actor.id), never a path param, so there is no admin
|
||||
* permission that applies. Listed in
|
||||
* test/authorization-coverage.e2e-spec.ts's ALLOWLIST.
|
||||
*/
|
||||
@ApiTags('profile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('profile')
|
||||
export class ProfileController {
|
||||
constructor(private readonly profileService: ProfileService) {}
|
||||
|
||||
@Patch()
|
||||
updateProfile(
|
||||
@Body() dto: UpdateProfileDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.profileService.updateProfile(actor.id, dto);
|
||||
}
|
||||
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('password')
|
||||
@HttpCode(204)
|
||||
changePassword(
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.profileService.changePassword(
|
||||
actor.id,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('avatar')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_AVATAR_SIZE_BYTES },
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!isAllowedAvatarMimeType(file.mimetype)) {
|
||||
callback(
|
||||
new BadRequestException('Nicht unterstützter Dateityp'),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@HttpCode(204)
|
||||
uploadAvatar(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
return this.profileService.uploadAvatar(actor.id, file);
|
||||
}
|
||||
|
||||
@Delete('avatar')
|
||||
@HttpCode(204)
|
||||
deleteAvatar(@CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.profileService.deleteAvatar(actor.id);
|
||||
}
|
||||
|
||||
@Get('avatar')
|
||||
async getAvatar(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, mimeType } = await this.profileService.getAvatar(actor.id);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Post('mfa/enroll')
|
||||
enrollMfa(@CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.profileService.enrollMfa(actor.id, actor.email);
|
||||
}
|
||||
|
||||
@Post('mfa/enroll/confirm')
|
||||
@HttpCode(204)
|
||||
confirmMfaEnrollment(
|
||||
@Body() dto: MfaConfirmDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.profileService.confirmMfaEnrollment(
|
||||
actor.id,
|
||||
actor.email,
|
||||
dto.setupToken,
|
||||
dto.code,
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('mfa/disable')
|
||||
@HttpCode(204)
|
||||
disableMfa(
|
||||
@Body() dto: DisableMfaDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.profileService.disableMfa(actor.id, dto.currentPassword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MfaModule } from '../auth/mfa/mfa.module';
|
||||
import { ProfileController } from './profile.controller';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
@Module({
|
||||
imports: [MfaModule],
|
||||
controllers: [ProfileController],
|
||||
providers: [ProfileService],
|
||||
})
|
||||
export class ProfileModule {}
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { ProfileService } from './profile.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService } from '../auth/mfa/mfa.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
describe('ProfileService', () => {
|
||||
let service: ProfileService;
|
||||
let prisma: {
|
||||
user: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
refreshToken: { updateMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let mfaService: {
|
||||
startEnrollment: jest.Mock;
|
||||
confirmEnrollment: jest.Mock;
|
||||
};
|
||||
let permissionResolver: { invalidateUser: jest.Mock };
|
||||
|
||||
const baseUser = {
|
||||
id: 'user-1',
|
||||
email: '[email protected]',
|
||||
name: 'Existing User',
|
||||
passwordHash: '',
|
||||
avatarPath: null as string | null,
|
||||
mfaEnabled: false,
|
||||
mfaSecret: null as string | null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
baseUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
||||
baseUser.avatarPath = null;
|
||||
baseUser.mfaEnabled = false;
|
||||
baseUser.mfaSecret = null;
|
||||
prisma = {
|
||||
user: { findUnique: jest.fn(), update: jest.fn() },
|
||||
refreshToken: { updateMany: jest.fn().mockResolvedValue({}) },
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
||||
permissionResolver = { invalidateUser: jest.fn() };
|
||||
service = new ProfileService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
mfaService as unknown as MfaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data'));
|
||||
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('throws NotFoundException when the user does not exist', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.updateProfile('missing', { name: 'New' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when changing email without the correct current password', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await expect(
|
||||
service.updateProfile('user-1', {
|
||||
email: '[email protected]',
|
||||
currentPassword: 'wrong-password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictException when the new email is already in use', async () => {
|
||||
prisma.user.findUnique
|
||||
.mockResolvedValueOnce(baseUser)
|
||||
.mockResolvedValueOnce({ id: 'someone-else' });
|
||||
await expect(
|
||||
service.updateProfile('user-1', {
|
||||
email: '[email protected]',
|
||||
currentPassword: 'correct-password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('updates name and email and records an audit entry', async () => {
|
||||
prisma.user.findUnique
|
||||
.mockResolvedValueOnce(baseUser)
|
||||
.mockResolvedValueOnce(null);
|
||||
prisma.user.update.mockResolvedValue({
|
||||
...baseUser,
|
||||
name: 'New Name',
|
||||
email: '[email protected]',
|
||||
});
|
||||
|
||||
const result = await service.updateProfile('user-1', {
|
||||
name: 'New Name',
|
||||
email: '[email protected]',
|
||||
currentPassword: 'correct-password',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user-1',
|
||||
name: 'New Name',
|
||||
email: '[email protected]',
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'User', action: 'UPDATE' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('throws UnauthorizedException when the current password is wrong', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await expect(
|
||||
service.changePassword('user-1', 'wrong-password', 'new-password123'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hashes the new password and revokes all active refresh tokens', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.changePassword(
|
||||
'user-1',
|
||||
'correct-password',
|
||||
'new-password123',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.user.update.mock.calls as [
|
||||
[{ data: { passwordHash: string; passwordChangedAt: Date } }],
|
||||
];
|
||||
expect(updateArg.data.passwordHash).not.toBe(baseUser.passwordHash);
|
||||
expect(updateArg.data.passwordChangedAt).toBeInstanceOf(Date);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', revokedAt: null },
|
||||
}),
|
||||
);
|
||||
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
const file = {
|
||||
buffer: Buffer.from('binary'),
|
||||
originalname: 'me.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1234,
|
||||
} as Express.Multer.File;
|
||||
|
||||
it('rejects an unsupported mime type', async () => {
|
||||
await expect(
|
||||
service.uploadAvatar('user-1', {
|
||||
...file,
|
||||
mimetype: 'application/zip',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('writes the file, updates avatarPath, and deletes the old file if present', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
...baseUser,
|
||||
avatarPath: '/uploads/avatars/old.jpg',
|
||||
});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.uploadAvatar('user-1', file);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/old.jpg');
|
||||
const [[updateArg]] = prisma.user.update.mock.calls as [
|
||||
[{ data: { avatarPath: string } }],
|
||||
];
|
||||
expect(updateArg.data.avatarPath).toContain('avatars');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAvatar', () => {
|
||||
it('does nothing when there is no avatar', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await service.deleteAvatar('user-1');
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unlinks the file and clears avatarPath', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
...baseUser,
|
||||
avatarPath: '/uploads/avatars/me.jpg',
|
||||
});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.deleteAvatar('user-1');
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/me.jpg');
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { avatarPath: null } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatar', () => {
|
||||
it('throws NotFoundException when no avatar is set', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await expect(service.getAvatar('user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the file and infers the mime type from the extension', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
...baseUser,
|
||||
avatarPath: '/uploads/avatars/me.png',
|
||||
});
|
||||
|
||||
const result = await service.getAvatar('user-1');
|
||||
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.buffer).toEqual(Buffer.from('data'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrollMfa', () => {
|
||||
it('throws ConflictException when MFA is already enabled', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
...baseUser,
|
||||
mfaEnabled: true,
|
||||
});
|
||||
await expect(
|
||||
service.enrollMfa('user-1', baseUser.email),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('delegates to MfaService.startEnrollment', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
mfaService.startEnrollment.mockResolvedValue({
|
||||
secret: 'secret',
|
||||
qrCodeSvg: '<svg></svg>',
|
||||
otpauthUri: 'otpauth://totp/x',
|
||||
setupToken: 'token',
|
||||
});
|
||||
|
||||
const result = await service.enrollMfa('user-1', baseUser.email);
|
||||
|
||||
expect(mfaService.startEnrollment).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
baseUser.email,
|
||||
);
|
||||
expect(result.setupToken).toBe('token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmMfaEnrollment', () => {
|
||||
it('persists the encrypted secret and records MFA_ENABLED', async () => {
|
||||
mfaService.confirmEnrollment.mockResolvedValue({
|
||||
encryptedSecret: 'encrypted',
|
||||
});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.confirmMfaEnrollment(
|
||||
'user-1',
|
||||
baseUser.email,
|
||||
'setup-token',
|
||||
'123456',
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { mfaEnabled: true, mfaSecret: 'encrypted' },
|
||||
}),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'MFA_ENABLED' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableMfa', () => {
|
||||
it('throws UnauthorizedException when the current password is wrong', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await expect(
|
||||
service.disableMfa('user-1', 'wrong-password'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears MFA fields and records MFA_DISABLED', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.disableMfa('user-1', 'correct-password');
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { mfaEnabled: false, mfaSecret: null },
|
||||
}),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'MFA_DISABLED' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import {
|
||||
ALLOWED_AVATAR_MIME_TYPES,
|
||||
mimeTypeForExtension,
|
||||
} from './avatar-mime-types';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
export interface ProfileSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function avatarStorageRoot(): string {
|
||||
return path.join(
|
||||
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
|
||||
'avatars',
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProfileService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
private async requireUser(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileSummary> {
|
||||
const before = await this.requireUser(userId);
|
||||
if (dto.email && dto.email !== before.email) {
|
||||
const currentMatches =
|
||||
dto.currentPassword &&
|
||||
(await bcrypt.compare(dto.currentPassword, before.passwordHash));
|
||||
if (!currentMatches) {
|
||||
throw new UnauthorizedException(
|
||||
'Aktuelles Passwort erforderlich, um die E-Mail-Adresse zu ändern',
|
||||
);
|
||||
}
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already in use');
|
||||
}
|
||||
}
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { name: dto.name, email: dto.email },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
beforeData: { name: before.name, email: before.email },
|
||||
afterData: { name: user.name, email: user.email },
|
||||
});
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.requireUser(userId);
|
||||
const currentMatches = await bcrypt.compare(
|
||||
currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!currentMatches) {
|
||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, passwordChangedAt: new Date() },
|
||||
});
|
||||
// A changed password should invalidate any session issued before the
|
||||
// change, on this device or any other: refresh tokens are revoked
|
||||
// outright, and the permission cache is cleared so JwtAuthGuard picks
|
||||
// up the new passwordChangedAt within this request cycle instead of
|
||||
// waiting out the cache TTL - that's what makes a still-live access
|
||||
// token stop working immediately (see PermissionResolverService).
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
this.permissionResolver.invalidateUser(userId);
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
|
||||
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
}
|
||||
const user = await this.requireUser(userId);
|
||||
const extension = ALLOWED_AVATAR_MIME_TYPES[file.mimetype];
|
||||
const directory = avatarStorageRoot();
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const avatarPath = path.join(
|
||||
directory,
|
||||
`${crypto.randomUUID()}${extension}`,
|
||||
);
|
||||
await fs.writeFile(avatarPath, file.buffer);
|
||||
if (user.avatarPath) {
|
||||
await fs.unlink(user.avatarPath).catch(() => undefined);
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatarPath },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAvatar(userId: string): Promise<void> {
|
||||
const user = await this.requireUser(userId);
|
||||
if (!user.avatarPath) {
|
||||
return;
|
||||
}
|
||||
await fs.unlink(user.avatarPath).catch(() => undefined);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatarPath: null },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async getAvatar(
|
||||
userId: string,
|
||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
const user = await this.requireUser(userId);
|
||||
if (!user.avatarPath) {
|
||||
throw new NotFoundException('No avatar set');
|
||||
}
|
||||
const buffer = await fs.readFile(user.avatarPath);
|
||||
return {
|
||||
buffer,
|
||||
mimeType: mimeTypeForExtension(path.extname(user.avatarPath)),
|
||||
};
|
||||
}
|
||||
|
||||
async enrollMfa(userId: string, email: string): Promise<MfaEnrollment> {
|
||||
const user = await this.requireUser(userId);
|
||||
if (user.mfaEnabled) {
|
||||
throw new ConflictException('MFA is already enabled');
|
||||
}
|
||||
return this.mfaService.startEnrollment(userId, email);
|
||||
}
|
||||
|
||||
async confirmMfaEnrollment(
|
||||
userId: string,
|
||||
email: string,
|
||||
setupToken: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
const { encryptedSecret } = await this.mfaService.confirmEnrollment(
|
||||
userId,
|
||||
setupToken,
|
||||
email,
|
||||
code,
|
||||
);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: true, mfaSecret: encryptedSecret },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'MFA_ENABLED',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async disableMfa(userId: string, currentPassword: string): Promise<void> {
|
||||
const user = await this.requireUser(userId);
|
||||
const currentMatches = await bcrypt.compare(
|
||||
currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!currentMatches) {
|
||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: false, mfaSecret: null },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
action: 'MFA_DISABLED',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user