107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
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 'Подтверждение действия';
|
||
}
|
||
}
|
||
}
|