Files
IdP/apps/sso-core/src/domain/totp.service.ts

179 lines
6.0 KiB
TypeScript
Raw Permalink 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, 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('Пользователь не найден');
}
}
}