more fix and update
This commit is contained in:
163
apps/sso-core/src/domain/totp.service.ts
Normal file
163
apps/sso-core/src/domain/totp.service.ts
Normal 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('Пользователь не найден');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user