diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 407eca07f..7dd1036b9 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -8,6 +8,7 @@ import { Query, UseGuards, Request, + UnauthorizedException, } from '@nestjs/common'; import { ApiBearerAuth, @@ -17,6 +18,7 @@ import { } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; +import { TwoFactorService } from './two-factor.service'; import { RegisterDto, LoginDto, @@ -24,12 +26,20 @@ import { VerifySignatureDto, LinkWalletDto, } from './dto/auth.dto'; +import { + VerifyTwoFactorDto, + LoginWithTwoFactorDto, + AdminDisableTwoFactorDto, +} from './dto/two-factor.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly twoFactorService: TwoFactorService, + ) {} @Post('register') @Throttle({ auth: { limit: 5, ttl: 15 * 60 * 1000 } }) @@ -99,4 +109,100 @@ export class AuthController { ) { return this.authService.linkWallet(req.user.id, dto); } + + // --- Two-Factor Authentication Endpoints --- + + @Post('2fa/enable') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Enable 2FA - generates secret and backup codes', + description: + 'Returns a TOTP secret, otpauth:// URL for QR code generation, and backup codes. ' + + 'Call POST /auth/2fa/verify with a valid token to activate.', + }) + @ApiResponse({ status: 201, description: 'Secret and backup codes generated' }) + @ApiResponse({ status: 400, description: '2FA already enabled' }) + enable2fa(@Request() req: { user: { id: string } }) { + return this.twoFactorService.enable(req.user.id); + } + + @Post('2fa/verify') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Verify and activate 2FA with a TOTP token', + description: + 'After enabling, submit a token from your authenticator app to confirm setup.', + }) + @ApiResponse({ status: 200, description: '2FA activated' }) + @ApiResponse({ status: 401, description: 'Invalid token' }) + verify2fa( + @Request() req: { user: { id: string } }, + @Body() dto: VerifyTwoFactorDto, + ) { + return this.twoFactorService.verify(req.user.id, dto.token); + } + + @Post('2fa/validate') + @Throttle({ auth: { limit: 5, ttl: 15 * 60 * 1000 } }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Complete login with 2FA token', + description: + 'When login returns requiresTwoFactor: true, call this endpoint with the userId and TOTP token.', + }) + @ApiResponse({ status: 200, description: 'JWT returned on success' }) + @ApiResponse({ status: 401, description: 'Invalid 2FA token' }) + async validate2fa( + @Body('userId') userId: string, + @Body() dto: LoginWithTwoFactorDto, + ) { + const valid = await this.twoFactorService.validateLogin(userId, dto.token); + if (!valid) { + throw new UnauthorizedException('Invalid 2FA token'); + } + return this.twoFactorService.completeLogin(userId); + } + + @Post('2fa/disable') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Disable 2FA for your account' }) + @ApiResponse({ status: 200, description: '2FA disabled' }) + @ApiResponse({ status: 400, description: '2FA not enabled' }) + disable2fa(@Request() req: { user: { id: string } }) { + return this.twoFactorService.disable(req.user.id); + } + + @Post('2fa/admin-disable') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Admin: disable 2FA for a locked account', + description: 'Requires ADMIN role', + }) + @ApiResponse({ status: 200, description: '2FA disabled for target user' }) + @ApiResponse({ status: 400, description: '2FA not enabled for user' }) + adminDisable2fa( + @Request() req: { user: { id: string; role: string } }, + @Body() dto: AdminDisableTwoFactorDto, + ) { + if (req.user.role !== 'ADMIN') { + throw new UnauthorizedException('Admin access required'); + } + return this.twoFactorService.adminDisable(dto.userId); + } + + @Get('2fa/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Check if 2FA is enabled for your account' }) + @ApiResponse({ status: 200, description: '2FA status' }) + get2faStatus(@Request() req: { user: { id: string } }) { + return this.twoFactorService.getStatus(req.user.id); + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index acbcc147d..df925ad88 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,16 +1,20 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; // import { CacheModule } from '@nestjs/cache-manager'; import { JwtStrategy } from './strategies/jwt.strategy'; import { UserModule } from '../modules/user/user.module'; import { AuthService } from './auth.service'; +import { TwoFactorService } from './two-factor.service'; import { AuthController } from './auth.controller'; +import { User } from '../modules/user/entities/user.entity'; @Module({ imports: [ UserModule, + TypeOrmModule.forFeature([User]), // CacheModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ @@ -28,7 +32,7 @@ import { AuthController } from './auth.controller'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [AuthService, JwtModule, PassportModule], + providers: [AuthService, TwoFactorService, JwtStrategy], + exports: [AuthService, TwoFactorService, JwtModule, PassportModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c26b016f9..5ca469e9b 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -50,6 +50,16 @@ export class AuthService { throw new UnauthorizedException('Invalid credentials'); } + // Check if 2FA is enabled + const fullUser = await this.userService.findByEmail(dto.email); + if (fullUser?.twoFactorEnabled) { + return { + requiresTwoFactor: true, + userId: user.id, + message: 'Please provide your 2FA token', + }; + } + return { accessToken: this.generateToken(user.id, user.email, user.role), }; diff --git a/backend/src/auth/dto/two-factor.dto.ts b/backend/src/auth/dto/two-factor.dto.ts new file mode 100644 index 000000000..d73ffdaa9 --- /dev/null +++ b/backend/src/auth/dto/two-factor.dto.ts @@ -0,0 +1,28 @@ +import { IsString, Length, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VerifyTwoFactorDto { + @ApiProperty({ + example: '123456', + description: '6-digit TOTP token from authenticator app', + }) + @IsString() + @Length(6, 8) // 6 for TOTP, 8 for backup codes + token: string; +} + +export class LoginWithTwoFactorDto { + @ApiProperty({ + example: '123456', + description: '6-digit TOTP token or backup code', + }) + @IsString() + @Length(6, 8) + token: string; +} + +export class AdminDisableTwoFactorDto { + @ApiProperty({ description: 'User ID to disable 2FA for' }) + @IsUUID() + userId: string; +} diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts new file mode 100644 index 000000000..8fe329d00 --- /dev/null +++ b/backend/src/auth/two-factor.service.ts @@ -0,0 +1,261 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHmac, randomBytes } from 'crypto'; +import { User } from '../modules/user/entities/user.entity'; + +const TOTP_STEP = 30; // seconds +const TOTP_DIGITS = 6; +const ISSUER = 'Nestera'; +const BACKUP_CODE_COUNT = 8; + +@Injectable() +export class TwoFactorService { + private readonly logger = new Logger(TwoFactorService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly jwtService: JwtService, + ) {} + + async enable(userId: string): Promise<{ + secret: string; + otpauthUrl: string; + backupCodes: string[]; + }> { + const user = await this.findUser(userId); + + if (user.twoFactorEnabled) { + throw new BadRequestException('2FA is already enabled'); + } + + // Generate a 20-byte secret, encode as base32 + const secretBuffer = randomBytes(20); + const secret = this.base32Encode(secretBuffer); + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + + // Store secret and backup codes (not yet enabled until verified) + await this.userRepository.update(userId, { + twoFactorSecret: secret, + twoFactorBackupCodes: backupCodes, + }); + + // Build otpauth:// URI for QR code generation by the client + const otpauthUrl = `otpauth://totp/${ISSUER}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=${ISSUER}&digits=${TOTP_DIGITS}&period=${TOTP_STEP}`; + + this.logger.log(`2FA setup initiated for user ${userId}`); + + return { secret, otpauthUrl, backupCodes }; + } + + async verify( + userId: string, + token: string, + ): Promise<{ enabled: boolean; message: string }> { + const user = await this.findUser(userId); + + if (!user.twoFactorSecret) { + throw new BadRequestException( + 'Call POST /auth/2fa/enable first to generate a secret', + ); + } + + if (!this.verifyTotp(user.twoFactorSecret, token)) { + throw new UnauthorizedException('Invalid 2FA token'); + } + + // Activate 2FA + await this.userRepository.update(userId, { twoFactorEnabled: true }); + + this.logger.log(`2FA enabled for user ${userId}`); + + return { enabled: true, message: '2FA has been enabled successfully' }; + } + + async validateLogin( + userId: string, + token: string, + ): Promise { + const user = await this.findUser(userId); + + if (!user.twoFactorEnabled || !user.twoFactorSecret) { + return true; // 2FA not enabled, skip + } + + // Try TOTP first + if (this.verifyTotp(user.twoFactorSecret, token)) { + return true; + } + + // Try backup code + if (user.twoFactorBackupCodes?.includes(token)) { + // Consume the backup code + const remaining = user.twoFactorBackupCodes.filter((c) => c !== token); + await this.userRepository.update(userId, { + twoFactorBackupCodes: remaining.length > 0 ? remaining : null, + }); + this.logger.log(`Backup code used for user ${userId}`); + return true; + } + + return false; + } + + async disable(userId: string): Promise<{ message: string }> { + const user = await this.findUser(userId); + + if (!user.twoFactorEnabled) { + throw new BadRequestException('2FA is not enabled'); + } + + await this.userRepository.update(userId, { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: null, + }); + + this.logger.log(`2FA disabled for user ${userId}`); + + return { message: '2FA has been disabled' }; + } + + async adminDisable( + targetUserId: string, + ): Promise<{ message: string }> { + const user = await this.findUser(targetUserId); + + if (!user.twoFactorEnabled) { + throw new BadRequestException('2FA is not enabled for this user'); + } + + await this.userRepository.update(targetUserId, { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: null, + }); + + this.logger.log(`2FA admin-disabled for user ${targetUserId}`); + + return { message: `2FA disabled for user ${targetUserId}` }; + } + + async getStatus(userId: string): Promise<{ enabled: boolean }> { + const user = await this.findUser(userId); + return { enabled: user.twoFactorEnabled }; + } + + async completeLogin( + userId: string, + ): Promise<{ accessToken: string }> { + const user = await this.findUser(userId); + return { + accessToken: this.jwtService.sign({ + sub: user.id, + email: user.email, + role: user.role, + }), + }; + } + + // --- TOTP Implementation using Node.js crypto --- + + private verifyTotp(secret: string, token: string): boolean { + const secretBuffer = this.base32Decode(secret); + const now = Math.floor(Date.now() / 1000); + + // Check current window and ±1 step for clock drift + for (let offset = -1; offset <= 1; offset++) { + const counter = Math.floor((now + offset * TOTP_STEP) / TOTP_STEP); + const expected = this.generateHotp(secretBuffer, counter); + if (expected === token) { + return true; + } + } + return false; + } + + private generateHotp(secret: Buffer, counter: number): string { + // Convert counter to 8-byte big-endian buffer + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0); + counterBuffer.writeUInt32BE(counter & 0xffffffff, 4); + + const hmac = createHmac('sha1', secret).update(counterBuffer).digest(); + + // Dynamic truncation + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + return (code % 10 ** TOTP_DIGITS).toString().padStart(TOTP_DIGITS, '0'); + } + + private base32Encode(buffer: Buffer): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + let output = ''; + + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += alphabet[(value << (5 - bits)) & 31]; + } + return output; + } + + private base32Decode(encoded: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + const output: number[] = []; + + for (const char of encoded.toUpperCase()) { + const idx = alphabet.indexOf(char); + if (idx === -1) continue; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(output); + } + + private generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < BACKUP_CODE_COUNT; i++) { + const code = randomBytes(4).toString('hex'); // 8-char hex codes + codes.push(code); + } + return codes; + } + + private async findUser(userId: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } +} diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index d0b953e27..389b317cb 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -56,6 +56,15 @@ export class User { @Column({ nullable: true }) nonce: string; + @Column({ type: 'boolean', default: false }) + twoFactorEnabled: boolean; + + @Column({ type: 'varchar', nullable: true }) + twoFactorSecret: string | null; + + @Column({ type: 'simple-array', nullable: true }) + twoFactorBackupCodes: string[] | null; + @CreateDateColumn() createdAt: Date;