diff --git a/maternal-app/maternal-app-backend/package-lock.json b/maternal-app/maternal-app-backend/package-lock.json index 5c3f350..cae3a57 100644 --- a/maternal-app/maternal-app-backend/package-lock.json +++ b/maternal-app/maternal-app-backend/package-lock.json @@ -28,6 +28,7 @@ "@sentry/node": "^10.17.0", "@sentry/profiling-node": "^10.17.0", "@types/pdfkit": "^0.17.3", + "@types/qrcode": "^1.5.5", "axios": "^1.12.2", "bcrypt": "^6.0.0", "cache-manager": "^7.2.2", @@ -42,11 +43,13 @@ "mailgun.js": "^12.1.0", "multer": "^2.0.2", "openai": "^5.23.2", + "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "qrcode": "^1.5.4", "redis": "^5.8.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -4468,6 +4471,53 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -5982,6 +6032,15 @@ "@types/pg": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "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", @@ -7453,7 +7512,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" @@ -8155,6 +8213,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/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -12024,6 +12088,17 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -12116,7 +12191,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" @@ -12226,7 +12300,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" @@ -12516,6 +12589,15 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, + "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", @@ -12700,6 +12782,127 @@ ], "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/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/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.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -12902,6 +13105,12 @@ "node": ">=8.6.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.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -13270,6 +13479,12 @@ "node": ">= 0.8.0" } }, + "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/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -14211,6 +14426,14 @@ "dev": true, "license": "MIT" }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -15110,6 +15333,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.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -15152,7 +15381,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/maternal-app/maternal-app-backend/package.json b/maternal-app/maternal-app-backend/package.json index b1deb02..9220820 100644 --- a/maternal-app/maternal-app-backend/package.json +++ b/maternal-app/maternal-app-backend/package.json @@ -40,6 +40,7 @@ "@sentry/node": "^10.17.0", "@sentry/profiling-node": "^10.17.0", "@types/pdfkit": "^0.17.3", + "@types/qrcode": "^1.5.5", "axios": "^1.12.2", "bcrypt": "^6.0.0", "cache-manager": "^7.2.2", @@ -54,11 +55,13 @@ "mailgun.js": "^12.1.0", "multer": "^2.0.2", "openai": "^5.23.2", + "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "qrcode": "^1.5.4", "redis": "^5.8.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts index d4b6f77..786227d 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts @@ -42,6 +42,25 @@ export class User { @Column({ name: 'email_verification_sent_at', type: 'timestamp without time zone', nullable: true }) emailVerificationSentAt?: Date | null; + // MFA fields + @Column({ name: 'mfa_enabled', default: false }) + mfaEnabled: boolean; + + @Column({ name: 'mfa_method', length: 20, nullable: true }) + mfaMethod?: 'totp' | 'email' | null; + + @Column({ name: 'totp_secret', length: 32, nullable: true }) + totpSecret?: string | null; + + @Column({ name: 'mfa_backup_codes', type: 'jsonb', nullable: true }) + mfaBackupCodes?: string[] | null; + + @Column({ name: 'email_mfa_code', length: 6, nullable: true }) + emailMfaCode?: string | null; + + @Column({ name: 'email_mfa_code_expires_at', type: 'timestamp without time zone', nullable: true }) + emailMfaCodeExpiresAt?: Date | null; + @Column({ type: 'jsonb', nullable: true }) preferences?: { notifications?: boolean; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V010_add_mfa_fields.sql b/maternal-app/maternal-app-backend/src/database/migrations/V010_add_mfa_fields.sql new file mode 100644 index 0000000..204f34f --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V010_add_mfa_fields.sql @@ -0,0 +1,22 @@ +-- V010: Add Multi-Factor Authentication (MFA) fields to users table +-- Created: 2025-10-01 +-- Description: Adds support for TOTP (Google Authenticator) and Email MFA + +-- Add MFA fields to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT false NOT NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_method VARCHAR(20); +ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(32); +ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_backup_codes JSONB; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_mfa_code VARCHAR(6); +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_mfa_code_expires_at TIMESTAMP WITHOUT TIME ZONE; + +-- Add index for MFA queries +CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON users(mfa_enabled) WHERE mfa_enabled = true; + +-- Comments +COMMENT ON COLUMN users.mfa_enabled IS 'Whether MFA is enabled for this user'; +COMMENT ON COLUMN users.mfa_method IS 'MFA method: totp (Google Authenticator) or email'; +COMMENT ON COLUMN users.totp_secret IS 'Encrypted TOTP secret for Google Authenticator'; +COMMENT ON COLUMN users.mfa_backup_codes IS 'Array of backup codes (hashed) for MFA recovery'; +COMMENT ON COLUMN users.email_mfa_code IS 'Temporary email verification code for email-based MFA'; +COMMENT ON COLUMN users.email_mfa_code_expires_at IS 'Expiration time for email MFA code (5 minutes)'; diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts index d70b142..2549072 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Post, Get, Patch, + Delete, Body, HttpCode, HttpStatus, @@ -13,11 +14,13 @@ import { } from '@nestjs/common'; import { AuthService } from './auth.service'; import { PasswordResetService } from './password-reset.service'; +import { MFAService } from './mfa.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { LogoutDto } from './dto/logout.dto'; import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto'; +import { VerifyMFACodeDto, EnableTOTPDto } from './dto/mfa.dto'; import { Public } from './decorators/public.decorator'; import { CurrentUser } from './decorators/current-user.decorator'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @@ -27,6 +30,7 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly passwordResetService: PasswordResetService, + private readonly mfaService: MFAService, ) {} @Public() @@ -120,4 +124,70 @@ export class AuthController { async resendEmailVerification(@CurrentUser() user: any) { return await this.passwordResetService.resendEmailVerification(user.userId); } + + // MFA Endpoints + @UseGuards(JwtAuthGuard) + @Get('mfa/status') + @HttpCode(HttpStatus.OK) + async getMFAStatus(@CurrentUser() user: any) { + return await this.mfaService.getMFAStatus(user.userId); + } + + @UseGuards(JwtAuthGuard) + @Post('mfa/totp/setup') + @HttpCode(HttpStatus.OK) + async setupTOTP(@CurrentUser() user: any) { + return await this.mfaService.setupTOTP(user.userId); + } + + @UseGuards(JwtAuthGuard) + @Post('mfa/totp/enable') + @HttpCode(HttpStatus.OK) + async enableTOTP( + @CurrentUser() user: any, + @Body(ValidationPipe) dto: EnableTOTPDto, + ) { + return await this.mfaService.verifyAndEnableTOTP(user.userId, dto.code); + } + + @UseGuards(JwtAuthGuard) + @Post('mfa/email/setup') + @HttpCode(HttpStatus.OK) + async setupEmailMFA(@CurrentUser() user: any) { + return await this.mfaService.setupEmailMFA(user.userId); + } + + @Public() + @Post('mfa/email/send-code') + @HttpCode(HttpStatus.OK) + async sendEmailMFACode(@Body() body: { userId: string }) { + return await this.mfaService.sendEmailMFACode(body.userId); + } + + @Public() + @Post('mfa/verify') + @HttpCode(HttpStatus.OK) + async verifyMFACode( + @Body() body: { userId: string; code: string }, + ) { + return await this.mfaService.verifyMFACode(body.userId, body.code); + } + + @UseGuards(JwtAuthGuard) + @Delete('mfa') + @HttpCode(HttpStatus.OK) + async disableMFA(@CurrentUser() user: any) { + return await this.mfaService.disableMFA(user.userId); + } + + @UseGuards(JwtAuthGuard) + @Post('mfa/backup-codes/regenerate') + @HttpCode(HttpStatus.OK) + async regenerateBackupCodes(@CurrentUser() user: any) { + const codes = await this.mfaService.regenerateBackupCodes(user.userId); + return { + success: true, + backupCodes: codes, + }; + } } \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts index 6ed1428..e79cd23 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts @@ -6,8 +6,10 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { PasswordResetService } from './password-reset.service'; +import { MFAService } from './mfa.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; +import { CommonModule } from '../../common/common.module'; import { User, DeviceRegistry, @@ -21,6 +23,7 @@ import { imports: [ TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]), PassportModule, + CommonModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -33,7 +36,7 @@ import { }), ], controllers: [AuthController], - providers: [AuthService, PasswordResetService, JwtStrategy, LocalStrategy], - exports: [AuthService, PasswordResetService], + providers: [AuthService, PasswordResetService, MFAService, JwtStrategy, LocalStrategy], + exports: [AuthService, PasswordResetService, MFAService], }) export class AuthModule {} \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/dto/mfa.dto.ts b/maternal-app/maternal-app-backend/src/modules/auth/dto/mfa.dto.ts new file mode 100644 index 0000000..5a43eda --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/dto/mfa.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsNotEmpty, IsIn, Length, Matches } from 'class-validator'; + +export class VerifyMFACodeDto { + @IsString() + @IsNotEmpty() + @Length(6, 8) + @Matches(/^[0-9A-F]+$/i, { message: 'Code must contain only numbers or hexadecimal characters' }) + code: string; +} + +export class SetupMFAMethodDto { + @IsString() + @IsNotEmpty() + @IsIn(['totp', 'email']) + method: 'totp' | 'email'; +} + +export class EnableTOTPDto { + @IsString() + @IsNotEmpty() + @Length(6, 6) + @Matches(/^[0-9]+$/, { message: 'Code must be 6 digits' }) + code: string; +} diff --git a/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.ts new file mode 100644 index 0000000..1574f25 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/mfa.service.ts @@ -0,0 +1,432 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { authenticator } from 'otplib'; +import * as QRCode from 'qrcode'; +import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; +import { User } from '../../database/entities'; +import { EmailService } from '../../common/services/email.service'; +import { ConfigService } from '@nestjs/config'; + +export interface MFASetupResult { + secret: string; + qrCodeUrl: string; + backupCodes: string[]; +} + +export interface MFAStatus { + enabled: boolean; + method?: 'totp' | 'email'; + hasBackupCodes: boolean; +} + +@Injectable() +export class MFAService { + private readonly logger = new Logger(MFAService.name); + private readonly appName: string; + + constructor( + @InjectRepository(User) + private userRepository: Repository, + private emailService: EmailService, + private configService: ConfigService, + ) { + this.appName = this.configService.get('APP_NAME', 'Maternal App'); + } + + /** + * Get MFA status for a user + */ + async getMFAStatus(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'mfaEnabled', 'mfaMethod', 'mfaBackupCodes'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return { + enabled: user.mfaEnabled, + method: user.mfaMethod as 'totp' | 'email' | undefined, + hasBackupCodes: !!user.mfaBackupCodes && user.mfaBackupCodes.length > 0, + }; + } + + /** + * Setup TOTP (Google Authenticator) MFA + */ + async setupTOTP(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'email', 'name'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Generate secret for TOTP + const secret = authenticator.generateSecret(); + + // Generate QR code + const otpauthUrl = authenticator.keyuri( + user.email, + this.appName, + secret, + ); + const qrCodeUrl = await QRCode.toDataURL(otpauthUrl); + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((code) => bcrypt.hash(code, 10)), + ); + + // Save secret and backup codes (but don't enable MFA yet) + await this.userRepository.update(userId, { + totpSecret: secret, + mfaBackupCodes: hashedBackupCodes, + }); + + this.logger.log(`TOTP setup initiated for user ${userId}`); + + return { + secret, + qrCodeUrl, + backupCodes, + }; + } + + /** + * Verify TOTP code and enable TOTP MFA + */ + async verifyAndEnableTOTP( + userId: string, + code: string, + ): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'totpSecret', 'mfaEnabled'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.totpSecret) { + throw new BadRequestException('TOTP is not set up. Please set up TOTP first.'); + } + + // Verify the TOTP code + const isValid = authenticator.verify({ + token: code, + secret: user.totpSecret, + }); + + if (!isValid) { + throw new BadRequestException('Invalid verification code'); + } + + // Enable MFA with TOTP method + await this.userRepository.update(userId, { + mfaEnabled: true, + mfaMethod: 'totp', + }); + + this.logger.log(`TOTP MFA enabled for user ${userId}`); + + return { + success: true, + message: 'Two-factor authentication enabled successfully', + }; + } + + /** + * Setup Email MFA + */ + async setupEmailMFA(userId: string): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'email', 'name'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((code) => bcrypt.hash(code, 10)), + ); + + // Enable Email MFA + await this.userRepository.update(userId, { + mfaEnabled: true, + mfaMethod: 'email', + mfaBackupCodes: hashedBackupCodes, + }); + + // Send confirmation email + try { + await this.emailService.sendEmail({ + to: user.email, + subject: 'Email Two-Factor Authentication Enabled', + html: ` +

Two-Factor Authentication Enabled

+

Hi ${user.name},

+

Email-based two-factor authentication has been enabled for your account.

+

You will now receive a verification code via email each time you log in.

+

Backup Codes

+

Save these backup codes in a safe place. You can use them to access your account if you don't have access to your email:

+
    + ${backupCodes.map((code) => `
  • ${code}
  • `).join('')} +
+

Each backup code can only be used once.

+ `, + }); + } catch (error) { + this.logger.error(`Failed to send MFA setup confirmation email: ${error.message}`); + } + + this.logger.log(`Email MFA enabled for user ${userId}`); + + return { + success: true, + message: 'Email-based two-factor authentication enabled successfully. Check your email for backup codes.', + }; + } + + /** + * Send email MFA code + */ + async sendEmailMFACode(userId: string): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'email', 'name', 'mfaEnabled', 'mfaMethod'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfaEnabled || user.mfaMethod !== 'email') { + throw new BadRequestException('Email MFA is not enabled for this user'); + } + + // Generate 6-digit code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 5); // Code expires in 5 minutes + + // Save code + await this.userRepository.update(userId, { + emailMfaCode: code, + emailMfaCodeExpiresAt: expiresAt, + }); + + // Send email with code + try { + await this.emailService.sendEmail({ + to: user.email, + subject: `Your verification code is ${code}`, + html: ` +

Verification Code

+

Hi ${user.name},

+

Your verification code is:

+

${code}

+

This code will expire in 5 minutes.

+

If you didn't request this code, please ignore this email.

+ `, + }); + + this.logger.log(`Email MFA code sent to user ${userId}`); + } catch (error) { + this.logger.error(`Failed to send MFA email code: ${error.message}`); + throw new BadRequestException('Failed to send verification code. Please try again.'); + } + + return { + success: true, + message: 'Verification code sent to your email', + }; + } + + /** + * Verify MFA code (TOTP or Email or Backup code) + */ + async verifyMFACode( + userId: string, + code: string, + ): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: [ + 'id', + 'mfaEnabled', + 'mfaMethod', + 'totpSecret', + 'emailMfaCode', + 'emailMfaCodeExpiresAt', + 'mfaBackupCodes', + ], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfaEnabled) { + throw new BadRequestException('MFA is not enabled for this user'); + } + + // Try TOTP verification + if (user.mfaMethod === 'totp' && user.totpSecret) { + const isValid = authenticator.verify({ + token: code, + secret: user.totpSecret, + }); + + if (isValid) { + return { + success: true, + message: 'Code verified successfully', + }; + } + } + + // Try Email code verification + if (user.mfaMethod === 'email' && user.emailMfaCode) { + if (!user.emailMfaCodeExpiresAt || new Date() > user.emailMfaCodeExpiresAt) { + throw new BadRequestException('Verification code has expired. Please request a new one.'); + } + + if (code === user.emailMfaCode) { + // Clear the used code + await this.userRepository.update(userId, { + emailMfaCode: null, + emailMfaCodeExpiresAt: null, + }); + + return { + success: true, + message: 'Code verified successfully', + }; + } + } + + // Try backup code verification + if (user.mfaBackupCodes && user.mfaBackupCodes.length > 0) { + for (let i = 0; i < user.mfaBackupCodes.length; i++) { + const isValid = await bcrypt.compare(code, user.mfaBackupCodes[i]); + if (isValid) { + // Remove used backup code + const updatedBackupCodes = [...user.mfaBackupCodes]; + updatedBackupCodes.splice(i, 1); + await this.userRepository.update(userId, { + mfaBackupCodes: updatedBackupCodes, + }); + + this.logger.log(`Backup code used for user ${userId}. ${updatedBackupCodes.length} codes remaining.`); + + return { + success: true, + message: `Backup code verified. You have ${updatedBackupCodes.length} backup codes remaining.`, + }; + } + } + } + + throw new BadRequestException('Invalid verification code'); + } + + /** + * Disable MFA + */ + async disableMFA(userId: string): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'mfaEnabled'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfaEnabled) { + return { + success: true, + message: 'MFA is already disabled', + }; + } + + // Disable MFA and clear all MFA data + await this.userRepository.update(userId, { + mfaEnabled: false, + mfaMethod: null, + totpSecret: null, + mfaBackupCodes: null, + emailMfaCode: null, + emailMfaCodeExpiresAt: null, + }); + + this.logger.log(`MFA disabled for user ${userId}`); + + return { + success: true, + message: 'Two-factor authentication disabled successfully', + }; + } + + /** + * Regenerate backup codes + */ + async regenerateBackupCodes(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'mfaEnabled'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfaEnabled) { + throw new BadRequestException('MFA is not enabled. Please enable MFA first.'); + } + + // Generate new backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((code) => bcrypt.hash(code, 10)), + ); + + await this.userRepository.update(userId, { + mfaBackupCodes: hashedBackupCodes, + }); + + this.logger.log(`Backup codes regenerated for user ${userId}`); + + return backupCodes; + } + + /** + * Generate backup codes (10 codes, 8 characters each) + */ + private generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < 10; i++) { + const code = crypto.randomBytes(4).toString('hex').toUpperCase(); + codes.push(code); + } + return codes; + } +}