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 { UsersService } from '../users/users.service'; import { ALLOWED_AVATAR_MIME_TYPES, MAX_AVATAR_SIZE_BYTES, 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 readonly usersService: UsersService, ) {} 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 { 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 { 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 deactivateAccount( userId: string, currentPassword: string, ): Promise { 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.usersService.deactivate(userId, userId); } async uploadAvatar(userId: string, dataUrl: string): Promise { const match = /^data:(image\/(?:jpeg|png|webp));base64,(.+)$/.exec(dataUrl); if (!match) { throw new BadRequestException('Ungültiges Bildformat'); } const [, mimeType, base64] = match; if (!(mimeType in ALLOWED_AVATAR_MIME_TYPES)) { throw new BadRequestException('Nicht unterstützter Dateityp'); } const buffer = Buffer.from(base64, 'base64'); if (buffer.length > MAX_AVATAR_SIZE_BYTES) { throw new BadRequestException('Datei zu groß (max. 2 MB)'); } const user = await this.requireUser(userId); const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType]; const directory = avatarStorageRoot(); await fs.mkdir(directory, { recursive: true }); const avatarPath = path.join( directory, `${crypto.randomUUID()}${extension}`, ); await fs.writeFile(avatarPath, 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 { 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 { 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 { 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 { 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, }); } }