From c6defaa1d8a17d458b74fd40c87a6aefe9b8cceb Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sat, 26 Jul 2025 16:12:58 +0330 Subject: [PATCH] chore: 2fa added --- .../migrations/add_2fa_fields_to_users.sql | 7 + src/common/enums/message.enum.ts | 16 ++ src/modules/auth/DTO/create-two-factor.dto.ts | 36 +++++ src/modules/auth/DTO/login.dto.ts | 9 +- src/modules/auth/auth.controller.ts | 63 +++++++- src/modules/auth/auth.module.ts | 13 +- src/modules/auth/services/auth.service.ts | 13 +- .../auth/services/two-factor.service.ts | 152 ++++++++++++++++++ src/modules/users/entities/user.entity.ts | 23 +-- 9 files changed, 301 insertions(+), 31 deletions(-) create mode 100644 database/migrations/add_2fa_fields_to_users.sql create mode 100644 src/modules/auth/DTO/create-two-factor.dto.ts create mode 100644 src/modules/auth/services/two-factor.service.ts diff --git a/database/migrations/add_2fa_fields_to_users.sql b/database/migrations/add_2fa_fields_to_users.sql new file mode 100644 index 0000000..defecf6 --- /dev/null +++ b/database/migrations/add_2fa_fields_to_users.sql @@ -0,0 +1,7 @@ +-- Add 2FA fields to users table +ALTER TABLE users +ADD COLUMN is_2fa_enabled BOOLEAN DEFAULT FALSE NOT NULL, +ADD COLUMN two_factor_enabled_at TIMESTAMPTZ DEFAULT NULL; + +-- Create index on is_2fa_enabled for query performance +CREATE INDEX idx_users_is_2fa_enabled ON users (is_2fa_enabled); \ No newline at end of file diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index d3eba93..9b75dec 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -570,3 +570,19 @@ export const enum AiMessage { MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = "ایمیل یافت نشد یا دسترسی آن را ندارید", EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = "محتوای ایمیل خالی است یا قابل استخراج نیست", } + +export const enum TwoFactorMessage { + TWO_FACTOR_SETUP_INITIATED = "راه‌اندازی احراز هویت دو مرحله‌ای با موفقیت آغاز شد", + TWO_FACTOR_ENABLED_SUCCESSFULLY = "احراز هویت دو مرحله‌ای با موفقیت فعال شد", + TWO_FACTOR_DISABLED_SUCCESSFULLY = "احراز هویت دو مرحله‌ای با موفقیت غیرفعال شد", + TWO_FACTOR_SETUP_FAILED = "راه‌اندازی احراز هویت دو مرحله‌ای ناموفق بود", + TWO_FACTOR_ENABLE_FAILED = "فعال‌سازی احراز هویت دو مرحله‌ای ناموفق بود", + TWO_FACTOR_TOKEN_VERIFIED_SUCCESSFULLY = "کد احراز هویت دو مرحله‌ای با موفقیت تأیید شد", + TWO_FACTOR_TOKEN_VERIFIED_FAILED = "کد احراز هویت دو مرحله‌ای نامعتبر می‌باشد", + TWO_FACTOR_NOT_ENABLED = "احراز هویت دو مرحله‌ای فعال نیست", + TWO_FACTOR_BACKUP_CODES_GENERATED_SUCCESSFULLY = "کدهای پشتیبان با موفقیت تولید شدند", + TWO_FACTOR_BACKUP_CODE_VERIFIED_SUCCESSFULLY = "کد پشتیبان با موفقیت تأیید شد", + TWO_FACTOR_BACKUP_CODE_VERIFIED_FAILED = "کد پشتیبان نامعتبر است", + TWO_FACTOR_TOKEN_REQUIRED = "کد احراز هویت دو مرحله‌ای الزامی است", + TWO_FACTOR_TOKEN_INVALID = "کد احراز هویت دو مرحله‌ای نامعتبر است", +} diff --git a/src/modules/auth/DTO/create-two-factor.dto.ts b/src/modules/auth/DTO/create-two-factor.dto.ts new file mode 100644 index 0000000..0362cd0 --- /dev/null +++ b/src/modules/auth/DTO/create-two-factor.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; + +export class Setup2FADto { + @ApiPropertyOptional({ description: "TOTP issuer name", example: "DMail Service" }) + @IsOptional() + @IsString() + issuer?: string; + + @ApiPropertyOptional({ description: "Generate fresh secret", default: false }) + @IsOptional() + @IsBoolean() + fresh?: boolean; +} + +export class Enable2FADto { + @ApiProperty({ description: "TOTP token from authenticator app", example: "123456" }) + @IsString() + token: string; +} + +export class Verify2FADto { + @ApiProperty({ description: "TOTP token for verification", example: "123456" }) + @IsString() + token: string; +} + +export class UseBackupCodeDto { + @ApiProperty({ description: "Backup code", example: "abcd1234" }) + @IsString() + code: string; +} + +export class GenerateBackupCodesDto { + // Empty DTO for endpoint consistency +} diff --git a/src/modules/auth/DTO/login.dto.ts b/src/modules/auth/DTO/login.dto.ts index 1555a5c..95c8086 100644 --- a/src/modules/auth/DTO/login.dto.ts +++ b/src/modules/auth/DTO/login.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsEmail, IsNotEmpty, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; import { AuthMessage } from "../../../common/enums/message.enum"; @@ -13,4 +13,9 @@ export class LoginDto { @IsString({ message: AuthMessage.INVALID_PASS_FORMAT }) @ApiProperty({ description: "User's password", example: "SecurePassword123!" }) password: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "Two-factor authentication token (required if 2FA is enabled)", example: "123456" }) + twoFactorToken?: string; } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 4ec8567..68ce22d 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, HttpCode, HttpStatus, Patch, Post } from "@nestjs/common"; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post } from "@nestjs/common"; import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger"; import { ChangePasswordDto } from "./DTO/change-password.dto"; +import { Enable2FADto, GenerateBackupCodesDto, Setup2FADto, UseBackupCodeDto, Verify2FADto } from "./DTO/create-two-factor.dto"; import { LoginDto } from "./DTO/login.dto"; import { RefreshTokenDto } from "./DTO/refresh-token.dto"; import { AuthService } from "./services/auth.service"; +import { TwoFactorService } from "./services/two-factor.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { RefreshTokenRateLimit, StrictRateLimit } from "../../common/decorators/rate-limit.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; @@ -12,7 +14,10 @@ import { UserDec } from "../../common/decorators/user.decorator"; @ApiTags("Authentication") @Controller("auth") export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly twoFactorService: TwoFactorService, + ) {} @StrictRateLimit() @Post("login") @@ -48,4 +53,58 @@ export class AuthController { changePassword(@UserDec("id") userId: string, @Body() changePasswordDto: ChangePasswordDto) { return this.authService.changePassword(userId, changePasswordDto); } + + @Post("setup") + @ApiOperation({ summary: "Setup TOTP 2FA for user" }) + @ApiResponse({ status: 200, description: "2FA setup initiated successfully" }) + @ApiResponse({ status: 400, description: "Failed to setup 2FA" }) + setup2FA(@UserDec("wildduckUserId") wildduckUserId: string, @Body() setup2FADto: Setup2FADto) { + return this.twoFactorService.setup2FA(wildduckUserId, setup2FADto); + } + + @Post("enable") + @ApiOperation({ summary: "Enable TOTP 2FA after verification" }) + @ApiResponse({ status: 200, description: "2FA enabled successfully" }) + @ApiResponse({ status: 400, description: "Invalid 2FA token or failed to enable 2FA" }) + enable2FA(@UserDec("wildduckUserId") wildduckUserId: string, @Body() enable2FADto: Enable2FADto) { + return this.twoFactorService.enable2FA(wildduckUserId, enable2FADto); + } + + @Post("verify") + @ApiOperation({ summary: "Verify TOTP token" }) + @ApiResponse({ status: 200, description: "2FA token verification result" }) + verify2FA(@UserDec("wildduckUserId") wildduckUserId: string, @Body() verify2FADto: Verify2FADto) { + return this.twoFactorService.verify2FA(wildduckUserId, verify2FADto); + } + + @Get("status") + @ApiOperation({ summary: "Get 2FA status for user" }) + @ApiResponse({ status: 200, description: "2FA status retrieved successfully" }) + @ApiResponse({ status: 400, description: "Failed to get 2FA status" }) + get2FAStatus(@UserDec("wildduckUserId") wildduckUserId: string) { + return this.twoFactorService.get2FAStatus(wildduckUserId); + } + + @Delete("disable") + @ApiOperation({ summary: "Disable 2FA for user" }) + @ApiResponse({ status: 200, description: "2FA disabled successfully" }) + @ApiResponse({ status: 400, description: "Failed to disable 2FA" }) + disable2FA(@UserDec("wildduckUserId") wildduckUserId: string) { + return this.twoFactorService.disable2FA(wildduckUserId); + } + + @Post("backup-codes/generate") + @ApiOperation({ summary: "Generate new backup codes" }) + @ApiResponse({ status: 200, description: "Backup codes generated successfully" }) + @ApiResponse({ status: 400, description: "2FA must be enabled to generate backup codes" }) + generateBackupCodes(@UserDec("wildduckUserId") wildduckUserId: string, @Body() _generateBackupCodesDto: GenerateBackupCodesDto) { + return this.twoFactorService.generateBackupCodes(wildduckUserId); + } + + @Post("backup-codes/use") + @ApiOperation({ summary: "Use backup code for authentication" }) + @ApiResponse({ status: 200, description: "Backup code verification result" }) + useBackupCode(@UserDec("wildduckUserId") wildduckUserId: string, @Body() useBackupCodeDto: UseBackupCodeDto) { + return this.twoFactorService.useBackupCode(wildduckUserId, useBackupCodeDto); + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 7653fc8..bd5aa90 100755 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -4,17 +4,18 @@ import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; import { AuthController } from "./auth.controller"; -import { jwtConfig } from "../../configs/jwt.config"; -import { MailServerModule } from "../mail-server/mail-server.module"; -import { UtilsModule } from "../utils/utils.module"; import { AuthService } from "./services/auth.service"; import { TokensService } from "./services/tokens.service"; -import { UsersModule } from "../users/users.module"; +import { TwoFactorService } from "./services/two-factor.service"; import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy"; import { LocalJwtStrategy } from "./strategies/local-jwt.strategy"; +import { jwtConfig } from "../../configs/jwt.config"; import { BusinessesModule } from "../businesses/businesses.module"; +import { MailServerModule } from "../mail-server/mail-server.module"; import { NotificationModule } from "../notifications/notifications.module"; import { User } from "../users/entities/user.entity"; +import { UsersModule } from "../users/users.module"; +import { UtilsModule } from "../utils/utils.module"; @Module({ imports: [ @@ -28,7 +29,7 @@ import { User } from "../users/entities/user.entity"; NotificationModule, ], controllers: [AuthController], - providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy], - exports: [AuthService], + providers: [AuthService, TwoFactorService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy], + exports: [AuthService, TwoFactorService, TokensService], }) export class AuthModule {} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 289566c..7d2e147 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -1,7 +1,8 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { TokensService } from "./tokens.service"; -import { AuthMessage } from "../../../common/enums/message.enum"; +import { TwoFactorService } from "./two-factor.service"; +import { AuthMessage, TwoFactorMessage } from "../../../common/enums/message.enum"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { UserRepository } from "../../users/repositories/user.repository"; import { PasswordService } from "../../utils/services/password.service"; @@ -15,17 +16,25 @@ export class AuthService { private readonly userRepository: UserRepository, private readonly tokensService: TokensService, private readonly passwordService: PasswordService, + private readonly twoFactorService: TwoFactorService, ) {} //============================================== //============================================== async login(loginDto: LoginDto) { - const { email, password } = loginDto; + const { email, password, twoFactorToken } = loginDto; const user = await this.checkUserLoginCredentialWithEmail(email, password); if (!user.isActive) throw new BadRequestException(AuthMessage.USER_ACCOUNT_INACTIVE); + if (user.is2FAEnabled) { + if (!twoFactorToken) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_TOKEN_REQUIRED); + + const is2FAValid = await this.twoFactorService.validateTwoFactorToken(user, twoFactorToken); + if (!is2FAValid) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_TOKEN_INVALID); + } + const tokens = await this.tokensService.generateTokens(user); await this.notificationQueue.addLoginNotification(user.id, { diff --git a/src/modules/auth/services/two-factor.service.ts b/src/modules/auth/services/two-factor.service.ts new file mode 100644 index 0000000..370b021 --- /dev/null +++ b/src/modules/auth/services/two-factor.service.ts @@ -0,0 +1,152 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { firstValueFrom } from "rxjs"; + +import { TwoFactorMessage, UserMessage } from "../../../common/enums/message.enum"; +import { MailServerService } from "../../mail-server/services/mail-server.service"; +import { User } from "../../users/entities/user.entity"; +import { UserRepository } from "../../users/repositories/user.repository"; +import { Enable2FADto, Setup2FADto, UseBackupCodeDto, Verify2FADto } from "../DTO/create-two-factor.dto"; + +@Injectable() +export class TwoFactorService { + constructor( + private readonly mailServerService: MailServerService, + private readonly userRepository: UserRepository, + private readonly em: EntityManager, + ) {} + + //============================================== + async setup2FA(wildduckUserId: string, setup2FADto: Setup2FADto) { + const response = await firstValueFrom( + this.mailServerService.twoFactor.setupTOTP(wildduckUserId, { + issuer: setup2FADto.issuer || "DMail Service", + fresh: setup2FADto.fresh || false, + }), + ); + + return { + message: TwoFactorMessage.TWO_FACTOR_SETUP_INITIATED, + qr: response.qrcode, + secret: response.seed, + }; + } + + //============================================== + async enable2FA(wildduckUserId: string, enable2FADto: Enable2FADto) { + const user = await this.userRepository.findOne({ wildduckUserId }); + + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + await firstValueFrom( + this.mailServerService.twoFactor.enableTOTP(wildduckUserId, { + token: enable2FADto.token, + }), + ); + + user.is2FAEnabled = true; + user.twoFactorEnabledAt = new Date(); + await this.em.flush(); + + return { + message: TwoFactorMessage.TWO_FACTOR_ENABLED_SUCCESSFULLY, + }; + } + + //============================================== + async verify2FA(wildduckUserId: string, verify2FADto: Verify2FADto) { + await firstValueFrom( + this.mailServerService.twoFactor.checkTOTP(wildduckUserId, { + token: verify2FADto.token, + }), + ); + + return { + message: TwoFactorMessage.TWO_FACTOR_TOKEN_VERIFIED_SUCCESSFULLY, + valid: true, + }; + } + + //============================================== + async get2FAStatus(wildduckUserId: string) { + const user = await this.userRepository.findOne({ wildduckUserId }); + + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + const response = await firstValueFrom(this.mailServerService.twoFactor.get2FAStatus(wildduckUserId)); + + return { + enabled: user.is2FAEnabled, + enabledMethods: response.enabled2fa, + totpEnabled: response.enabled2fa.includes("totp"), + backupCodesAvailable: response.enabled2fa.includes("backup"), + enabledAt: user.twoFactorEnabledAt, + }; + } + + //============================================== + async disable2FA(wildduckUserId: string) { + const user = await this.userRepository.findOne({ wildduckUserId }); + + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + await firstValueFrom(this.mailServerService.twoFactor.disableAll2FA(wildduckUserId)); + + user.is2FAEnabled = false; + user.twoFactorEnabledAt = undefined; + await this.em.flush(); + + return { + message: TwoFactorMessage.TWO_FACTOR_DISABLED_SUCCESSFULLY, + }; + } + + //============================================== + async generateBackupCodes(wildduckUserId: string) { + const user = await this.userRepository.findOne({ wildduckUserId }); + + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + if (!user.is2FAEnabled) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_NOT_ENABLED); + + const response = await firstValueFrom(this.mailServerService.twoFactor.generateBackupCodes(user.wildduckUserId)); + + return { + message: TwoFactorMessage.TWO_FACTOR_BACKUP_CODES_GENERATED_SUCCESSFULLY, + backupCodes: response.codes, + }; + } + + //============================================== + async useBackupCode(wildduckUserId: string, useBackupCodeDto: UseBackupCodeDto) { + const user = await this.userRepository.findOne({ wildduckUserId }); + + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + if (!user.is2FAEnabled) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_NOT_ENABLED); + + await firstValueFrom( + this.mailServerService.twoFactor.useBackupCode(wildduckUserId, { + code: useBackupCodeDto.code, + }), + ); + + return { + message: TwoFactorMessage.TWO_FACTOR_BACKUP_CODE_VERIFIED_SUCCESSFULLY, + valid: true, + }; + } + + //============================================== + async validateTwoFactorToken(user: User, token: string): Promise { + if (!user.is2FAEnabled) return true; + + const totpResult = await this.verify2FA(user.wildduckUserId, { token }); + if (totpResult.valid) { + return true; + } + + const backupResult = await this.useBackupCode(user.wildduckUserId, { code: token }); + return backupResult.valid; + } +} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 528f68c..7dbb4cd 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -8,18 +8,6 @@ import { Domain } from "../../domains/entities/domain.entity"; import { Template } from "../../templates/entities/template.entity"; import { UserRepository } from "../repositories/user.repository"; -export interface WebPushSubscriptionData { - endpoint: string; - keys: { - p256dh: string; - auth: string; - }; - deviceType?: string; - deviceName?: string; - registeredAt?: Date; - updatedAt?: Date; -} - @Entity({ repository: () => UserRepository }) @Unique({ properties: ["userName", "domain"], name: "unique_user_name_domain", options: { where: "deleted_at is null" } }) export class User extends BaseEntity { @@ -63,15 +51,12 @@ export class User extends BaseEntity { @Property({ default: true, nullable: false }) isActive: boolean & Opt; - // Push notification fields - now stores web push subscription objects - @Property({ type: "json", nullable: true }) - pushTokens?: WebPushSubscriptionData[]; - - @Property({ type: "boolean", default: true }) - pushNotificationsEnabled: boolean & Opt; + // Two-Factor Authentication fields + @Property({ type: "boolean", default: false }) + is2FAEnabled: boolean & Opt; @Property({ type: "timestamptz", nullable: true }) - lastPushNotificationAt?: Date; + twoFactorEnabledAt?: Date; //=========================