streamflix-api / src /modules /super-admin /two-factor-auth /two-factor-auth.service.ts
Akshar2325
✨ feat(super-admin-2fa): introduce two-factor authentication
add3ead
Raw
History Blame
25.2 kB
import {
Injectable,
UnauthorizedException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { createHash, randomInt } from 'crypto';
import * as CryptoJS from 'crypto-js';
import { authenticator } from 'otplib';
import { toDataURL } from 'qrcode';
import { nanoid } from 'nanoid';
import { EmailService } from 'src/shared/modules/email/email.service';
import { CommonService } from 'src/shared/modules/common/common.service';
import { SuperAdminCoreService } from 'src/core/super-admin-core/super-admin-core.service';
import { SuperAdminSessionCoreService } from 'src/core/super-admin-session-core/super-admin-session-core.service';
import { SuperAdminTwoFactorMethodCoreService } from 'src/core/super-admin-two-factor-method-core/super-admin-two-factor-method-core.service';
import { SuperAdminEmailTwoFactorCoreService } from 'src/core/super-admin-email-two-factor-core/super-admin-email-two-factor-core.service';
import { SuperAdminTotpTwoFactorCoreService } from 'src/core/super-admin-totp-two-factor-core/super-admin-totp-two-factor-core.service';
import { SuperAdminRecoveryCodeCoreService } from 'src/core/super-admin-recovery-code-core/super-admin-recovery-code-core.service';
import { SuperAdminTrustedDeviceCoreService } from 'src/core/super-admin-trusted-device-core/super-admin-trusted-device-core.service';
import { SuperAdminEmailTwoFactorMessages } from 'src/shared/keys/super-admin-email-two-factor.keys';
import { SuperAdminTwoFactorMethodMessages } from 'src/shared/keys/super-admin-two-factor-method.keys';
import { SuperAdminTotpTwoFactorMessages } from 'src/shared/keys/super-admin-totp-two-factor.keys';
import { SuperAdminRecoveryCodeMessages } from 'src/shared/keys/super-admin-recovery-code.keys';
import { SuperAdminMessages } from 'src/shared/keys/super-admin.keys';
import {
accessTokenSignSettings,
refreshTokenSignSettings,
TOKEN_USER_TYPE,
TOKEN_TYPE,
} from 'src/shared/keys/auth.keys';
import { TWO_FACTOR_METHOD_TYPE, SESSION_STATUS } from '@prisma/client';
import { COMMON_ERROR_MESSAGES } from 'src/keys';
@Injectable()
export class TwoFactorAuthService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly emailService: EmailService,
private readonly commonService: CommonService,
private readonly superAdminCoreService: SuperAdminCoreService,
private readonly superAdminSessionCoreService: SuperAdminSessionCoreService,
private readonly twoFactorMethodCoreService: SuperAdminTwoFactorMethodCoreService,
private readonly emailTwoFactorCoreService: SuperAdminEmailTwoFactorCoreService,
private readonly totpTwoFactorCoreService: SuperAdminTotpTwoFactorCoreService,
private readonly recoveryCodeCoreService: SuperAdminRecoveryCodeCoreService,
private readonly trustedDeviceCoreService: SuperAdminTrustedDeviceCoreService,
) {}
// ==================== EMAIL 2FA ====================
async enableEmail2FA(email: string) {
const superAdmin = await this.superAdminCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!superAdmin) {
throw new NotFoundException(SuperAdminMessages.NOT_FOUND);
}
// Check if already enabled
const existingMethod = await this.twoFactorMethodCoreService
.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.EMAIL,
isDeleted: false,
},
})
.catch(() => null);
if (existingMethod && existingMethod.isEnabled) {
throw new BadRequestException(
SuperAdminTwoFactorMethodMessages.ALREADY_ENABLED,
);
}
// Create or update 2FA method
const method = existingMethod
? await this.twoFactorMethodCoreService.update({
where: { id: existingMethod.id },
data: { isEnabled: true },
})
: await this.twoFactorMethodCoreService.create({
data: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.EMAIL,
isEnabled: true,
isPrimary: true,
},
});
// Create email 2FA config if not exists
const emailConfig = await this.emailTwoFactorCoreService
.findFirst({
where: { methodId: method.id, isDeleted: false },
})
.catch(() => null);
if (!emailConfig) {
await this.emailTwoFactorCoreService.create({
data: {
methodId: method.id,
failedAttempts: 0,
},
});
}
// Update super admin
await this.superAdminCoreService.update({
where: { id: superAdmin.id },
data: { isTwoFactorEnabled: true },
});
return {
message: SuperAdminTwoFactorMethodMessages.ENABLE_SUCCESS,
};
}
async sendEmail2FACode(pending2faToken: string, request: any) {
const { ipAddress, userAgent } = this.commonService.getClientInfo(request);
const clientInfo = this.commonService.getClientInfo(request);
// Verify pending token
const payload = await this.verifyPending2FAToken(pending2faToken);
const superAdmin = await this.superAdminCoreService.findUnique({
where: { id: payload.superAdminId },
});
if (!superAdmin) {
throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN);
}
// Get email 2FA method
const method = await this.twoFactorMethodCoreService.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.EMAIL,
isEnabled: true,
isDeleted: false,
},
});
if (!method) {
throw new NotFoundException(
SuperAdminTwoFactorMethodMessages.NOT_ENABLED,
);
}
const emailTwoFactor = await this.emailTwoFactorCoreService.findFirst({
where: { methodId: method.id, isDeleted: false },
});
if (!emailTwoFactor) {
throw new NotFoundException(SuperAdminEmailTwoFactorMessages.NOT_FOUND);
}
// Generate 2FA code
const code = this.generateTwoFactorCode();
const codeHash = this.hashCode(code);
const expiresAt = new Date(
Date.now() +
this.configService.get<number>('TWO_FA_CODE_EXPIRY_MINUTES', 10) *
60 *
1000,
);
// Update email 2FA config
await this.emailTwoFactorCoreService.update({
where: { id: emailTwoFactor.id },
data: {
lastCodeHash: codeHash,
lastCodeExpiresAt: expiresAt,
lastSentAt: new Date(),
failedAttempts: 0,
},
});
// Build location string from geoIP data
const location = clientInfo.geoLocation
? `${clientInfo.geoLocation.city || ''}, ${clientInfo.geoLocation.region || ''}, ${clientInfo.geoLocation.country || ''}`
.replace(/, ,/g, ',')
.replace(/^,\s*|,\s*$/g, '') || 'Unknown Location'
: 'Unknown Location';
// Send email
await this.emailService.send2FACode(
superAdmin.email,
superAdmin.name,
code,
ipAddress ?? undefined,
userAgent ?? 'Unknown',
location,
);
return {
message: SuperAdminEmailTwoFactorMessages.CODE_SENT,
};
}
async verifyEmail2FACode(
pending2faToken: string,
code: string,
trustDevice: boolean,
request: any,
) {
const { ipAddress, userAgent } = this.commonService.getClientInfo(request);
// Verify pending token
const payload = await this.verifyPending2FAToken(pending2faToken);
const superAdmin = await this.superAdminCoreService.findUnique({
where: { id: payload.superAdminId },
});
if (!superAdmin) {
throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN);
}
// Get email 2FA config
const method = await this.twoFactorMethodCoreService.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.EMAIL,
isEnabled: true,
isDeleted: false,
},
});
if (!method) {
throw new NotFoundException(
SuperAdminTwoFactorMethodMessages.NOT_ENABLED,
);
}
const emailConfig = await this.emailTwoFactorCoreService.findFirst({
where: { methodId: method.id, isDeleted: false },
});
if (!emailConfig) {
throw new NotFoundException(SuperAdminEmailTwoFactorMessages.NOT_FOUND);
}
// Check attempts
const maxAttempts = this.configService.get<number>(
'TWO_FA_MAX_ATTEMPTS',
5,
);
if (emailConfig.failedAttempts >= maxAttempts) {
throw new BadRequestException(
SuperAdminEmailTwoFactorMessages.TOO_MANY_ATTEMPTS,
);
}
// Check expiry
if (
!emailConfig.lastCodeExpiresAt ||
emailConfig.lastCodeExpiresAt < new Date()
) {
throw new BadRequestException(
SuperAdminEmailTwoFactorMessages.CODE_EXPIRED,
);
}
// Verify code
const codeHash = this.hashCode(code);
if (codeHash !== emailConfig.lastCodeHash) {
// Increment failed attempts
await this.emailTwoFactorCoreService.update({
where: { id: emailConfig.id },
data: { failedAttempts: emailConfig.failedAttempts + 1 },
});
throw new UnauthorizedException(
SuperAdminEmailTwoFactorMessages.CODE_INVALID,
);
}
// Code is valid - clear the code
await this.emailTwoFactorCoreService.update({
where: { id: emailConfig.id },
data: {
lastCodeHash: null,
lastCodeExpiresAt: null,
failedAttempts: 0,
},
});
// Generate tokens and create session
const { accessToken, refreshToken } = await this.generateTokensAndSession(
superAdmin,
request,
);
// Handle trusted device
let trustedDeviceId: string | null = null;
if (trustDevice) {
trustedDeviceId = await this.createTrustedDevice(
superAdmin.id,
ipAddress ?? 'Unknown',
userAgent ?? 'Unknown',
);
}
return {
message: SuperAdminEmailTwoFactorMessages.VERIFIED_SUCCESS,
accessToken,
refreshToken,
...(trustedDeviceId && { trustedDeviceId }),
};
}
// ==================== TOTP 2FA ====================
async initTotp2FA(email: string) {
const superAdmin = await this.superAdminCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!superAdmin) {
throw new NotFoundException(SuperAdminMessages.NOT_FOUND);
}
// Check if already enabled
const existingMethod = await this.twoFactorMethodCoreService
.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR,
isDeleted: false,
},
})
.catch(() => null);
let totpTwoFactor: Awaited<
ReturnType<typeof this.totpTwoFactorCoreService.findFirst>
> | null = null;
if (existingMethod) {
totpTwoFactor = await this.totpTwoFactorCoreService
.findFirst({
where: { methodId: existingMethod.id, isDeleted: false },
})
.catch(() => null);
}
if (totpTwoFactor?.confirmedAt) {
throw new BadRequestException(
SuperAdminTotpTwoFactorMessages.ALREADY_CONFIRMED,
);
}
// Generate secret
const secret = authenticator.generateSecret();
// Build otpauth URL
const otpauthUrl = authenticator.keyuri(
superAdmin.email,
this.configService.get<string>('TOTP_ISSUER', 'Streamflix'),
secret,
);
// Encrypt secret
const encryptedSecret = this.encryptTotpSecret(secret);
// Create or update method
let method = existingMethod;
if (!method) {
method = await this.twoFactorMethodCoreService.create({
data: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR,
isEnabled: false,
},
});
}
// Create or update TOTP config
if (totpTwoFactor) {
await this.totpTwoFactorCoreService.update({
where: { id: totpTwoFactor.id },
data: { secretEncrypted: encryptedSecret, confirmedAt: null },
});
} else {
await this.totpTwoFactorCoreService.create({
data: {
methodId: method.id,
secretEncrypted: encryptedSecret,
},
});
}
// Generate QR code
const qrCodeDataUrl = await toDataURL(otpauthUrl);
return {
message: SuperAdminTotpTwoFactorMessages.SETUP_SUCCESS,
secret, // For manual entry
otpauthUrl,
qrCodeDataUrl,
appName: this.configService.get<string>(
'TOTP_APP_NAME',
'Streamflix Admin',
),
};
}
async verifyTotpSetup(email: string, code: string) {
const superAdmin = await this.superAdminCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!superAdmin) {
throw new NotFoundException(SuperAdminMessages.NOT_FOUND);
}
// Get TOTP method
const method = await this.twoFactorMethodCoreService.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR,
isDeleted: false,
},
});
if (!method) {
throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND);
}
const totpConfig = await this.totpTwoFactorCoreService.findFirst({
where: { methodId: method.id, isDeleted: false },
});
if (!totpConfig) {
throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND);
}
if (totpConfig.confirmedAt) {
throw new BadRequestException(
SuperAdminTotpTwoFactorMessages.ALREADY_CONFIRMED,
);
}
// Decrypt secret
const secret = this.decryptTotpSecret(totpConfig.secretEncrypted);
// Verify code
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new UnauthorizedException(
SuperAdminTotpTwoFactorMessages.INVALID_CODE,
);
}
// Confirm TOTP
await this.totpTwoFactorCoreService.update({
where: { id: totpConfig.id },
data: { confirmedAt: new Date() },
});
// Enable method
await this.twoFactorMethodCoreService.update({
where: { id: method.id },
data: { isEnabled: true },
});
// Update super admin
await this.superAdminCoreService.update({
where: { id: superAdmin.id },
data: { isTwoFactorEnabled: true },
});
// Generate recovery codes
const recoveryCodes = await this.generateRecoveryCodes(superAdmin.id);
return {
message: SuperAdminTotpTwoFactorMessages.VERIFIED_SUCCESS,
recoveryCodes,
warning:
'Save these recovery codes in a safe place. They will not be shown again!',
};
}
async verifyTotp2FACode(
pending2faToken: string,
code: string,
trustDevice: boolean,
request: any,
) {
const { ipAddress, userAgent } = this.commonService.getClientInfo(request);
// Verify pending token
const payload = await this.verifyPending2FAToken(pending2faToken);
const superAdmin = await this.superAdminCoreService.findUnique({
where: { id: payload.superAdminId },
});
if (!superAdmin) {
throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN);
}
// Get TOTP method
const method = await this.twoFactorMethodCoreService.findFirst({
where: {
superAdminId: superAdmin.id,
type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR,
isEnabled: true,
isDeleted: false,
},
});
if (!method) {
throw new NotFoundException(
SuperAdminTwoFactorMethodMessages.NOT_ENABLED,
);
}
const totpConfig = await this.totpTwoFactorCoreService.findFirst({
where: { methodId: method.id, isDeleted: false },
});
if (!totpConfig) {
throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND);
}
if (!totpConfig.confirmedAt) {
throw new BadRequestException(
SuperAdminTotpTwoFactorMessages.NOT_CONFIRMED,
);
}
// Decrypt secret
const secret = this.decryptTotpSecret(totpConfig.secretEncrypted);
// Verify code
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new UnauthorizedException(
SuperAdminTotpTwoFactorMessages.INVALID_CODE,
);
}
// Generate tokens and create session
const tokens = await this.generateTokensAndSession(superAdmin, request);
// Handle trusted device
let trustedDeviceId: string | null = null;
if (trustDevice) {
trustedDeviceId = await this.createTrustedDevice(
superAdmin.id,
ipAddress ?? 'Unknown',
userAgent ?? 'Unknown',
);
}
return {
message: SuperAdminTotpTwoFactorMessages.VERIFIED_SUCCESS,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
trustedDeviceId,
superAdmin: {
id: superAdmin.id,
email: superAdmin.email,
name: superAdmin.name,
},
};
}
// ==================== RECOVERY CODES ====================
async verifyRecoveryCode(
pending2faToken: string,
code: string,
trustDevice: boolean,
request: any,
) {
const { ipAddress, userAgent } = this.commonService.getClientInfo(request);
// Verify pending token
const payload = await this.verifyPending2FAToken(pending2faToken);
const superAdmin = await this.superAdminCoreService.findUnique({
where: { id: payload.superAdminId },
});
if (!superAdmin) {
throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN);
}
// Hash the code
const codeHash = this.hashCode(code);
// Find unused recovery code
const recoveryCode = await this.recoveryCodeCoreService
.findFirst({
where: {
superAdminId: superAdmin.id,
codeHash,
usedAt: null,
isDeleted: false,
},
})
.catch(() => null);
if (!recoveryCode) {
throw new UnauthorizedException(
SuperAdminRecoveryCodeMessages.INVALID_CODE,
);
}
// Mark as used
await this.recoveryCodeCoreService.update({
where: { id: recoveryCode.id },
data: { usedAt: new Date() },
});
// Generate tokens and create session
const tokens = await this.generateTokensAndSession(superAdmin, request);
// Handle trusted device
let trustedDeviceId: string | null = null;
if (trustDevice) {
trustedDeviceId = await this.createTrustedDevice(
superAdmin.id,
ipAddress ?? 'Unknown',
userAgent ?? 'Unknown',
);
}
return {
message: SuperAdminRecoveryCodeMessages.VERIFIED_SUCCESS,
warning: SuperAdminRecoveryCodeMessages.ALREADY_USED_VALID,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
trustedDeviceId,
superAdmin: {
id: superAdmin.id,
email: superAdmin.email,
name: superAdmin.name,
},
};
}
async regenerateRecoveryCodes(email: string) {
const superAdmin = await this.superAdminCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!superAdmin) {
throw new NotFoundException(SuperAdminMessages.NOT_FOUND);
}
// Delete old codes
await this.recoveryCodeCoreService.deleteMany({
where: { superAdminId: superAdmin.id },
});
// Generate new codes
const recoveryCodes = await this.generateRecoveryCodes(superAdmin.id);
return {
message: SuperAdminRecoveryCodeMessages.GENERATED_SUCCESS,
recoveryCodes,
warning:
'Save these recovery codes in a safe place. They will not be shown again!',
};
}
// ==================== HELPER METHODS ====================
/**
* Generate a cryptographically secure random code for 2FA
* Uses crypto.randomInt() for security instead of Math.random()
*/
private generateTwoFactorCode(): string {
const codeLength = this.configService.get<number>('TWO_FA_CODE_LENGTH', 6);
const min = Math.pow(10, codeLength - 1);
const max = Math.pow(10, codeLength);
return randomInt(min, max).toString();
}
private hashCode(code: string): string {
return createHash('sha256').update(code).digest('hex');
}
private encryptTotpSecret(secret: string): string {
const encryptionKey = this.configService.get<string>('TOTP_ENCRYPTION_KEY');
if (!encryptionKey) {
throw new Error('TOTP_ENCRYPTION_KEY is not configured');
}
return CryptoJS.AES.encrypt(secret, encryptionKey).toString();
}
private decryptTotpSecret(encryptedSecret: string): string {
const encryptionKey = this.configService.get<string>('TOTP_ENCRYPTION_KEY');
if (!encryptionKey) {
throw new Error('TOTP_ENCRYPTION_KEY is not configured');
}
const bytes = CryptoJS.AES.decrypt(encryptedSecret, encryptionKey);
return bytes.toString(CryptoJS.enc.Utf8);
}
private async generateRecoveryCodes(superAdminId: string): Promise<string[]> {
const count = this.configService.get<number>('RECOVERY_CODES_COUNT', 10);
const codeLength = this.configService.get<number>(
'RECOVERY_CODE_LENGTH',
8,
);
const segmentLength = Math.floor(codeLength / 2);
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const code =
`${nanoid(segmentLength)}-${nanoid(segmentLength)}`.toUpperCase();
codes.push(code);
const codeHash = this.hashCode(code);
await this.recoveryCodeCoreService.create({
data: {
superAdminId,
codeHash,
},
});
}
return codes;
}
private async verifyPending2FAToken(token: string): Promise<any> {
try {
return this.jwtService.verify(token, {
secret: this.configService.get<string>('PENDING_2FA_TOKEN_SECRET'),
});
} catch {
throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_2FA_TOKEN);
}
}
private async generateTokensAndSession(superAdmin: any, request: any) {
// Generate tokens using auth service pattern
const payload = {
id: superAdmin.id,
email: superAdmin.email,
userType: TOKEN_USER_TYPE.SUPER_ADMIN,
};
const accessToken = await this.jwtService.signAsync(
{ ...payload, type: TOKEN_TYPE.ACCESS },
accessTokenSignSettings,
);
const refreshToken = await this.jwtService.signAsync(
{ ...payload, type: TOKEN_TYPE.REFRESH },
refreshTokenSignSettings,
);
// Get client info
const clientInfo = this.commonService.getClientInfo(request);
// Create session
await this.superAdminSessionCoreService.create({
data: {
superAdminId: superAdmin.id,
accessToken,
refreshToken,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
geoIpCountry: clientInfo.geoLocation?.country || '',
city: clientInfo.geoLocation?.city || '',
state: clientInfo.geoLocation?.region || '',
latitude: clientInfo.geoLocation?.ll?.[0] || null,
longitude: clientInfo.geoLocation?.ll?.[1] || null,
isFromAdmin: true,
status: SESSION_STATUS.CURRENT,
loginAt: new Date(),
},
});
return { accessToken, refreshToken };
}
/**
* Create a trusted device and return a JWT token signed with TRUSTED_DEVICE_TOKEN_SECRET
* This token can be stored in cookies/localStorage for device recognition
*/
private async createTrustedDevice(
superAdminId: string,
ipAddress: string,
userAgent: string,
): Promise<string> {
const deviceId = nanoid(32);
const expiryDays = parseInt(
this.configService
.get<string>('TRUSTED_DEVICE_TOKEN_EXPIRY', '30d')
.replace('d', ''),
);
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
await this.trustedDeviceCoreService.create({
data: {
superAdminId,
deviceId,
deviceName: userAgent || 'Unknown device',
ipAddress,
userAgent,
expiresAt,
},
});
// Create JWT token using TRUSTED_DEVICE_TOKEN_SECRET
const token = await this.jwtService.signAsync(
{
deviceId,
superAdminId,
ipAddress,
type: 'TRUSTED_DEVICE',
},
{
secret: this.configService.get<string>('TRUSTED_DEVICE_TOKEN_SECRET'),
expiresIn: `${expiryDays}d`,
},
);
return token;
}
async get2FAStatus(email: string) {
const superAdmin = await this.superAdminCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!superAdmin) {
throw new NotFoundException(SuperAdminMessages.NOT_FOUND);
}
const methods = await this.twoFactorMethodCoreService.findMany({
where: {
superAdminId: superAdmin.id,
isDeleted: false,
},
});
const enabledMethods = methods
.filter((m) => m.isEnabled)
.map((m) => m.type);
return {
is2FAEnabled: superAdmin.isTwoFactorEnabled,
enabledMethods,
};
}
}