feat: Implement two-factor authentication (2FA) with dedicated endpoints for setup, enabling, disabling, verification, and backup code management.

This commit is contained in:
pradeepkumar
2026-02-06 09:30:12 +05:30
parent ea05e18c15
commit 750cb55764
10 changed files with 989 additions and 19 deletions

View File

@@ -18,6 +18,7 @@ import {
import type { Request } from 'express';
import { AuthService } from './auth.service';
import { TwoFactorService } from './two-factor/two-factor.service';
import {
RegisterDto,
LoginDto,
@@ -27,13 +28,23 @@ import {
RefreshTokenDto,
VerifyEmailDto,
} from './dto';
import {
VerifyTwoFactorDto,
VerifyTwoFactorLoginDto,
VerifyBackupCodeDto,
DisableTwoFactorDto,
RegenerateBackupCodesDto,
} from './two-factor/dto';
import { JwtAuthGuard } from './guards';
import { Public, CurrentUser } from './decorators';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly twoFactorService: TwoFactorService,
) {}
// ==========================================
// REGISTER
@@ -356,4 +367,162 @@ export class AuthController {
async logoutAll(@CurrentUser('id') userId: string) {
return this.authService.logout(userId);
}
// ==========================================
// TWO-FACTOR AUTHENTICATION ENDPOINTS
// ==========================================
// Setup 2FA - Generate secret and QR code
@Post('2fa/setup')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Setup two-factor authentication' })
@ApiResponse({
status: 200,
description: 'Returns secret and QR code for 2FA setup',
schema: {
example: {
secret: 'JBSWY3DPEHPK3PXP',
qrCode: 'data:image/png;base64,...',
manualEntry: 'JBSWY3DPEHPK3PXP',
},
},
})
async setupTwoFactor(@CurrentUser('id') userId: string) {
return this.twoFactorService.setupTwoFactor(userId);
}
// Enable 2FA - Verify token and enable
@Post('2fa/enable')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Enable two-factor authentication' })
@ApiBody({ type: VerifyTwoFactorDto })
@ApiResponse({
status: 200,
description: '2FA enabled successfully with backup codes',
schema: {
example: {
backupCodes: ['ABC12345', 'DEF67890', '...'],
},
},
})
async enableTwoFactor(
@CurrentUser('id') userId: string,
@Body() dto: VerifyTwoFactorDto,
) {
return this.twoFactorService.enableTwoFactor(userId, dto.token);
}
// Disable 2FA
@Post('2fa/disable')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Disable two-factor authentication' })
@ApiBody({ type: DisableTwoFactorDto })
@ApiResponse({
status: 200,
description: '2FA disabled successfully',
})
async disableTwoFactor(
@CurrentUser('id') userId: string,
@Body() dto: DisableTwoFactorDto,
) {
await this.twoFactorService.disableTwoFactor(userId, dto.password);
return { message: 'Two-factor authentication disabled successfully' };
}
// Verify 2FA token during login
@Public()
@Post('2fa/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify 2FA token during login' })
@ApiBody({ type: VerifyTwoFactorLoginDto })
@ApiResponse({
status: 200,
description: 'Login successful with 2FA verification',
schema: {
example: {
user: {
id: 'uuid',
email: 'john@example.com',
role: 'USER',
},
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
})
async verifyTwoFactorLogin(@Body() dto: VerifyTwoFactorLoginDto) {
return this.authService.verifyTwoFactorLogin(
dto.tempToken,
dto.token,
dto.userAgent,
dto.ipAddress,
);
}
// Verify backup code during login
@Public()
@Post('2fa/verify-backup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify backup code during login' })
@ApiBody({ type: VerifyBackupCodeDto })
@ApiResponse({
status: 200,
description: 'Login successful with backup code verification',
})
async verifyBackupCode(@Body() dto: VerifyBackupCodeDto) {
return this.authService.verifyBackupCodeLogin(
dto.tempToken,
dto.backupCode,
dto.userAgent,
dto.ipAddress,
);
}
// Get 2FA status
@Get('2fa/status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get 2FA status' })
@ApiResponse({
status: 200,
description: '2FA status',
schema: {
example: {
enabled: true,
verifiedAt: '2024-01-01T00:00:00.000Z',
},
},
})
async getTwoFactorStatus(@CurrentUser('id') userId: string) {
return this.twoFactorService.getStatus(userId);
}
// Regenerate backup codes
@Post('2fa/backup-codes')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Regenerate backup codes' })
@ApiBody({ type: RegenerateBackupCodesDto })
@ApiResponse({
status: 200,
description: 'New backup codes generated',
schema: {
example: {
backupCodes: ['ABC12345', 'DEF67890', '...'],
},
},
})
async regenerateBackupCodes(
@CurrentUser('id') userId: string,
@Body() dto: RegenerateBackupCodesDto,
) {
return this.twoFactorService.regenerateBackupCodes(userId, dto.password);
}
}

