chore: add 2fa

This commit is contained in:
mahyargdz
2025-07-27 10:24:23 +03:30
parent cfc05d89b6
commit 7cee095149
10 changed files with 411 additions and 84 deletions
+329 -12
View File
@@ -6,36 +6,341 @@ export class MailServerException extends HttpException {
}
}
// User-related exceptions
export class UserNotFoundException extends MailServerException {
constructor(userId: string) {
super(`User with ID '${userId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MailboxNotFoundException extends MailServerException {
constructor(mailboxId: string) {
super(`Mailbox with ID '${mailboxId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MessageNotFoundException extends MailServerException {
constructor(messageId: number) {
super(`Message with ID '${messageId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class UserAlreadyExistsException extends MailServerException {
constructor(username: string) {
super(`User '${username}' already exists`, HttpStatus.CONFLICT);
}
}
export class UserQuotaExceededException extends MailServerException {
constructor(userId: string, quotaType?: string) {
super(`User '${userId}' has exceeded ${quotaType || "storage"} quota`, HttpStatus.INSUFFICIENT_STORAGE);
}
}
export class UserDisabledException extends MailServerException {
constructor(userId: string) {
super(`User '${userId}' is disabled`, HttpStatus.FORBIDDEN);
}
}
// Authentication exceptions
export class AuthenticationFailedException extends MailServerException {
constructor(details?: string) {
super(`Authentication failed${details ? `: ${details}` : ""}`, HttpStatus.UNAUTHORIZED);
}
}
export class InvalidCredentialsException extends MailServerException {
constructor() {
super("Invalid username or password", HttpStatus.UNAUTHORIZED);
}
}
export class PasswordExpiredException extends MailServerException {
constructor() {
super("Password has expired and must be changed", HttpStatus.UNAUTHORIZED);
}
}
export class TooManyLoginAttemptsException extends MailServerException {
constructor() {
super("Too many login attempts, account temporarily locked", HttpStatus.TOO_MANY_REQUESTS);
}
}
// 2FA exceptions
export class TwoFactorRequiredException extends MailServerException {
constructor(methods: string[]) {
super(`Two-factor authentication required. Available methods: ${methods.join(", ")}`, HttpStatus.UNAUTHORIZED);
}
}
export class InvalidTwoFactorTokenException extends MailServerException {
constructor(method?: string) {
super(`Invalid ${method || "2FA"} token provided`, HttpStatus.UNAUTHORIZED);
}
}
export class TwoFactorNotSetupException extends MailServerException {
constructor(method?: string) {
super(`${method || "Two-factor authentication"} is not setup for this user`, HttpStatus.PRECONDITION_FAILED);
}
}
export class TwoFactorAlreadySetupException extends MailServerException {
constructor(method?: string) {
super(`${method || "Two-factor authentication"} is already setup for this user`, HttpStatus.CONFLICT);
}
}
export class InvalidBackupCodeException extends MailServerException {
constructor() {
super("Invalid or already used backup code", HttpStatus.UNAUTHORIZED);
}
}
export class NoBackupCodesException extends MailServerException {
constructor() {
super("No backup codes available for this user", HttpStatus.NOT_FOUND);
}
}
// Mailbox exceptions
export class MailboxNotFoundException extends MailServerException {
constructor(mailboxId: string) {
super(`Mailbox with ID '${mailboxId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MailboxAlreadyExistsException extends MailServerException {
constructor(mailboxPath: string) {
super(`Mailbox '${mailboxPath}' already exists`, HttpStatus.CONFLICT);
}
}
export class SpecialMailboxException extends MailServerException {
constructor(operation: string) {
super(`Cannot ${operation} special mailbox`, HttpStatus.FORBIDDEN);
}
}
// Message exceptions
export class MessageNotFoundException extends MailServerException {
constructor(messageId: number) {
super(`Message with ID '${messageId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MessageTooLargeException extends MailServerException {
constructor(size?: number, limit?: number) {
const sizeInfo = size && limit ? ` (${size} bytes, limit: ${limit} bytes)` : "";
super(`Message is too large${sizeInfo}`, HttpStatus.PAYLOAD_TOO_LARGE);
}
}
export class InvalidMessageFormatException extends MailServerException {
constructor(details?: string) {
super(`Invalid message format${details ? `: ${details}` : ""}`, HttpStatus.BAD_REQUEST);
}
}
export class MessageExpiredException extends MailServerException {
constructor(messageId: number) {
super(`Message with ID '${messageId}' has expired`, HttpStatus.GONE);
}
}
// Address exceptions
export class AddressNotFoundException extends MailServerException {
constructor(address: string) {
super(`Address '${address}' not found`, HttpStatus.NOT_FOUND);
}
}
export class AddressAlreadyExistsException extends MailServerException {
constructor(address: string) {
super(`Address '${address}' already exists`, HttpStatus.CONFLICT);
}
}
export class InvalidAddressFormatException extends MailServerException {
constructor(address: string) {
super(`Invalid email address format: '${address}'`, HttpStatus.BAD_REQUEST);
}
}
export class AddressQuotaExceededException extends MailServerException {
constructor(userId: string) {
super(`User '${userId}' has reached maximum number of addresses`, HttpStatus.FORBIDDEN);
}
}
// Validation exceptions
export class ValidationException extends MailServerException {
constructor(field: string, value?: string) {
super(`Validation failed for field '${field}'${value ? ` with value '${value}'` : ""}`, HttpStatus.BAD_REQUEST);
}
}
export class RequiredFieldException extends MailServerException {
constructor(field: string) {
super(`Required field '${field}' is missing`, HttpStatus.BAD_REQUEST);
}
}
export class InvalidParameterException extends MailServerException {
constructor(parameter: string, expectedType?: string) {
super(`Invalid parameter '${parameter}'${expectedType ? `, expected ${expectedType}` : ""}`, HttpStatus.BAD_REQUEST);
}
}
export class DuplicateValueException extends MailServerException {
constructor(field: string, value: string) {
super(`Duplicate value '${value}' for field '${field}'`, HttpStatus.CONFLICT);
}
}
// Storage and quota exceptions
export class StorageQuotaExceededException extends MailServerException {
constructor(used?: number, limit?: number) {
const quotaInfo = used && limit ? ` (used: ${used}MB, limit: ${limit}MB)` : "";
super(`Storage quota exceeded${quotaInfo}`, HttpStatus.INSUFFICIENT_STORAGE);
}
}
export class RecipientQuotaExceededException extends MailServerException {
constructor(count?: number, limit?: number) {
const quotaInfo = count && limit ? ` (current: ${count}, limit: ${limit})` : "";
super(`Recipient quota exceeded${quotaInfo}`, HttpStatus.TOO_MANY_REQUESTS);
}
}
export class ConnectionQuotaExceededException extends MailServerException {
constructor(type: string, count?: number, limit?: number) {
const quotaInfo = count && limit ? ` (current: ${count}, limit: ${limit})` : "";
super(`${type} connection quota exceeded${quotaInfo}`, HttpStatus.TOO_MANY_REQUESTS);
}
}
// Filter exceptions
export class FilterNotFoundException extends MailServerException {
constructor(filterId: string) {
super(`Filter with ID '${filterId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class InvalidFilterRuleException extends MailServerException {
constructor(details?: string) {
super(`Invalid filter rule${details ? `: ${details}` : ""}`, HttpStatus.BAD_REQUEST);
}
}
// DKIM exceptions
export class DKIMNotFoundException extends MailServerException {
constructor(domain: string) {
super(`DKIM key not found for domain '${domain}'`, HttpStatus.NOT_FOUND);
}
}
export class InvalidDKIMKeyException extends MailServerException {
constructor(details?: string) {
super(`Invalid DKIM key${details ? `: ${details}` : ""}`, HttpStatus.BAD_REQUEST);
}
}
// Submission exceptions
export class SubmissionFailedException extends MailServerException {
constructor(details?: string) {
super(`Message submission failed${details ? `: ${details}` : ""}`, HttpStatus.BAD_GATEWAY);
}
}
export class RateLimitExceededException extends MailServerException {
constructor(type?: string) {
super(`Rate limit exceeded${type ? ` for ${type}` : ""}`, HttpStatus.TOO_MANY_REQUESTS);
}
}
// Application Password exceptions
export class ApplicationPasswordNotFoundException extends MailServerException {
constructor(aspId: string) {
super(`Application password with ID '${aspId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class ApplicationPasswordExpiredException extends MailServerException {
constructor(aspId: string) {
super(`Application password with ID '${aspId}' has expired`, HttpStatus.UNAUTHORIZED);
}
}
// Export/Import exceptions
export class ExportNotFoundException extends MailServerException {
constructor(exportId: string) {
super(`Export with ID '${exportId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class ExportInProgressException extends MailServerException {
constructor(exportId: string) {
super(`Export with ID '${exportId}' is still in progress`, HttpStatus.CONFLICT);
}
}
export class ImportFailedException extends MailServerException {
constructor(details?: string) {
super(`Import failed${details ? `: ${details}` : ""}`, HttpStatus.BAD_REQUEST);
}
}
// Audit exceptions
export class AuditLogNotFoundException extends MailServerException {
constructor(entryId: string) {
super(`Audit log entry with ID '${entryId}' not found`, HttpStatus.NOT_FOUND);
}
}
// Webhook exceptions
export class WebhookNotFoundException extends MailServerException {
constructor(webhookId: string) {
super(`Webhook with ID '${webhookId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class WebhookDeliveryFailedException extends MailServerException {
constructor(url: string, details?: string) {
super(`Webhook delivery failed to '${url}'${details ? `: ${details}` : ""}`, HttpStatus.BAD_GATEWAY);
}
}
// Autoreply exceptions
export class AutoreplyAlreadyActiveException extends MailServerException {
constructor(userId: string) {
super(`Autoreply is already active for user '${userId}'`, HttpStatus.CONFLICT);
}
}
export class AutoreplyNotActiveException extends MailServerException {
constructor(userId: string) {
super(`Autoreply is not active for user '${userId}'`, HttpStatus.NOT_FOUND);
}
}
// Archive exceptions
export class ArchiveNotFoundException extends MailServerException {
constructor(archiveId: string) {
super(`Archive with ID '${archiveId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class ArchiveCorruptedException extends MailServerException {
constructor(archiveId: string) {
super(`Archive with ID '${archiveId}' is corrupted`, HttpStatus.UNPROCESSABLE_ENTITY);
}
}
// Domain Access exceptions
export class DomainAccessForbiddenException extends MailServerException {
constructor(domain: string) {
super(`Access to domain '${domain}' is forbidden`, HttpStatus.FORBIDDEN);
}
}
export class DomainNotAllowedException extends MailServerException {
constructor(domain: string) {
super(`Domain '${domain}' is not allowed`, HttpStatus.FORBIDDEN);
}
}
// Connection and server exceptions
export class MailServerConnectionException extends MailServerException {
constructor(details?: string) {
super(`Mail server connection failed${details ? `: ${details}` : ""}`, HttpStatus.SERVICE_UNAVAILABLE);
@@ -47,3 +352,15 @@ export class InvalidMailServerResponseException extends MailServerException {
super("Invalid response from mail server", HttpStatus.BAD_GATEWAY);
}
}
export class MailServerTimeoutException extends MailServerException {
constructor(operation?: string) {
super(`Mail server timeout${operation ? ` during ${operation}` : ""}`, HttpStatus.GATEWAY_TIMEOUT);
}
}
export class MailServerMaintenanceException extends MailServerException {
constructor() {
super("Mail server is under maintenance", HttpStatus.SERVICE_UNAVAILABLE);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ async function bootstrap() {
app.useGlobalInterceptors(new ResponseInterceptor(), new PaginationInterceptor());
app.useGlobalFilters(new MailServerExceptionFilter(), new HttpExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter(), new MailServerExceptionFilter());
app.enableCors({
origin: true,
@@ -25,6 +25,12 @@ export class Verify2FADto {
token: string;
}
export class Disable2FADto {
@ApiProperty({ description: "TOTP token or backup code for verification", example: "123456" })
@IsString()
token: string;
}
export class UseBackupCodeDto {
@ApiProperty({ description: "Backup code", example: "abcd1234" })
@IsString()
+5 -14
View File
@@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post } from "@nestjs/common";
import { Body, Controller, 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, UseBackupCodeDto, Verify2FADto } from "./DTO/create-two-factor.dto";
import { Disable2FADto, Enable2FADto, GenerateBackupCodesDto, 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";
@@ -81,21 +81,12 @@ export class AuthController {
}
@AuthGuards()
@Get("2fa/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);
}
@AuthGuards()
@Delete("2fa/disable")
@Post("2fa/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);
disable2FA(@UserDec("wildduckUserId") wildduckUserId: string, @Body() disable2FADto: Disable2FADto) {
return this.twoFactorService.disable2FA(wildduckUserId, disable2FADto);
}
@AuthGuards()
+6 -1
View File
@@ -29,7 +29,12 @@ export class AuthService {
if (!user.isActive) throw new BadRequestException(AuthMessage.USER_ACCOUNT_INACTIVE);
if (user.is2FAEnabled) {
if (!twoFactorToken) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_TOKEN_REQUIRED);
if (!twoFactorToken) {
return {
message: TwoFactorMessage.TWO_FACTOR_TOKEN_REQUIRED,
requires2FA: true,
};
}
const is2FAValid = await this.twoFactorService.validateTwoFactorToken(user, twoFactorToken);
if (!is2FAValid) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_TOKEN_INVALID);
@@ -6,7 +6,7 @@ import { TwoFactorMessage, UserMessage } from "../../../common/enums/message.enu
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, UseBackupCodeDto, Verify2FADto } from "../DTO/create-two-factor.dto";
import { Disable2FADto, Enable2FADto, UseBackupCodeDto, Verify2FADto } from "../DTO/create-two-factor.dto";
@Injectable()
export class TwoFactorService {
@@ -72,27 +72,14 @@ export class TwoFactorService {
}
//==============================================
async get2FAStatus(wildduckUserId: string) {
async disable2FA(wildduckUserId: string, disable2FADto: Disable2FADto) {
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));
if (!user.is2FAEnabled) throw new BadRequestException(TwoFactorMessage.TWO_FACTOR_NOT_ENABLED);
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 this.validateTwoFactorToken(user, disable2FADto.token);
await firstValueFrom(this.mailServerService.twoFactor.disableAll2FA(wildduckUserId));
@@ -1,17 +1,3 @@
export interface TwoFactorStatusResponse {
success: true;
enabled2fa: string[];
totp?: {
enabled: boolean;
url?: string;
label?: string;
};
u2f?: {
enabled: boolean;
keyHandle?: string;
};
}
export interface TOTPSetupResponse {
success: true;
qrcode: string;
@@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
import { AxiosResponse } from "axios";
import { Observable, catchError, map, throwError } from "rxjs";
import * as MailServerExceptions from "../../../core/exceptions/mail-server.exceptions";
import { MailServerException } from "../../../core/exceptions/mail-server.exceptions";
import { WildDuckErrorResponse, WildDuckSuccessResponse } from "../interfaces/wildduck-response.interface";
@@ -19,17 +20,15 @@ export class WildDuckBaseService {
this.baseURL = this.configService.getOrThrow<string>("WILDDUCK_BASE_URL");
}
protected buildUrl(path: string, params?: Record<string, unknown>): string {
protected buildUrl(path: string, params?: Record<string, any>): string {
const url = new URL(path, this.baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
Object.keys(params).forEach((key) => {
if (params[key] !== undefined && params[key] !== null) {
url.searchParams.append(key, params[key].toString());
}
});
}
return url.toString();
}
@@ -41,7 +40,7 @@ export class WildDuckBaseService {
const data = response.data;
if ("success" in data && data.success === false) {
throw new MailServerException(data.error || "WildDuck API error");
throw this.handleMailServerError(data.error || "WildDuck API error", response.status);
}
if ("success" in data && data.success === true) {
@@ -54,14 +53,57 @@ export class WildDuckBaseService {
this.logger.error(`WildDuck API error: ${error.message}`, error.stack);
if (error.response?.data?.error) {
return throwError(() => new MailServerException(error.response.data.error));
return throwError(() => this.handleMailServerError(error.response.data.error, error.response.status));
}
return throwError(() => error);
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
return throwError(() => new MailServerExceptions.MailServerConnectionException(error.message));
}
return throwError(() => new MailServerException(error.message || "Unknown mail server error"));
}),
);
}
/**
* Handle mail server errors and throw appropriate exceptions
*/
protected handleMailServerError(errorMessage: string, statusCode?: number): MailServerException {
// Map HTTP status codes to appropriate exceptions
if (statusCode) {
switch (statusCode) {
case 401:
return new MailServerExceptions.AuthenticationFailedException(errorMessage);
case 403:
return new MailServerExceptions.UserDisabledException("user");
case 404:
return new MailServerExceptions.UserNotFoundException("unknown");
case 409:
return new MailServerExceptions.UserAlreadyExistsException("user");
case 413:
return new MailServerExceptions.MessageTooLargeException();
case 429:
return new MailServerExceptions.TooManyLoginAttemptsException();
case 502:
return new MailServerExceptions.InvalidMailServerResponseException();
case 503:
return new MailServerExceptions.MailServerMaintenanceException();
case 504:
return new MailServerExceptions.MailServerTimeoutException();
case 507:
return new MailServerExceptions.StorageQuotaExceededException();
default:
if (statusCode >= 500) {
return new MailServerExceptions.InvalidMailServerResponseException();
}
break;
}
}
// Default to generic mail server exception with the error message
return new MailServerException(errorMessage);
}
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
const url = this.buildUrl(path, params);
this.logger.debug(`WildDuck GET: ${url}`);
@@ -81,6 +123,6 @@ export class WildDuckBaseService {
protected delete<T>(path: string, params?: Record<string, any>): Observable<T> {
const url = this.buildUrl(path, params);
return this.handleResponse<T>(this.httpService.delete(url));
return this.handleResponse<T>(this.httpService.delete(url, { params }));
}
}
@@ -3,6 +3,7 @@ import { Observable } from "rxjs";
import { catchError, map, throwError } from "rxjs";
import { WildDuckBaseService } from "./wildduck-base.service";
import * as MailServerExceptions from "../../../core/exceptions/mail-server.exceptions";
import { MailServerException } from "../../../core/exceptions/mail-server.exceptions";
import { ListMessagesQueryDto, SearchMessagesMailServerQueryDto, UpdateMessageDto, UploadMessageDto } from "../DTO/message.dto";
import { AttachmentResponse } from "../interfaces/attachment.interface";
@@ -101,10 +102,14 @@ export class WildDuckMessagesService extends WildDuckBaseService {
this.logger.error(`WildDuck API error: ${error.message}`, error.stack);
if (error.response?.data?.error) {
return throwError(() => new MailServerException(error.response.data.error));
return throwError(() => this.handleMailServerError(error.response.data.error, error.response.status));
}
return throwError(() => error);
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
return throwError(() => new MailServerExceptions.MailServerConnectionException(error.message));
}
return throwError(() => new MailServerException(error.message || "Unknown mail server error"));
}),
);
}
@@ -2,12 +2,7 @@ import { Injectable } from "@nestjs/common";
import { Observable } from "rxjs";
import { WildDuckBaseService } from "./wildduck-base.service";
import {
BackupCodesGenerateResponse,
BackupCodesListResponse,
TOTPSetupResponse,
TwoFactorStatusResponse,
} from "../interfaces/two-factor-response.interface";
import { BackupCodesGenerateResponse, BackupCodesListResponse, TOTPSetupResponse } from "../interfaces/two-factor-response.interface";
import {
BackupCodeData,
BaseTwoFactorData,
@@ -20,13 +15,6 @@ import { WildDuckSuccessResponse } from "../interfaces/wildduck-response.interfa
@Injectable()
export class WildDuckTwoFactorService extends WildDuckBaseService {
/**
* Get 2FA status for user
*/
get2FAStatus(userId: string): Observable<TwoFactorStatusResponse> {
return this.get<TwoFactorStatusResponse>(`/users/${userId}/2fa`);
}
// TOTP Methods
/**
* Setup TOTP 2FA