diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..a1cd742 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "assets": ["**/*.proto"], + "deleteOutDir": true, + "watchAssets": true + }, + "projects": { + "flash-whatsapp-service": { + "type": "application", + "root": "", + "entryFile": "main", + "sourceRoot": "src", + "compilerOptions": { + "tsConfigPath": "tsconfig.json" + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 81bb3bc..c678471 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,13 @@ "@nestjs/common": "^11.1.0", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.0", "@nestjs/swagger": "^11.2.0", "@types/passport-jwt": "^4.0.1", + "@types/speakeasy": "^2.0.10", "amqplib": "^0.10.7", "axios": "^1.10.0", "bolt11": "^1.4.1", @@ -73,8 +75,10 @@ "puppeteer": "^24.10.2", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", + "redux-persist": "^6.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "speakeasy": "^2.0.0", "swagger-ui-express": "^5.0.1", "twilio": "^5.7.1", "whatsapp-web.js": "^1.30.0", diff --git a/src/modules/admin-dashboard/admin-dashboard-enhanced.module.ts b/src/modules/admin-dashboard/admin-dashboard-enhanced.module.ts new file mode 100644 index 0000000..88cc7cc --- /dev/null +++ b/src/modules/admin-dashboard/admin-dashboard-enhanced.module.ts @@ -0,0 +1,119 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; + +// Controllers +import { AdminDashboardController } from './controllers/admin-dashboard.controller'; +import { AdminAuthController } from './controllers/admin-auth.controller'; + +// Services +import { AdminDashboardService } from './services/admin-dashboard.service'; +import { AdminAuthService } from './services/admin-auth.service'; +import { EnhancedAdminAuthService } from './services/enhanced-admin-auth.service'; +import { AdminFacadeService } from './services/admin-facade.service'; +import { AdminHealthService } from './services/admin-health.service'; +import { TOTPAuthService } from './services/totp-auth.service'; +import { DeviceFingerprintService } from './services/device-fingerprint.service'; +import { SecurityEventService } from './services/security-event.service'; +import { RBACService } from './services/rbac.service'; + +// Guards and Filters +import { AdminGuard } from './guards/admin.guard'; +import { AdminRateLimitGuard } from './guards/admin-rate-limit.guard'; +import { AdminExceptionFilter } from './filters/admin-exception.filter'; +import { RBACGuard } from './guards/rbac.guard'; + +// Import other modules +import { AuthModule } from '../auth/auth.module'; +import { WhatsappModule } from '../whatsapp/whatsapp.module'; +import { FlashApiModule } from '../flash-api/flash-api.module'; +import { RedisModule } from '../redis/redis.module'; +import { EventsModule } from '../events/events.module'; + +@Module({ + imports: [ + ConfigModule, + EventEmitterModule.forRoot({ + wildcard: true, + delimiter: '.', + maxListeners: 10, + verboseMemoryLeak: true, + }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: '24h', + issuer: 'flash-admin', + audience: 'flash-admin-dashboard', + }, + }), + inject: [ConfigService], + }), + AuthModule, + WhatsappModule, + FlashApiModule, + RedisModule, + EventsModule, + ], + controllers: [ + AdminDashboardController, + AdminAuthController, + ], + providers: [ + // Core Services + AdminDashboardService, + AdminAuthService, + EnhancedAdminAuthService, + AdminFacadeService, + AdminHealthService, + + // Security Services + TOTPAuthService, + DeviceFingerprintService, + SecurityEventService, + RBACService, + + // Guards + AdminGuard, + AdminRateLimitGuard, + RBACGuard, + + // Global exception filter for admin routes + { + provide: APP_FILTER, + useClass: AdminExceptionFilter, + }, + + // Global RBAC guard for admin routes + { + provide: APP_GUARD, + useClass: RBACGuard, + }, + ], + exports: [ + AdminDashboardService, + AdminAuthService, + EnhancedAdminAuthService, + TOTPAuthService, + SecurityEventService, + RBACService, + ], +}) +export class AdminDashboardEnhancedModule { + constructor(private readonly securityEventService: SecurityEventService) { + // Log module initialization + this.securityEventService.logEvent({ + type: 'system_startup' as any, + severity: 'info' as any, + ipAddress: 'system', + details: { + module: 'AdminDashboardEnhanced', + timestamp: new Date(), + }, + }).catch(err => console.error('Failed to log startup event:', err)); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/controllers/enhanced-admin-dashboard.controller.ts b/src/modules/admin-dashboard/controllers/enhanced-admin-dashboard.controller.ts new file mode 100644 index 0000000..5d2c202 --- /dev/null +++ b/src/modules/admin-dashboard/controllers/enhanced-admin-dashboard.controller.ts @@ -0,0 +1,291 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, + Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AdminDashboardService } from '../services/admin-dashboard.service'; +import { AdminGuard } from '../guards/admin.guard'; +import { AdminRateLimitGuard, RateLimit } from '../guards/admin-rate-limit.guard'; +import { RBACGuard, Permissions, PermissionLogic } from '../guards/rbac.guard'; +import { Permission } from '../services/rbac.service'; +import { SecurityEventService, SecurityEventType } from '../services/security-event.service'; + +@ApiTags('Admin Dashboard') +@Controller('api/admin/dashboard') +@UseGuards(AdminGuard, RBACGuard, AdminRateLimitGuard) +@ApiBearerAuth() +export class EnhancedAdminDashboardController { + constructor( + private readonly dashboardService: AdminDashboardService, + private readonly securityEventService: SecurityEventService, + ) {} + + @Get('stats') + @Permissions(Permission.SYSTEM_HEALTH_VIEW) + @ApiOperation({ summary: 'Get dashboard statistics' }) + @ApiResponse({ status: 200, description: 'Dashboard statistics' }) + async getStats(@Req() req: any) { + await this.logDataAccess(req, 'dashboard_stats'); + return this.dashboardService.getDashboardStats(); + } + + @Get('sessions') + @Permissions(Permission.SESSION_VIEW) + @ApiOperation({ summary: 'Get all user sessions' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getUserSessions( + @Query('page') page = '1', + @Query('limit') limit = '20', + @Req() req: any, + ) { + await this.logDataAccess(req, 'user_sessions', { page, limit }); + return this.dashboardService.getUserSessions(parseInt(page, 10), parseInt(limit, 10)); + } + + @Delete('sessions/:whatsappId') + @HttpCode(HttpStatus.NO_CONTENT) + @Permissions(Permission.SESSION_DELETE) + @ApiOperation({ summary: 'Clear specific user session' }) + async clearUserSession( + @Param('whatsappId') whatsappId: string, + @Req() req: any, + ) { + await this.logDataModification(req, 'clear_session', { whatsappId }); + await this.dashboardService.clearUserSession(whatsappId); + } + + @Delete('sessions') + @Permissions(Permission.SESSION_DELETE, Permission.USER_DELETE) + @PermissionLogic('AND') + @RateLimit(1, 3600000) // 1 clear all per hour + @ApiOperation({ summary: 'Clear all user sessions' }) + async clearAllSessions(@Req() req: any) { + const result = await this.dashboardService.clearAllSessions(); + await this.logBulkOperation(req, 'clear_all_sessions', result); + return result; + } + + @Post('announcement') + @Permissions(Permission.ANNOUNCEMENT_SEND) + @RateLimit(5, 300000) // 5 announcements per 5 minutes + @ApiOperation({ summary: 'Send announcement to all users' }) + async sendAnnouncement( + @Body() body: { message: string; includeUnlinked?: boolean; testMode?: boolean }, + @Req() req: any, + ) { + const result = await this.dashboardService.sendAnnouncement(body.message, { + includeUnlinked: body.includeUnlinked, + testMode: body.testMode, + }); + + await this.logBulkOperation(req, 'send_announcement', { + ...result, + testMode: body.testMode, + }); + + return result; + } + + @Post('support-mode/:whatsappId') + @Permissions(Permission.USER_EDIT) + @ApiOperation({ summary: 'Toggle support mode for a user' }) + async toggleSupportMode( + @Param('whatsappId') whatsappId: string, + @Body() body: { enable: boolean }, + @Req() req: any, + ) { + await this.logDataModification(req, 'toggle_support_mode', { + whatsappId, + enable: body.enable, + }); + + await this.dashboardService.toggleSupportMode(whatsappId, body.enable); + return { success: true }; + } + + @Get('whatsapp/status') + @Permissions(Permission.WHATSAPP_VIEW) + @ApiOperation({ summary: 'Get WhatsApp connection status' }) + async getWhatsAppStatus(@Req() req: any) { + await this.logDataAccess(req, 'whatsapp_status'); + const stats = await this.dashboardService.getDashboardStats(); + return stats.system.whatsappStatus; + } + + @Get('whatsapp/qr') + @Permissions(Permission.WHATSAPP_VIEW) + @ApiOperation({ summary: 'Get WhatsApp QR code' }) + async getWhatsAppQr(@Req() req: any) { + await this.logDataAccess(req, 'whatsapp_qr'); + const qr = await this.dashboardService.getWhatsAppQr(); + return { qr }; + } + + @Post('whatsapp/disconnect') + @HttpCode(HttpStatus.NO_CONTENT) + @Permissions(Permission.WHATSAPP_MANAGE) + @ApiOperation({ summary: 'Disconnect WhatsApp session' }) + async disconnectWhatsApp(@Req() req: any) { + await this.logDataModification(req, 'disconnect_whatsapp'); + await this.dashboardService.disconnectWhatsApp(); + } + + @Post('test-message') + @Permissions(Permission.WHATSAPP_MANAGE) + @RateLimit(10, 60000) // 10 test messages per minute + @ApiOperation({ summary: 'Send test message' }) + async sendTestMessage( + @Body() body: { to: string; message: string }, + @Req() req: any, + ) { + await this.logDataAccess(req, 'send_test_message', { + to: body.to.substring(0, 6) + '****', // Partial number for privacy + }); + + await this.dashboardService.sendTestMessage(body.to, body.message); + return { success: true }; + } + + @Post('command') + @Permissions(Permission.COMMAND_EXECUTE) + @RateLimit(50, 300000) // 50 commands per 5 minutes + @ApiOperation({ summary: 'Execute admin command' }) + async executeCommand( + @Body() body: { command: string }, + @Req() req: any, + ) { + await this.logDataModification(req, 'execute_command', { + command: body.command.split(' ')[0], // Log command type only + }); + + const result = await this.dashboardService.executeAdminCommand( + body.command, + req.user.phoneNumber, + ); + return { result }; + } + + @Get('command/history') + @Permissions(Permission.COMMAND_HISTORY_VIEW) + @ApiOperation({ summary: 'Get admin command history' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getCommandHistory( + @Query('limit') limit = '50', + @Req() req: any, + ) { + await this.logDataAccess(req, 'command_history', { limit }); + return this.dashboardService.getCommandHistory(parseInt(limit, 10)); + } + + @Get('logs') + @Permissions(Permission.SYSTEM_LOGS_VIEW) + @ApiOperation({ summary: 'Get system logs' }) + @ApiQuery({ name: 'level', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getSystemLogs( + @Query('level') level?: string, + @Query('limit') limit = '100', + @Req() req?: any, + ) { + await this.logDataAccess(req, 'system_logs', { level, limit }); + return this.dashboardService.getSystemLogs({ + level, + limit: parseInt(limit, 10), + }); + } + + @Get('security/events') + @Permissions(Permission.SYSTEM_LOGS_VIEW) + @ApiOperation({ summary: 'Get security events' }) + @ApiQuery({ name: 'type', required: false }) + @ApiQuery({ name: 'severity', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getSecurityEvents( + @Query('type') type?: string, + @Query('severity') severity?: string, + @Query('limit') limit = '100', + @Req() req?: any, + ) { + await this.logDataAccess(req, 'security_events', { type, severity, limit }); + + return this.securityEventService.queryEvents({ + types: type ? [type as SecurityEventType] : undefined, + severities: severity ? [severity as any] : undefined, + limit: parseInt(limit, 10), + }); + } + + @Get('security/metrics') + @Permissions(Permission.SYSTEM_HEALTH_VIEW) + @ApiOperation({ summary: 'Get security metrics' }) + async getSecurityMetrics(@Req() req: any) { + await this.logDataAccess(req, 'security_metrics'); + + const now = new Date(); + const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return this.securityEventService.getMetrics({ + start: dayAgo, + end: now, + }); + } + + /** + * Private logging methods + */ + private async logDataAccess(req: any, resource: string, details?: any): Promise { + await this.securityEventService.logEvent({ + type: SecurityEventType.DATA_ACCESS, + userId: req.user?.userId, + sessionId: req.user?.sessionId, + ipAddress: req.ip || 'unknown', + userAgent: req.headers['user-agent'], + details: { + resource, + method: req.method, + path: req.path, + ...details, + }, + }); + } + + private async logDataModification(req: any, action: string, details?: any): Promise { + await this.securityEventService.logEvent({ + type: SecurityEventType.DATA_MODIFICATION, + userId: req.user?.userId, + sessionId: req.user?.sessionId, + ipAddress: req.ip || 'unknown', + userAgent: req.headers['user-agent'], + details: { + action, + method: req.method, + path: req.path, + ...details, + }, + }); + } + + private async logBulkOperation(req: any, operation: string, result: any): Promise { + await this.securityEventService.logEvent({ + type: SecurityEventType.BULK_OPERATION, + userId: req.user?.userId, + sessionId: req.user?.sessionId, + ipAddress: req.ip || 'unknown', + userAgent: req.headers['user-agent'], + details: { + operation, + result, + }, + }); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/dto/admin-auth.dto.ts b/src/modules/admin-dashboard/dto/admin-auth.dto.ts index 95749f9..ac74d97 100644 --- a/src/modules/admin-dashboard/dto/admin-auth.dto.ts +++ b/src/modules/admin-dashboard/dto/admin-auth.dto.ts @@ -12,6 +12,27 @@ export class AdminLoginDto { message: 'Invalid phone number format', }) phoneNumber: string; + + @ApiProperty({ + description: 'Device fingerprint for security', + required: false, + }) + @IsString() + deviceFingerprint?: string; + + @ApiProperty({ + description: 'Client IP address', + required: false, + }) + @IsString() + ipAddress?: string; + + @ApiProperty({ + description: 'User agent string', + required: false, + }) + @IsString() + userAgent?: string; } export class AdminVerifyOtpDto { @@ -30,6 +51,13 @@ export class AdminVerifyOtpDto { @IsNotEmpty() @Length(6, 6) otp: string; + + @ApiProperty({ + description: 'Device fingerprint for security', + required: false, + }) + @IsString() + deviceFingerprint?: string; } export class AdminRefreshTokenDto { @@ -41,6 +69,44 @@ export class AdminRefreshTokenDto { refreshToken: string; } +export class AdminTOTPVerifyDto { + @ApiProperty({ + description: 'Session ID from OTP verification', + }) + @IsString() + @IsNotEmpty() + sessionId: string; + + @ApiProperty({ + description: 'TOTP code from authenticator app', + example: '123456', + }) + @IsString() + @IsNotEmpty() + @Length(6, 6) + totpCode: string; + + @ApiProperty({ + description: 'Alternative token field', + required: false, + }) + @IsString() + token?: string; + + @ApiProperty({ + description: 'Device fingerprint for security', + required: false, + }) + @IsString() + deviceFingerprint?: string; + + @ApiProperty({ + description: 'Trust this device for future logins', + required: false, + }) + trustDevice?: boolean; +} + export class AdminSessionDto { @ApiProperty() accessToken: string; @@ -56,4 +122,13 @@ export class AdminSessionDto { @ApiProperty() sessionId: string; + + @ApiProperty({ required: false }) + totpRequired?: boolean; + + @ApiProperty({ required: false }) + user?: any; + + @ApiProperty({ required: false }) + message?: string; } diff --git a/src/modules/admin-dashboard/guards/rbac.guard.ts b/src/modules/admin-dashboard/guards/rbac.guard.ts new file mode 100644 index 0000000..704c01c --- /dev/null +++ b/src/modules/admin-dashboard/guards/rbac.guard.ts @@ -0,0 +1,125 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RBACService, Permission } from '../services/rbac.service'; +import { SecurityEventService, SecurityEventType } from '../services/security-event.service'; + +export const PERMISSIONS_KEY = 'permissions'; +export const Permissions = (...permissions: Permission[]) => + SetMetadata(PERMISSIONS_KEY, permissions); + +export const PERMISSION_LOGIC_KEY = 'permission_logic'; +export const PermissionLogic = (logic: 'AND' | 'OR') => + SetMetadata(PERMISSION_LOGIC_KEY, logic); + +@Injectable() +export class RBACGuard implements CanActivate { + constructor( + private reflector: Reflector, + private rbacService: RBACService, + private securityEventService: SecurityEventService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get required permissions from metadata + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + // If no permissions required, allow access + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + // Get permission logic (default to AND) + const logic = this.reflector.getAllAndOverride<'AND' | 'OR'>( + PERMISSION_LOGIC_KEY, + [context.getHandler(), context.getClass()], + ) || 'AND'; + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + // No user = no access + if (!user) { + await this.logDeniedAccess(context, 'No user'); + throw new ForbiddenException('Authentication required'); + } + + // Check permissions + const hasAccess = this.checkPermissions( + user.role, + user.permissions || [], + requiredPermissions, + logic, + ); + + if (!hasAccess) { + await this.logDeniedAccess(context, 'Insufficient permissions', user); + throw new ForbiddenException( + `Required permissions: ${requiredPermissions.join(', ')}`, + ); + } + + return true; + } + + private checkPermissions( + userRole: string, + userPermissions: Permission[], + requiredPermissions: Permission[], + logic: 'AND' | 'OR', + ): boolean { + // Super admin always has access + if (userRole === 'super_admin') { + return true; + } + + // Use RBAC service to check role permissions + const rolePermissions = this.rbacService.getPermissionsForRole(userRole as any); + const allUserPermissions = new Set([...userPermissions, ...rolePermissions]); + + if (logic === 'AND') { + return requiredPermissions.every(permission => allUserPermissions.has(permission)); + } else { + return requiredPermissions.some(permission => allUserPermissions.has(permission)); + } + } + + private async logDeniedAccess( + context: ExecutionContext, + reason: string, + user?: any, + ): Promise { + const request = context.switchToHttp().getRequest(); + const handler = context.getHandler(); + const controller = context.getClass(); + + await this.securityEventService.logEvent({ + type: SecurityEventType.PERMISSION_DENIED, + userId: user?.userId, + sessionId: user?.sessionId, + ipAddress: request.ip || 'unknown', + userAgent: request.headers['user-agent'], + details: { + reason, + controller: controller.name, + handler: handler.name, + method: request.method, + path: request.path, + requiredPermissions: this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [handler, controller], + ), + userRole: user?.role, + userPermissions: user?.permissions, + }, + }); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/services/device-fingerprint.service.ts b/src/modules/admin-dashboard/services/device-fingerprint.service.ts new file mode 100644 index 0000000..7a1bf1c --- /dev/null +++ b/src/modules/admin-dashboard/services/device-fingerprint.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; + +export interface IDeviceFingerprint { + browser: string; + os: string; + screenResolution: string; + timezone: string; + language: string; + colorDepth: number; + hardwareConcurrency: number; + deviceMemory?: number; + platform: string; + plugins: string[]; + canvas: string; + webgl: string; + fonts: string[]; + userAgent: string; + [key: string]: any; +} + +@Injectable() +export class DeviceFingerprintService { + /** + * Generate a unique device ID from fingerprint data + */ + async generateDeviceId(fingerprint: IDeviceFingerprint): Promise { + // Combine stable fingerprint components + const stableComponents = [ + fingerprint.browser, + fingerprint.os, + fingerprint.screenResolution, + fingerprint.timezone, + fingerprint.language, + fingerprint.colorDepth.toString(), + fingerprint.hardwareConcurrency.toString(), + fingerprint.platform, + fingerprint.canvas, + fingerprint.webgl, + ...fingerprint.fonts.slice(0, 10), // Use top 10 fonts for stability + ].filter(Boolean); + + // Create hash of components + const fingerprintString = stableComponents.join('|'); + const hash = crypto + .createHash('sha256') + .update(fingerprintString) + .digest('hex'); + + return hash; + } + + /** + * Calculate similarity between two fingerprints + */ + calculateSimilarity( + fingerprint1: IDeviceFingerprint, + fingerprint2: IDeviceFingerprint, + ): number { + const features = [ + 'browser', + 'os', + 'screenResolution', + 'timezone', + 'language', + 'colorDepth', + 'hardwareConcurrency', + 'platform', + ]; + + let matchCount = 0; + + features.forEach(feature => { + if (fingerprint1[feature] === fingerprint2[feature]) { + matchCount++; + } + }); + + // Check canvas similarity + if (this.isCanvasSimilar(fingerprint1.canvas, fingerprint2.canvas)) { + matchCount++; + } + + // Check font similarity + const fontSimilarity = this.calculateArraySimilarity( + fingerprint1.fonts, + fingerprint2.fonts, + ); + if (fontSimilarity > 0.8) { + matchCount++; + } + + return matchCount / (features.length + 2); // +2 for canvas and fonts + } + + /** + * Check if a fingerprint is suspicious + */ + isSuspiciousFingerprint(fingerprint: IDeviceFingerprint): boolean { + // Check for common spoofing indicators + const suspiciousIndicators = [ + // Inconsistent platform/OS combinations + fingerprint.platform.includes('Win') && fingerprint.os.includes('Mac'), + fingerprint.platform.includes('Mac') && fingerprint.os.includes('Windows'), + + // Missing critical data + !fingerprint.canvas || fingerprint.canvas.length < 50, + !fingerprint.webgl || fingerprint.webgl.length < 50, + fingerprint.fonts.length === 0, + + // Unrealistic hardware + fingerprint.hardwareConcurrency > 64, + fingerprint.colorDepth !== 24 && fingerprint.colorDepth !== 32, + + // Common bot indicators + fingerprint.plugins.length === 0 && fingerprint.browser.includes('Chrome'), + fingerprint.language === undefined || fingerprint.language === '', + ]; + + return suspiciousIndicators.some(indicator => indicator === true); + } + + /** + * Get human-readable device name from fingerprint + */ + getDeviceName(fingerprint: IDeviceFingerprint): string { + const browser = this.extractBrowserName(fingerprint.browser); + const os = this.extractOSName(fingerprint.os); + + return `${browser} on ${os}`; + } + + /** + * Validate fingerprint data + */ + validateFingerprint(fingerprint: any): fingerprint is IDeviceFingerprint { + const requiredFields = [ + 'browser', + 'os', + 'screenResolution', + 'timezone', + 'language', + 'colorDepth', + 'hardwareConcurrency', + 'platform', + 'canvas', + 'webgl', + 'userAgent', + ]; + + return requiredFields.every(field => + fingerprint.hasOwnProperty(field) && fingerprint[field] !== null + ); + } + + /** + * Anonymize fingerprint for logging + */ + anonymizeFingerprint(fingerprint: IDeviceFingerprint): any { + return { + browser: fingerprint.browser, + os: fingerprint.os, + timezone: fingerprint.timezone, + language: fingerprint.language, + screenResolution: this.generalizeResolution(fingerprint.screenResolution), + hardwareConcurrency: this.generalizeHardware(fingerprint.hardwareConcurrency), + fontCount: fingerprint.fonts.length, + pluginCount: fingerprint.plugins.length, + }; + } + + /** + * Private helper methods + */ + private isCanvasSimilar(canvas1: string, canvas2: string): boolean { + if (!canvas1 || !canvas2) return false; + + // Simple similarity check - in production, use more sophisticated comparison + const distance = this.levenshteinDistance( + canvas1.substring(0, 100), + canvas2.substring(0, 100), + ); + + return distance < 10; + } + + private calculateArraySimilarity(arr1: string[], arr2: string[]): number { + const set1 = new Set(arr1); + const set2 = new Set(arr2); + + const intersection = new Set([...set1].filter(x => set2.has(x))); + const union = new Set([...set1, ...set2]); + + return intersection.size / union.size; + } + + private levenshteinDistance(str1: string, str2: string): number { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1, + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + private extractBrowserName(browserString: string): string { + if (browserString.includes('Chrome')) return 'Chrome'; + if (browserString.includes('Firefox')) return 'Firefox'; + if (browserString.includes('Safari') && !browserString.includes('Chrome')) return 'Safari'; + if (browserString.includes('Edge')) return 'Edge'; + return 'Unknown Browser'; + } + + private extractOSName(osString: string): string { + if (osString.includes('Windows')) return 'Windows'; + if (osString.includes('Mac')) return 'macOS'; + if (osString.includes('Linux')) return 'Linux'; + if (osString.includes('Android')) return 'Android'; + if (osString.includes('iOS')) return 'iOS'; + return 'Unknown OS'; + } + + private generalizeResolution(resolution: string): string { + const [width] = resolution.split('x').map(Number); + + if (width < 1280) return 'Small'; + if (width < 1920) return 'Medium'; + if (width < 2560) return 'Large'; + return 'Very Large'; + } + + private generalizeHardware(cores: number): string { + if (cores <= 2) return 'Low'; + if (cores <= 4) return 'Medium'; + if (cores <= 8) return 'High'; + return 'Very High'; + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/services/enhanced-admin-auth.service.ts b/src/modules/admin-dashboard/services/enhanced-admin-auth.service.ts new file mode 100644 index 0000000..9903e40 --- /dev/null +++ b/src/modules/admin-dashboard/services/enhanced-admin-auth.service.ts @@ -0,0 +1,541 @@ +import { Injectable, UnauthorizedException, Logger, ForbiddenException } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { OtpService } from '../../auth/services/otp.service'; +import { SessionService } from '../../auth/services/session.service'; +import { WhatsAppWebService } from '../../whatsapp/services/whatsapp-web.service'; +import { TOTPAuthService } from './totp-auth.service'; +import { DeviceFingerprintService } from './device-fingerprint.service'; +import { SecurityEventService } from './security-event.service'; +import { RBACService } from './rbac.service'; +import { + AdminLoginDto, + AdminVerifyOtpDto, + AdminSessionDto, + AdminTOTPVerifyDto, +} from '../dto/admin-auth.dto'; +import { UserRole, Permission } from '../types/auth.types'; +import { SecurityEventType } from './security-event.service'; + +export interface EnhancedAdminUser { + id: string; + phoneNumber: string; + role: UserRole; + permissions: Permission[]; + totpEnabled: boolean; + createdAt: Date; + lastLoginAt?: Date; +} + +@Injectable() +export class EnhancedAdminAuthService { + private readonly logger = new Logger(EnhancedAdminAuthService.name); + private readonly adminConfig: Map; + + constructor( + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + private readonly otpService: OtpService, + private readonly sessionService: SessionService, + private readonly whatsappWebService: WhatsAppWebService, + private readonly totpService: TOTPAuthService, + private readonly deviceFingerprintService: DeviceFingerprintService, + private readonly securityEventService: SecurityEventService, + private readonly rbacService: RBACService, + ) { + // Load admin configuration from environment + this.adminConfig = this.loadAdminConfig(); + } + + /** + * Initiate login with phone number + */ + async initiateLogin(loginDto: AdminLoginDto): Promise<{ message: string; sessionId: string }> { + const { phoneNumber, deviceFingerprint } = loginDto; + const ipAddress = loginDto.ipAddress || 'unknown'; + const userAgent = loginDto.userAgent || 'unknown'; + + try { + // Validate and clean phone number + const cleanNumber = this.cleanPhoneNumber(phoneNumber); + + // Check if admin exists + const adminConfig = this.getAdminConfig(cleanNumber); + if (!adminConfig) { + await this.securityEventService.logEvent({ + type: SecurityEventType.LOGIN_FAILURE, + ipAddress, + userAgent, + details: { reason: 'unauthorized_phone', phoneNumber: cleanNumber }, + }); + throw new UnauthorizedException('This phone number is not authorized for admin access'); + } + + // Check rate limiting + const isRateLimited = await this.checkRateLimit(cleanNumber, ipAddress); + if (isRateLimited) { + await this.securityEventService.logEvent({ + type: SecurityEventType.RATE_LIMIT_EXCEEDED, + ipAddress, + userAgent, + details: { phoneNumber: cleanNumber }, + }); + throw new ForbiddenException('Too many login attempts. Please try again later.'); + } + + // Create temporary session + const sessionId = await this.createTempSession(cleanNumber, deviceFingerprint, ipAddress); + + // Generate and send OTP + const otp = await this.otpService.generateOtp(cleanNumber, sessionId); + await this.sendOTP(cleanNumber, otp); + + // Log login attempt + await this.securityEventService.logEvent({ + type: SecurityEventType.LOGIN_ATTEMPT, + ipAddress, + userAgent, + sessionId, + details: { phoneNumber: cleanNumber }, + }); + + return { + message: 'Verification code sent to your WhatsApp', + sessionId, + }; + } catch (error) { + this.logger.error(`Login initiation failed: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Verify OTP and proceed with authentication + */ + async verifyOtp(verifyDto: AdminVerifyOtpDto): Promise> { + const { sessionId, otp, deviceFingerprint } = verifyDto; + + // Get temp session + const tempSession = await this.getTempSession(sessionId); + if (!tempSession) { + throw new UnauthorizedException('Invalid or expired session'); + } + + const { phoneNumber, ipAddress } = tempSession; + + try { + // Verify OTP + const isValid = await this.otpService.verifyOtp(sessionId, otp); + if (!isValid) { + await this.securityEventService.logEvent({ + type: SecurityEventType.LOGIN_FAILURE, + ipAddress, + sessionId, + details: { reason: 'invalid_otp' }, + }); + throw new UnauthorizedException('Invalid or expired verification code'); + } + + // Get admin user + const adminUser = await this.getOrCreateAdminUser(phoneNumber); + + // Check if TOTP is enabled + const totpEnabled = await this.totpService.isTOTPEnabled(adminUser.id); + + // Check if device is trusted (skip TOTP for trusted devices) + let isTrustedDevice = false; + if (deviceFingerprint) { + const fingerprintObj = typeof deviceFingerprint === 'string' + ? JSON.parse(deviceFingerprint) + : deviceFingerprint; + const deviceId = await this.deviceFingerprintService.generateDeviceId(fingerprintObj); + isTrustedDevice = await this.totpService.isDeviceTrusted(adminUser.id, deviceId); + } + + if (totpEnabled && !isTrustedDevice) { + // Return partial session requiring TOTP + return { + sessionId, + totpRequired: true, + message: 'Please enter your authenticator code', + }; + } + + // Complete authentication + return this.completeAuthentication(adminUser, deviceFingerprint, ipAddress); + } catch (error) { + this.logger.error(`OTP verification failed: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Verify TOTP and complete authentication + */ + async verifyTOTP(verifyDto: AdminTOTPVerifyDto): Promise { + const { sessionId, totpCode, token, deviceFingerprint, trustDevice } = verifyDto; + const actualToken = totpCode || token; + + // Get temp session + const tempSession = await this.getTempSession(sessionId); + if (!tempSession) { + throw new UnauthorizedException('Invalid or expired session'); + } + + const { phoneNumber, ipAddress } = tempSession; + + try { + // Get admin user + const adminUser = await this.getOrCreateAdminUser(phoneNumber); + + // Verify TOTP + if (!actualToken) { + throw new UnauthorizedException('TOTP code is required'); + } + const isValid = await this.totpService.verifyTOTP(adminUser.id, actualToken); + if (!isValid) { + await this.securityEventService.logEvent({ + type: SecurityEventType.TOTP_FAILED, + userId: adminUser.id, + ipAddress, + sessionId, + }); + throw new UnauthorizedException('Invalid authenticator code'); + } + + // Trust device if requested + if (trustDevice && deviceFingerprint) { + const fingerprintObj = typeof deviceFingerprint === 'string' + ? JSON.parse(deviceFingerprint) + : deviceFingerprint; + const deviceId = await this.deviceFingerprintService.generateDeviceId(fingerprintObj); + const deviceName = this.deviceFingerprintService.getDeviceName(fingerprintObj); + await this.totpService.registerTrustedDevice(adminUser.id, deviceId, deviceName); + } + + // Log successful TOTP verification + await this.securityEventService.logEvent({ + type: SecurityEventType.TOTP_VERIFIED, + userId: adminUser.id, + ipAddress, + sessionId, + }); + + // Complete authentication + return this.completeAuthentication(adminUser, deviceFingerprint, ipAddress); + } catch (error) { + this.logger.error(`TOTP verification failed: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Setup TOTP for admin user + */ + async setupTOTP(userId: string, phoneNumber: string): Promise { + const setup = await this.totpService.setupTOTP(userId, phoneNumber); + + await this.securityEventService.logEvent({ + type: SecurityEventType.TOTP_SETUP, + userId, + details: { phoneNumber }, + }); + + return setup; + } + + /** + * Complete authentication and create session + */ + private async completeAuthentication( + adminUser: EnhancedAdminUser, + deviceFingerprint: any, + ipAddress: string, + ): Promise { + // Create JWT tokens + const payload = { + userId: adminUser.id, + phoneNumber: adminUser.phoneNumber, + role: adminUser.role, + type: 'admin-dashboard', + }; + + const accessToken = this.jwtService.sign(payload, { + expiresIn: '24h', + }); + + const refreshToken = this.jwtService.sign(payload, { + expiresIn: '30d', + }); + + // Create admin session + const session = await this.createAdminSession( + adminUser, + accessToken, + refreshToken, + deviceFingerprint, + ipAddress, + ); + + // Update last login + await this.updateLastLogin(adminUser.id); + + // Log successful login + await this.securityEventService.logEvent({ + type: SecurityEventType.LOGIN_SUCCESS, + userId: adminUser.id, + sessionId: session.id, + ipAddress, + }); + + return { + accessToken, + refreshToken, + expiresIn: 86400, + phoneNumber: adminUser.phoneNumber, + user: adminUser, + sessionId: session.id, + }; + } + + /** + * Validate token with enhanced security checks + */ + async validateToken(token: string): Promise { + try { + const payload = this.jwtService.verify(token); + + if (payload.type !== 'admin-dashboard') { + return null; + } + + // Get admin user + const adminUser = await this.getAdminUser(payload.userId); + if (!adminUser) { + return null; + } + + // Check if user still has admin access + const adminConfig = this.getAdminConfig(adminUser.phoneNumber); + if (!adminConfig) { + return null; + } + + return { + ...payload, + permissions: adminUser.permissions, + }; + } catch { + return null; + } + } + + /** + * Helper methods + */ + private loadAdminConfig(): Map { + const adminConfig = new Map(); + + // Load from environment variable + const adminPhones = this.configService.get('ADMIN_PHONE_NUMBERS', '').split(','); + const adminRoles = this.configService.get('ADMIN_ROLES', '').split(','); + + adminPhones.forEach((phone, index) => { + const cleanPhone = phone.trim(); + if (cleanPhone) { + adminConfig.set(cleanPhone, { + phoneNumber: cleanPhone, + role: (adminRoles[index]?.trim() as UserRole) || UserRole.ADMIN, + }); + } + }); + + return adminConfig; + } + + private getAdminConfig(phoneNumber: string): AdminConfig | undefined { + return this.adminConfig.get(phoneNumber); + } + + private async getOrCreateAdminUser(phoneNumber: string): Promise { + const adminConfig = this.getAdminConfig(phoneNumber); + if (!adminConfig) { + throw new UnauthorizedException('Not authorized'); + } + + // Check if user exists + let adminUser = await this.getAdminUserByPhone(phoneNumber); + + if (!adminUser) { + // Create new admin user + adminUser = await this.createAdminUser(phoneNumber, adminConfig.role); + } + + // Get permissions for role + const permissions = await this.rbacService.getPermissionsForRole(adminUser.role); + + return { + ...adminUser, + permissions, + }; + } + + private async getAdminUser(userId: string): Promise { + const userData = await this.sessionService.get(`admin:user:${userId}`); + return userData ? JSON.parse(userData) : null; + } + + private async getAdminUserByPhone(phoneNumber: string): Promise { + const userId = await this.sessionService.get(`admin:phone:${phoneNumber}`); + return userId ? this.getAdminUser(userId) : null; + } + + private async createAdminUser(phoneNumber: string, role: UserRole): Promise { + const userId = this.generateUserId(); + const adminUser: EnhancedAdminUser = { + id: userId, + phoneNumber, + role, + permissions: [], + totpEnabled: false, + createdAt: new Date(), + }; + + // Store user + await this.sessionService.set(`admin:user:${userId}`, JSON.stringify(adminUser)); + await this.sessionService.set(`admin:phone:${phoneNumber}`, userId); + + return adminUser; + } + + private async updateLastLogin(userId: string): Promise { + const adminUser = await this.getAdminUser(userId); + if (adminUser) { + adminUser.lastLoginAt = new Date(); + await this.sessionService.set(`admin:user:${userId}`, JSON.stringify(adminUser)); + } + } + + private async checkRateLimit(phoneNumber: string, ipAddress: string): Promise { + const phoneKey = `ratelimit:login:phone:${phoneNumber}`; + const ipKey = `ratelimit:login:ip:${ipAddress}`; + + const [phoneAttempts, ipAttempts] = await Promise.all([ + this.sessionService.incr(phoneKey), + this.sessionService.incr(ipKey), + ]); + + // Set expiry if first attempt + if (phoneAttempts === 1) { + await this.sessionService.expire(phoneKey, 300); // 5 minutes + } + if (ipAttempts === 1) { + await this.sessionService.expire(ipKey, 300); // 5 minutes + } + + // Check limits + return phoneAttempts > 5 || ipAttempts > 10; + } + + private async sendOTP(phoneNumber: string, otp: string): Promise { + if (!this.whatsappWebService.isClientReady()) { + this.logger.warn('WhatsApp not connected, OTP not sent via WhatsApp'); + return; + } + + const message = + `🔐 *Admin Dashboard Login*\n\n` + + `Your verification code is: *${otp}*\n\n` + + `This code expires in 5 minutes.\n` + + `If you didn't request this, please ignore.`; + + try { + await this.whatsappWebService.sendMessage(`${phoneNumber}@c.us`, message); + } catch (error) { + this.logger.error('Failed to send OTP via WhatsApp:', error); + } + } + + private cleanPhoneNumber(phoneNumber: string): string { + let cleaned = phoneNumber.replace(/\D/g, ''); + if (!cleaned.startsWith('1') && cleaned.length === 10) { + cleaned = '1' + cleaned; + } + return cleaned; + } + + private generateUserId(): string { + return crypto.randomBytes(16).toString('hex'); + } + + private async createTempSession( + phoneNumber: string, + deviceFingerprint: any, + ipAddress: string, + ): Promise { + const sessionId = this.generateSessionId(); + await this.sessionService.set( + `admin:temp:${sessionId}`, + { + phoneNumber, + deviceFingerprint, + ipAddress, + createdAt: Date.now(), + }, + 300, // 5 minutes + ); + return sessionId; + } + + private async getTempSession(sessionId: string): Promise { + return this.sessionService.get(`admin:temp:${sessionId}`); + } + + private async createAdminSession( + user: EnhancedAdminUser, + accessToken: string, + refreshToken: string, + deviceFingerprint: any, + ipAddress: string, + ): Promise { + const sessionId = this.generateSessionId(); + const deviceId = await this.deviceFingerprintService.generateDeviceId(deviceFingerprint); + + const session = { + id: sessionId, + userId: user.id, + phoneNumber: user.phoneNumber, + accessToken, + refreshToken, + deviceId, + ipAddress, + createdAt: Date.now(), + lastActivity: Date.now(), + }; + + await this.sessionService.set( + `admin:session:${sessionId}`, + session, + 2592000, // 30 days + ); + + await this.securityEventService.logEvent({ + type: SecurityEventType.SESSION_CREATED, + userId: user.id, + sessionId, + ipAddress, + }); + + return session; + } + + private generateSessionId(): string { + return Math.random().toString(36).substring(2) + Date.now().toString(36); + } +} + +interface AdminConfig { + phoneNumber: string; + role: UserRole; +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/services/rbac.service.ts b/src/modules/admin-dashboard/services/rbac.service.ts new file mode 100644 index 0000000..0265013 --- /dev/null +++ b/src/modules/admin-dashboard/services/rbac.service.ts @@ -0,0 +1,281 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export enum UserRole { + SUPER_ADMIN = 'super_admin', + ADMIN = 'admin', + MODERATOR = 'moderator', + READ_ONLY = 'read_only', +} + +export enum Permission { + // User management + USER_VIEW = 'user:view', + USER_EDIT = 'user:edit', + USER_DELETE = 'user:delete', + + // Session management + SESSION_VIEW = 'session:view', + SESSION_DELETE = 'session:delete', + + // WhatsApp management + WHATSAPP_VIEW = 'whatsapp:view', + WHATSAPP_MANAGE = 'whatsapp:manage', + + // Announcements + ANNOUNCEMENT_SEND = 'announcement:send', + ANNOUNCEMENT_SCHEDULE = 'announcement:schedule', + + // System + SYSTEM_LOGS_VIEW = 'system:logs:view', + SYSTEM_HEALTH_VIEW = 'system:health:view', + SYSTEM_CONFIG_EDIT = 'system:config:edit', + + // Admin commands + COMMAND_EXECUTE = 'command:execute', + COMMAND_HISTORY_VIEW = 'command:history:view', +} + +export interface RoleDefinition { + name: string; + description: string; + permissions: Permission[]; + inherits?: UserRole[]; +} + +@Injectable() +export class RBACService { + private roleDefinitions: Map; + + constructor(private readonly configService: ConfigService) { + this.roleDefinitions = this.initializeRoles(); + } + + /** + * Check if a role has a specific permission + */ + hasPermission(role: UserRole, permission: Permission): boolean { + const permissions = this.getPermissionsForRole(role); + return permissions.includes(permission); + } + + /** + * Check if a user can perform an action on a resource + */ + canAccess( + userRole: UserRole, + resource: string, + action: string, + ): boolean { + const permission = `${resource}:${action}` as Permission; + return this.hasPermission(userRole, permission); + } + + /** + * Enforce permission check - throws if not allowed + */ + enforcePermission( + userRole: UserRole, + permission: Permission, + message?: string, + ): void { + if (!this.hasPermission(userRole, permission)) { + throw new ForbiddenException( + message || `Permission denied: ${permission} required`, + ); + } + } + + /** + * Get all permissions for a role (including inherited) + */ + getPermissionsForRole(role: UserRole): Permission[] { + const roleDefinition = this.roleDefinitions.get(role); + if (!roleDefinition) { + return []; + } + + const permissions = new Set(roleDefinition.permissions); + + // Add inherited permissions + if (roleDefinition.inherits) { + roleDefinition.inherits.forEach(inheritedRole => { + const inheritedPermissions = this.getPermissionsForRole(inheritedRole); + inheritedPermissions.forEach(p => permissions.add(p)); + }); + } + + return Array.from(permissions); + } + + /** + * Get role hierarchy + */ + getRoleHierarchy(): Map { + const hierarchy = new Map(); + + this.roleDefinitions.forEach((definition, role) => { + hierarchy.set(role, definition.inherits || []); + }); + + return hierarchy; + } + + /** + * Check if one role is higher than another + */ + isRoleHigherOrEqual(role1: UserRole, role2: UserRole): boolean { + if (role1 === role2) return true; + + const hierarchy = { + [UserRole.SUPER_ADMIN]: 4, + [UserRole.ADMIN]: 3, + [UserRole.MODERATOR]: 2, + [UserRole.READ_ONLY]: 1, + }; + + return (hierarchy[role1] || 0) >= (hierarchy[role2] || 0); + } + + /** + * Get all available roles + */ + getAllRoles(): RoleDefinition[] { + return Array.from(this.roleDefinitions.values()); + } + + /** + * Create custom permission check + */ + createPermissionCheck( + requiredPermissions: Permission[], + requireAll = true, + ): (role: UserRole) => boolean { + return (role: UserRole) => { + const userPermissions = this.getPermissionsForRole(role); + + if (requireAll) { + return requiredPermissions.every(p => userPermissions.includes(p)); + } else { + return requiredPermissions.some(p => userPermissions.includes(p)); + } + }; + } + + /** + * Initialize role definitions + */ + private initializeRoles(): Map { + const roles = new Map(); + + // Super Admin - Full access + roles.set(UserRole.SUPER_ADMIN, { + name: 'Super Administrator', + description: 'Full system access with all permissions', + permissions: Object.values(Permission), + }); + + // Admin - Most permissions except system config + roles.set(UserRole.ADMIN, { + name: 'Administrator', + description: 'Administrative access with user and session management', + permissions: [ + Permission.USER_VIEW, + Permission.USER_EDIT, + Permission.USER_DELETE, + Permission.SESSION_VIEW, + Permission.SESSION_DELETE, + Permission.WHATSAPP_VIEW, + Permission.WHATSAPP_MANAGE, + Permission.ANNOUNCEMENT_SEND, + Permission.ANNOUNCEMENT_SCHEDULE, + Permission.SYSTEM_LOGS_VIEW, + Permission.SYSTEM_HEALTH_VIEW, + Permission.COMMAND_EXECUTE, + Permission.COMMAND_HISTORY_VIEW, + ], + }); + + // Moderator - Limited management capabilities + roles.set(UserRole.MODERATOR, { + name: 'Moderator', + description: 'Moderate users and send announcements', + permissions: [ + Permission.USER_VIEW, + Permission.SESSION_VIEW, + Permission.SESSION_DELETE, + Permission.WHATSAPP_VIEW, + Permission.ANNOUNCEMENT_SEND, + Permission.SYSTEM_HEALTH_VIEW, + Permission.COMMAND_HISTORY_VIEW, + ], + }); + + // Read Only - View only access + roles.set(UserRole.READ_ONLY, { + name: 'Read Only', + description: 'View-only access to dashboard', + permissions: [ + Permission.USER_VIEW, + Permission.SESSION_VIEW, + Permission.WHATSAPP_VIEW, + Permission.SYSTEM_HEALTH_VIEW, + Permission.COMMAND_HISTORY_VIEW, + ], + }); + + // Load custom role definitions from config if available + const customRoles = this.configService.get('CUSTOM_ROLES'); + if (customRoles) { + try { + const parsed = JSON.parse(customRoles); + Object.entries(parsed).forEach(([role, definition]) => { + roles.set(role as UserRole, definition as RoleDefinition); + }); + } catch (error) { + console.error('Failed to parse custom roles:', error); + } + } + + return roles; + } + + /** + * Generate permission matrix for UI + */ + getPermissionMatrix(): Record> { + const matrix: Record> = {} as any; + + Object.values(UserRole).forEach(role => { + matrix[role] = {} as Record; + const rolePermissions = this.getPermissionsForRole(role); + + Object.values(Permission).forEach(permission => { + matrix[role][permission] = rolePermissions.includes(permission); + }); + }); + + return matrix; + } + + /** + * Check multiple permissions with custom logic + */ + checkPermissions( + userRole: UserRole, + checks: { + permissions: Permission[]; + logic: 'AND' | 'OR'; + }[], + ): boolean { + const userPermissions = this.getPermissionsForRole(userRole); + + return checks.every(check => { + if (check.logic === 'AND') { + return check.permissions.every(p => userPermissions.includes(p)); + } else { + return check.permissions.some(p => userPermissions.includes(p)); + } + }); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/services/security-event.service.ts b/src/modules/admin-dashboard/services/security-event.service.ts new file mode 100644 index 0000000..1392606 --- /dev/null +++ b/src/modules/admin-dashboard/services/security-event.service.ts @@ -0,0 +1,503 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { RedisService } from '../../redis/redis.service'; +import * as crypto from 'crypto'; + +export enum SecurityEventType { + LOGIN_ATTEMPT = 'login_attempt', + LOGIN_SUCCESS = 'login_success', + LOGIN_FAILURE = 'login_failure', + TOTP_SETUP = 'totp_setup', + TOTP_VERIFIED = 'totp_verified', + TOTP_FAILED = 'totp_failed', + SESSION_CREATED = 'session_created', + SESSION_EXPIRED = 'session_expired', + SESSION_REVOKED = 'session_revoked', + PERMISSION_DENIED = 'permission_denied', + RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded', + SUSPICIOUS_ACTIVITY = 'suspicious_activity', + DATA_ACCESS = 'data_access', + DATA_MODIFICATION = 'data_modification', + CONFIGURATION_CHANGE = 'configuration_change', + BULK_OPERATION = 'bulk_operation', +} + +export enum SecurityEventSeverity { + INFO = 'info', + WARNING = 'warning', + ERROR = 'error', + CRITICAL = 'critical', +} + +export interface SecurityEvent { + id: string; + type: SecurityEventType; + severity: SecurityEventSeverity; + userId?: string; + sessionId?: string; + ipAddress: string; + userAgent?: string; + details: Record; + timestamp: Date; + hash?: string; // For tamper detection +} + +export interface SecurityEventFilter { + types?: SecurityEventType[]; + severities?: SecurityEventSeverity[]; + userId?: string; + sessionId?: string; + ipAddress?: string; + startDate?: Date; + endDate?: Date; + limit?: number; +} + +export interface SecurityMetrics { + totalEvents: number; + eventsByType: Record; + eventsBySeverity: Record; + topIpAddresses: Array<{ ip: string; count: number }>; + suspiciousActivities: number; + failedLogins: number; + successfulLogins: number; +} + +@Injectable() +export class SecurityEventService { + private readonly logger = new Logger(SecurityEventService.name); + private readonly maxEventsStored = 100000; + private readonly eventTTL = 90 * 24 * 60 * 60; // 90 days + + constructor( + private readonly redisService: RedisService, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Log a security event + */ + async logEvent(eventData: Partial): Promise { + const event: SecurityEvent = { + id: this.generateEventId(), + severity: this.determineSeverity(eventData.type!), + timestamp: new Date(), + ipAddress: eventData.ipAddress || 'unknown', + ...eventData, + } as SecurityEvent; + + // Generate hash for tamper detection + event.hash = this.generateEventHash(event); + + // Store event + await this.storeEvent(event); + + // Emit event for real-time monitoring + this.eventEmitter.emit('security.event', event); + + // Check for suspicious patterns + await this.analyzeSuspiciousActivity(event); + + // Log critical events + if (event.severity === SecurityEventSeverity.CRITICAL) { + this.logger.error(`Critical security event: ${event.type}`, event); + } + + return event; + } + + /** + * Query security events + */ + async queryEvents(filter: SecurityEventFilter): Promise { + const events = await this.getAllEvents(); + + return events + .filter(event => this.matchesFilter(event, filter)) + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) + .slice(0, filter.limit || 1000); + } + + /** + * Get security metrics + */ + async getMetrics(timeRange: { start: Date; end: Date }): Promise { + const events = await this.getAllEvents(); + const filteredEvents = events.filter( + e => e.timestamp >= timeRange.start && e.timestamp <= timeRange.end, + ); + + const metrics: SecurityMetrics = { + totalEvents: filteredEvents.length, + eventsByType: {} as Record, + eventsBySeverity: {} as Record, + topIpAddresses: [], + suspiciousActivities: 0, + failedLogins: 0, + successfulLogins: 0, + }; + + // Count events by type + const typeCount = new Map(); + const severityCount = new Map(); + const ipCount = new Map(); + + filteredEvents.forEach(event => { + // Type counting + typeCount.set(event.type, (typeCount.get(event.type) || 0) + 1); + + // Severity counting + severityCount.set(event.severity, (severityCount.get(event.severity) || 0) + 1); + + // IP counting + ipCount.set(event.ipAddress, (ipCount.get(event.ipAddress) || 0) + 1); + + // Specific metrics + if (event.type === SecurityEventType.SUSPICIOUS_ACTIVITY) { + metrics.suspiciousActivities++; + } + if (event.type === SecurityEventType.LOGIN_FAILURE) { + metrics.failedLogins++; + } + if (event.type === SecurityEventType.LOGIN_SUCCESS) { + metrics.successfulLogins++; + } + }); + + // Convert maps to objects + metrics.eventsByType = Object.fromEntries(typeCount) as any; + metrics.eventsBySeverity = Object.fromEntries(severityCount) as any; + + // Get top IPs + metrics.topIpAddresses = Array.from(ipCount.entries()) + .map(([ip, count]) => ({ ip, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return metrics; + } + + /** + * Detect anomalies in security events + */ + async detectAnomalies(): Promise> { + const anomalies = []; + const recentEvents = await this.getRecentEvents(60 * 60 * 1000); // Last hour + + // Check for brute force attempts + const failedLogins = recentEvents.filter( + e => e.type === SecurityEventType.LOGIN_FAILURE, + ); + const failedLoginsByIp = this.groupBy(failedLogins, 'ipAddress'); + + for (const [ip, events] of Object.entries(failedLoginsByIp)) { + if (events.length > 10) { + anomalies.push({ + type: 'brute_force', + description: `Possible brute force attack from IP ${ip}`, + severity: SecurityEventSeverity.CRITICAL, + events, + }); + } + } + + // Check for rapid session creation + const sessionCreations = recentEvents.filter( + e => e.type === SecurityEventType.SESSION_CREATED, + ); + if (sessionCreations.length > 50) { + anomalies.push({ + type: 'session_flood', + description: 'Abnormal number of sessions created', + severity: SecurityEventSeverity.WARNING, + events: sessionCreations, + }); + } + + // Check for permission denied patterns + const permissionDenied = recentEvents.filter( + e => e.type === SecurityEventType.PERMISSION_DENIED, + ); + const deniedByUser = this.groupBy(permissionDenied, 'userId'); + + for (const [userId, events] of Object.entries(deniedByUser)) { + if (events.length > 20) { + anomalies.push({ + type: 'privilege_escalation_attempt', + description: `User ${userId} attempting unauthorized actions`, + severity: SecurityEventSeverity.ERROR, + events, + }); + } + } + + return anomalies; + } + + /** + * Export security events for audit + */ + async exportEvents( + filter: SecurityEventFilter, + format: 'json' | 'csv', + ): Promise { + const events = await this.queryEvents(filter); + + if (format === 'json') { + return JSON.stringify(events, null, 2); + } else { + // CSV format + const headers = [ + 'ID', + 'Type', + 'Severity', + 'Timestamp', + 'User ID', + 'Session ID', + 'IP Address', + 'User Agent', + 'Details', + ]; + + const rows = events.map(event => [ + event.id, + event.type, + event.severity, + event.timestamp.toISOString(), + event.userId || '', + event.sessionId || '', + event.ipAddress, + event.userAgent || '', + JSON.stringify(event.details), + ]); + + return [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')), + ].join('\n'); + } + } + + /** + * Private helper methods + */ + private async storeEvent(event: SecurityEvent): Promise { + const key = `security:event:${event.id}`; + await this.redisService.set(key, JSON.stringify(event), this.eventTTL); + + // Add to sorted set for efficient querying + const score = event.timestamp.getTime(); + await this.redisService.zadd('security:events:timeline', score, event.id); + + // Maintain event count limit + const count = await this.redisService.zcard('security:events:timeline'); + if (count > this.maxEventsStored) { + // Remove oldest events + const toRemove = count - this.maxEventsStored; + const oldestIds = await this.redisService.zrange( + 'security:events:timeline', + 0, + toRemove - 1, + ); + + for (const id of oldestIds) { + await this.redisService.delete(`security:event:${id}`); + } + + await this.redisService.zremrangebyrank( + 'security:events:timeline', + 0, + toRemove - 1, + ); + } + } + + private async getAllEvents(): Promise { + const eventIds = await this.redisService.zrevrange( + 'security:events:timeline', + 0, + -1, + ); + + const events: SecurityEvent[] = []; + + for (const id of eventIds) { + const eventData = await this.redisService.get(`security:event:${id}`); + if (eventData) { + const event = JSON.parse(eventData); + event.timestamp = new Date(event.timestamp); + events.push(event); + } + } + + return events; + } + + private async getRecentEvents(timeWindowMs: number): Promise { + const now = Date.now(); + const startTime = now - timeWindowMs; + + const eventIds = await this.redisService.zrevrangebyscore( + 'security:events:timeline', + now, + startTime, + ); + + const events: SecurityEvent[] = []; + + for (const id of eventIds) { + const eventData = await this.redisService.get(`security:event:${id}`); + if (eventData) { + const event = JSON.parse(eventData); + event.timestamp = new Date(event.timestamp); + events.push(event); + } + } + + return events; + } + + private matchesFilter(event: SecurityEvent, filter: SecurityEventFilter): boolean { + if (filter.types && !filter.types.includes(event.type)) { + return false; + } + + if (filter.severities && !filter.severities.includes(event.severity)) { + return false; + } + + if (filter.userId && event.userId !== filter.userId) { + return false; + } + + if (filter.sessionId && event.sessionId !== filter.sessionId) { + return false; + } + + if (filter.ipAddress && event.ipAddress !== filter.ipAddress) { + return false; + } + + if (filter.startDate && event.timestamp < filter.startDate) { + return false; + } + + if (filter.endDate && event.timestamp > filter.endDate) { + return false; + } + + return true; + } + + private determineSeverity(type: SecurityEventType): SecurityEventSeverity { + const severityMap: Record = { + [SecurityEventType.LOGIN_ATTEMPT]: SecurityEventSeverity.INFO, + [SecurityEventType.LOGIN_SUCCESS]: SecurityEventSeverity.INFO, + [SecurityEventType.LOGIN_FAILURE]: SecurityEventSeverity.WARNING, + [SecurityEventType.TOTP_SETUP]: SecurityEventSeverity.INFO, + [SecurityEventType.TOTP_VERIFIED]: SecurityEventSeverity.INFO, + [SecurityEventType.TOTP_FAILED]: SecurityEventSeverity.WARNING, + [SecurityEventType.SESSION_CREATED]: SecurityEventSeverity.INFO, + [SecurityEventType.SESSION_EXPIRED]: SecurityEventSeverity.INFO, + [SecurityEventType.SESSION_REVOKED]: SecurityEventSeverity.WARNING, + [SecurityEventType.PERMISSION_DENIED]: SecurityEventSeverity.WARNING, + [SecurityEventType.RATE_LIMIT_EXCEEDED]: SecurityEventSeverity.WARNING, + [SecurityEventType.SUSPICIOUS_ACTIVITY]: SecurityEventSeverity.CRITICAL, + [SecurityEventType.DATA_ACCESS]: SecurityEventSeverity.INFO, + [SecurityEventType.DATA_MODIFICATION]: SecurityEventSeverity.WARNING, + [SecurityEventType.CONFIGURATION_CHANGE]: SecurityEventSeverity.ERROR, + [SecurityEventType.BULK_OPERATION]: SecurityEventSeverity.WARNING, + }; + + return severityMap[type] || SecurityEventSeverity.INFO; + } + + private generateEventId(): string { + return `evt_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; + } + + private generateEventHash(event: SecurityEvent): string { + const content = JSON.stringify({ + id: event.id, + type: event.type, + userId: event.userId, + sessionId: event.sessionId, + ipAddress: event.ipAddress, + timestamp: event.timestamp, + }); + + return crypto + .createHash('sha256') + .update(content) + .digest('hex'); + } + + private async analyzeSuspiciousActivity(event: SecurityEvent): Promise { + // Check for suspicious patterns + const suspiciousPatterns = [ + // Multiple failed logins from same IP + async () => { + if (event.type === SecurityEventType.LOGIN_FAILURE) { + const recentFailures = await this.getRecentEvents(5 * 60 * 1000); + const sameIpFailures = recentFailures.filter( + e => e.type === SecurityEventType.LOGIN_FAILURE && + e.ipAddress === event.ipAddress, + ); + + if (sameIpFailures.length > 5) { + await this.logEvent({ + type: SecurityEventType.SUSPICIOUS_ACTIVITY, + ipAddress: event.ipAddress, + details: { + reason: 'Multiple failed login attempts', + failureCount: sameIpFailures.length, + }, + }); + } + } + }, + + // Rapid session creation + async () => { + if (event.type === SecurityEventType.SESSION_CREATED && event.userId) { + const recentSessions = await this.getRecentEvents(60 * 1000); + const userSessions = recentSessions.filter( + e => e.type === SecurityEventType.SESSION_CREATED && + e.userId === event.userId, + ); + + if (userSessions.length > 3) { + await this.logEvent({ + type: SecurityEventType.SUSPICIOUS_ACTIVITY, + userId: event.userId, + ipAddress: event.ipAddress, + details: { + reason: 'Rapid session creation', + sessionCount: userSessions.length, + }, + }); + } + } + }, + ]; + + // Run all pattern checks + await Promise.all(suspiciousPatterns.map(check => check())); + } + + private groupBy(array: T[], key: keyof T): Record { + return array.reduce((result, item) => { + const group = String(item[key]); + if (!result[group]) { + result[group] = []; + } + result[group].push(item); + return result; + }, {} as Record); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/services/totp-auth.service.ts b/src/modules/admin-dashboard/services/totp-auth.service.ts new file mode 100644 index 0000000..ac24286 --- /dev/null +++ b/src/modules/admin-dashboard/services/totp-auth.service.ts @@ -0,0 +1,356 @@ +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import * as crypto from 'crypto'; +import { RedisService } from '../../redis/redis.service'; +import { SessionService } from '../../auth/services/session.service'; + +export interface TOTPSetup { + secret: string; + qrCode: string; + backupCodes: string[]; +} + +export interface TOTPDevice { + id: string; + name: string; + lastUsed: Date; + trusted: boolean; +} + +@Injectable() +export class TOTPAuthService { + private readonly issuerName: string; + private readonly backupCodeCount = 10; + private readonly codeLength = 8; + + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + private readonly sessionService: SessionService, + ) { + this.issuerName = this.configService.get('APP_NAME', 'Flash Admin'); + } + + /** + * Generate TOTP secret and QR code for user setup + */ + async setupTOTP(userId: string, phoneNumber: string): Promise { + // Generate secret + const secret = speakeasy.generateSecret({ + length: 32, + name: `${this.issuerName} (${phoneNumber})`, + issuer: this.issuerName, + }); + + // Generate QR code + const qrCode = await QRCode.toDataURL(secret.otpauth_url || ''); + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + + // Store encrypted secret and backup codes + await this.storeTOTPSecret(userId, secret.base32, backupCodes); + + return { + secret: secret.base32, + qrCode: await qrCode, + backupCodes, + }; + } + + /** + * Verify TOTP token + */ + async verifyTOTP(userId: string, token: string): Promise { + const secret = await this.getTOTPSecret(userId); + + if (!secret) { + throw new UnauthorizedException('TOTP not set up for this user'); + } + + // Check if it's a backup code + const isBackupCode = await this.verifyBackupCode(userId, token); + if (isBackupCode) { + return true; + } + + // Verify TOTP token + const verified = speakeasy.totp.verify({ + secret, + encoding: 'base32', + token, + window: 2, // Allow 2 time steps for clock drift + }); + + if (verified) { + // Record successful verification + await this.recordTOTPUsage(userId); + } + + return verified; + } + + /** + * Enable TOTP after successful verification + */ + async enableTOTP(userId: string, verificationToken: string): Promise { + const verified = await this.verifyTOTP(userId, verificationToken); + + if (!verified) { + throw new BadRequestException('Invalid verification code'); + } + + await this.redisService.set(`totp:enabled:${userId}`, 'true'); + } + + /** + * Disable TOTP (requires recent authentication) + */ + async disableTOTP(userId: string, verificationToken: string): Promise { + const verified = await this.verifyTOTP(userId, verificationToken); + + if (!verified) { + throw new UnauthorizedException('Invalid verification code'); + } + + await this.removeTOTPSecret(userId); + await this.redisService.delete(`totp:enabled:${userId}`); + } + + /** + * Check if user has TOTP enabled + */ + async isTOTPEnabled(userId: string): Promise { + const enabled = await this.redisService.get(`totp:enabled:${userId}`); + return enabled === 'true'; + } + + /** + * Generate new backup codes + */ + async regenerateBackupCodes(userId: string, verificationToken: string): Promise { + const verified = await this.verifyTOTP(userId, verificationToken); + + if (!verified) { + throw new UnauthorizedException('Invalid verification code'); + } + + const backupCodes = this.generateBackupCodes(); + const secret = await this.getTOTPSecret(userId); + + if (!secret) { + throw new BadRequestException('TOTP not set up'); + } + + await this.storeTOTPSecret(userId, secret, backupCodes); + + return backupCodes; + } + + /** + * Register trusted device + */ + async registerTrustedDevice( + userId: string, + deviceId: string, + deviceName: string, + ): Promise { + const device: TOTPDevice = { + id: deviceId, + name: deviceName, + lastUsed: new Date(), + trusted: true, + }; + + await this.redisService.set( + `totp:device:${userId}:${deviceId}`, + JSON.stringify(device), + 30 * 24 * 60 * 60, // 30 days + ); + } + + /** + * Check if device is trusted + */ + async isDeviceTrusted(userId: string, deviceId: string): Promise { + const deviceData = await this.redisService.get(`totp:device:${userId}:${deviceId}`); + + if (!deviceData) { + return false; + } + + const device: TOTPDevice = JSON.parse(deviceData); + return device.trusted; + } + + /** + * Get user's trusted devices + */ + async getTrustedDevices(userId: string): Promise { + const pattern = `totp:device:${userId}:*`; + const devices: TOTPDevice[] = []; + + const keys = await this.redisService.keys(pattern); + + for (const key of keys) { + const deviceData = await this.redisService.get(key); + if (deviceData) { + devices.push(JSON.parse(deviceData)); + } + } + + return devices.sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()); + } + + /** + * Revoke trusted device + */ + async revokeTrustedDevice(userId: string, deviceId: string): Promise { + await this.redisService.delete(`totp:device:${userId}:${deviceId}`); + } + + /** + * Private helper methods + */ + private generateBackupCodes(): string[] { + const codes: string[] = []; + + for (let i = 0; i < this.backupCodeCount; i++) { + const code = crypto + .randomBytes(4) + .toString('hex') + .toUpperCase() + .match(/.{4}/g) + ?.join('-') || ''; + codes.push(code); + } + + return codes; + } + + private async storeTOTPSecret( + userId: string, + secret: string, + backupCodes: string[], + ): Promise { + // Encrypt sensitive data + const encryptedSecret = this.encrypt(secret); + const hashedBackupCodes = backupCodes.map(code => this.hashBackupCode(code)); + + const data = { + secret: encryptedSecret, + backupCodes: hashedBackupCodes, + createdAt: new Date(), + }; + + await this.redisService.set(`totp:secret:${userId}`, JSON.stringify(data)); + } + + private async getTOTPSecret(userId: string): Promise { + const data = await this.redisService.get(`totp:secret:${userId}`); + + if (!data) { + return null; + } + + const parsed = JSON.parse(data); + return this.decrypt(parsed.secret); + } + + private async removeTOTPSecret(userId: string): Promise { + await this.redisService.delete(`totp:secret:${userId}`); + + // Remove all trusted devices + const pattern = `totp:device:${userId}:*`; + const keys = await this.redisService.keys(pattern); + + for (const key of keys) { + await this.redisService.delete(key); + } + } + + private async verifyBackupCode(userId: string, code: string): Promise { + const data = await this.redisService.get(`totp:secret:${userId}`); + + if (!data) { + return false; + } + + const parsed = JSON.parse(data); + const hashedCode = this.hashBackupCode(code); + const index = parsed.backupCodes.indexOf(hashedCode); + + if (index === -1) { + return false; + } + + // Remove used backup code + parsed.backupCodes.splice(index, 1); + await this.redisService.set(`totp:secret:${userId}`, JSON.stringify(parsed)); + + return true; + } + + private hashBackupCode(code: string): string { + return crypto + .createHash('sha256') + .update(code) + .digest('hex'); + } + + private encrypt(text: string): string { + const algorithm = 'aes-256-gcm'; + const key = Buffer.from( + this.configService.get('ENCRYPTION_KEY', ''), + 'hex', + ); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; + } + + private decrypt(encryptedText: string): string { + const algorithm = 'aes-256-gcm'; + const key = Buffer.from( + this.configService.get('ENCRYPTION_KEY', ''), + 'hex', + ); + + const parts = encryptedText.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = parts[2]; + + const decipher = crypto.createDecipheriv(algorithm, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } + + private async recordTOTPUsage(userId: string): Promise { + const key = `totp:usage:${userId}`; + const usage = { + lastUsed: new Date(), + count: 1, + }; + + const existing = await this.redisService.get(key); + if (existing) { + const parsed = JSON.parse(existing); + usage.count = parsed.count + 1; + } + + await this.redisService.set(key, JSON.stringify(usage)); + } +} \ No newline at end of file diff --git a/src/modules/admin-dashboard/types/auth.types.ts b/src/modules/admin-dashboard/types/auth.types.ts new file mode 100644 index 0000000..c78f22f --- /dev/null +++ b/src/modules/admin-dashboard/types/auth.types.ts @@ -0,0 +1,35 @@ +export enum UserRole { + SUPER_ADMIN = 'super_admin', + ADMIN = 'admin', + MODERATOR = 'moderator', + READ_ONLY = 'read_only', +} + +export enum Permission { + // User management + USER_VIEW = 'user:view', + USER_EDIT = 'user:edit', + USER_DELETE = 'user:delete', + + // Session management + SESSION_VIEW = 'session:view', + SESSION_DELETE = 'session:delete', + + // WhatsApp management + WHATSAPP_VIEW = 'whatsapp:view', + WHATSAPP_MANAGE = 'whatsapp:manage', + + // Announcements + ANNOUNCEMENT_SEND = 'announcement:send', + ANNOUNCEMENT_SCHEDULE = 'announcement:schedule', + + // System + SYSTEM_LOGS_VIEW = 'system:logs:view', + SYSTEM_HEALTH_VIEW = 'system:health:view', + SYSTEM_CONFIG_EDIT = 'system:config:edit', + + // Admin commands + COMMAND_EXECUTE = 'command:execute', + COMMAND_HISTORY_VIEW = 'command:history:view', +} + diff --git a/src/modules/auth/services/session.service.ts b/src/modules/auth/services/session.service.ts index e3d879d..dc4c340 100644 --- a/src/modules/auth/services/session.service.ts +++ b/src/modules/auth/services/session.service.ts @@ -346,4 +346,18 @@ export class SessionService { return results; } + + /** + * Increment a counter (for rate limiting) + */ + async incr(key: string): Promise { + return this.redisService.incr(key); + } + + /** + * Set expiry on a key (for rate limiting) + */ + async expire(key: string, seconds: number): Promise { + await this.redisService.expire(key, seconds); + } } diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index 50b3a83..df8102b 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -66,6 +66,13 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { await this.redisClient.del(key); } + /** + * Delete key (alias for del) + */ + async delete(key: string): Promise { + await this.redisClient.del(key); + } + /** * Set key with expiry only if it doesn't exist */ @@ -88,6 +95,62 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { return this.redisClient.smembers(key); } + /** + * Add one or more members to a sorted set + */ + async zadd(key: string, score: number, member: string): Promise { + return this.redisClient.zadd(key, score, member); + } + + /** + * Get the number of members in a sorted set + */ + async zcard(key: string): Promise { + return this.redisClient.zcard(key); + } + + /** + * Return a range of members in a sorted set, by index + */ + async zrange(key: string, start: number, stop: number, withScores?: boolean): Promise { + if (withScores) { + return this.redisClient.zrange(key, start, stop, 'WITHSCORES'); + } + return this.redisClient.zrange(key, start, stop); + } + + /** + * Return a range of members in a sorted set, by index, with scores ordered from high to low + */ + async zrevrange(key: string, start: number, stop: number, withScores?: boolean): Promise { + if (withScores) { + return this.redisClient.zrevrange(key, start, stop, 'WITHSCORES'); + } + return this.redisClient.zrevrange(key, start, stop); + } + + /** + * Return a range of members in a sorted set, by score, with scores ordered from high to low + */ + async zrevrangebyscore(key: string, max: number | string, min: number | string, withScores?: boolean, limit?: { offset: number; count: number }): Promise { + if (withScores && limit) { + return this.redisClient.zrevrangebyscore(key, max, min, 'WITHSCORES', 'LIMIT', limit.offset, limit.count); + } else if (withScores) { + return this.redisClient.zrevrangebyscore(key, max, min, 'WITHSCORES'); + } else if (limit) { + return this.redisClient.zrevrangebyscore(key, max, min, 'LIMIT', limit.offset, limit.count); + } else { + return this.redisClient.zrevrangebyscore(key, max, min); + } + } + + /** + * Remove all members in a sorted set within the given indexes + */ + async zremrangebyrank(key: string, start: number, stop: number): Promise { + return this.redisClient.zremrangebyrank(key, start, stop); + } + /** * Get keys matching pattern */ diff --git a/tsconfig.json b/tsconfig.json index 7cc04f9..9d6856f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,7 @@ "paths": { "@app/*": ["src/*"] } - } + }, + "include": ["src/**/*"], + "exclude": ["./admin-dashboard-v2/**/*", "node_modules", "dist"] } \ No newline at end of file