feat: Implement two-factor authentication (2FA) with dedicated endpoints for setup, enabling, disabling, verification, and backup code management.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
13
src/auth/two-factor/dto/disable-two-factor.dto.ts
Normal file
13
src/auth/two-factor/dto/disable-two-factor.dto.ts
Normal 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;
|
||||
}
|
||||
2
src/auth/two-factor/dto/index.ts
Normal file
2
src/auth/two-factor/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './verify-two-factor.dto';
|
||||
export * from './disable-two-factor.dto';
|
||||
41
src/auth/two-factor/dto/verify-two-factor.dto.ts
Normal file
41
src/auth/two-factor/dto/verify-two-factor.dto.ts
Normal 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;
|
||||
}
|
||||
360
src/auth/two-factor/two-factor.service.ts
Normal file
360
src/auth/two-factor/two-factor.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user