more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -0,0 +1,163 @@
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';
@Injectable()
export class TotpService {
private readonly totp = authenticator.clone({ window: 2 });
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly settings: SettingsService
) {}
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 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('Пользователь не найден');
}
}
}