diff --git a/package-lock.json b/package-lock.json index a5ca9d8..11914f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@types/nodemailer": "^7.0.10", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.3", @@ -53,11 +54,13 @@ "nest-winston": "^1.9.0", "nestjs-i18n": "^10.6.0", "nodemailer": "^8.0.1", + "otplib": "^13.4.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pg": "^8.11.0", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.34.5", @@ -3887,6 +3890,74 @@ "node": ">=14" } }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3914,6 +3985,15 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sentry-internal/tracing": { "version": "7.120.4", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.4.tgz", @@ -5289,6 +5369,15 @@ "@types/passport": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "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", @@ -6810,7 +6899,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7519,6 +7607,15 @@ "ms": "2.0.0" } }, + "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/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -7709,6 +7806,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "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/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -8614,7 +8717,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -10814,7 +10916,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -12005,6 +12106,20 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -12025,7 +12140,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -12038,7 +12152,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -12070,7 +12183,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" @@ -12195,7 +12307,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" @@ -12431,6 +12542,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/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12701,6 +12821,75 @@ ], "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/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/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.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -12933,6 +13122,12 @@ "license": "MIT", "optional": true }, + "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/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -13274,8 +13469,7 @@ "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", - "optional": true + "license": "ISC" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -15458,6 +15652,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/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -15630,7 +15830,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", diff --git a/package.json b/package.json index 5d9a433..63cedd5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/nodemailer": "^7.0.10", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.3", @@ -63,11 +64,13 @@ "nest-winston": "^1.9.0", "nestjs-i18n": "^10.6.0", "nodemailer": "^8.0.1", + "otplib": "^13.4.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pg": "^8.11.0", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sharp": "^0.34.5", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index e6fc78f..5b92ca1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Req } from "@nestjs/common" +import { Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Req, Body } from "@nestjs/common" import type { AuthService } from "./auth.service" import { RegisterUserDto } from "./dto/register-user.dto" import { LoginUserDto } from "./dto/login-user.dto" @@ -7,12 +7,14 @@ import { ResetPasswordDto } from "./dto/reset-password.dto" import { VerifyEmailDto } from "./dto/verify-email.dto" import { JwtAuthGuard } from "./guards/jwt-auth.guard" import { RefreshJwtAuthGuard } from "./guards/refresh-jwt-auth.guard" +import { MfaPendingAuthGuard } from "./guards/mfa-pending-auth.guard" import type { RequestWithUser } from "./interfaces/request-with-user.interface" import { Roles } from "./decorators/roles.decorator" import { RolesGuard } from "./guards/roles.guard" import { UserRole } from "./constants" import { AuthGuard } from "@nestjs/passport" import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from "@nestjs/swagger" +import { VerifyTwoFactorDto, ChallengeTwoFactorDto, DisableTwoFactorDto } from "./dto/two-factor.dto" @ApiTags("Authentication") @Controller("auth") @@ -213,4 +215,128 @@ export class AuthController { ...tokens, } } + + // Two-Factor Authentication Endpoints + @Post("2fa/setup") + @ApiOperation({ summary: "Setup 2FA - generate TOTP secret and QR code" }) + @ApiBearerAuth() + @ApiResponse({ + status: 201, + description: "2FA secret generated. Scan QR code with authenticator app.", + schema: { + example: { + secret: "JBSWY3DPEHPK3PXP", + otpauthUrl: "otpauth://totp/Quest%20Service:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Quest%20Service", + qrCodeDataUri: "data:image/png;base64,...", + }, + }, + }) + @ApiResponse({ status: 400, description: "2FA already enabled." }) + @ApiResponse({ status: 401, description: "Unauthorized." }) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + async setupTwoFactor(@Req() req: RequestWithUser) { + const result = await this.authService.generateTwoFactorSecret(req.user.id) + return result + } + + @Post("2fa/verify") + @ApiOperation({ summary: "Verify 2FA setup with TOTP code" }) + @ApiBearerAuth() + @ApiResponse({ + status: 200, + description: "2FA enabled successfully. Backup codes returned.", + schema: { + example: { + message: "2FA enabled successfully", + backupCodes: ["ABCD1234", "EFGH5678", "IJKL9012", "MNOP3456", "QRST7890", "UVWX1234", "YZAB5678", "CDEF9012"], + }, + }, + }) + @ApiResponse({ status: 400, description: "Invalid TOTP code or 2FA already enabled." }) + @ApiResponse({ status: 401, description: "Unauthorized." }) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + async verifyTwoFactor(@Req() req: RequestWithUser, @Body() verifyDto: VerifyTwoFactorDto) { + const result = await this.authService.verifyTwoFactorSetup(req.user.id, verifyDto.code) + return result + } + + @Post("2fa/challenge") + @ApiOperation({ summary: "Challenge endpoint to exchange mfa_pending token for full JWT tokens" }) + @ApiResponse({ + status: 200, + description: "2FA verified, full JWT tokens issued.", + schema: { example: { accessToken: "...", refreshToken: "..." } }, + }) + @ApiResponse({ status: 401, description: "Invalid MFA pending token or 2FA code." }) + @ApiBody({ + schema: { + type: "object", + properties: { + mfaPendingToken: { type: "string", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }, + code: { type: "string", example: "123456" }, + }, + required: ["mfaPendingToken", "code"], + }, + }) + @HttpCode(HttpStatus.OK) + async challengeTwoFactor(@Body() challengeDto: ChallengeTwoFactorDto & { mfaPendingToken: string }) { + const result = await this.authService.challengeTwoFactor(challengeDto.mfaPendingToken, challengeDto.code) + return result + } + + @Post("2fa/disable") + @ApiOperation({ summary: "Disable 2FA (requires current TOTP code + password)" }) + @ApiBearerAuth() + @ApiResponse({ status: 200, description: "2FA disabled successfully." }) + @ApiResponse({ status: 400, description: "Invalid TOTP code or 2FA not enabled." }) + @ApiResponse({ status: 401, description: "Unauthorized (invalid password)." }) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + async disableTwoFactor(@Req() req: RequestWithUser, @Body() disableDto: DisableTwoFactorDto) { + const result = await this.authService.disableTwoFactor(req.user.id, disableDto.code, disableDto.password) + return result + } + + @Get("2fa/status") + @ApiOperation({ summary: "Get 2FA status for authenticated user" }) + @ApiBearerAuth() + @ApiResponse({ + status: 200, + description: "2FA status.", + schema: { example: { isTwoFactorEnabled: true } }, + }) + @ApiResponse({ status: 401, description: "Unauthorized." }) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + async getTwoFactorStatus(@Req() req: RequestWithUser) { + const result = await this.authService.getTwoFactorStatus(req.user.id) + return result + } + + @Post("2fa/backup-codes/regenerate") + @ApiOperation({ summary: "Regenerate backup codes (invalidates old ones)" }) + @ApiBearerAuth() + @ApiResponse({ + status: 201, + description: "Backup codes regenerated.", + schema: { + example: { + message: "Backup codes regenerated", + backupCodes: ["ABCD1234", "EFGH5678", "IJKL9012", "MNOP3456", "QRST7890", "UVWX1234", "YZAB5678", "CDEF9012"], + }, + }, + }) + @ApiResponse({ status: 400, description: "2FA not enabled." }) + @ApiResponse({ status: 401, description: "Unauthorized." }) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + async regenerateBackupCodes(@Req() req: RequestWithUser) { + const backupCodes = await this.authService.regenerateBackupCodes(req.user.id) + return { + message: "Backup codes regenerated", + backupCodes, + } + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index dad8386..3239580 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -7,21 +7,23 @@ import { AuthController } from "./auth.controller" import { User } from "./entities/user.entity" import { Role } from "./entities/role.entity" import { RefreshToken } from "./entities/refresh-token.entity" +import { TwoFactorBackupCode } from "./entities/two-factor-backup-code.entity" import { JwtStrategy } from "./strategies/jwt.strategy" import { RefreshJwtStrategy } from "./strategies/refresh-jwt.strategy" import { GoogleStrategy } from "./strategies/google.strategy" import { jwtConstants } from "./constants" +import { MfaPendingAuthGuard } from "./guards/mfa-pending-auth.guard" @Module({ imports: [ - TypeOrmModule.forFeature([User, Role, RefreshToken]), + TypeOrmModule.forFeature([User, Role, RefreshToken, TwoFactorBackupCode]), PassportModule, JwtModule.register({ secret: jwtConstants.secret, signOptions: { expiresIn: jwtConstants.accessExpiresIn as `${number}m` }, }), ], - providers: [AuthService, JwtStrategy, RefreshJwtStrategy, GoogleStrategy], + providers: [AuthService, JwtStrategy, RefreshJwtStrategy, GoogleStrategy, MfaPendingAuthGuard], controllers: [AuthController], exports: [AuthService], // Export AuthService if other modules need to use it }) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index f3ba0a8..8db3051 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from "@nestjs/typeorm" import { User } from "./entities/user.entity" import { Role } from "./entities/role.entity" import { RefreshToken } from "./entities/refresh-token.entity" +import { TwoFactorBackupCode } from "./entities/two-factor-backup-code.entity" import { JwtService } from "@nestjs/jwt" import * as bcrypt from "bcrypt" import type { Repository } from "typeorm" @@ -32,6 +33,13 @@ const mockRefreshTokenRepository = () => ({ update: jest.fn(), }) +const mockBackupCodeRepository = () => ({ + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), +}) + // Mock JwtService const mockJwtService = () => ({ sign: jest.fn(() => "mockAccessToken"), @@ -43,6 +51,7 @@ describe("AuthService", () => { let userRepository: Repository let roleRepository: Repository let refreshTokenRepository: Repository + let backupCodeRepository: Repository let jwtService: JwtService beforeEach(async () => { @@ -52,6 +61,7 @@ describe("AuthService", () => { { provide: getRepositoryToken(User), useFactory: mockUserRepository }, { provide: getRepositoryToken(Role), useFactory: mockRoleRepository }, { provide: getRepositoryToken(RefreshToken), useFactory: mockRefreshTokenRepository }, + { provide: getRepositoryToken(TwoFactorBackupCode), useFactory: mockBackupCodeRepository }, { provide: JwtService, useFactory: mockJwtService }, ], }).compile() @@ -60,6 +70,7 @@ describe("AuthService", () => { userRepository = module.get>(getRepositoryToken(User)) roleRepository = module.get>(getRepositoryToken(Role)) refreshTokenRepository = module.get>(getRepositoryToken(RefreshToken)) + backupCodeRepository = module.get>(getRepositoryToken(TwoFactorBackupCode)) jwtService = module.get(JwtService) // Mock bcrypt.hash and bcrypt.compare @@ -146,7 +157,7 @@ describe("AuthService", () => { } as User jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) - jest.spyOn(bcrypt, "compare").mockResolvedValue(true) + jest.spyOn(bcrypt, "compare").mockImplementation(() => Promise.resolve(true)) await expect(service.login(loginDto)).rejects.toThrow(UnauthorizedException) expect(userRepository.findOne).toHaveBeenCalledWith( @@ -452,4 +463,116 @@ describe("AuthService", () => { ) }) }) + + describe("Two-Factor Authentication", () => { + describe("generateTwoFactorSecret", () => { + it("should generate TOTP secret and QR code for user", async () => { + const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: false } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + jest.spyOn(userRepository, "save").mockResolvedValue(mockUser) + + const result = await service.generateTwoFactorSecret("uuid-1") + expect(result).toHaveProperty("secret") + expect(result).toHaveProperty("otpauthUrl") + expect(result).toHaveProperty("qrCodeDataUri") + }) + + it("should throw error if user not found", async () => { + jest.spyOn(userRepository, "findOne").mockResolvedValue(null) + await expect(service.generateTwoFactorSecret("invalid-id")).rejects.toThrow(UnauthorizedException) + }) + + it("should throw error if 2FA already enabled", async () => { + const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: true } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + await expect(service.generateTwoFactorSecret("uuid-1")).rejects.toThrow(BadRequestException) + }) + }) + + describe("verifyTwoFactorSetup", () => { + it("should enable 2FA and return backup codes on valid code", async () => { + const mockUser = { + id: "uuid-1", + email: "test@example.com", + twoFactorSecret: "TESTSECRET", + isTwoFactorEnabled: false, + } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + jest.spyOn(userRepository, "save").mockResolvedValue({ ...mockUser, isTwoFactorEnabled: true }) + jest.spyOn(backupCodeRepository, "create").mockReturnValue({} as TwoFactorBackupCode) + jest.spyOn(backupCodeRepository, "save").mockResolvedValue({} as TwoFactorBackupCode) + + const result = await service.verifyTwoFactorSetup("uuid-1", "123456") + expect(result.message).toBe("2FA enabled successfully") + expect(result.backupCodes).toHaveLength(8) + }) + + it("should throw error if secret not generated", async () => { + const mockUser = { id: "uuid-1", email: "test@example.com", twoFactorSecret: null } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + await expect(service.verifyTwoFactorSetup("uuid-1", "123456")).rejects.toThrow(BadRequestException) + }) + }) + + describe("disableTwoFactor", () => { + it("should disable 2FA with valid code and password", async () => { + const mockUser = { + id: "uuid-1", + email: "test@example.com", + password: "hashedPassword", + twoFactorSecret: "TESTSECRET", + isTwoFactorEnabled: true, + } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + jest.spyOn(userRepository, "save").mockResolvedValue({ ...mockUser, isTwoFactorEnabled: false, twoFactorSecret: null }) + jest.spyOn(bcrypt, "compare").mockImplementation(() => Promise.resolve(true)) + jest.spyOn(backupCodeRepository, "update").mockResolvedValue({ affected: 8 } as any) + + const result = await service.disableTwoFactor("uuid-1", "123456", "password123") + expect(result.message).toBe("2FA disabled successfully") + }) + + it("should throw error if 2FA not enabled", async () => { + const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: false } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + await expect(service.disableTwoFactor("uuid-1", "123456", "password123")).rejects.toThrow(BadRequestException) + }) + }) + + describe("challengeTwoFactor", () => { + it("should exchange mfa_pending token for full tokens", async () => { + const mockPayload = { sub: "uuid-1", email: "test@example.com", isMfaPending: true } + const mockUser = { id: "uuid-1", email: "test@example.com", role: { name: UserRole.USER } } as User + + jest.spyOn(jwtService, "verifyAsync").mockResolvedValue(mockPayload) + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + jest.spyOn(refreshTokenRepository, "create").mockReturnValue({} as RefreshToken) + jest.spyOn(refreshTokenRepository, "save").mockResolvedValue({} as RefreshToken) + + const result = await service.challengeTwoFactor("mfa-pending-token", "123456") + expect(result).toHaveProperty("accessToken") + expect(result).toHaveProperty("refreshToken") + }) + + it("should throw error if mfa_pending token is invalid", async () => { + jest.spyOn(jwtService, "verifyAsync").mockRejectedValue(new Error("Invalid token")) + await expect(service.challengeTwoFactor("invalid-token", "123456")).rejects.toThrow() + }) + }) + + describe("getTwoFactorStatus", () => { + it("should return 2FA status", async () => { + const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: true } as User + jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser) + + const result = await service.getTwoFactorStatus("uuid-1") + expect(result.isTwoFactorEnabled).toBe(true) + }) + + it("should throw error if user not found", async () => { + jest.spyOn(userRepository, "findOne").mockResolvedValue(null) + await expect(service.getTwoFactorStatus("invalid-id")).rejects.toThrow(UnauthorizedException) + }) + }) + }) }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e2c4a12..045cb0d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -4,9 +4,12 @@ import { Repository } from "typeorm" import type { DeepPartial } from "typeorm" import { JwtService } from "@nestjs/jwt" import * as bcrypt from "bcrypt" +import * as otplib from "otplib" +import * as QRCode from "qrcode" import { User } from "./entities/user.entity" import { Role } from "./entities/role.entity" import { RefreshToken } from "./entities/refresh-token.entity" +import { TwoFactorBackupCode } from "./entities/two-factor-backup-code.entity" import type { RegisterUserDto } from "./dto/register-user.dto" import type { LoginUserDto } from "./dto/login-user.dto" import type { ForgotPasswordDto } from "./dto/forgot-password.dto" @@ -25,6 +28,8 @@ export class AuthService { private rolesRepository: Repository, @InjectRepository(RefreshToken) private refreshTokensRepository: Repository, + @InjectRepository(TwoFactorBackupCode) + private backupCodesRepository: Repository, private jwtService: JwtService, ) { } @@ -100,7 +105,7 @@ export class AuthService { const user = await this.usersRepository.findOne({ where: { email }, - select: ["id", "email", "password", "isVerified", "role"], // Explicitly select password + select: ["id", "email", "password", "isVerified", "isTwoFactorEnabled", "role"], // Explicitly select password and 2FA status relations: ["role"], }) @@ -112,9 +117,68 @@ export class AuthService { throw new UnauthorizedException("Please verify your email before logging in.") } + // If 2FA is enabled, issue mfa_pending token instead of full tokens + if (user.isTwoFactorEnabled) { + return this.generateMfaPendingToken(user) + } + return this.generateTokens(user) } + async generateMfaPendingToken(user: User) { + const payload: JwtPayload & { isMfaPending: boolean } = { + sub: user.id, + email: user.email, + roles: user.role ? [user.role.name] : [], + isMfaPending: true, + } + + const mfaPendingToken = this.jwtService.sign(payload, { + expiresIn: jwtConstants.mfaPendingExpiresIn as `${number}m`, + }) + + return { + mfaPendingToken, + message: "2FA verification required", + } + } + + async challengeTwoFactor(mfaPendingToken: string, code: string) { + try { + const payload = await this.jwtService.verifyAsync(mfaPendingToken, { + secret: jwtConstants.secret, + }) + + if (!payload.isMfaPending || !payload.sub) { + throw new UnauthorizedException("Invalid MFA pending token") + } + + // Verify the 2FA code + const isValid = await this.verifyTwoFactorCode(payload.sub, code) + if (!isValid) { + throw new UnauthorizedException("Invalid 2FA code") + } + + // Get user with role + const user = await this.usersRepository.findOne({ + where: { id: payload.sub }, + relations: ["role"], + }) + + if (!user) { + throw new UnauthorizedException("User not found") + } + + // Issue full tokens + return this.generateTokens(user) + } catch (error) { + if (error.name === "TokenExpiredError") { + throw new UnauthorizedException("MFA pending token expired. Please login again.") + } + throw error + } + } + async verifyEmail(verifyEmailDto: VerifyEmailDto) { const { token } = verifyEmailDto const user = await this.usersRepository.findOne({ where: { verificationToken: token } }) @@ -271,4 +335,193 @@ export class AuthService { return this.usersRepository.save(newUser); } + + // Two-Factor Authentication Methods + async generateTwoFactorSecret(userId: string) { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException("User not found"); + } + + if (user.isTwoFactorEnabled) { + throw new BadRequestException("2FA is already enabled for this user"); + } + + const secret = otplib.generateSecret(); + const otpauthUrl = `otpauth://totp/Quest%20Service:${encodeURIComponent(user.email)}?secret=${secret}&issuer=Quest%20Service`; + + // Store the secret temporarily (not enabling 2FA yet) + user.twoFactorSecret = secret; + await this.usersRepository.save(user); + + const qrCodeDataUri = await QRCode.toDataURL(otpauthUrl); + + return { + secret, + otpauthUrl, + qrCodeDataUri, + }; + } + + async verifyTwoFactorSetup(userId: string, code: string) { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException("User not found"); + } + + if (!user.twoFactorSecret) { + throw new BadRequestException("2FA secret not generated. Call setup endpoint first."); + } + + if (user.isTwoFactorEnabled) { + throw new BadRequestException("2FA is already enabled for this user"); + } + + const isValid = speakeasy.authenticator.verify({ + secret: user.twoFactorSecret, + encoding: "base32", + token: code, + }); + + if (!isValid) { + throw new BadRequestException("Invalid TOTP code"); + } + + // Enable 2FA and generate backup codes + user.isTwoFactorEnabled = true; + await this.usersRepository.save(user); + + // Generate and hash backup codes + const backupCodes = await this.generateBackupCodes(userId); + + return { + message: "2FA enabled successfully", + backupCodes, + }; + } + + async disableTwoFactor(userId: string, code: string, password: string) { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + select: ["id", "email", "password", "twoFactorSecret", "isTwoFactorEnabled"], + }); + + if (!user) { + throw new UnauthorizedException("User not found"); + } + + if (!user.isTwoFactorEnabled) { + throw new BadRequestException("2FA is not enabled for this user"); + } + + // Verify password + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + throw new UnauthorizedException("Invalid password"); + } + + // Verify TOTP code + const isValid = otplib.authenticator.verify({ + secret: user.twoFactorSecret!, + encoding: "base32", + token: code, + }); + + if (!isValid) { + throw new BadRequestException("Invalid TOTP code"); + } + + // Disable 2FA + user.isTwoFactorEnabled = false; + user.twoFactorSecret = null; + await this.usersRepository.save(user); + + // Invalidate all backup codes + await this.backupCodesRepository.update( + { userId: user.id, isUsed: false }, + { isUsed: true }, + ); + + return { message: "2FA disabled successfully" }; + } + + async verifyTwoFactorCode(userId: string, code: string): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + select: ["id", "twoFactorSecret", "isTwoFactorEnabled"], + }); + + if (!user || !user.isTwoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException("2FA is not enabled for this user"); + } + + // First check if it's a backup code + const backupCode = await this.backupCodesRepository.findOne({ + where: { userId: user.id, codeHash: await bcrypt.hash(code, BCRYPT_SALT_ROUNDS), isUsed: false }, + }); + + if (backupCode) { + // Mark backup code as used + backupCode.isUsed = true; + backupCode.usedAt = new Date(); + await this.backupCodesRepository.save(backupCode); + return true; + } + + // Verify TOTP code + const isValid = otplib.verify({ + secret: user.twoFactorSecret, + token: code, + }); + + return !!isValid; + } + + private async generateBackupCodes(userId: string): Promise { + // Generate 8 random backup codes + const codes = Array.from({ length: 8 }, () => uuidv4().replace(/-/g, "").substring(0, 8).toUpperCase()); + + // Hash and store each code + for (const code of codes) { + const hashedCode = await bcrypt.hash(code, BCRYPT_SALT_ROUNDS); + const backupCode = this.backupCodesRepository.create({ + codeHash: hashedCode, + userId, + }); + await this.backupCodesRepository.save(backupCode); + } + + return codes; + } + + async getTwoFactorStatus(userId: string) { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException("User not found"); + } + + return { + isTwoFactorEnabled: user.isTwoFactorEnabled, + }; + } + + async regenerateBackupCodes(userId: string): Promise { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException("User not found"); + } + + if (!user.isTwoFactorEnabled) { + throw new BadRequestException("2FA must be enabled to regenerate backup codes"); + } + + // Invalidate old backup codes + await this.backupCodesRepository.update( + { userId: user.id, isUsed: false }, + { isUsed: true }, + ); + + // Generate new backup codes + return this.generateBackupCodes(userId); + } } diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 6f2a40f..378dc34 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -4,6 +4,7 @@ export const jwtConstants = { refreshExpiresIn: "7d", // Refresh token expiry emailVerificationExpiresIn: "1h", // Email verification token expiry passwordResetExpiresIn: "1h", // Password reset token expiry + mfaPendingExpiresIn: "5m", // MFA pending token expiry (short-lived) } export const BCRYPT_SALT_ROUNDS = 10 diff --git a/src/auth/dto/two-factor.dto.ts b/src/auth/dto/two-factor.dto.ts new file mode 100644 index 0000000..42a9668 --- /dev/null +++ b/src/auth/dto/two-factor.dto.ts @@ -0,0 +1,30 @@ +import { IsNotEmpty, IsString, MinLength } from "class-validator" +import { ApiProperty } from "@nestjs/swagger" + +export class VerifyTwoFactorDto { + @ApiProperty({ description: "TOTP code from authenticator app", example: "123456" }) + @IsNotEmpty() + @IsString() + @MinLength(6) + code: string +} + +export class ChallengeTwoFactorDto { + @ApiProperty({ description: "TOTP code or backup code", example: "123456" }) + @IsNotEmpty() + @IsString() + code: string +} + +export class DisableTwoFactorDto { + @ApiProperty({ description: "Current TOTP code", example: "123456" }) + @IsNotEmpty() + @IsString() + code: string + + @ApiProperty({ description: "User password for confirmation", example: "password123" }) + @IsNotEmpty() + @IsString() + @MinLength(8) + password: string +} diff --git a/src/auth/entities/two-factor-backup-code.entity.ts b/src/auth/entities/two-factor-backup-code.entity.ts new file mode 100644 index 0000000..db18272 --- /dev/null +++ b/src/auth/entities/two-factor-backup-code.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from "typeorm" +import { User } from "./user.entity" + +@Entity("two_factor_backup_codes") +export class TwoFactorBackupCode { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column() + codeHash: string + + @Column({ default: false }) + isUsed: boolean + + @CreateDateColumn() + usedAt?: Date + + @ManyToOne( + () => User, + (user) => user.backupCodes, + { onDelete: "CASCADE" }, + ) + @JoinColumn({ name: "userId" }) + user: User + + @Column() + userId: string + + @CreateDateColumn() + createdAt: Date +} diff --git a/src/auth/entities/user.entity.ts b/src/auth/entities/user.entity.ts index a08f8a8..2e7e6fd 100644 --- a/src/auth/entities/user.entity.ts +++ b/src/auth/entities/user.entity.ts @@ -10,6 +10,7 @@ import { } from "typeorm" import { Role } from "./role.entity" import { RefreshToken } from "./refresh-token.entity" +import { TwoFactorBackupCode } from "./two-factor-backup-code.entity" @Entity("users") export class User { @@ -51,6 +52,12 @@ export class User { ) refreshTokens: RefreshToken[] + @OneToMany( + () => TwoFactorBackupCode, + (backupCode) => backupCode.user, + ) + backupCodes: TwoFactorBackupCode[] + @CreateDateColumn() createdAt: Date @@ -69,4 +76,11 @@ export class User { @Column({ nullable: true }) twitterId?: string + + // Two-Factor Authentication fields + @Column({ nullable: true, select: false }) + twoFactorSecret?: string + + @Column({ default: false }) + isTwoFactorEnabled: boolean } diff --git a/src/auth/guards/mfa-pending-auth.guard.ts b/src/auth/guards/mfa-pending-auth.guard.ts new file mode 100644 index 0000000..6722974 --- /dev/null +++ b/src/auth/guards/mfa-pending-auth.guard.ts @@ -0,0 +1,39 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common" +import { JwtService } from "@nestjs/jwt" +import { Request } from "express" +import { jwtConstants } from "../constants" + +@Injectable() +export class MfaPendingAuthGuard implements CanActivate { + constructor(private jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest() + const token = this.extractTokenFromHeader(request) + + if (!token) { + throw new UnauthorizedException("MFA pending token required") + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: jwtConstants.secret, + }) + + // Check if this is a valid MFA pending token + if (!payload.isMfaPending || !payload.sub) { + throw new UnauthorizedException("Invalid MFA pending token") + } + + request["user"] = payload + return true + } catch { + throw new UnauthorizedException("Invalid or expired MFA pending token") + } + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(" ") ?? [] + return type === "Bearer" ? token : undefined + } +} diff --git a/src/migrations/1709654400000-AddTwoFactorAuthentication.ts b/src/migrations/1709654400000-AddTwoFactorAuthentication.ts new file mode 100644 index 0000000..3880081 --- /dev/null +++ b/src/migrations/1709654400000-AddTwoFactorAuthentication.ts @@ -0,0 +1,111 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from "typeorm" + +export class AddTwoFactorAuthentication1709654400000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add 2FA columns to users table + await queryRunner.addColumn( + "users", + new TableColumn({ + name: "two_factor_secret", + type: "varchar", + isNullable: true, + comment: "TOTP secret for 2FA authentication", + }), + ) + + await queryRunner.addColumn( + "users", + new TableColumn({ + name: "is_two_factor_enabled", + type: "boolean", + default: false, + comment: "Whether 2FA is enabled for this user", + }), + ) + + // Create two_factor_backup_codes table + await queryRunner.createTable( + new Table({ + name: "two_factor_backup_codes", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + }, + { + name: "code_hash", + type: "varchar", + isNullable: false, + comment: "Bcrypt hashed backup code", + }, + { + name: "is_used", + type: "boolean", + default: false, + comment: "Whether the backup code has been used", + }, + { + name: "used_at", + type: "timestamp", + isNullable: true, + comment: "When the backup code was used", + }, + { + name: "user_id", + type: "uuid", + isNullable: false, + comment: "Foreign key to users table", + }, + { + name: "created_at", + type: "timestamp", + default: "CURRENT_TIMESTAMP", + comment: "When the backup code was created", + }, + ], + }), + ) + + // Add foreign key constraint + await queryRunner.createForeignKey( + "two_factor_backup_codes", + new TableForeignKey({ + name: "FK_TWO_FACTOR_BACKUP_CODES_USER", + columnNames: ["user_id"], + referencedTableName: "users", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + }), + ) + + // Add index on user_id for faster lookups + await queryRunner.createIndex( + "two_factor_backup_codes", + new TableIndex({ + name: "IDX_TWO_FACTOR_BACKUP_CODES_USER", + columnNames: ["user_id"], + }), + ) + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop index + await queryRunner.dropIndex("two_factor_backup_codes", "IDX_TWO_FACTOR_BACKUP_CODES_USER") + + // Drop foreign key + const table = await queryRunner.getTable("two_factor_backup_codes") + const foreignKey = table?.foreignKeys.find((fk) => fk.name === "FK_TWO_FACTOR_BACKUP_CODES_USER") + if (foreignKey) { + await queryRunner.dropForeignKey("two_factor_backup_codes", foreignKey) + } + + // Drop two_factor_backup_codes table + await queryRunner.dropTable("two_factor_backup_codes") + + // Remove 2FA columns from users table + await queryRunner.dropColumn("users", "two_factor_secret") + await queryRunner.dropColumn("users", "is_two_factor_enabled") + } +}