Files
IdP/apps/sso-core/src/domain/otp.service.ts
2026-06-25 07:23:34 +03:00

107 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 'Подтверждение действия';
}
}
}