chore: 2fa added
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
//=========================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user