import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { SessionStatus } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { AuthService } from './auth.service'; import { SessionService } from './session.service'; import { SettingsService } from './settings.service'; @Injectable() export class PinService { constructor( private readonly prisma: PrismaService, private readonly auth: AuthService, private readonly settings: SettingsService, private readonly session: SessionService ) {} private async getPinLengthBounds() { const min = await this.settings.getNumber('PIN_MIN_LENGTH', 4); const max = await this.settings.getNumber('PIN_MAX_LENGTH', 6); return { min, max }; } private async assertValidPinFormat(pin: string) { const { min, max } = await this.getPinLengthBounds(); const pattern = new RegExp(`^\\d{${min},${max}}$`); if (!pattern.test(pin)) { throw new BadRequestException(`PIN-код должен содержать от ${min} до ${max} цифр`); } } async enablePin(userId: string, pin: string) { await this.assertValidPinFormat(pin); return this.prisma.pinCode.upsert({ where: { userId }, create: { userId, hash: await bcrypt.hash(pin, 12), isEnabled: true, deletionRequestedAt: null }, update: { hash: await bcrypt.hash(pin, 12), isEnabled: true, deletionRequestedAt: null } }); } async setupPin(userId: string, pin: string) { const pinCode = await this.enablePin(userId, pin); return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() }; } async updatePin(userId: string, pin: string) { const pinCode = await this.enablePin(userId, pin); await this.prisma.session.updateMany({ where: { userId, status: SessionStatus.ACTIVE }, data: { pinVerified: false, status: SessionStatus.LOCKED } }); return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() }; } async requestPinDeletion(userId: string, pin?: string) { const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); if (!pinCode?.isEnabled) { throw new BadRequestException('PIN-код не включен'); } const requirePin = await this.settings.getBoolean('PIN_REQUIRE_ON_DELETE', true); if (requirePin) { if (!pin) { throw new BadRequestException('Для удаления PIN-кода требуется подтверждение текущим PIN'); } const matches = await bcrypt.compare(pin, pinCode.hash); if (!matches) { throw new UnauthorizedException('Неверный PIN-код'); } } const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); const deletionRequestedAt = new Date(); const effectiveAt = new Date(deletionRequestedAt.getTime() + graceMinutes * 60_000); await this.prisma.pinCode.update({ where: { userId }, data: { deletionRequestedAt } }); return { userId, deletionRequestedAt: deletionRequestedAt.toISOString(), effectiveAt: effectiveAt.toISOString(), graceMinutes }; } async cancelPinDeletion(userId: string) { const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); if (!pinCode) { throw new BadRequestException('PIN-код не найден'); } await this.prisma.pinCode.update({ where: { userId }, data: { deletionRequestedAt: null } }); return { userId, cancelled: true }; } async finalizeDuePinDeletions() { const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); const threshold = new Date(Date.now() - graceMinutes * 60_000); const due = await this.prisma.pinCode.findMany({ where: { isEnabled: true, deletionRequestedAt: { lte: threshold } } }); for (const pinCode of due) { await this.disablePin(pinCode.userId, { skipGraceCheck: true }); } return { count: due.length }; } async disablePin(userId: string, options?: { skipGraceCheck?: boolean }) { const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); if (!pinCode?.isEnabled) { throw new BadRequestException('PIN-код не включен'); } if (!options?.skipGraceCheck && pinCode.deletionRequestedAt) { const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); const effectiveAt = pinCode.deletionRequestedAt.getTime() + graceMinutes * 60_000; if (Date.now() < effectiveAt) { throw new BadRequestException('PIN-код будет удалён после истечения периода ожидания'); } } return this.prisma.pinCode.update({ where: { userId }, data: { isEnabled: false, deletionRequestedAt: null } }); } async verifyPin(sessionId: string, pin: string) { const session = await this.prisma.session.findUnique({ where: { id: sessionId }, include: { user: { include: { pinCode: true } } } }); if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) { throw new UnauthorizedException('Сессия недействительна'); } if (!session.user.pinCode?.isEnabled) { throw new BadRequestException('PIN-код не включен'); } const matches = await bcrypt.compare(pin, session.user.pinCode.hash); if (!matches) { throw new UnauthorizedException('Неверный PIN-код'); } await this.prisma.session.update({ where: { id: sessionId }, data: { pinVerified: true, status: SessionStatus.ACTIVE, lastActivityAt: new Date(), updatedAt: new Date() } }); return { accessToken: await this.auth.issueAccessToken(session.user, session.id, true), pinVerified: true }; } async lockExpiredPinSessions() { return this.session.lockExpiredPinSessions(); } }