import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { authenticator } from 'otplib'; import { EncryptionService } from '../infra/encryption.service'; import { PrismaService } from '../infra/prisma.service'; import { SettingsService } from './settings.service'; import { AccessService } from './access.service'; @Injectable() export class TotpService { private readonly totp = authenticator.clone({ window: 2 }); constructor( private readonly prisma: PrismaService, private readonly encryption: EncryptionService, private readonly settings: SettingsService, private readonly access: AccessService ) {} async getStatus(userId: string) { const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); return { isEnabled: Boolean(record?.isEnabled) }; } async isEnabledForUser(userId: string) { const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); return Boolean(record?.isEnabled); } async setup(userId: string) { await this.ensureUserExists(userId); const issuer = await this.resolveIssuer(); const user = await this.prisma.user.findUnique({ where: { id: userId } }); const accountLabel = this.buildAccountLabel(user, userId); const existing = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); if (existing && !existing.isEnabled) { const secret = this.tryDecryptSecret(existing.secretEnc); if (secret) { return { secret, otpauthUrl: this.totp.keyuri(accountLabel, issuer, secret) }; } } const secret = this.totp.generateSecret(); const secretEnc = this.encryption.encrypt(secret); this.assertSecretRoundTrip(secret, secretEnc); const otpauthUrl = this.totp.keyuri(accountLabel, issuer, secret); await this.prisma.userTotpSecret.upsert({ where: { userId }, create: { userId, secretEnc, isEnabled: false }, update: { secretEnc, isEnabled: false } }); return { secret, otpauthUrl }; } async enable(userId: string, code: string) { const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); if (!record) { throw new BadRequestException('Сначала начните настройку приложения-аутентификатора'); } if (record.isEnabled) { return { isEnabled: true }; } this.assertValidCode(record.secretEnc, code); await this.prisma.userTotpSecret.update({ where: { userId }, data: { isEnabled: true } }); return { isEnabled: true }; } async disable(userId: string, code: string) { const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); if (!record?.isEnabled) { throw new BadRequestException('Двухфакторная аутентификация не включена'); } this.assertValidCode(record.secretEnc, code); await this.prisma.userTotpSecret.delete({ where: { userId } }); return { isEnabled: false }; } async adminDisable(actorUserId: string, targetUserId: string) { await this.access.assertSuperAdmin(actorUserId); await this.ensureUserExists(targetUserId); const record = await this.prisma.userTotpSecret.findUnique({ where: { userId: targetUserId } }); if (!record?.isEnabled) { throw new BadRequestException('Двухфакторная аутентификация не включена'); } await this.prisma.userTotpSecret.delete({ where: { userId: targetUserId } }); return { isEnabled: false }; } async verifyEnabledCode(userId: string, code: string) { const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } }); if (!record?.isEnabled) { return false; } return this.isValidCode(record.secretEnc, code); } private async resolveIssuer() { const projectName = (await this.settings.getValue('PROJECT_NAME', 'MVK ID')).trim(); return projectName || 'MVK ID'; } private buildAccountLabel( user: { email: string | null; phone: string | null; username: string | null } | null, userId: string ) { return user?.email ?? user?.phone ?? user?.username ?? userId; } private tryDecryptSecret(secretEnc: string) { try { const secret = this.encryption.decrypt(secretEnc).trim(); return secret.length > 0 ? secret : null; } catch { return null; } } private assertSecretRoundTrip(secret: string, secretEnc: string) { const decrypted = this.encryption.decrypt(secretEnc); if (decrypted !== secret) { throw new InternalServerErrorException('Не удалось сохранить секрет аутентификатора'); } const probeToken = this.totp.generate(secret); if (!this.totp.verify({ token: probeToken, secret: decrypted })) { throw new InternalServerErrorException('Не удалось проверить секрет аутентификатора'); } } private assertValidCode(secretEnc: string, code: string) { if (!this.isValidCode(secretEnc, code)) { throw new BadRequestException( 'Неверный код аутентификатора. Убедитесь, что отсканирован последний QR-код, и повторите настройку при необходимости.' ); } } private isValidCode(secretEnc: string, code: string) { const normalized = code.replace(/\s/g, ''); if (!/^\d{6}$/.test(normalized)) { return false; } const secret = this.tryDecryptSecret(secretEnc); if (!secret) { return false; } return this.totp.verify({ token: normalized, secret }); } private async ensureUserExists(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || user.deletedAt) { throw new NotFoundException('Пользователь не найден'); } } }