import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { randomInt } from 'node:crypto'; import { PrismaService } from '../infra/prisma.service'; import { SettingsService } from './settings.service'; import { MessagingService } from '../infra/messaging.service'; import { RedisService } from '../infra/redis.service'; import { NotificationsService } from './notifications.service'; @Injectable() export class OtpService { constructor( private readonly prisma: PrismaService, private readonly settings: SettingsService, private readonly messaging: MessagingService, private readonly redis: RedisService, private readonly notifications: NotificationsService ) {} async send(command: { target: string; channel: string; purpose: string; userId?: string; ipAddress?: string }) { const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10); const code = String(randomInt(100000, 999999)); const expiresAt = new Date(Date.now() + expiryMinutes * 60_000); await this.prisma.authCode.create({ data: { userId: command.userId, target: command.target, channel: command.channel, purpose: command.purpose, codeHash: await bcrypt.hash(code, 10), expiresAt } }); const channel = command.channel === 'sms' ? 'sms' : 'email'; await this.messaging.sendVerificationCode({ channel, target: command.target, code, expiryMinutes }); if (command.userId) { const ipLabel = command.ipAddress?.trim() || 'неизвестного IP'; await this.notifications.create( command.userId, 'otp_code', this.purposeLabel(command.purpose), `Попытка входа с ${ipLabel} — код для верификации: ${code}`, { code, ipAddress: command.ipAddress ?? null, purpose: command.purpose } ); } await this.redis.client.del(this.attemptKey(command.target, command.purpose)); return { sent: true, expiresAt: expiresAt.toISOString() }; } async verify(command: { target: string; code: string; purpose: string }) { const authCode = await this.prisma.authCode.findFirst({ where: { target: command.target, purpose: command.purpose, usedAt: null, expiresAt: { gt: new Date() } }, orderBy: { createdAt: 'desc' } }); if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек'); const attemptKey = this.attemptKey(command.target, command.purpose); const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5); const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0); if (attempts >= maxAttempts) { throw new UnauthorizedException('Превышено число попыток ввода кода'); } const matches = await bcrypt.compare(command.code, authCode.codeHash); if (!matches) { await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60); throw new UnauthorizedException('Неверный код подтверждения'); } await this.redis.client.del(attemptKey); await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } }); if (authCode.userId) { await this.notifications.dismissOtpCodeNotifications(authCode.userId); } return { verified: true }; } private attemptKey(target: string, purpose: string) { return `otp:attempts:${purpose}:${target.toLowerCase()}`; } private purposeLabel(purpose: string) { switch (purpose) { case 'register': return 'Регистрация'; case 'login': return 'Вход в аккаунт'; case 'password-reset': return 'Сброс пароля'; case 'password-change': return 'Смена пароля'; default: return 'Подтверждение действия'; } } }