Files
IdP/apps/sso-core/src/domain/pin.service.ts
2026-06-24 14:45:45 +03:00

186 lines
6.1 KiB
TypeScript

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,
updatedAt: new Date()
}
});
return {
accessToken: await this.auth.issueAccessToken(session.user, session.id, true),
pinVerified: true
};
}
async lockExpiredPinSessions() {
return this.session.lockExpiredPinSessions();
}
}