diff --git a/package-lock.json b/package-lock.json index ba08a28..4a34ec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,12 +64,14 @@ "pg": "^8.16.3", "pino-http": "^11.0.0", "prisma": "^7.2.0", + "qrcode": "^1.5.4", "redis": "^5.10.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sharp": "^0.34.5", "slugify": "^1.6.6", "socket.io": "^4.8.1", + "speakeasy": "^2.0.0", "stripe": "^20.1.0", "uuid": "^13.0.0", "winston": "^3.19.0", @@ -91,6 +93,8 @@ "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/supertest": "^6.0.2", "autocannon": "^8.0.0", "eslint": "^9.18.0", @@ -2711,16 +2715,6 @@ "@electric-sql/pglite": "0.3.2" } }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -6817,6 +6811,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -6904,6 +6908,16 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -8016,6 +8030,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9333,6 +9353,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -9498,6 +9527,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/display-notification": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/display-notification/-/display-notification-2.0.0.tgz", @@ -15326,7 +15361,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -15539,7 +15573,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15898,6 +15931,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -16425,6 +16467,157 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -16650,7 +16843,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16666,6 +16858,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -17103,6 +17301,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -17462,6 +17666,18 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -19328,6 +19544,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -19422,7 +19644,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -19476,7 +19697,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -19486,7 +19706,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" diff --git a/package.json b/package.json index feb465d..091c02b 100644 --- a/package.json +++ b/package.json @@ -82,12 +82,14 @@ "pg": "^8.16.3", "pino-http": "^11.0.0", "prisma": "^7.2.0", + "qrcode": "^1.5.4", "redis": "^5.10.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sharp": "^0.34.5", "slugify": "^1.6.6", "socket.io": "^4.8.1", + "speakeasy": "^2.0.0", "stripe": "^20.1.0", "uuid": "^13.0.0", "winston": "^3.19.0", @@ -109,6 +111,8 @@ "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/supertest": "^6.0.2", "autocannon": "^8.0.0", "eslint": "^9.18.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da1ccb9..abffa5a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,6 +83,12 @@ model User { facebookId String? @unique twitterId String? @unique + // Two-Factor Authentication + twoFactorEnabled Boolean @default(false) + twoFactorSecret String? // Encrypted TOTP secret + twoFactorBackupCodes String? // JSON array of hashed backup codes + twoFactorVerifiedAt DateTime? // When 2FA was enabled + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index dae85c6..5a2a473 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d63792f..a6a3adc 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ad75007..0b9a8f3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 // ========================================== diff --git a/src/auth/two-factor/dto/disable-two-factor.dto.ts b/src/auth/two-factor/dto/disable-two-factor.dto.ts new file mode 100644 index 0000000..8557106 --- /dev/null +++ b/src/auth/two-factor/dto/disable-two-factor.dto.ts @@ -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; +} diff --git a/src/auth/two-factor/dto/index.ts b/src/auth/two-factor/dto/index.ts new file mode 100644 index 0000000..a34cf51 --- /dev/null +++ b/src/auth/two-factor/dto/index.ts @@ -0,0 +1,2 @@ +export * from './verify-two-factor.dto'; +export * from './disable-two-factor.dto'; diff --git a/src/auth/two-factor/dto/verify-two-factor.dto.ts b/src/auth/two-factor/dto/verify-two-factor.dto.ts new file mode 100644 index 0000000..f27d14a --- /dev/null +++ b/src/auth/two-factor/dto/verify-two-factor.dto.ts @@ -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; +} diff --git a/src/auth/two-factor/two-factor.service.ts b/src/auth/two-factor/two-factor.service.ts new file mode 100644 index 0000000..c76865a --- /dev/null +++ b/src/auth/two-factor/two-factor.service.ts @@ -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('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 { + 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 { + 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 { + 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 { + 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('jwt.secret'), + expiresIn: '5m', + }, + ); + } + + // ========================================== + // VERIFY TEMP TOKEN + // ========================================== + verifyTempToken(token: string): string { + try { + const payload = this.jwtService.verify(token, { + secret: this.configService.get('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 { + 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; + } +}