179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||
import { authenticator } from 'otplib';
|
||
|
||
import { EncryptionService } from '../infra/encryption.service';
|
||
import { PrismaService } from '../infra/prisma.service';
|
||
import { SettingsService } from './settings.service';
|
||
import { AccessService } from './access.service';
|
||
|
||
@Injectable()
|
||
export class TotpService {
|
||
private readonly totp = authenticator.clone({ window: 2 });
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly encryption: EncryptionService,
|
||
private readonly settings: SettingsService,
|
||
private readonly access: AccessService
|
||
) {}
|
||
|
||
async getStatus(userId: string) {
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
return { isEnabled: Boolean(record?.isEnabled) };
|
||
}
|
||
|
||
async isEnabledForUser(userId: string) {
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
return Boolean(record?.isEnabled);
|
||
}
|
||
|
||
async setup(userId: string) {
|
||
await this.ensureUserExists(userId);
|
||
|
||
const issuer = await this.resolveIssuer();
|
||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
const accountLabel = this.buildAccountLabel(user, userId);
|
||
const existing = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
|
||
if (existing && !existing.isEnabled) {
|
||
const secret = this.tryDecryptSecret(existing.secretEnc);
|
||
if (secret) {
|
||
return {
|
||
secret,
|
||
otpauthUrl: this.totp.keyuri(accountLabel, issuer, secret)
|
||
};
|
||
}
|
||
}
|
||
|
||
const secret = this.totp.generateSecret();
|
||
const secretEnc = this.encryption.encrypt(secret);
|
||
this.assertSecretRoundTrip(secret, secretEnc);
|
||
|
||
const otpauthUrl = this.totp.keyuri(accountLabel, issuer, secret);
|
||
|
||
await this.prisma.userTotpSecret.upsert({
|
||
where: { userId },
|
||
create: {
|
||
userId,
|
||
secretEnc,
|
||
isEnabled: false
|
||
},
|
||
update: {
|
||
secretEnc,
|
||
isEnabled: false
|
||
}
|
||
});
|
||
|
||
return { secret, otpauthUrl };
|
||
}
|
||
|
||
async enable(userId: string, code: string) {
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
if (!record) {
|
||
throw new BadRequestException('Сначала начните настройку приложения-аутентификатора');
|
||
}
|
||
if (record.isEnabled) {
|
||
return { isEnabled: true };
|
||
}
|
||
this.assertValidCode(record.secretEnc, code);
|
||
await this.prisma.userTotpSecret.update({
|
||
where: { userId },
|
||
data: { isEnabled: true }
|
||
});
|
||
return { isEnabled: true };
|
||
}
|
||
|
||
async disable(userId: string, code: string) {
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
if (!record?.isEnabled) {
|
||
throw new BadRequestException('Двухфакторная аутентификация не включена');
|
||
}
|
||
this.assertValidCode(record.secretEnc, code);
|
||
await this.prisma.userTotpSecret.delete({ where: { userId } });
|
||
return { isEnabled: false };
|
||
}
|
||
|
||
async adminDisable(actorUserId: string, targetUserId: string) {
|
||
await this.access.assertSuperAdmin(actorUserId);
|
||
await this.ensureUserExists(targetUserId);
|
||
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId: targetUserId } });
|
||
if (!record?.isEnabled) {
|
||
throw new BadRequestException('Двухфакторная аутентификация не включена');
|
||
}
|
||
|
||
await this.prisma.userTotpSecret.delete({ where: { userId: targetUserId } });
|
||
return { isEnabled: false };
|
||
}
|
||
|
||
async verifyEnabledCode(userId: string, code: string) {
|
||
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
|
||
if (!record?.isEnabled) {
|
||
return false;
|
||
}
|
||
return this.isValidCode(record.secretEnc, code);
|
||
}
|
||
|
||
private async resolveIssuer() {
|
||
const projectName = (await this.settings.getValue('PROJECT_NAME', 'MVK ID')).trim();
|
||
return projectName || 'MVK ID';
|
||
}
|
||
|
||
private buildAccountLabel(
|
||
user: { email: string | null; phone: string | null; username: string | null } | null,
|
||
userId: string
|
||
) {
|
||
return user?.email ?? user?.phone ?? user?.username ?? userId;
|
||
}
|
||
|
||
private tryDecryptSecret(secretEnc: string) {
|
||
try {
|
||
const secret = this.encryption.decrypt(secretEnc).trim();
|
||
return secret.length > 0 ? secret : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private assertSecretRoundTrip(secret: string, secretEnc: string) {
|
||
const decrypted = this.encryption.decrypt(secretEnc);
|
||
if (decrypted !== secret) {
|
||
throw new InternalServerErrorException('Не удалось сохранить секрет аутентификатора');
|
||
}
|
||
|
||
const probeToken = this.totp.generate(secret);
|
||
if (!this.totp.verify({ token: probeToken, secret: decrypted })) {
|
||
throw new InternalServerErrorException('Не удалось проверить секрет аутентификатора');
|
||
}
|
||
}
|
||
|
||
private assertValidCode(secretEnc: string, code: string) {
|
||
if (!this.isValidCode(secretEnc, code)) {
|
||
throw new BadRequestException(
|
||
'Неверный код аутентификатора. Убедитесь, что отсканирован последний QR-код, и повторите настройку при необходимости.'
|
||
);
|
||
}
|
||
}
|
||
|
||
private isValidCode(secretEnc: string, code: string) {
|
||
const normalized = code.replace(/\s/g, '');
|
||
if (!/^\d{6}$/.test(normalized)) {
|
||
return false;
|
||
}
|
||
|
||
const secret = this.tryDecryptSecret(secretEnc);
|
||
if (!secret) {
|
||
return false;
|
||
}
|
||
|
||
return this.totp.verify({ token: normalized, secret });
|
||
}
|
||
|
||
private async ensureUserExists(userId: string) {
|
||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
if (!user || user.deletedAt) {
|
||
throw new NotFoundException('Пользователь не найден');
|
||
}
|
||
}
|
||
}
|