361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import {
|
|
Injectable,
|
|
BadRequestException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as speakeasy from 'speakeasy';
|
|
import * as QRCode from 'qrcode';
|
|
import * as argon2 from 'argon2';
|
|
import * as crypto from 'crypto';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
@Injectable()
|
|
export class TwoFactorService {
|
|
private readonly encryptionKey: Buffer;
|
|
private readonly algorithm = 'aes-256-gcm';
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly configService: ConfigService,
|
|
private readonly jwtService: JwtService,
|
|
) {
|
|
// Use JWT secret as base for encryption key (derive a 32-byte key)
|
|
const secret = this.configService.get<string>('jwt.secret') || 'default-secret';
|
|
this.encryptionKey = crypto.scryptSync(secret, 'salt', 32);
|
|
}
|
|
|
|
// ==========================================
|
|
// ENCRYPTION HELPERS
|
|
// ==========================================
|
|
private encrypt(text: string): string {
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
|
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
const authTag = cipher.getAuthTag();
|
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
}
|
|
|
|
private decrypt(encryptedData: string): string {
|
|
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
|
|
decipher.setAuthTag(authTag);
|
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
return decrypted;
|
|
}
|
|
|
|
// ==========================================
|
|
// SETUP 2FA - Generate secret and QR code
|
|
// ==========================================
|
|
async setupTwoFactor(userId: string): Promise<{ secret: string; qrCode: string; manualEntry: string }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
if (user.twoFactorEnabled) {
|
|
throw new BadRequestException('Two-factor authentication is already enabled');
|
|
}
|
|
|
|
// Generate secret
|
|
const secret = speakeasy.generateSecret({
|
|
name: `Re-Quest:${user.email}`,
|
|
issuer: 'Re-Quest',
|
|
length: 32,
|
|
});
|
|
|
|
// Store encrypted secret temporarily (not yet verified)
|
|
const encryptedSecret = this.encrypt(secret.base32);
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: { twoFactorSecret: encryptedSecret },
|
|
});
|
|
|
|
// Generate QR code
|
|
const qrCode = await QRCode.toDataURL(secret.otpauth_url!);
|
|
|
|
return {
|
|
secret: secret.base32,
|
|
qrCode,
|
|
manualEntry: secret.base32,
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// ENABLE 2FA - Verify token and enable
|
|
// ==========================================
|
|
async enableTwoFactor(userId: string, token: string): Promise<{ backupCodes: string[] }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user || !user.twoFactorSecret) {
|
|
throw new BadRequestException('Please setup two-factor authentication first');
|
|
}
|
|
|
|
if (user.twoFactorEnabled) {
|
|
throw new BadRequestException('Two-factor authentication is already enabled');
|
|
}
|
|
|
|
// Decrypt and verify token
|
|
const decryptedSecret = this.decrypt(user.twoFactorSecret);
|
|
const isValid = speakeasy.totp.verify({
|
|
secret: decryptedSecret,
|
|
encoding: 'base32',
|
|
token,
|
|
window: 1, // Allow 1 step before/after for clock drift
|
|
});
|
|
|
|
if (!isValid) {
|
|
throw new BadRequestException('Invalid verification code');
|
|
}
|
|
|
|
// Generate backup codes
|
|
const backupCodes = await this.generateBackupCodesInternal();
|
|
const hashedCodes = await Promise.all(
|
|
backupCodes.map((code) => argon2.hash(code)),
|
|
);
|
|
|
|
// Enable 2FA
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
twoFactorEnabled: true,
|
|
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
|
twoFactorVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return { backupCodes };
|
|
}
|
|
|
|
// ==========================================
|
|
// DISABLE 2FA
|
|
// ==========================================
|
|
async disableTwoFactor(userId: string, password: string): Promise<void> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
if (!user.twoFactorEnabled) {
|
|
throw new BadRequestException('Two-factor authentication is not enabled');
|
|
}
|
|
|
|
// Verify password
|
|
if (!user.password) {
|
|
throw new BadRequestException('Cannot disable 2FA for social login accounts');
|
|
}
|
|
|
|
const isPasswordValid = await argon2.verify(user.password, password);
|
|
if (!isPasswordValid) {
|
|
throw new UnauthorizedException('Invalid password');
|
|
}
|
|
|
|
// Disable 2FA
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
twoFactorEnabled: false,
|
|
twoFactorSecret: null,
|
|
twoFactorBackupCodes: null,
|
|
twoFactorVerifiedAt: null,
|
|
},
|
|
});
|
|
|
|
// Invalidate all sessions except current
|
|
await this.prisma.session.deleteMany({
|
|
where: { userId },
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VERIFY TOKEN (during login)
|
|
// ==========================================
|
|
async verifyToken(userId: string, token: string): Promise<boolean> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user || !user.twoFactorEnabled || !user.twoFactorSecret) {
|
|
throw new BadRequestException('Two-factor authentication is not enabled');
|
|
}
|
|
|
|
const decryptedSecret = this.decrypt(user.twoFactorSecret);
|
|
const isValid = speakeasy.totp.verify({
|
|
secret: decryptedSecret,
|
|
encoding: 'base32',
|
|
token,
|
|
window: 1,
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
// ==========================================
|
|
// VERIFY BACKUP CODE
|
|
// ==========================================
|
|
async verifyBackupCode(userId: string, code: string): Promise<boolean> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user || !user.twoFactorEnabled || !user.twoFactorBackupCodes) {
|
|
throw new BadRequestException('Two-factor authentication is not enabled');
|
|
}
|
|
|
|
const hashedCodes: string[] = JSON.parse(user.twoFactorBackupCodes);
|
|
|
|
// Find and verify the backup code
|
|
for (let i = 0; i < hashedCodes.length; i++) {
|
|
try {
|
|
const isValid = await argon2.verify(hashedCodes[i], code);
|
|
if (isValid) {
|
|
// Remove used code
|
|
hashedCodes.splice(i, 1);
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ==========================================
|
|
// REGENERATE BACKUP CODES
|
|
// ==========================================
|
|
async regenerateBackupCodes(userId: string, password: string): Promise<{ backupCodes: string[] }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
if (!user.twoFactorEnabled) {
|
|
throw new BadRequestException('Two-factor authentication is not enabled');
|
|
}
|
|
|
|
// Verify password
|
|
if (!user.password) {
|
|
throw new BadRequestException('Cannot regenerate codes for social login accounts');
|
|
}
|
|
|
|
const isPasswordValid = await argon2.verify(user.password, password);
|
|
if (!isPasswordValid) {
|
|
throw new UnauthorizedException('Invalid password');
|
|
}
|
|
|
|
// Generate new backup codes
|
|
const backupCodes = await this.generateBackupCodesInternal();
|
|
const hashedCodes = await Promise.all(
|
|
backupCodes.map((code) => argon2.hash(code)),
|
|
);
|
|
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
|
},
|
|
});
|
|
|
|
return { backupCodes };
|
|
}
|
|
|
|
// ==========================================
|
|
// GET 2FA STATUS
|
|
// ==========================================
|
|
async getStatus(userId: string): Promise<{ enabled: boolean; verifiedAt: Date | null }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
twoFactorEnabled: true,
|
|
twoFactorVerifiedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
return {
|
|
enabled: user.twoFactorEnabled,
|
|
verifiedAt: user.twoFactorVerifiedAt,
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// GENERATE TEMP TOKEN FOR 2FA FLOW
|
|
// ==========================================
|
|
async generateTempToken(userId: string): Promise<string> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { email: true },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
return this.jwtService.sign(
|
|
{ sub: userId, email: user.email, type: '2fa-pending' },
|
|
{
|
|
secret: this.configService.get<string>('jwt.secret'),
|
|
expiresIn: '5m',
|
|
},
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// VERIFY TEMP TOKEN
|
|
// ==========================================
|
|
verifyTempToken(token: string): string {
|
|
try {
|
|
const payload = this.jwtService.verify(token, {
|
|
secret: this.configService.get<string>('jwt.secret'),
|
|
});
|
|
|
|
if (payload.type !== '2fa-pending') {
|
|
throw new UnauthorizedException('Invalid temporary token');
|
|
}
|
|
|
|
return payload.sub;
|
|
} catch {
|
|
throw new UnauthorizedException('Invalid or expired temporary token');
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// HELPER: Generate backup codes
|
|
// ==========================================
|
|
private async generateBackupCodesInternal(): Promise<string[]> {
|
|
const codes: string[] = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
// Generate 8 character alphanumeric codes
|
|
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
|
codes.push(code);
|
|
}
|
|
return codes;
|
|
}
|
|
}
|