chore: 2fa added

This commit is contained in:
mahyargdz
2025-07-26 16:12:58 +03:30
parent e921b00da2
commit c6defaa1d8
9 changed files with 301 additions and 31 deletions
@@ -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);
+16
View File
@@ -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 = "کد احراز هویت دو مرحله‌ای نامعتبر است",
}
@@ -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
}
+7 -2
View File
@@ -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;
}
+61 -2
View File
@@ -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);
}
}
+7 -6
View File
@@ -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 {}
+11 -2
View File
@@ -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, {
@@ -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<boolean> {
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;
}
}
+4 -19
View File
@@ -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;
//=========================