View File

@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { TwoFactorService } from './two-factor/two-factor.service';
import { JwtStrategy } from './strategies';
import { PrismaModule } from '../prisma';
@@ -27,7 +28,7 @@ import { PrismaModule } from '../prisma';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule, PassportModule],
providers: [AuthService, TwoFactorService, JwtStrategy],
exports: [AuthService, TwoFactorService, JwtModule, PassportModule],
})
export class AuthModule {}

View File

@@ -4,6 +4,8 @@ import {
ConflictException,
BadRequestException,
NotFoundException,
Inject,
forwardRef,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -22,6 +24,7 @@ import {
VerifyEmailDto,
} from './dto';
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
import { TwoFactorService } from './two-factor/two-factor.service';
@Injectable()
export class AuthService {
@@ -30,6 +33,8 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => TwoFactorService))
private readonly twoFactorService: TwoFactorService,
) {}
// ==========================================
@@ -162,6 +167,21 @@ export class AuthService {
throw new UnauthorizedException('Invalid email or password');
}
// Check if 2FA is enabled
if (user.twoFactorEnabled) {
// Generate temporary token for 2FA verification
const tempToken = await this.twoFactorService.generateTempToken(user.id);
return {
requiresTwoFactor: true,
tempToken,
user: {
id: user.id,
email: user.email,
},
};
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
@@ -615,6 +635,141 @@ export class AuthService {
return { message: 'Logged out successfully' };
}
// ==========================================
// TWO-FACTOR AUTHENTICATION - VERIFY LOGIN
// ==========================================
async verifyTwoFactorLogin(
tempToken: string,
token: string,
userAgent?: string,
ipAddress?: string,
) {
// Verify temp token and get user ID
const userId = await this.twoFactorService.verifyTempToken(tempToken);
// Verify the TOTP token
const isValid = await this.twoFactorService.verifyToken(userId, token);
if (!isValid) {
throw new UnauthorizedException('Invalid verification code');
}
// Get user with profile
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('User not found or inactive');
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Create session
await this.createSession(
user.id,
tokens.accessToken,
tokens.refreshToken,
userAgent,
ipAddress,
);
// Update last login
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Get profile based on role
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
return {
user: {
id: user.id,
email: user.email,
role: user.role,
firstName: profile?.firstName,
lastName: profile?.lastName,
avatar: profile?.avatar,
},
...tokens,
};
}
// ==========================================
// TWO-FACTOR AUTHENTICATION - VERIFY BACKUP CODE
// ==========================================
async verifyBackupCodeLogin(
tempToken: string,
backupCode: string,
userAgent?: string,
ipAddress?: string,
) {
// Verify temp token and get user ID
const userId = await this.twoFactorService.verifyTempToken(tempToken);
// Verify and consume the backup code
const isValid = await this.twoFactorService.verifyBackupCode(
userId,
backupCode,
);
if (!isValid) {
throw new UnauthorizedException('Invalid backup code');
}
// Get user with profile
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('User not found or inactive');
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Create session
await this.createSession(
user.id,
tokens.accessToken,
tokens.refreshToken,
userAgent,
ipAddress,
);
// Update last login
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Get profile based on role
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
return {
user: {
id: user.id,
email: user.email,
role: user.role,
firstName: profile?.firstName,
lastName: profile?.lastName,
avatar: profile?.avatar,
},
...tokens,
};
}
// ==========================================
// GET CURRENT USER
// ==========================================

View File

@@ -0,0 +1,13 @@
import { IsString, MinLength } from 'class-validator';
export class DisableTwoFactorDto {
@IsString()
@MinLength(6, { message: 'Password must be at least 6 characters' })
password: string;
}
export class RegenerateBackupCodesDto {
@IsString()
@MinLength(6, { message: 'Password must be at least 6 characters' })
password: string;
}

View File

@@ -0,0 +1,2 @@
export * from './verify-two-factor.dto';
export * from './disable-two-factor.dto';

View File

@@ -0,0 +1,41 @@
import { IsString, Length, IsOptional } from 'class-validator';
export class VerifyTwoFactorDto {
@IsString()
@Length(6, 6, { message: 'Verification code must be 6 digits' })
token: string;
}
export class VerifyTwoFactorLoginDto {
@IsString()
tempToken: string;
@IsString()
@Length(6, 8, { message: 'Code must be 6-8 characters' })
token: string;
@IsOptional()
@IsString()
userAgent?: string;
@IsOptional()
@IsString()
ipAddress?: string;
}
export class VerifyBackupCodeDto {
@IsString()
tempToken: string;
@IsString()
@Length(8, 8, { message: 'Backup code must be 8 characters' })
backupCode: string;
@IsOptional()
@IsString()
userAgent?: string;
@IsOptional()
@IsString()
ipAddress?: string;
}

View File

@@ -0,0 +1,360 @@
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;
}
}