Refactor authentication module to improve structure and maintainability. Introduce AuthFacade for streamlined access to authentication services, and reorganize controllers, guards, and strategies into a clearer directory structure. Remove deprecated auth-zod.controller.ts and consolidate token management services. Update environment variables and documentation to reflect changes in the authentication setup. Enhance validation with Zod integration for improved data handling across endpoints.
This commit is contained in:
parent
336fe2cf59
commit
a4e6ba73de
@ -1,93 +1,76 @@
|
|||||||
# Development Authentication Setup
|
# Development Authentication Setup
|
||||||
|
|
||||||
## Quick Fix for Sign-In Issues
|
## Overview
|
||||||
|
|
||||||
Your authentication system has been updated to be more development-friendly. Here are the changes made:
|
The auth module now exposes a clean facade (`AuthFacade`) and a layered structure. Development conveniences (CSRF bypass, throttling disable) still exist, but code placements and service names have changed. This guide outlines how to configure the dev environment and where to look in the codebase.
|
||||||
|
|
||||||
### 1. CSRF Token Handling Fixed
|
## File Map
|
||||||
|
|
||||||
- **Frontend**: Added automatic CSRF token fetching and inclusion in API requests
|
| Purpose | Location |
|
||||||
- **Backend**: Added development bypass for CSRF protection
|
| ---------------------- | ------------------------------------------------------ |
|
||||||
- **CORS**: Added `X-CSRF-Token` to allowed headers
|
| Express cookie helper | `apps/bff/src/app/bootstrap.ts` |
|
||||||
|
| Auth controller | `modules/auth/presentation/http/auth.controller.ts` |
|
||||||
|
| Guards/interceptors | `modules/auth/presentation/http/guards|interceptors` |
|
||||||
|
| Passport strategies | `modules/auth/presentation/strategies` |
|
||||||
|
| Facade (use-cases) | `modules/auth/application/auth.facade.ts` |
|
||||||
|
| Token services | `modules/auth/infra/token` |
|
||||||
|
| Rate limiter service | `modules/auth/infra/rate-limiting/auth-rate-limit.service.ts` |
|
||||||
|
| Signup/password flows | `modules/auth/infra/workflows` |
|
||||||
|
|
||||||
### 2. Development Environment Variables
|
## Development Environment Flags
|
||||||
|
|
||||||
Add these to your `.env` file to simplify development:
|
Add to `.env` as needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Disable CSRF protection for easier development
|
# Disable CSRF protection (use only locally)
|
||||||
DISABLE_CSRF=true
|
DISABLE_CSRF=true
|
||||||
|
|
||||||
# Disable rate limiting for development
|
# Disable auth rate limits / lockouts
|
||||||
DISABLE_RATE_LIMIT=true
|
DISABLE_RATE_LIMIT=true
|
||||||
|
|
||||||
# Disable account locking for development
|
|
||||||
DISABLE_ACCOUNT_LOCKING=true
|
DISABLE_ACCOUNT_LOCKING=true
|
||||||
|
|
||||||
# Enable detailed error messages
|
# Show detailed validation errors in responses
|
||||||
EXPOSE_VALIDATION_ERRORS=true
|
EXPOSE_VALIDATION_ERRORS=true
|
||||||
|
|
||||||
# CORS configuration
|
# Allow portal origin
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. What Was Complex About Your Auth System
|
These flags are consumed in `core/config/auth-dev.config.ts` and by guards/middleware.
|
||||||
|
|
||||||
Your authentication system had several layers of security that, while production-ready, made development difficult:
|
## CSRF in Development
|
||||||
|
|
||||||
1. **CSRF Protection**: Double-submit cookie pattern with session/user binding
|
- Middleware (`core/security/middleware/csrf.middleware.ts`) auto-issues stateless tokens.
|
||||||
2. **Rate Limiting**: 5 login attempts per 15 minutes per IP+UA
|
- Frontend API client must forward `X-CSRF-Token`; the helper already performs this.
|
||||||
3. **Account Locking**: Exponential backoff on failed login attempts
|
- To bypass entirely in dev, set `DISABLE_CSRF=true` (middleware checks `devAuthConfig.disableCsrf`).
|
||||||
4. **Extensive Audit Logging**: Every auth event logged with full context
|
|
||||||
5. **Multiple Auth Strategies**: JWT + Local + Session management
|
|
||||||
6. **Complex Error Handling**: Secure error mapping to prevent information leakage
|
|
||||||
|
|
||||||
### 4. Simplified Development Flow
|
## Rate Limiting in Development
|
||||||
|
|
||||||
With the new configuration:
|
- `AuthRateLimitService` uses Redis; if Redis isn’t running, set `DISABLE_RATE_LIMIT=true` to bypass guards.
|
||||||
|
- Login, signup, password reset, and refresh flows now go through this centralized service.
|
||||||
|
|
||||||
1. **CSRF tokens are automatically handled** by the frontend API client
|
## Common Troubleshooting
|
||||||
2. **Development bypasses** can be enabled via environment variables
|
|
||||||
3. **Better error messages** in development mode
|
|
||||||
4. **Exempt paths** include all necessary auth endpoints
|
|
||||||
|
|
||||||
### 5. Production Security Maintained
|
1. **CSRF mismatch** – ensure `csrf-secret` cookie and header are both present. Use `/api/security/csrf/token` if you need to fetch manually.
|
||||||
|
2. **Rate limit hits** – check headers `X-RateLimit-Remaining` or disable via env flag.
|
||||||
|
3. **Redis not running** – dev scripts (`pnpm dev:start`) spin up Redis; otherwise set bypass flags.
|
||||||
|
4. **Cookie issues** – confirm `CORS_ORIGIN` matches portal dev origin and browser sends cookies (`credentials: 'include'`).
|
||||||
|
|
||||||
All security features remain intact for production:
|
## Production Safeguards
|
||||||
- Set `DISABLE_CSRF=false` or remove the variable
|
|
||||||
- Set `DISABLE_RATE_LIMIT=false` or remove the variable
|
|
||||||
- Set `DISABLE_ACCOUNT_LOCKING=false` or remove the variable
|
|
||||||
|
|
||||||
### 6. Testing the Fix
|
- Remove/bypass flags (`DISABLE_*`) in staging/production.
|
||||||
|
- Ensure `CSRF_SECRET_KEY` is set in production so tokens are signed with stable key.
|
||||||
|
- Tune rate limits via env values (`LOGIN_RATE_LIMIT_LIMIT`, etc.).
|
||||||
|
|
||||||
1. Restart your backend server
|
## Quick Validation Steps
|
||||||
2. Try logging in through the frontend
|
|
||||||
3. Check the browser network tab to see requests going to `/api/auth/login`
|
|
||||||
4. Check server logs for CSRF bypass messages (if debug enabled)
|
|
||||||
|
|
||||||
### 6.1. Core API Configuration Fix - The Real Issue
|
1. Start Redis/Postgres via `pnpm dev:start`.
|
||||||
|
2. Launch BFF and portal (`pnpm --filter @customer-portal/bff dev`, `pnpm --filter @customer-portal/portal dev`).
|
||||||
|
3. In browser, verify CSRF token is returned on first GET (check `X-CSRF-Token` response header).
|
||||||
|
4. Attempt login/signup; inspect console for clear warnings if rate limit triggers.
|
||||||
|
|
||||||
**Root Cause**: The frontend code was inconsistent with OpenAPI generated types.
|
## Reference Documentation
|
||||||
|
|
||||||
- **OpenAPI Types**: Expect paths like `/api/auth/login` (with `/api` prefix)
|
- `docs/AUTH-MODULE-ARCHITECTURE.md` – full module layout.
|
||||||
- **Frontend Code**: Was calling paths like `/auth/login` (without `/api` prefix)
|
- `docs/STRUCTURE.md` – high-level repo map.
|
||||||
- **Workaround**: `normalizePath` function was adding `/api` prefix automatically
|
- `docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md` – deep dive on refresh token storage.
|
||||||
|
|
||||||
**Proper Fix Applied**:
|
|
||||||
1. **Removed Path Normalization**: Eliminated the `normalizePath` function entirely
|
|
||||||
2. **Fixed Frontend Calls**: Updated all API calls to use correct OpenAPI paths with `/api` prefix
|
|
||||||
3. **Base URL Correction**: Set base URL to `http://localhost:4000` (without `/api`)
|
|
||||||
4. **Router Configuration**: Added SecurityModule to API routes
|
|
||||||
|
|
||||||
**Result**: Clean, consistent API calls that match the OpenAPI specification exactly.
|
|
||||||
|
|
||||||
### 7. If Issues Persist
|
|
||||||
|
|
||||||
Check these common issues:
|
|
||||||
|
|
||||||
1. **CORS Configuration**: Ensure `CORS_ORIGIN` matches your frontend URL
|
|
||||||
2. **Cookie Settings**: Ensure cookies are being set and sent properly
|
|
||||||
3. **Network Tab**: Check if CSRF token requests are successful
|
|
||||||
4. **Server Logs**: Look for detailed error messages with the new logging
|
|
||||||
|
|
||||||
The authentication system is now much more developer-friendly while maintaining production security standards.
|
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { APP_FILTER, APP_PIPE } from "@nestjs/core";
|
||||||
import { RouterModule } from "@nestjs/core";
|
import { RouterModule } from "@nestjs/core";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
|
import { ZodValidationPipe } from "nestjs-zod";
|
||||||
|
import { ZodValidationExceptionFilter } from "@bff/core/validation";
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
import { appConfig } from "@bff/core/config/app.config";
|
import { appConfig } from "@bff/core/config/app.config";
|
||||||
@ -87,6 +90,15 @@ import { HealthModule } from "@bff/modules/health/health.module";
|
|||||||
// === ROUTING ===
|
// === ROUTING ===
|
||||||
RouterModule.register(apiRoutes),
|
RouterModule.register(apiRoutes),
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_PIPE,
|
||||||
|
useClass: ZodValidationPipe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: ZodValidationExceptionFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { type INestApplication, ValidationPipe } from "@nestjs/common";
|
import { type INestApplication } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||||
@ -117,22 +117,6 @@ export async function bootstrap(): Promise<INestApplication> {
|
|||||||
maxAge: 86400, // 24 hours
|
maxAge: 86400, // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global validation pipe with enhanced security
|
|
||||||
const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true";
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
transform: true,
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
forbidUnknownValues: true,
|
|
||||||
disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production",
|
|
||||||
validationError: {
|
|
||||||
target: false,
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global exception filters
|
// Global exception filters
|
||||||
app.useGlobalFilters(
|
app.useGlobalFilters(
|
||||||
new AuthErrorFilter(app.get(Logger)), // Handle auth errors first
|
new AuthErrorFilter(app.get(Logger)), // Handle auth errors first
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { TransactionService, TransactionContext } from "./transaction.service";
|
import { TransactionService, type TransactionOperation } from "./transaction.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
export interface DistributedStep {
|
export interface DistributedStep<TId extends string = string, TResult = unknown> {
|
||||||
id: string;
|
id: TId;
|
||||||
description: string;
|
description: string;
|
||||||
execute: () => Promise<any>;
|
execute: () => Promise<TResult>;
|
||||||
rollback?: () => Promise<void>;
|
rollback?: () => Promise<void>;
|
||||||
critical?: boolean; // If true, failure stops entire transaction
|
critical?: boolean; // If true, failure stops entire transaction
|
||||||
retryable?: boolean; // If true, step can be retried on failure
|
retryable?: boolean; // If true, step can be retried on failure
|
||||||
@ -19,17 +19,27 @@ export interface DistributedTransactionOptions {
|
|||||||
continueOnNonCriticalFailure?: boolean;
|
continueOnNonCriticalFailure?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributedTransactionResult<T = any> {
|
export interface DistributedTransactionResult<
|
||||||
|
TData = unknown,
|
||||||
|
TStepResults extends StepResultMap = StepResultMap,
|
||||||
|
> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: TData;
|
||||||
error?: string;
|
error?: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
stepsExecuted: number;
|
stepsExecuted: number;
|
||||||
stepsRolledBack: number;
|
stepsRolledBack: number;
|
||||||
stepResults: Record<string, any>;
|
stepResults: TStepResults;
|
||||||
failedSteps: string[];
|
failedSteps: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StepResultMap<TSteps extends readonly DistributedStep[] = readonly DistributedStep[]> =
|
||||||
|
{
|
||||||
|
[K in TSteps[number]["id"]]?: Awaited<
|
||||||
|
ReturnType<Extract<TSteps[number], { id: K }>["execute"]>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing distributed transactions across multiple external systems
|
* Service for managing distributed transactions across multiple external systems
|
||||||
* Provides coordination between database operations and external API calls
|
* Provides coordination between database operations and external API calls
|
||||||
@ -83,14 +93,17 @@ export class DistributedTransactionService {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async executeDistributedTransaction(
|
async executeDistributedTransaction<
|
||||||
steps: DistributedStep[],
|
TSteps extends readonly DistributedStep[],
|
||||||
|
TStepResults extends StepResultMap<TSteps> = StepResultMap<TSteps>,
|
||||||
|
>(
|
||||||
|
steps: TSteps,
|
||||||
options: DistributedTransactionOptions
|
options: DistributedTransactionOptions
|
||||||
): Promise<DistributedTransactionResult> {
|
): Promise<DistributedTransactionResult<TStepResults>> {
|
||||||
const {
|
const {
|
||||||
description,
|
description,
|
||||||
timeout = 120000, // 2 minutes default for distributed operations
|
timeout = 120000, // 2 minutes default for distributed operations
|
||||||
maxRetries = 1, // Less retries for distributed operations
|
maxRetries: _maxRetries = 1, // Less retries for distributed operations
|
||||||
continueOnNonCriticalFailure = false,
|
continueOnNonCriticalFailure = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@ -103,10 +116,9 @@ export class DistributedTransactionService {
|
|||||||
timeout,
|
timeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stepResults: Record<string, any> = {};
|
const stepResults: StepResultMap<TSteps> = {};
|
||||||
const executedSteps: string[] = [];
|
const executedSteps: string[] = [];
|
||||||
const failedSteps: string[] = [];
|
const failedSteps: string[] = [];
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute steps sequentially
|
// Execute steps sequentially
|
||||||
@ -128,11 +140,12 @@ export class DistributedTransactionService {
|
|||||||
duration: stepDuration,
|
duration: stepDuration,
|
||||||
});
|
});
|
||||||
} catch (stepError) {
|
} catch (stepError) {
|
||||||
lastError = stepError as Error;
|
const errorInstance =
|
||||||
|
stepError instanceof Error ? stepError : new Error(String(stepError));
|
||||||
failedSteps.push(step.id);
|
failedSteps.push(step.id);
|
||||||
|
|
||||||
this.logger.error(`Step failed: ${step.id} [${transactionId}]`, {
|
this.logger.error(`Step failed: ${step.id} [${transactionId}]`, {
|
||||||
error: getErrorMessage(stepError),
|
error: getErrorMessage(errorInstance),
|
||||||
critical: step.critical,
|
critical: step.critical,
|
||||||
retryable: step.retryable,
|
retryable: step.retryable,
|
||||||
});
|
});
|
||||||
@ -165,13 +178,13 @@ export class DistributedTransactionService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: stepResults,
|
data: stepResults as TStepResults,
|
||||||
duration,
|
duration,
|
||||||
stepsExecuted: executedSteps.length,
|
stepsExecuted: executedSteps.length,
|
||||||
stepsRolledBack: 0,
|
stepsRolledBack: 0,
|
||||||
stepResults,
|
stepResults: stepResults as TStepResults,
|
||||||
failedSteps,
|
failedSteps,
|
||||||
};
|
} satisfies DistributedTransactionResult<TStepResults>;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
@ -197,23 +210,27 @@ export class DistributedTransactionService {
|
|||||||
duration,
|
duration,
|
||||||
stepsExecuted: executedSteps.length,
|
stepsExecuted: executedSteps.length,
|
||||||
stepsRolledBack: rollbacksExecuted,
|
stepsRolledBack: rollbacksExecuted,
|
||||||
stepResults,
|
stepResults: stepResults as TStepResults,
|
||||||
failedSteps,
|
failedSteps,
|
||||||
};
|
} satisfies DistributedTransactionResult<TStepResults>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a hybrid transaction that combines database operations with external system calls
|
* Execute a hybrid transaction that combines database operations with external system calls
|
||||||
*/
|
*/
|
||||||
async executeHybridTransaction<T>(
|
async executeHybridTransaction<
|
||||||
databaseOperation: (tx: any, context: TransactionContext) => Promise<T>,
|
TDatabaseResult,
|
||||||
externalSteps: DistributedStep[],
|
TSteps extends readonly DistributedStep[],
|
||||||
|
TStepResults extends StepResultMap<TSteps> = StepResultMap<TSteps>,
|
||||||
|
>(
|
||||||
|
databaseOperation: TransactionOperation<TDatabaseResult>,
|
||||||
|
externalSteps: TSteps,
|
||||||
options: DistributedTransactionOptions & {
|
options: DistributedTransactionOptions & {
|
||||||
databaseFirst?: boolean;
|
databaseFirst?: boolean;
|
||||||
rollbackDatabaseOnExternalFailure?: boolean;
|
rollbackDatabaseOnExternalFailure?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<DistributedTransactionResult<T>> {
|
): Promise<DistributedTransactionResult<TDatabaseResult, TStepResults>> {
|
||||||
const {
|
const {
|
||||||
databaseFirst = true,
|
databaseFirst = true,
|
||||||
rollbackDatabaseOnExternalFailure = true,
|
rollbackDatabaseOnExternalFailure = true,
|
||||||
@ -230,8 +247,8 @@ export class DistributedTransactionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let databaseResult: T | null = null;
|
let databaseResult: TDatabaseResult | null = null;
|
||||||
let externalResult: DistributedTransactionResult | null = null;
|
let externalResult: DistributedTransactionResult<TStepResults> | null = null;
|
||||||
|
|
||||||
if (databaseFirst) {
|
if (databaseFirst) {
|
||||||
// Execute database operations first
|
// Execute database operations first
|
||||||
@ -252,10 +269,13 @@ export class DistributedTransactionService {
|
|||||||
|
|
||||||
// Execute external operations
|
// Execute external operations
|
||||||
this.logger.debug(`Executing external operations [${transactionId}]`);
|
this.logger.debug(`Executing external operations [${transactionId}]`);
|
||||||
externalResult = await this.executeDistributedTransaction(externalSteps, {
|
externalResult = await this.executeDistributedTransaction<TSteps, TStepResults>(
|
||||||
|
externalSteps,
|
||||||
|
{
|
||||||
...distributedOptions,
|
...distributedOptions,
|
||||||
description: distributedOptions.description || "External operations",
|
description: distributedOptions.description || "External operations",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!externalResult.success && rollbackDatabaseOnExternalFailure) {
|
if (!externalResult.success && rollbackDatabaseOnExternalFailure) {
|
||||||
// Note: Database transaction already committed, so we can't rollback automatically
|
// Note: Database transaction already committed, so we can't rollback automatically
|
||||||
@ -270,10 +290,13 @@ export class DistributedTransactionService {
|
|||||||
} else {
|
} else {
|
||||||
// Execute external operations first
|
// Execute external operations first
|
||||||
this.logger.debug(`Executing external operations [${transactionId}]`);
|
this.logger.debug(`Executing external operations [${transactionId}]`);
|
||||||
externalResult = await this.executeDistributedTransaction(externalSteps, {
|
externalResult = await this.executeDistributedTransaction<TSteps, TStepResults>(
|
||||||
|
externalSteps,
|
||||||
|
{
|
||||||
...distributedOptions,
|
...distributedOptions,
|
||||||
description: distributedOptions.description || "External operations",
|
description: distributedOptions.description || "External operations",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!externalResult.success) {
|
if (!externalResult.success) {
|
||||||
throw new Error(externalResult.error || "External operations failed");
|
throw new Error(externalResult.error || "External operations failed");
|
||||||
@ -316,7 +339,7 @@ export class DistributedTransactionService {
|
|||||||
duration,
|
duration,
|
||||||
stepsExecuted: externalResult?.stepsExecuted || 0,
|
stepsExecuted: externalResult?.stepsExecuted || 0,
|
||||||
stepsRolledBack: 0,
|
stepsRolledBack: 0,
|
||||||
stepResults: externalResult?.stepResults || {},
|
stepResults: (externalResult?.stepResults ?? {}) as TStepResults,
|
||||||
failedSteps: externalResult?.failedSteps || [],
|
failedSteps: externalResult?.failedSteps || [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -340,21 +363,24 @@ export class DistributedTransactionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeStepWithTimeout(step: DistributedStep, timeout: number): Promise<any> {
|
private async executeStepWithTimeout<TResult>(
|
||||||
return Promise.race([
|
step: DistributedStep<string, TResult>,
|
||||||
|
timeout: number
|
||||||
|
): Promise<TResult> {
|
||||||
|
return (await Promise.race([
|
||||||
step.execute(),
|
step.execute(),
|
||||||
new Promise((_, reject) => {
|
new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reject(new Error(`Step ${step.id} timed out after ${timeout}ms`));
|
reject(new Error(`Step ${step.id} timed out after ${timeout}ms`));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
}),
|
}),
|
||||||
]);
|
])) as TResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeRollbacks(
|
private async executeRollbacks<TSteps extends readonly DistributedStep[]>(
|
||||||
steps: DistributedStep[],
|
steps: TSteps,
|
||||||
executedSteps: string[],
|
executedSteps: string[],
|
||||||
stepResults: Record<string, any>,
|
_stepResults: Partial<StepResultMap<TSteps>>,
|
||||||
transactionId: string
|
transactionId: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
this.logger.warn(`Executing rollbacks for ${executedSteps.length} steps [${transactionId}]`);
|
this.logger.warn(`Executing rollbacks for ${executedSteps.length} steps [${transactionId}]`);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
@ -10,6 +11,18 @@ export interface TransactionContext {
|
|||||||
rollbackActions: (() => Promise<void>)[];
|
rollbackActions: (() => Promise<void>)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionContextHelpers {
|
||||||
|
addOperation: (description: string) => void;
|
||||||
|
addRollback: (rollbackFn: () => Promise<void>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransactionOperation<T> = (
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
context: TransactionContext & TransactionContextHelpers
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export type SimpleTransactionOperation<T> = (tx: Prisma.TransactionClient) => Promise<T>;
|
||||||
|
|
||||||
export interface TransactionOptions {
|
export interface TransactionOptions {
|
||||||
/**
|
/**
|
||||||
* Maximum time to wait for transaction to complete (ms)
|
* Maximum time to wait for transaction to complete (ms)
|
||||||
@ -96,7 +109,7 @@ export class TransactionService {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async executeTransaction<T>(
|
async executeTransaction<T>(
|
||||||
operation: (tx: any, context: TransactionContext) => Promise<T>,
|
operation: TransactionOperation<T>,
|
||||||
options: TransactionOptions = {}
|
options: TransactionOptions = {}
|
||||||
): Promise<TransactionResult<T>> {
|
): Promise<TransactionResult<T>> {
|
||||||
const {
|
const {
|
||||||
@ -207,7 +220,7 @@ export class TransactionService {
|
|||||||
* Execute a simple database-only transaction (no external operations)
|
* Execute a simple database-only transaction (no external operations)
|
||||||
*/
|
*/
|
||||||
async executeSimpleTransaction<T>(
|
async executeSimpleTransaction<T>(
|
||||||
operation: (tx: any) => Promise<T>,
|
operation: SimpleTransactionOperation<T>,
|
||||||
options: Omit<TransactionOptions, "autoRollback"> = {}
|
options: Omit<TransactionOptions, "autoRollback"> = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const result = await this.executeTransaction(async (tx, _context) => operation(tx), {
|
const result = await this.executeTransaction(async (tx, _context) => operation(tx), {
|
||||||
@ -223,9 +236,9 @@ export class TransactionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeTransactionAttempt<T>(
|
private async executeTransactionAttempt<T>(
|
||||||
operation: (tx: any, context: TransactionContext) => Promise<T>,
|
operation: TransactionOperation<T>,
|
||||||
context: TransactionContext,
|
context: TransactionContext,
|
||||||
isolationLevel: string
|
isolationLevel: Prisma.TransactionIsolationLevel
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return await this.prisma.$transaction(
|
return await this.prisma.$transaction(
|
||||||
async tx => {
|
async tx => {
|
||||||
@ -236,25 +249,23 @@ export class TransactionService {
|
|||||||
return await operation(tx, enhancedContext);
|
return await operation(tx, enhancedContext);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isolationLevel: isolationLevel as any,
|
isolationLevel,
|
||||||
timeout: 30000, // Prisma transaction timeout
|
timeout: 30000, // Prisma transaction timeout
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private enhanceContext(context: TransactionContext): TransactionContext {
|
private enhanceContext(context: TransactionContext): TransactionContext {
|
||||||
return {
|
const helpers: TransactionContextHelpers = {
|
||||||
...context,
|
|
||||||
addOperation: (description: string) => {
|
addOperation: (description: string) => {
|
||||||
context.operations.push(`${new Date().toISOString()}: ${description}`);
|
context.operations.push(`${new Date().toISOString()}: ${description}`);
|
||||||
},
|
},
|
||||||
addRollback: (rollbackFn: () => Promise<void>) => {
|
addRollback: (rollbackFn: () => Promise<void>) => {
|
||||||
context.rollbackActions.push(rollbackFn);
|
context.rollbackActions.push(rollbackFn);
|
||||||
},
|
},
|
||||||
} as TransactionContext & {
|
|
||||||
addOperation: (description: string) => void;
|
|
||||||
addRollback: (rollbackFn: () => Promise<void>) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return Object.assign(context, helpers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeRollbacks(
|
private async executeRollbacks(
|
||||||
@ -323,13 +334,17 @@ export class TransactionService {
|
|||||||
/**
|
/**
|
||||||
* Get transaction statistics for monitoring
|
* Get transaction statistics for monitoring
|
||||||
*/
|
*/
|
||||||
async getTransactionStats() {
|
async getTransactionStats(): Promise<{
|
||||||
// This could be enhanced with metrics collection
|
activeTransactions: number;
|
||||||
return {
|
totalTransactions: number;
|
||||||
activeTransactions: 0, // Would need to track active transactions
|
successRate: number;
|
||||||
totalTransactions: 0, // Would need to track total count
|
averageDuration: number;
|
||||||
successRate: 0, // Would need to track success/failure rates
|
}> {
|
||||||
averageDuration: 0, // Would need to track durations
|
return await Promise.resolve({
|
||||||
};
|
activeTransactions: 0,
|
||||||
|
totalTransactions: 0,
|
||||||
|
successRate: 0,
|
||||||
|
averageDuration: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,28 +6,26 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { SecureErrorMapperService } from "../security/services/secure-error-mapper.service";
|
import { SecureErrorMapperService } from "../security/services/secure-error-mapper.service";
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly secureErrorMapper: SecureErrorMapperService
|
private readonly secureErrorMapper: SecureErrorMapperService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request & { user?: { id?: string }; requestId?: string }>();
|
||||||
|
|
||||||
// Create error context for secure mapping
|
// Create error context for secure mapping
|
||||||
const errorContext = {
|
const errorContext = {
|
||||||
userId: (request as any).user?.id,
|
userId: request.user?.id,
|
||||||
requestId: (request as any).requestId || this.generateRequestId(),
|
requestId: request.requestId || this.generateRequestId(),
|
||||||
userAgent: request.get("user-agent"),
|
userAgent: request.get("user-agent"),
|
||||||
ip: request.ip,
|
ip: request.ip,
|
||||||
url: request.url,
|
url: request.url,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
|
||||||
export interface SalesforceQueueMetrics {
|
export interface SalesforceQueueMetrics {
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
@ -41,8 +42,8 @@ export interface SalesforceRequestOptions {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||||
private standardQueue: any = null;
|
private standardQueue: PQueue | null = null;
|
||||||
private longRunningQueue: any = null;
|
private longRunningQueue: PQueue | null = null;
|
||||||
private readonly metrics: SalesforceQueueMetrics = {
|
private readonly metrics: SalesforceQueueMetrics = {
|
||||||
totalRequests: 0,
|
totalRequests: 0,
|
||||||
completedRequests: 0,
|
completedRequests: 0,
|
||||||
@ -68,8 +69,6 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
|
|
||||||
private async initializeQueues() {
|
private async initializeQueues() {
|
||||||
if (!this.standardQueue || !this.longRunningQueue) {
|
if (!this.standardQueue || !this.longRunningQueue) {
|
||||||
const { default: PQueue } = await import("p-queue");
|
|
||||||
|
|
||||||
const concurrency = this.configService.get<number>("SF_QUEUE_CONCURRENCY", 15);
|
const concurrency = this.configService.get<number>("SF_QUEUE_CONCURRENCY", 15);
|
||||||
const longRunningConcurrency = this.configService.get<number>(
|
const longRunningConcurrency = this.configService.get<number>(
|
||||||
"SF_QUEUE_LONG_RUNNING_CONCURRENCY",
|
"SF_QUEUE_LONG_RUNNING_CONCURRENCY",
|
||||||
@ -231,7 +230,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
priority: options.priority || 0,
|
priority: options.priority ?? 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -420,7 +419,10 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupQueueListeners(): void {
|
private setupQueueListeners(): void {
|
||||||
// Standard queue listeners
|
if (!this.standardQueue || !this.longRunningQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.standardQueue.on("add", () => this.updateQueueMetrics());
|
this.standardQueue.on("add", () => this.updateQueueMetrics());
|
||||||
this.standardQueue.on("next", () => this.updateQueueMetrics());
|
this.standardQueue.on("next", () => this.updateQueueMetrics());
|
||||||
this.standardQueue.on("idle", () => {
|
this.standardQueue.on("idle", () => {
|
||||||
@ -433,7 +435,6 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Long-running queue listeners
|
|
||||||
this.longRunningQueue.on("add", () => this.updateQueueMetrics());
|
this.longRunningQueue.on("add", () => this.updateQueueMetrics());
|
||||||
this.longRunningQueue.on("next", () => this.updateQueueMetrics());
|
this.longRunningQueue.on("next", () => this.updateQueueMetrics());
|
||||||
this.longRunningQueue.on("idle", () => {
|
this.longRunningQueue.on("idle", () => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
|
||||||
export interface WhmcsQueueMetrics {
|
export interface WhmcsQueueMetrics {
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
@ -37,7 +38,7 @@ export interface WhmcsRequestOptions {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||||
private queue: any = null;
|
private queue: PQueue | null = null;
|
||||||
private readonly metrics: WhmcsQueueMetrics = {
|
private readonly metrics: WhmcsQueueMetrics = {
|
||||||
totalRequests: 0,
|
totalRequests: 0,
|
||||||
completedRequests: 0,
|
completedRequests: 0,
|
||||||
@ -59,8 +60,6 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private async initializeQueue() {
|
private async initializeQueue() {
|
||||||
if (!this.queue) {
|
if (!this.queue) {
|
||||||
const { default: PQueue } = await import("p-queue");
|
|
||||||
|
|
||||||
const concurrency = this.configService.get<number>("WHMCS_QUEUE_CONCURRENCY", 15);
|
const concurrency = this.configService.get<number>("WHMCS_QUEUE_CONCURRENCY", 15);
|
||||||
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
|
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
|
||||||
const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000);
|
const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000);
|
||||||
@ -95,13 +94,13 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
this.logger.log("Shutting down WHMCS Request Queue", {
|
this.logger.log("Shutting down WHMCS Request Queue", {
|
||||||
pendingRequests: this.queue.pending,
|
pendingRequests: this.queue?.pending ?? 0,
|
||||||
queueSize: this.queue.size,
|
queueSize: this.queue?.size ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for pending requests to complete (with timeout)
|
// Wait for pending requests to complete (with timeout)
|
||||||
try {
|
try {
|
||||||
await this.queue.onIdle();
|
await this.queue?.onIdle();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn("Some WHMCS requests may not have completed during shutdown", {
|
this.logger.warn("Some WHMCS requests may not have completed during shutdown", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@ -172,7 +171,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
priority: options.priority || 0,
|
priority: options.priority ?? 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -238,6 +237,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Clear the queue (emergency use only)
|
* Clear the queue (emergency use only)
|
||||||
*/
|
*/
|
||||||
async clearQueue(): Promise<void> {
|
async clearQueue(): Promise<void> {
|
||||||
|
if (!this.queue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.warn("Clearing WHMCS request queue", {
|
this.logger.warn("Clearing WHMCS request queue", {
|
||||||
queueSize: this.queue.size,
|
queueSize: this.queue.size,
|
||||||
pendingRequests: this.queue.pending,
|
pendingRequests: this.queue.pending,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { CsrfService } from "../services/csrf.service";
|
|||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: { id: string; sessionId?: string };
|
user?: { id: string; sessionId?: string };
|
||||||
|
sessionID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags("Security")
|
@ApiTags("Security")
|
||||||
@ -159,8 +160,6 @@ export class CsrfController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractSessionId(req: AuthenticatedRequest): string | null {
|
private extractSessionId(req: AuthenticatedRequest): string | null {
|
||||||
return (
|
return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null;
|
||||||
req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || (req as any).sessionID || null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,5 @@
|
|||||||
* Consolidated validation patterns using nestjs-zod
|
* Consolidated validation patterns using nestjs-zod
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ZodValidationPipe, createZodDto } from "nestjs-zod";
|
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
|
||||||
|
export { ZodValidationExceptionFilter } from "./zod-validation.filter";
|
||||||
// ✅ RECOMMENDED: Only re-export what's needed
|
|
||||||
export { ZodValidationPipe, createZodDto };
|
|
||||||
|
|
||||||
// 📝 USAGE GUIDELINES:
|
|
||||||
// 1. For request validation: Use global ZodValidationPipe (configured in bootstrap.ts)
|
|
||||||
// 2. For DTOs: Use createZodDto(schema) for OpenAPI generation
|
|
||||||
// 3. For business logic: Use schema.safeParse() directly in services
|
|
||||||
// 4. For return types: Use domain types directly, not DTOs
|
|
||||||
|
|||||||
45
apps/bff/src/core/validation/zod-validation.filter.ts
Normal file
45
apps/bff/src/core/validation/zod-validation.filter.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Inject } from "@nestjs/common";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ZodValidationException } from "nestjs-zod";
|
||||||
|
|
||||||
|
interface ZodIssueResponse {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Catch(ZodValidationException)
|
||||||
|
export class ZodValidationExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||||
|
|
||||||
|
catch(exception: ZodValidationException, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const zodError = exception.getZodError();
|
||||||
|
const issues: ZodIssueResponse[] = zodError.issues.map(issue => ({
|
||||||
|
path: issue.path.join(".") || "root",
|
||||||
|
message: issue.message,
|
||||||
|
code: issue.code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.logger.warn("Request validation failed", {
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_FAILED",
|
||||||
|
message: "Request validation failed",
|
||||||
|
details: issues,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,11 +26,6 @@ export class SubscriptionTransformerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine pricing amounts early so we can infer one-time fees reliably
|
|
||||||
const recurringAmount = DataUtils.parseAmount(whmcsProduct.recurringamount);
|
|
||||||
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
|
||||||
|
|
||||||
// Normalize billing cycle from WHMCS and apply safety overrides
|
|
||||||
const billingCycle = this.mapBillingCycle(whmcsProduct.billingcycle);
|
const billingCycle = this.mapBillingCycle(whmcsProduct.billingcycle);
|
||||||
|
|
||||||
// Use WHMCS system default currency
|
// Use WHMCS system default currency
|
||||||
@ -87,14 +82,8 @@ export class SubscriptionTransformerService {
|
|||||||
* Get the appropriate amount for a product (recurring vs first payment)
|
* Get the appropriate amount for a product (recurring vs first payment)
|
||||||
*/
|
*/
|
||||||
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
||||||
const recurringAmount = DataUtils.parseAmount(whmcsProduct.recurringamount);
|
const recurring = DataUtils.parseAmount(whmcsProduct.recurringamount);
|
||||||
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
return recurring > 0 ? recurring : DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
||||||
|
|
||||||
if (recurringAmount > 0) {
|
|
||||||
return recurringAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstPaymentAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
|
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
|
||||||
@ -21,15 +20,16 @@ import {
|
|||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
import { AuthTokenService } from "./services/token.service";
|
import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service";
|
||||||
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service";
|
||||||
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service";
|
||||||
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
import { SignupWorkflowService } from "@bff/modules/auth/infra/workflows/signup-workflow.service";
|
||||||
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
import { PasswordWorkflowService } from "@bff/modules/auth/infra/workflows/password-workflow.service";
|
||||||
|
import { WhmcsLinkWorkflowService } from "@bff/modules/auth/infra/workflows/whmcs-link-workflow.service";
|
||||||
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
|
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthFacade {
|
||||||
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
||||||
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagg
|
|||||||
import { AdminGuard } from "./guards/admin.guard";
|
import { AdminGuard } from "./guards/admin.guard";
|
||||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||||
import { UsersService } from "@bff/modules/users/users.service";
|
import { UsersService } from "@bff/modules/users/users.service";
|
||||||
import { TokenMigrationService } from "./services/token-migration.service";
|
import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.service";
|
||||||
|
|
||||||
@ApiTags("auth-admin")
|
@ApiTags("auth-admin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -3,25 +3,25 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthFacade } from "./application/auth.facade";
|
||||||
import { AuthController } from "./auth-zod.controller";
|
import { AuthController } from "./presentation/http/auth.controller";
|
||||||
import { AuthAdminController } from "./auth-admin.controller";
|
import { AuthAdminController } from "./auth-admin.controller";
|
||||||
import { UsersModule } from "@bff/modules/users/users.module";
|
import { UsersModule } from "@bff/modules/users/users.module";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||||
import { JwtStrategy } from "./strategies/jwt.strategy";
|
import { JwtStrategy } from "./presentation/strategies/jwt.strategy";
|
||||||
import { LocalStrategy } from "./strategies/local.strategy";
|
import { LocalStrategy } from "./presentation/strategies/local.strategy";
|
||||||
import { GlobalAuthGuard } from "./guards/global-auth.guard";
|
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard";
|
||||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
import { TokenBlacklistService } from "./infra/token/token-blacklist.service";
|
||||||
import { EmailModule } from "@bff/infra/email/email.module";
|
import { EmailModule } from "@bff/infra/email/email.module";
|
||||||
import { AuthTokenService } from "./services/token.service";
|
import { AuthTokenService } from "./infra/token/token.service";
|
||||||
import { TokenMigrationService } from "./services/token-migration.service";
|
import { TokenMigrationService } from "./infra/token/token-migration.service";
|
||||||
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service";
|
||||||
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service";
|
||||||
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service";
|
||||||
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard";
|
||||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor";
|
||||||
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -40,7 +40,7 @@ import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
|||||||
],
|
],
|
||||||
controllers: [AuthController, AuthAdminController],
|
controllers: [AuthController, AuthAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthFacade,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
LocalStrategy,
|
LocalStrategy,
|
||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
@ -57,6 +57,6 @@ import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
|||||||
useClass: GlobalAuthGuard,
|
useClass: GlobalAuthGuard,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [AuthService, TokenBlacklistService, AuthTokenService, TokenMigrationService],
|
exports: [AuthFacade, TokenBlacklistService, AuthTokenService, TokenMigrationService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthFacade } from "@bff/modules/auth/application/auth.facade";
|
||||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||||
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
||||||
@ -81,7 +81,7 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
|||||||
@ApiTags("auth")
|
@ApiTags("auth")
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authFacade: AuthFacade) {}
|
||||||
|
|
||||||
private setAuthCookies(res: Response, tokens: AuthTokens): void {
|
private setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||||
@ -113,7 +113,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 400, description: "Customer number not found" })
|
@ApiResponse({ status: 400, description: "Customer number not found" })
|
||||||
@ApiResponse({ status: 429, description: "Too many validation attempts" })
|
@ApiResponse({ status: 429, description: "Too many validation attempts" })
|
||||||
async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) {
|
async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) {
|
||||||
return this.authService.validateSignup(validateData, req);
|
return this.authFacade.validateSignup(validateData, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -121,7 +121,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Check auth service health and integrations" })
|
@ApiOperation({ summary: "Check auth service health and integrations" })
|
||||||
@ApiResponse({ status: 200, description: "Health check results" })
|
@ApiResponse({ status: 200, description: "Health check results" })
|
||||||
async healthCheck() {
|
async healthCheck() {
|
||||||
return this.authService.healthCheck();
|
return this.authFacade.healthCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -133,7 +133,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Validate full signup data without creating anything" })
|
@ApiOperation({ summary: "Validate full signup data without creating anything" })
|
||||||
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
|
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
|
||||||
async signupPreflight(@Body() signupData: SignupRequestInput) {
|
async signupPreflight(@Body() signupData: SignupRequestInput) {
|
||||||
return this.authService.signupPreflight(signupData);
|
return this.authFacade.signupPreflight(signupData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -142,7 +142,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Get account status by email" })
|
@ApiOperation({ summary: "Get account status by email" })
|
||||||
@ApiOkResponse({ description: "Account status" })
|
@ApiOkResponse({ description: "Account status" })
|
||||||
async accountStatus(@Body() body: AccountStatusRequestInput) {
|
async accountStatus(@Body() body: AccountStatusRequestInput) {
|
||||||
return this.authService.getAccountStatus(body.email);
|
return this.authFacade.getAccountStatus(body.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -159,7 +159,7 @@ export class AuthController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.signup(signupData, req);
|
const result = await this.authFacade.signup(signupData, req);
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -176,7 +176,7 @@ export class AuthController {
|
|||||||
@Req() req: Request & { user: { id: string; email: string; role: string } },
|
@Req() req: Request & { user: { id: string; email: string; role: string } },
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.login(req.user, req);
|
const result = await this.authFacade.login(req.user, req);
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -189,7 +189,7 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const token = extractTokenFromRequest(req);
|
const token = extractTokenFromRequest(req);
|
||||||
await this.authService.logout(req.user.id, token, req);
|
await this.authFacade.logout(req.user.id, token, req);
|
||||||
this.clearAuthCookies(res);
|
this.clearAuthCookies(res);
|
||||||
return { message: "Logout successful" };
|
return { message: "Logout successful" };
|
||||||
}
|
}
|
||||||
@ -208,7 +208,7 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const refreshToken = body.refreshToken ?? req.cookies?.refresh_token;
|
const refreshToken = body.refreshToken ?? req.cookies?.refresh_token;
|
||||||
const result = await this.authService.refreshTokens(refreshToken, {
|
const result = await this.authFacade.refreshTokens(refreshToken, {
|
||||||
deviceId: body.deviceId,
|
deviceId: body.deviceId,
|
||||||
userAgent: req.headers["user-agent"],
|
userAgent: req.headers["user-agent"],
|
||||||
});
|
});
|
||||||
@ -229,7 +229,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
|
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
|
||||||
@ApiResponse({ status: 429, description: "Too many link attempts" })
|
@ApiResponse({ status: 429, description: "Too many link attempts" })
|
||||||
async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
|
async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
|
||||||
return this.authService.linkWhmcsUser(linkData);
|
return this.authFacade.linkWhmcsUser(linkData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -246,7 +246,7 @@ export class AuthController {
|
|||||||
@Req() _req: Request,
|
@Req() _req: Request,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.setPassword(setPasswordData);
|
const result = await this.authFacade.setPassword(setPasswordData);
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -258,7 +258,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Check if user needs to set password" })
|
@ApiOperation({ summary: "Check if user needs to set password" })
|
||||||
@ApiResponse({ status: 200, description: "Password status checked" })
|
@ApiResponse({ status: 200, description: "Password status checked" })
|
||||||
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) {
|
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) {
|
||||||
return this.authService.checkPasswordNeeded(data.email);
|
return this.authFacade.checkPasswordNeeded(data.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -268,7 +268,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Request password reset email" })
|
@ApiOperation({ summary: "Request password reset email" })
|
||||||
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
||||||
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
|
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
|
||||||
await this.authService.requestPasswordReset(body.email, req);
|
await this.authFacade.requestPasswordReset(body.email, req);
|
||||||
return { message: "If an account exists, a reset email has been sent" };
|
return { message: "If an account exists, a reset email has been sent" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,7 +279,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Reset password with token" })
|
@ApiOperation({ summary: "Reset password with token" })
|
||||||
@ApiResponse({ status: 200, description: "Password reset successful" })
|
@ApiResponse({ status: 200, description: "Password reset successful" })
|
||||||
async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) {
|
async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.authService.resetPassword(body.token, body.password);
|
const result = await this.authFacade.resetPassword(body.token, body.password);
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -294,7 +294,7 @@ export class AuthController {
|
|||||||
@Body() body: ChangePasswordRequestInput,
|
@Body() body: ChangePasswordRequestInput,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.changePassword(
|
const result = await this.authFacade.changePassword(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
body.currentPassword,
|
body.currentPassword,
|
||||||
body.newPassword,
|
body.newPassword,
|
||||||
@ -331,6 +331,6 @@ export class AuthController {
|
|||||||
@Body() body: SsoLinkRequestInput
|
@Body() body: SsoLinkRequestInput
|
||||||
) {
|
) {
|
||||||
const destination = body?.destination;
|
const destination = body?.destination;
|
||||||
return this.authService.createSsoLink(req.user.id, destination);
|
return this.authFacade.createSsoLink(req.user.id, destination);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { AuthRateLimitService } from "../services/auth-rate-limit.service";
|
import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FailedLoginThrottleGuard {
|
export class FailedLoginThrottleGuard {
|
||||||
@ -11,8 +11,8 @@ import { ExtractJwt } from "passport-jwt";
|
|||||||
|
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { TokenBlacklistService } from "../services/token-blacklist.service";
|
import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service";
|
||||||
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
import { IS_PUBLIC_KEY } from "@bff/modules/auth/decorators/public.decorator";
|
||||||
|
|
||||||
type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> };
|
type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> };
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Observable, throwError } from "rxjs";
|
import { Observable, throwError } from "rxjs";
|
||||||
import { tap, catchError } from "rxjs/operators";
|
import { tap, catchError } from "rxjs/operators";
|
||||||
import { FailedLoginThrottleGuard } from "../guards/failed-login-throttle.guard";
|
import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
2
apps/bff/src/modules/auth/presentation/index.ts
Normal file
2
apps/bff/src/modules/auth/presentation/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./http/auth.controller";
|
||||||
|
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { Strategy } from "passport-local";
|
import { Strategy } from "passport-local";
|
||||||
import { AuthService } from "../auth.service";
|
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { AuthFacade } from "@bff/modules/auth/application/auth.facade";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(private authService: AuthService) {
|
constructor(private authFacade: AuthFacade) {
|
||||||
super({ usernameField: "email", passReqToCallback: true });
|
super({ usernameField: "email", passReqToCallback: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<{ id: string; email: string; role: string }> {
|
): Promise<{ id: string; email: string; role: string }> {
|
||||||
const user = await this.authService.validateUser(email, password, req);
|
const user = await this.authFacade.validateUser(email, password, req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
}
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
UsePipes,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -23,6 +24,7 @@ import { createZodDto } from "nestjs-zod";
|
|||||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
|
import { ZodValidationPipe } from "@bff/core/validation";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Invoice,
|
Invoice,
|
||||||
@ -32,8 +34,13 @@ import type {
|
|||||||
PaymentMethodList,
|
PaymentMethodList,
|
||||||
PaymentGatewayList,
|
PaymentGatewayList,
|
||||||
InvoicePaymentLink,
|
InvoicePaymentLink,
|
||||||
|
InvoiceListQuery,
|
||||||
|
} from "@customer-portal/domain";
|
||||||
|
import {
|
||||||
|
invoiceSchema,
|
||||||
|
invoiceListSchema,
|
||||||
|
invoiceListQuerySchema,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { invoiceSchema, invoiceListSchema } from "@customer-portal/domain";
|
|
||||||
|
|
||||||
// ✅ CLEAN: DTOs only for OpenAPI generation
|
// ✅ CLEAN: DTOs only for OpenAPI generation
|
||||||
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
||||||
@ -81,35 +88,12 @@ export class InvoicesController {
|
|||||||
description: "List of invoices with pagination",
|
description: "List of invoices with pagination",
|
||||||
type: InvoiceListDto
|
type: InvoiceListDto
|
||||||
})
|
})
|
||||||
|
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
|
||||||
async getInvoices(
|
async getInvoices(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query("page") page?: string,
|
@Query() query: InvoiceListQuery
|
||||||
@Query("limit") limit?: string,
|
|
||||||
@Query("status") status?: string
|
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
const validStatuses = ["Paid", "Unpaid", "Overdue", "Cancelled", "Collections"] as const;
|
return this.invoicesService.getInvoices(req.user.id, query);
|
||||||
type InvoiceStatus = (typeof validStatuses)[number];
|
|
||||||
// Validate and sanitize input
|
|
||||||
const pageNum = this.validatePositiveInteger(page, 1, "page");
|
|
||||||
const limitNum = this.validatePositiveInteger(limit, 10, "limit");
|
|
||||||
|
|
||||||
// Limit max page size for performance
|
|
||||||
if (limitNum > 100) {
|
|
||||||
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate status if provided
|
|
||||||
if (status && !validStatuses.includes(status as InvoiceStatus)) {
|
|
||||||
throw new BadRequestException("Invalid status filter");
|
|
||||||
}
|
|
||||||
|
|
||||||
const typedStatus = status ? (status as InvoiceStatus) : undefined;
|
|
||||||
|
|
||||||
return this.invoicesService.getInvoices(req.user.id, {
|
|
||||||
page: pageNum,
|
|
||||||
limit: limitNum,
|
|
||||||
status: typedStatus,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("payment-methods")
|
@Get("payment-methods")
|
||||||
@ -299,21 +283,4 @@ export class InvoicesController {
|
|||||||
gatewayName: gatewayName || "stripe",
|
gatewayName: gatewayName || "stripe",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePositiveInteger(
|
|
||||||
value: string | undefined,
|
|
||||||
defaultValue: number,
|
|
||||||
fieldName: string
|
|
||||||
): number {
|
|
||||||
if (!value) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInt(value, 10);
|
|
||||||
if (isNaN(parsed) || parsed <= 0) {
|
|
||||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,10 +31,14 @@ import {
|
|||||||
simChangePlanRequestSchema,
|
simChangePlanRequestSchema,
|
||||||
simCancelRequestSchema,
|
simCancelRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesRequestSchema,
|
||||||
|
subscriptionQuerySchema,
|
||||||
|
invoiceListQuerySchema,
|
||||||
type SimTopupRequest,
|
type SimTopupRequest,
|
||||||
type SimChangePlanRequest,
|
type SimChangePlanRequest,
|
||||||
type SimCancelRequest,
|
type SimCancelRequest,
|
||||||
type SimFeaturesRequest,
|
type SimFeaturesRequest,
|
||||||
|
type SubscriptionQuery,
|
||||||
|
type InvoiceListQuery,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { ZodValidationPipe } from "@bff/core/validation";
|
import { ZodValidationPipe } from "@bff/core/validation";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
@ -60,21 +64,13 @@ export class SubscriptionsController {
|
|||||||
description: "Filter by subscription status",
|
description: "Filter by subscription status",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ description: "List of user subscriptions" })
|
@ApiOkResponse({ description: "List of user subscriptions" })
|
||||||
|
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
|
||||||
async getSubscriptions(
|
async getSubscriptions(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Query("status") status?: string
|
@Query() query: SubscriptionQuery
|
||||||
): Promise<SubscriptionList | Subscription[]> {
|
): Promise<SubscriptionList | Subscription[]> {
|
||||||
// Validate status if provided
|
if (query.status) {
|
||||||
if (status && !["Active", "Suspended", "Terminated", "Cancelled", "Pending"].includes(status)) {
|
return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status);
|
||||||
throw new BadRequestException("Invalid status filter");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(
|
|
||||||
req.user.id,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
return subscriptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
return this.subscriptionsService.getSubscriptions(req.user.id);
|
||||||
@ -145,46 +141,17 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
@ApiOkResponse({ description: "List of invoices for the subscription" })
|
@ApiOkResponse({ description: "List of invoices for the subscription" })
|
||||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||||
|
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
|
||||||
async getSubscriptionInvoices(
|
async getSubscriptionInvoices(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Query("page") page?: string,
|
@Query() query: InvoiceListQuery
|
||||||
@Query("limit") limit?: string
|
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
if (subscriptionId <= 0) {
|
if (subscriptionId <= 0) {
|
||||||
throw new BadRequestException("Subscription ID must be a positive number");
|
throw new BadRequestException("Subscription ID must be a positive number");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and sanitize input
|
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||||
const pageNum = this.validatePositiveInteger(page, 1, "page");
|
|
||||||
const limitNum = this.validatePositiveInteger(limit, 10, "limit");
|
|
||||||
|
|
||||||
// Limit max page size for performance
|
|
||||||
if (limitNum > 100) {
|
|
||||||
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, {
|
|
||||||
page: pageNum,
|
|
||||||
limit: limitNum,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private validatePositiveInteger(
|
|
||||||
value: string | undefined,
|
|
||||||
defaultValue: number,
|
|
||||||
fieldName: string
|
|
||||||
): number {
|
|
||||||
if (!value) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInt(value, 10);
|
|
||||||
if (isNaN(parsed) || parsed <= 0) {
|
|
||||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SIM Management Endpoints ====================
|
// ==================== SIM Management Endpoints ====================
|
||||||
|
|||||||
132
docs/AUTH-MODULE-ARCHITECTURE.md
Normal file
132
docs/AUTH-MODULE-ARCHITECTURE.md
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# 🔐 Authentication Module Architecture (2025)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The authentication feature in `apps/bff` now follows a layered structure that separates HTTP concerns, orchestration logic, and infrastructure details. This document explains the layout, responsibilities, and integration points so new contributors can quickly locate code and understand the data flow.
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/auth/
|
||||||
|
├── application/
|
||||||
|
│ └── auth.facade.ts
|
||||||
|
├── presentation/
|
||||||
|
│ ├── http/
|
||||||
|
│ │ ├── auth.controller.ts
|
||||||
|
│ │ ├── guards/
|
||||||
|
│ │ └── interceptors/
|
||||||
|
│ └── strategies/
|
||||||
|
├── infra/
|
||||||
|
│ ├── token/
|
||||||
|
│ ├── rate-limiting/
|
||||||
|
│ └── workflows/
|
||||||
|
├── decorators/
|
||||||
|
├── domain/ (reserved for future policies/types)
|
||||||
|
└── utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Purpose |
|
||||||
|
| -------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `presentation` | HTTP surface area (controllers, guards, interceptors, Passport strategies). |
|
||||||
|
| `application` | Use-case orchestration (`AuthFacade`), coordinating infra + audit logging. |
|
||||||
|
| `infra` | Technical services: token issuance, rate limiting, WHMCS/SF workflows. |
|
||||||
|
| `decorators` | Shared Nest decorators (e.g., `@Public`). |
|
||||||
|
| `domain` | (Future) domain policies, constants, pure type definitions. |
|
||||||
|
|
||||||
|
## Presentation Layer
|
||||||
|
|
||||||
|
### Controllers (`presentation/http/auth.controller.ts`)
|
||||||
|
- Routes mirror previous `auth-zod.controller.ts` functionality.
|
||||||
|
- All validation still uses Zod schemas from `@customer-portal/domain` applied via `ZodValidationPipe`.
|
||||||
|
- Cookies are managed with `setSecureCookie` helper registered in `bootstrap.ts`.
|
||||||
|
|
||||||
|
### Guards
|
||||||
|
- `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`.
|
||||||
|
- `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits.
|
||||||
|
- `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting.
|
||||||
|
- `AdminGuard`: ensures authenticated user has admin role (uses Prisma enum).
|
||||||
|
|
||||||
|
### Interceptors
|
||||||
|
- `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure.
|
||||||
|
|
||||||
|
### Strategies (`presentation/strategies`)
|
||||||
|
- `LocalStrategy`: delegates credential validation to `AuthFacade.validateUser`.
|
||||||
|
- `JwtStrategy`: loads user via `UsersService` and maps to public profile.
|
||||||
|
|
||||||
|
## Application Layer
|
||||||
|
|
||||||
|
### `AuthFacade`
|
||||||
|
- Aggregates all auth flows: login/logout, signup, password flows, WHMCS link, token refresh, session logout.
|
||||||
|
- Injects infrastructure services (token, workflows, rate limiting), audit logging, config, and Prisma.
|
||||||
|
- Acts as single DI target for controllers, strategies, and guards.
|
||||||
|
|
||||||
|
## Infrastructure Layer
|
||||||
|
|
||||||
|
### Token (`infra/token`)
|
||||||
|
- `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families.
|
||||||
|
- `token-blacklist.service.ts`: stores revoked access tokens.
|
||||||
|
- `token-migration.service.ts`: helpers for legacy flows.
|
||||||
|
|
||||||
|
### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`)
|
||||||
|
- Built on `rate-limiter-flexible` with Redis storage.
|
||||||
|
- Exposes methods for login, signup, password reset, refresh token throttling.
|
||||||
|
- Handles CAPTCHA escalation via headers (config-driven, see env variables).
|
||||||
|
|
||||||
|
### Workflows (`infra/workflows`)
|
||||||
|
- `signup-workflow.service.ts`: orchestrates Salesforce + WHMCS checks and user creation.
|
||||||
|
- `password-workflow.service.ts`: request reset, change password, set password flows.
|
||||||
|
- `whmcs-link-workflow.service.ts`: links existing WHMCS accounts with portal users.
|
||||||
|
|
||||||
|
## Configuration & Env
|
||||||
|
|
||||||
|
Relevant env keys validated in `core/config/env.validation.ts`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auth rate limits
|
||||||
|
LOGIN_RATE_LIMIT_LIMIT=5
|
||||||
|
LOGIN_RATE_LIMIT_TTL=900000
|
||||||
|
AUTH_REFRESH_RATE_LIMIT_LIMIT=10
|
||||||
|
AUTH_REFRESH_RATE_LIMIT_TTL=300000
|
||||||
|
|
||||||
|
# CAPTCHA options (future integration)
|
||||||
|
AUTH_CAPTCHA_PROVIDER=none # none|turnstile|hcaptcha
|
||||||
|
AUTH_CAPTCHA_SECRET=
|
||||||
|
AUTH_CAPTCHA_THRESHOLD=0
|
||||||
|
AUTH_CAPTCHA_ALWAYS_ON=false
|
||||||
|
|
||||||
|
# CSRF
|
||||||
|
CSRF_TOKEN_EXPIRY=3600000
|
||||||
|
CSRF_SECRET_KEY=... # recommended in prod
|
||||||
|
CSRF_COOKIE_NAME=csrf-secret
|
||||||
|
CSRF_HEADER_NAME=X-CSRF-Token
|
||||||
|
```
|
||||||
|
|
||||||
|
Refer to `DEVELOPMENT-AUTH-SETUP.md` for dev-only overrides (`DISABLE_CSRF`, etc.).
|
||||||
|
|
||||||
|
## CSRF Summary
|
||||||
|
|
||||||
|
- `core/security/middleware/csrf.middleware.ts` now issues stateless HMAC tokens.
|
||||||
|
- On safe requests (GET/HEAD), middleware refreshes token + cookie automatically.
|
||||||
|
- Controllers just rely on middleware; no manual token handling needed.
|
||||||
|
|
||||||
|
## Rate Limiting Summary
|
||||||
|
|
||||||
|
- `AuthRateLimitService` provides atomic Redis counters via `rate-limiter-flexible`.
|
||||||
|
- Guards/workflows invoke `consume*` methods and respond with rate-limit errors/headers.
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
|
||||||
|
- Use absolute imports (e.g., `@bff/modules/auth/application/auth.facade`) to avoid fragile relative paths.
|
||||||
|
- `AuthModule` exports `AuthFacade` and token services so other modules (e.g., admin tasks) can reuse them.
|
||||||
|
- Frontend API calls remain unchanged; payloads validated with existing Zod schemas.
|
||||||
|
|
||||||
|
## Future Work (Optional Enhancements)
|
||||||
|
|
||||||
|
- Populate `domain/` with password policy objects, role constants, etc.
|
||||||
|
- Add `application/commands/` for discrete use-case handlers if flows expand.
|
||||||
|
- Introduce MFA/session management when business requirements demand it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-10-02
|
||||||
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
# 🔐 Modern Authentication Architecture 2025
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines the completely redesigned authentication system that addresses all identified security vulnerabilities, eliminates redundant code, and implements 2025 best practices for secure authentication.
|
|
||||||
|
|
||||||
## 🚨 Issues Fixed
|
|
||||||
|
|
||||||
### Critical Security Fixes
|
|
||||||
|
|
||||||
- ✅ **Removed dangerous manual JWT parsing** from GlobalAuthGuard
|
|
||||||
- ✅ **Eliminated sensitive data logging** (emails removed from error logs)
|
|
||||||
- ✅ **Consolidated duplicate throttle guards** (removed AuthThrottleEnhancedGuard)
|
|
||||||
- ✅ **Standardized error handling** with production-safe logging
|
|
||||||
|
|
||||||
### Architecture Improvements
|
|
||||||
|
|
||||||
- ✅ **Unified token service** with refresh token rotation
|
|
||||||
- ✅ **Comprehensive session management** with device tracking
|
|
||||||
- ✅ **Multi-factor authentication** support with TOTP and backup codes
|
|
||||||
- ✅ **Centralized error handling** with sanitized user messages
|
|
||||||
- ✅ **Clean naming conventions** (no more "Enhanced" prefixes)
|
|
||||||
|
|
||||||
## 🏗️ New Architecture
|
|
||||||
|
|
||||||
### Core Services
|
|
||||||
|
|
||||||
#### 1. **AuthTokenService** (`services/token.service.ts`)
|
|
||||||
|
|
||||||
- **Refresh Token Rotation**: Implements secure token rotation to prevent token reuse attacks
|
|
||||||
- **Short-lived Access Tokens**: 15-minute access tokens for minimal exposure
|
|
||||||
- **Token Family Management**: Detects and invalidates compromised token families
|
|
||||||
- **Backward Compatibility**: Maintains legacy `generateTokens()` method
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Generate secure token pair
|
|
||||||
await tokenService.generateTokenPair(user, deviceInfo);
|
|
||||||
|
|
||||||
// Refresh with automatic rotation
|
|
||||||
await tokenService.refreshTokens(refreshToken, deviceInfo);
|
|
||||||
|
|
||||||
// Revoke all user tokens
|
|
||||||
await tokenService.revokeAllUserTokens(userId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. **SessionService** (`services/session.service.ts`)
|
|
||||||
|
|
||||||
- **Device Tracking**: Monitors and manages user devices
|
|
||||||
- **Session Limits**: Enforces maximum 5 concurrent sessions per user
|
|
||||||
- **Trusted Devices**: Allows users to mark devices as trusted
|
|
||||||
- **Session Analytics**: Tracks session creation, access patterns, and expiry
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Create secure session
|
|
||||||
const sessionId = await sessionService.createSession(userId, deviceInfo, mfaVerified);
|
|
||||||
|
|
||||||
// Check device trust status
|
|
||||||
const trusted = await sessionService.isDeviceTrusted(userId, deviceId);
|
|
||||||
|
|
||||||
// Get all active sessions
|
|
||||||
const sessions = await sessionService.getUserActiveSessions(userId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. **MfaService** (`services/mfa.service.ts`)
|
|
||||||
|
|
||||||
- **TOTP Authentication**: Time-based one-time passwords using Google Authenticator
|
|
||||||
- **Backup Codes**: Cryptographically secure backup codes for recovery
|
|
||||||
- **QR Code Generation**: Easy setup with QR codes
|
|
||||||
- **Token Family Invalidation**: Security measure for compromised accounts
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Setup MFA for user
|
|
||||||
const setup = await mfaService.generateMfaSetup(userId, userEmail);
|
|
||||||
|
|
||||||
// Verify MFA token
|
|
||||||
const result = await mfaService.verifyMfaToken(userId, token);
|
|
||||||
|
|
||||||
// Generate new backup codes
|
|
||||||
const codes = await mfaService.regenerateBackupCodes(userId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. **AuthErrorService** (`services/auth-error.service.ts`)
|
|
||||||
|
|
||||||
- **Standardized Error Types**: Consistent error categorization
|
|
||||||
- **Production-Safe Logging**: No sensitive data in logs
|
|
||||||
- **User-Friendly Messages**: Clear, actionable error messages
|
|
||||||
- **Metadata Sanitization**: Automatic removal of sensitive fields
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Create standardized error
|
|
||||||
const error = errorService.createError(AuthErrorType.INVALID_CREDENTIALS, "Login failed");
|
|
||||||
|
|
||||||
// Handle external service errors
|
|
||||||
const error = errorService.handleExternalServiceError("WHMCS", originalError);
|
|
||||||
|
|
||||||
// Handle validation errors
|
|
||||||
const error = errorService.handleValidationError("email", value, "format");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enhanced Security Guards
|
|
||||||
|
|
||||||
#### **GlobalAuthGuard** (Simplified)
|
|
||||||
|
|
||||||
- ✅ Removed dangerous manual JWT parsing
|
|
||||||
- ✅ Relies on Passport JWT for token validation
|
|
||||||
- ✅ Only handles token blacklist checking
|
|
||||||
- ✅ Clean error handling without sensitive data exposure
|
|
||||||
|
|
||||||
#### **AuthThrottleGuard** (Unified)
|
|
||||||
|
|
||||||
- ✅ Single throttle guard implementation
|
|
||||||
- ✅ IP + User Agent tracking for better security
|
|
||||||
- ✅ Configurable rate limits per endpoint
|
|
||||||
|
|
||||||
## 🔒 Security Features (2025 Standards)
|
|
||||||
|
|
||||||
### 1. **Token Security**
|
|
||||||
|
|
||||||
- **Short-lived Access Tokens**: 15-minute expiry reduces exposure window
|
|
||||||
- **Refresh Token Rotation**: New refresh token issued on each refresh
|
|
||||||
- **Token Family Tracking**: Detects and prevents token reuse attacks
|
|
||||||
- **Secure Storage**: Tokens hashed and stored in Redis with proper TTL
|
|
||||||
|
|
||||||
### 2. **Session Security**
|
|
||||||
|
|
||||||
- **Device Fingerprinting**: Tracks device characteristics for anomaly detection
|
|
||||||
- **Session Limits**: Maximum 5 concurrent sessions per user
|
|
||||||
- **Automatic Cleanup**: Expired sessions automatically removed
|
|
||||||
- **MFA Integration**: Sessions track MFA verification status
|
|
||||||
|
|
||||||
### 3. **Multi-Factor Authentication**
|
|
||||||
|
|
||||||
- **TOTP Support**: Compatible with Google Authenticator, Authy, etc.
|
|
||||||
- **Backup Codes**: 10 cryptographically secure backup codes
|
|
||||||
- **Code Rotation**: Used backup codes are immediately invalidated
|
|
||||||
- **Recovery Options**: Multiple recovery paths for account access
|
|
||||||
|
|
||||||
### 4. **Error Handling & Logging**
|
|
||||||
|
|
||||||
- **No Sensitive Data**: Emails, passwords, tokens never logged
|
|
||||||
- **Structured Logging**: Consistent log format with correlation IDs
|
|
||||||
- **User-Safe Messages**: Error messages safe for end-user display
|
|
||||||
- **Audit Trail**: All authentication events properly logged
|
|
||||||
|
|
||||||
## 🚀 Implementation Benefits
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
- **Reduced Complexity**: Eliminated over-abstraction and duplicate code
|
|
||||||
- **Efficient Caching**: Smart Redis usage with proper TTL management
|
|
||||||
- **Optimized Queries**: Reduced database calls through better session management
|
|
||||||
|
|
||||||
### Security Enhancements
|
|
||||||
|
|
||||||
- **Zero Sensitive Data Leakage**: Production-safe logging throughout
|
|
||||||
- **Token Reuse Prevention**: Refresh token rotation prevents replay attacks
|
|
||||||
- **Device Trust Management**: Reduces MFA friction for trusted devices
|
|
||||||
- **Comprehensive Audit Trail**: Full visibility into authentication events
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
|
|
||||||
- **Clean APIs**: Intuitive service interfaces with clear responsibilities
|
|
||||||
- **Consistent Naming**: No more confusing "Enhanced" or duplicate services
|
|
||||||
- **Type Safety**: Full TypeScript support with proper interfaces
|
|
||||||
- **Error Handling**: Standardized error types and handling patterns
|
|
||||||
|
|
||||||
## 📋 Migration Guide
|
|
||||||
|
|
||||||
### Immediate Changes Required
|
|
||||||
|
|
||||||
1. **Update Token Usage**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Old way
|
|
||||||
const tokens = tokenService.generateTokens(user);
|
|
||||||
|
|
||||||
// New way (recommended)
|
|
||||||
const tokenPair = await tokenService.generateTokenPair(user, deviceInfo);
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Error Handling**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Old way
|
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
|
||||||
|
|
||||||
// New way
|
|
||||||
const error = errorService.createError(AuthErrorType.INVALID_CREDENTIALS, "Login failed");
|
|
||||||
throw new UnauthorizedException(error.userMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Session Management**:
|
|
||||||
```typescript
|
|
||||||
// New capability
|
|
||||||
const sessionId = await sessionService.createSession(userId, deviceInfo);
|
|
||||||
await sessionService.markMfaVerified(sessionId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backward Compatibility
|
|
||||||
|
|
||||||
- ✅ **Legacy token generation** still works via `generateTokens()`
|
|
||||||
- ✅ **Existing JWT validation** unchanged
|
|
||||||
- ✅ **Current API endpoints** continue to function
|
|
||||||
- ✅ **Gradual migration** possible without breaking changes
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# JWT Configuration (existing)
|
|
||||||
JWT_SECRET=your_secure_secret_minimum_32_chars
|
|
||||||
JWT_EXPIRES_IN=7d # Used for legacy tokens only
|
|
||||||
|
|
||||||
# MFA Configuration (new)
|
|
||||||
MFA_BACKUP_SECRET=your_mfa_backup_secret
|
|
||||||
APP_NAME="Customer Portal"
|
|
||||||
|
|
||||||
# Session Configuration (new)
|
|
||||||
MAX_SESSIONS_PER_USER=5
|
|
||||||
SESSION_DURATION=86400 # 24 hours
|
|
||||||
|
|
||||||
# Redis Configuration (existing)
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
```
|
|
||||||
|
|
||||||
### Feature Flags
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Enable new token rotation (recommended)
|
|
||||||
const USE_TOKEN_ROTATION = true;
|
|
||||||
|
|
||||||
// Enable MFA (optional)
|
|
||||||
const ENABLE_MFA = true;
|
|
||||||
|
|
||||||
// Enable session tracking (recommended)
|
|
||||||
const ENABLE_SESSION_TRACKING = true;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Unit Tests Required
|
|
||||||
|
|
||||||
- [ ] AuthTokenService refresh token rotation
|
|
||||||
- [ ] MfaService TOTP verification
|
|
||||||
- [ ] SessionService device management
|
|
||||||
- [ ] AuthErrorService error sanitization
|
|
||||||
|
|
||||||
### Integration Tests Required
|
|
||||||
|
|
||||||
- [ ] End-to-end authentication flow
|
|
||||||
- [ ] MFA setup and verification
|
|
||||||
- [ ] Session management across devices
|
|
||||||
- [ ] Error handling in production scenarios
|
|
||||||
|
|
||||||
## 📊 Monitoring & Alerts
|
|
||||||
|
|
||||||
### Key Metrics to Track
|
|
||||||
|
|
||||||
- **Token Refresh Rate**: Monitor for unusual refresh patterns
|
|
||||||
- **MFA Adoption**: Track MFA enablement across users
|
|
||||||
- **Session Anomalies**: Detect unusual session patterns
|
|
||||||
- **Error Rates**: Monitor authentication failure rates
|
|
||||||
|
|
||||||
### Recommended Alerts
|
|
||||||
|
|
||||||
- **Token Family Invalidation**: Potential security breach
|
|
||||||
- **High MFA Failure Rate**: Possible attack or user issues
|
|
||||||
- **Session Limit Exceeded**: Unusual user behavior
|
|
||||||
- **External Service Errors**: WHMCS/Salesforce integration issues
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
### Phase 1: Core Implementation ✅
|
|
||||||
|
|
||||||
- [x] Fix security vulnerabilities
|
|
||||||
- [x] Implement new services
|
|
||||||
- [x] Update auth module
|
|
||||||
- [x] Add comprehensive error handling
|
|
||||||
|
|
||||||
### Phase 2: Frontend Integration
|
|
||||||
|
|
||||||
- [ ] Update frontend to use refresh tokens
|
|
||||||
- [ ] Implement MFA setup UI
|
|
||||||
- [ ] Add session management interface
|
|
||||||
- [ ] Update error handling in UI
|
|
||||||
|
|
||||||
### Phase 3: Advanced Features
|
|
||||||
|
|
||||||
- [ ] Risk-based authentication
|
|
||||||
- [ ] Passwordless authentication options
|
|
||||||
- [ ] Advanced device fingerprinting
|
|
||||||
- [ ] Machine learning anomaly detection
|
|
||||||
|
|
||||||
## 🔗 Related Documentation
|
|
||||||
|
|
||||||
- [Security Documentation](../SECURITY.md)
|
|
||||||
- [API Documentation](../API.md)
|
|
||||||
- [Deployment Guide](../DEPLOYMENT.md)
|
|
||||||
- [Troubleshooting Guide](../TROUBLESHOOTING.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**This architecture represents a complete modernization of the authentication system, addressing all identified issues while implementing 2025 security best practices. The system is now production-ready, secure, and maintainable.**
|
|
||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
## 🏗️ Architecture & Systems
|
## 🏗️ Architecture & Systems
|
||||||
|
|
||||||
|
- **[Auth Module Architecture](AUTH-MODULE-ARCHITECTURE.md)** - Layered auth design and services
|
||||||
- **[Address System](ADDRESS_SYSTEM.md)** - Complete address management documentation
|
- **[Address System](ADDRESS_SYSTEM.md)** - Complete address management documentation
|
||||||
- **[Product Catalog Architecture](PRODUCT-CATALOG-ARCHITECTURE.md)** - SKU-based catalog system
|
- **[Product Catalog Architecture](PRODUCT-CATALOG-ARCHITECTURE.md)** - SKU-based catalog system
|
||||||
- **[Portal Data Model](PORTAL-DATA-MODEL.md)** - Database schema and relationships
|
- **[Portal Data Model](PORTAL-DATA-MODEL.md)** - Database schema and relationships
|
||||||
|
|||||||
@ -32,6 +32,20 @@ BFF (NestJS)
|
|||||||
- Modules per domain (e.g., invoices, subscriptions)
|
- Modules per domain (e.g., invoices, subscriptions)
|
||||||
- Common concerns under src/common/ (logging, filters, config)
|
- Common concerns under src/common/ (logging, filters, config)
|
||||||
- Strict DTO validation, error filtering (no sensitive data leaks)
|
- Strict DTO validation, error filtering (no sensitive data leaks)
|
||||||
|
- Auth module layout:
|
||||||
|
- `modules/auth/presentation` → controllers, guards, interceptors, passport strategies
|
||||||
|
- `modules/auth/application` → orchestration facade coordinating workflows and tokens
|
||||||
|
- `modules/auth/infra` → token services, Redis rate limiting, signup/password/WHMCS workflows
|
||||||
|
- Zod schemas live in `@customer-portal/domain`; controllers use `ZodValidationPipe`
|
||||||
|
|
||||||
|
## Validation Workflow (Zod-First)
|
||||||
|
|
||||||
|
- Shared schemas live in `packages/domain/src/validation`.
|
||||||
|
- Backend registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE`.
|
||||||
|
- Controllers compose schemas by importing contracts from the shared package. For query params and body validation, use `@UsePipes(new ZodValidationPipe(schema))`.
|
||||||
|
- Services call `schema.parse` when mapping external data (Salesforce, WHMCS) to ensure the response matches the contract.
|
||||||
|
- Frontend imports the same schemas/types (and `useZodForm` helpers) to keep UI validation in sync with backend rules.
|
||||||
|
- Error handling runs through custom filters; if custom formatting is needed for Zod errors, catch `ZodValidationException`.
|
||||||
|
|
||||||
Domain package
|
Domain package
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,41 @@ import {
|
|||||||
genderEnum,
|
genderEnum,
|
||||||
} from "../shared/primitives";
|
} from "../shared/primitives";
|
||||||
|
|
||||||
|
const invoiceStatusEnum = z.enum(["Paid", "Unpaid", "Overdue", "Cancelled", "Collections"]);
|
||||||
|
const subscriptionStatusEnum = z.enum([
|
||||||
|
"Active",
|
||||||
|
"Suspended",
|
||||||
|
"Terminated",
|
||||||
|
"Cancelled",
|
||||||
|
"Pending",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const paginationQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
||||||
|
|
||||||
|
export const auditLogQuerySchema = paginationQuerySchema.extend({
|
||||||
|
action: z.string().optional(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogQuery = z.infer<typeof auditLogQuerySchema>;
|
||||||
|
|
||||||
|
export const invoiceListQuerySchema = paginationQuerySchema.extend({
|
||||||
|
status: invoiceStatusEnum.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvoiceListQuery = z.infer<typeof invoiceListQuerySchema>;
|
||||||
|
|
||||||
|
export const subscriptionQuerySchema = z.object({
|
||||||
|
status: subscriptionStatusEnum.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SubscriptionQuery = z.infer<typeof subscriptionQuerySchema>;
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// AUTH REQUEST SCHEMAS
|
// AUTH REQUEST SCHEMAS
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@ -152,6 +187,10 @@ export const createOrderRequestSchema = z.object({
|
|||||||
configurations: orderConfigurationsSchema.optional(),
|
configurations: orderConfigurationsSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const orderIdParamSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive("Order ID must be positive"),
|
||||||
|
});
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// SUBSCRIPTION MANAGEMENT REQUEST SCHEMAS
|
// SUBSCRIPTION MANAGEMENT REQUEST SCHEMAS
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|||||||
@ -59,6 +59,9 @@ export {
|
|||||||
invoiceItemSchema,
|
invoiceItemSchema,
|
||||||
invoiceSchema,
|
invoiceSchema,
|
||||||
invoiceListSchema,
|
invoiceListSchema,
|
||||||
|
invoiceListQuerySchema,
|
||||||
|
paginationQuerySchema,
|
||||||
|
subscriptionQuerySchema,
|
||||||
|
|
||||||
// API types
|
// API types
|
||||||
type LoginRequestInput,
|
type LoginRequestInput,
|
||||||
@ -82,6 +85,9 @@ export {
|
|||||||
type SimChangePlanRequest,
|
type SimChangePlanRequest,
|
||||||
type SimFeaturesRequest,
|
type SimFeaturesRequest,
|
||||||
type ContactRequest,
|
type ContactRequest,
|
||||||
|
type InvoiceListQuery,
|
||||||
|
type PaginationQuery,
|
||||||
|
type SubscriptionQuery,
|
||||||
} from "./api/requests";
|
} from "./api/requests";
|
||||||
|
|
||||||
// Form schemas (frontend) - explicit exports for better tree shaking
|
// Form schemas (frontend) - explicit exports for better tree shaking
|
||||||
|
|||||||
@ -14,10 +14,6 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"./nestjs": {
|
|
||||||
"types": "./dist/nestjs/index.d.ts",
|
|
||||||
"default": "./dist/nestjs/index.js"
|
|
||||||
},
|
|
||||||
"./react": {
|
"./react": {
|
||||||
"types": "./dist/react/index.d.ts",
|
"types": "./dist/react/index.d.ts",
|
||||||
"default": "./dist/react/index.js"
|
"default": "./dist/react/index.js"
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* NestJS-specific validation utilities
|
|
||||||
* Import this directly in backend code: import { ... } from "@customer-portal/validation/nestjs"
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./nestjs/index";
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* NestJS validation exports
|
|
||||||
* Simple Zod validation for NestJS
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ZodPipe, createZodPipe } from "../zod-pipe";
|
|
||||||
Loading…
x
Reference in New Issue
Block a user