diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ca4912dc..3f4a7dad 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,11 +1,3 @@ -DB_HOST=localhost -DB_PORT=5432 -DB_USERNAME=lumenpulse_user -DB_PASSWORD=yourpassword -DB_DATABASE=lumenpulse_db -PYTHON_API_URL=http://localhost:8000 -# Backend Configuration - # Database (matches docker-compose postgres service) DB_HOST=localhost DB_PORT=5432 @@ -35,6 +27,10 @@ REDIS_PORT=6379 # Default cache TTL in milliseconds (300000 = 5 minutes) CACHE_TTL_MS=300000 +# Two-Factor Authentication (TOTP) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +TOTP_ENCRYPTION_KEY= + # Webhook — shared secret for HMAC-SHA256 signature verification # Must match WEBHOOK_SECRET set in the data-processing service WEBHOOK_SECRET=your_webhook_secret_here diff --git a/apps/backend/package.json b/apps/backend/package.json index f67432ba..f8c6123a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -49,10 +49,12 @@ "class-validator": "^0.14.3", "dotenv": "^17.2.3", "helmet": "^8.1.0", + "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.17.2", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3", diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index d1ee7e84..48b9a8c3 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -27,6 +27,13 @@ import { GetChallengeDto, VerifyChallengeDto } from './dto/auth.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { RefreshTokenDto, LogoutDto } from './dto/refresh-token.dto'; +import { + TwoFactorGenerateResponseDto, + TwoFactorEnableDto, + TwoFactorVerifyDto, + TwoFactorDisableDto, + TwoFactorPendingResponseDto, +} from './dto/totp.dto'; import { ApiTags, ApiOperation, @@ -49,18 +56,22 @@ export class AuthController { @ApiOperation({ summary: 'Login with email and password' }) @ApiResponse({ status: 200, - description: 'Login successful', + description: 'Login successful or 2FA required', schema: { - properties: { - access_token: { - type: 'string', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + oneOf: [ + { + properties: { + access_token: { type: 'string' }, + refresh_token: { type: 'string' }, + }, }, - refresh_token: { - type: 'string', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + { + properties: { + requiresTwoFactor: { type: 'boolean', example: true }, + userId: { type: 'string' }, + }, }, - }, + ], }, }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) @@ -69,6 +80,17 @@ export class AuthController { if (!user) { throw new UnauthorizedException(); } + + // Check if 2FA is enabled + const fullUser = await this.usersService.findById(user.id); + if (fullUser?.twoFactorEnabled) { + // Do NOT issue a session token yet + return { + requiresTwoFactor: true, + userId: user.id, + }; + } + return this.authService.login(user); } @@ -230,6 +252,99 @@ export class AuthController { }; } + @UseGuards(JwtAuthGuard) + @Post('2fa/generate') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Generate TOTP secret and QR code for 2FA setup' }) + @ApiResponse({ + status: 200, + description: 'QR code and OTP auth URI generated', + type: TwoFactorGenerateResponseDto, + }) + @ApiResponse({ status: 400, description: '2FA already enabled' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async generateTwoFactor(@Request() req: { user: { sub: string } }) { + return this.authService.generateTwoFactorSecret(req.user.sub); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/enable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Verify TOTP and enable 2FA' }) + @ApiResponse({ + status: 200, + description: '2FA enabled successfully', + schema: { + properties: { + message: { type: 'string', example: '2FA enabled successfully' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid token or not pending' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async enableTwoFactor( + @Request() req: { user: { sub: string } }, + @Body() body: TwoFactorEnableDto, + ) { + return this.authService.enableTwoFactor(req.user.sub, body.token); + } + + @Post('2fa/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify TOTP during login (no auth required)' }) + @ApiResponse({ + status: 200, + description: '2FA verified, login successful', + schema: { + properties: { + access_token: { type: 'string' }, + refresh_token: { type: 'string' }, + }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request or token', + }) + async verifyTwoFactor( + @Body() body: TwoFactorVerifyDto, + @Request() req: ExpressRequest, + ) { + const ipAddress = req.ip || req.connection?.remoteAddress; + // Note: Rate limiting should be applied to this endpoint in production + return this.authService.verifyTwoFactor( + body.userId, + body.token, + undefined, + ipAddress, + ); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/disable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Verify TOTP and disable 2FA' }) + @ApiResponse({ + status: 200, + description: '2FA disabled successfully', + schema: { + properties: { + message: { type: 'string', example: '2FA disabled successfully' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid token or 2FA not enabled' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async disableTwoFactor( + @Request() req: { user: { sub: string } }, + @Body() body: TwoFactorDisableDto, + ) { + return this.authService.disableTwoFactor(req.user.sub, body.token); + } + @Get('challenge') @ApiOperation({ summary: 'Get authentication challenge for Stellar wallet' }) @ApiResponse({ diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 9773ad24..da0d809d 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -25,6 +25,9 @@ import { import * as crypto from 'crypto'; import { ConfigService } from '@nestjs/config'; import { EmailService } from '../email/email.service'; +import { authenticator } from 'otplib'; +import * as QRCode from 'qrcode'; +import { encrypt, decrypt } from '../utils/encryption'; interface ChallengeData { nonce: string; @@ -568,4 +571,194 @@ export class AuthService { return { message: 'Successfully logged out from all devices' }; } + + /** + * Generate a new TOTP secret and QR code for 2FA setup + */ + async generateTwoFactorSecret( + userId: string, + ): Promise<{ otpauthUri: string; qrCode: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.twoFactorEnabled) { + throw new BadRequestException({ + message: 'Two-factor authentication is already enabled', + error: 'TOTP_ALREADY_ENABLED', + }); + } + + // Generate new secret + const secret = authenticator.generateSecret(); + + // Encrypt and store secret, mark as pending + const encryptedSecret = encrypt(secret); + user.twoFactorSecret = encryptedSecret; + user.twoFactorPending = true; + await this.userRepository.save(user); + + // Generate otpauth URI + const otpauthUri = authenticator.keyuri( + user.email || user.id, + 'Lumenpulse', + secret, + ); + + // Generate QR code + const qrCode = await QRCode.toDataURL(otpauthUri); + + this.logger.log(`Generated 2FA secret for user ${userId}`); + + return { otpauthUri, qrCode }; + } + + /** + * Verify TOTP token and enable 2FA for the user + */ + async enableTwoFactor( + userId: string, + token: string, + ): Promise<{ message: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.twoFactorPending || !user.twoFactorSecret) { + throw new BadRequestException({ + message: 'Generate a 2FA secret first', + error: 'TOTP_NOT_PENDING', + }); + } + + if (user.twoFactorEnabled) { + throw new BadRequestException({ + message: 'Two-factor authentication is already enabled', + error: 'TOTP_ALREADY_ENABLED', + }); + } + + // Decrypt secret and verify token + const secret = decrypt(user.twoFactorSecret); + const isValid = authenticator.verify({ token, secret }); + + if (!isValid) { + throw new BadRequestException({ + message: 'Invalid or expired token', + error: 'TOTP_INVALID_TOKEN', + }); + } + + // Enable 2FA + user.twoFactorEnabled = true; + user.twoFactorPending = false; + await this.userRepository.save(user); + + this.logger.log(`Enabled 2FA for user ${userId}`); + + return { message: '2FA enabled successfully' }; + } + + /** + * Verify TOTP during login flow + * Note: Rate limiting should be applied to this endpoint in production + */ + async verifyTwoFactor( + userId: string, + token: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise<{ + access_token: string; + refresh_token: string; + }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + // Do not leak whether user exists or has 2FA enabled + if (!user || !user.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException({ + message: 'Invalid request', + error: 'TOTP_INVALID_REQUEST', + }); + } + + // Decrypt secret and verify token + const secret = decrypt(user.twoFactorSecret); + const isValid = authenticator.verify({ token, secret }); + + if (!isValid) { + throw new BadRequestException({ + message: 'Invalid or expired token', + error: 'TOTP_INVALID_TOKEN', + }); + } + + // Double-check 2FA is still enabled + if (!user.twoFactorEnabled) { + throw new BadRequestException({ + message: 'Invalid request', + error: 'TOTP_INVALID_REQUEST', + }); + } + + // Issue tokens + const payload = { email: user.email, sub: user.id, role: user.role }; + const accessToken = this.jwtService.sign(payload); + + const refreshToken = this.generateRefreshToken(); + await this.storeRefreshToken(refreshToken, user.id, deviceInfo, ipAddress); + + this.logger.log(`Successful 2FA verification for user ${userId}`); + + return { + access_token: accessToken, + refresh_token: refreshToken, + }; + } + + /** + * Disable 2FA for the user after verifying their TOTP + */ + async disableTwoFactor( + userId: string, + token: string, + ): Promise<{ message: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException({ + message: 'Two-factor authentication is not enabled', + error: 'TOTP_NOT_ENABLED', + }); + } + + // Decrypt secret and verify token + const secret = decrypt(user.twoFactorSecret); + const isValid = authenticator.verify({ token, secret }); + + if (!isValid) { + throw new BadRequestException({ + message: 'Invalid token', + error: 'TOTP_INVALID_TOKEN', + }); + } + + // Disable 2FA and clear secret + user.twoFactorEnabled = false; + user.twoFactorPending = false; + user.twoFactorSecret = null; + await this.userRepository.save(user); + + this.logger.log(`Disabled 2FA for user ${userId}`); + + return { message: '2FA disabled successfully' }; + } } diff --git a/apps/backend/src/auth/dto/totp.dto.ts b/apps/backend/src/auth/dto/totp.dto.ts new file mode 100644 index 00000000..44047819 --- /dev/null +++ b/apps/backend/src/auth/dto/totp.dto.ts @@ -0,0 +1,84 @@ +import { IsString, IsNotEmpty, Matches, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class TwoFactorGenerateResponseDto { + @ApiProperty({ + description: 'OTPAuth URI for manual entry into authenticator apps', + example: 'otpauth://totp/Lumenpulse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Lumenpulse', + }) + otpauthUri: string; + + @ApiProperty({ + description: 'Base64 encoded QR code data URL for scanning', + example: 'data:image/png;base64,iVBORw0KGgoAAAANS...', + }) + qrCode: string; +} + +export class TwoFactorEnableDto { + @ApiProperty({ + description: '6-digit TOTP code from authenticator app', + example: '123456', + minLength: 6, + maxLength: 6, + }) + @IsString() + @IsNotEmpty({ message: 'Token is required' }) + @Matches(/^\d{6}$/, { + message: 'Token must be exactly 6 numeric digits', + }) + token: string; +} + +export class TwoFactorVerifyDto { + @ApiProperty({ + description: 'User ID from the pending 2FA login step', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsString() + @IsNotEmpty({ message: 'User ID is required' }) + @IsUUID('4', { message: 'User ID must be a valid UUID' }) + userId: string; + + @ApiProperty({ + description: '6-digit TOTP code from authenticator app', + example: '123456', + minLength: 6, + maxLength: 6, + }) + @IsString() + @IsNotEmpty({ message: 'Token is required' }) + @Matches(/^\d{6}$/, { + message: 'Token must be exactly 6 numeric digits', + }) + token: string; +} + +export class TwoFactorDisableDto { + @ApiProperty({ + description: '6-digit TOTP code from authenticator app to confirm disabling', + example: '123456', + minLength: 6, + maxLength: 6, + }) + @IsString() + @IsNotEmpty({ message: 'Token is required' }) + @Matches(/^\d{6}$/, { + message: 'Token must be exactly 6 numeric digits', + }) + token: string; +} + +export class TwoFactorPendingResponseDto { + @ApiProperty({ + description: 'Indicates that 2FA verification is required to complete login', + example: true, + }) + requiresTwoFactor: true; + + @ApiProperty({ + description: 'User ID to use for the 2FA verification step', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + userId: string; +} diff --git a/apps/backend/src/database/migrations/1770000000000-AddTwoFactorAuth.ts b/apps/backend/src/database/migrations/1770000000000-AddTwoFactorAuth.ts new file mode 100644 index 00000000..f2b8c7b5 --- /dev/null +++ b/apps/backend/src/database/migrations/1770000000000-AddTwoFactorAuth.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTwoFactorAuth1770000000000 implements MigrationInterface { + name = 'AddTwoFactorAuth1770000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add 2FA columns to users table + await queryRunner.query( + `ALTER TABLE "users" ADD "twoFactorSecret" character varying(500)`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "twoFactorEnabled" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "twoFactorPending" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove 2FA columns from users table + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "twoFactorPending"`, + ); + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "twoFactorEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "twoFactorSecret"`, + ); + } +} diff --git a/apps/backend/src/users/entities/user.entity.ts b/apps/backend/src/users/entities/user.entity.ts index 3994abf0..3b57beb8 100644 --- a/apps/backend/src/users/entities/user.entity.ts +++ b/apps/backend/src/users/entities/user.entity.ts @@ -76,6 +76,15 @@ export class User { }) preferences: UserPreferences; + @Column({ type: 'varchar', length: 500, nullable: true }) + twoFactorSecret: string | null; + + @Column({ type: 'boolean', default: false }) + twoFactorEnabled: boolean; + + @Column({ type: 'boolean', default: false }) + twoFactorPending: boolean; + @OneToMany(() => StellarAccount, (stellarAccount) => stellarAccount.user) stellarAccounts: StellarAccount[]; diff --git a/apps/backend/src/utils/encryption.ts b/apps/backend/src/utils/encryption.ts new file mode 100644 index 00000000..49a6e750 --- /dev/null +++ b/apps/backend/src/utils/encryption.ts @@ -0,0 +1,64 @@ +import * as crypto from 'crypto'; + +// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +const ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY; + +if (!ENCRYPTION_KEY) { + throw new Error( + 'TOTP_ENCRYPTION_KEY is not configured. Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"', + ); +} + +if (ENCRYPTION_KEY.length !== 64) { + throw new Error( + 'TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)', + ); +} + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +/** + * Encrypt plaintext using AES-256-GCM + * @param plaintext - The text to encrypt + * @returns iv:authTag:ciphertext hex string + */ +export function encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const key = Buffer.from(ENCRYPTION_KEY!, 'hex'); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Format: iv:authTag:ciphertext (all hex) + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +/** + * Decrypt ciphertext using AES-256-GCM + * @param ciphertext - The encrypted string in format iv:authTag:ciphertext + * @returns The decrypted plaintext + */ +export function decrypt(ciphertext: string): string { + const parts = ciphertext.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted format'); + } + + const [ivHex, authTagHex, encryptedHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const key = Buffer.from(ENCRYPTION_KEY!, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/package.json b/package.json index 11b39120..024e11f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "devDependencies": { "@types/node": "^25.3.0", + "@types/qrcode": "^1.5.6", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f126fb64..1d3cf9e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/node': specifier: ^25.3.0 version: 25.3.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@25.3.0)(typescript@5.9.3) @@ -49,6 +52,9 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -143,6 +149,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.3.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0