From 976b1159b9bc6b014f9aeb9ca2b2e294a3d9d4f4 Mon Sep 17 00:00:00 2001 From: Rukayat Zakariyau Date: Sat, 25 Apr 2026 21:30:21 +0100 Subject: [PATCH] Two factor Authentification --- .../cmmty/analytics/analytics.controller.ts | 29 ++ backend/cmmty/analytics/analytics.module.ts | 14 + .../cmmty/analytics/analytics.service.spec.ts | 269 +++++++++++++++ backend/cmmty/analytics/analytics.service.ts | 195 +++++++++++ .../bulk-upload/bulk-upload.controller.ts | 56 ++++ .../cmmty/bulk-upload/bulk-upload.module.ts | 14 + .../bulk-upload/bulk-upload.service.spec.ts | 305 +++++++++++++++++ .../cmmty/bulk-upload/bulk-upload.service.ts | 147 ++++++++ .../dto/update-notification-prefs.dto.ts | 31 ++ .../notification-prefs.controller.ts | 39 +++ .../notification-prefs.entity.ts | 32 ++ .../notification-prefs.module.ts | 13 + .../notification-prefs.service.spec.ts | 241 ++++++++++++++ .../notification-prefs.service.ts | 62 ++++ .../two-factor-auth.controller.ts | 134 ++++++++ .../two-factor-auth/two-factor-auth.module.ts | 13 + .../two-factor-auth.service.spec.ts | 315 ++++++++++++++++++ .../two-factor-auth.service.ts | 144 ++++++++ backend/package-lock.json | 63 ++-- backend/package.json | 4 +- backend/src/app.module.ts | 9 + backend/src/mail/mail.module.ts | 4 +- backend/src/mail/mail.service.ts | 32 +- backend/src/users/entities/user.entity.ts | 9 + 24 files changed, 2144 insertions(+), 30 deletions(-) create mode 100644 backend/cmmty/analytics/analytics.controller.ts create mode 100644 backend/cmmty/analytics/analytics.module.ts create mode 100644 backend/cmmty/analytics/analytics.service.spec.ts create mode 100644 backend/cmmty/analytics/analytics.service.ts create mode 100644 backend/cmmty/bulk-upload/bulk-upload.controller.ts create mode 100644 backend/cmmty/bulk-upload/bulk-upload.module.ts create mode 100644 backend/cmmty/bulk-upload/bulk-upload.service.spec.ts create mode 100644 backend/cmmty/bulk-upload/bulk-upload.service.ts create mode 100644 backend/cmmty/notification-prefs/dto/update-notification-prefs.dto.ts create mode 100644 backend/cmmty/notification-prefs/notification-prefs.controller.ts create mode 100644 backend/cmmty/notification-prefs/notification-prefs.entity.ts create mode 100644 backend/cmmty/notification-prefs/notification-prefs.module.ts create mode 100644 backend/cmmty/notification-prefs/notification-prefs.service.spec.ts create mode 100644 backend/cmmty/notification-prefs/notification-prefs.service.ts create mode 100644 backend/cmmty/two-factor-auth/two-factor-auth.controller.ts create mode 100644 backend/cmmty/two-factor-auth/two-factor-auth.module.ts create mode 100644 backend/cmmty/two-factor-auth/two-factor-auth.service.spec.ts create mode 100644 backend/cmmty/two-factor-auth/two-factor-auth.service.ts diff --git a/backend/cmmty/analytics/analytics.controller.ts b/backend/cmmty/analytics/analytics.controller.ts new file mode 100644 index 0000000..2640f7a --- /dev/null +++ b/backend/cmmty/analytics/analytics.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Query, UseGuards, Req, ForbiddenException } from '@nestjs/common'; +import { AnalyticsService, AnalyticsData } from './analytics.service'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { User } from '../../src/users/entities/user.entity'; + +interface AuthenticatedRequest extends Request { + user?: User; +} + +@Controller('admin/analytics') +@UseGuards(JwtAuthGuard) +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get() + async getAnalytics( + @Req() req: AuthenticatedRequest, + @Query('period') period: '7d' | '30d' | '90d' = '30d', + ): Promise { + if (req.user?.role !== 'admin') { + throw new ForbiddenException('Admin access required'); + } + if (!['7d', '30d', '90d'].includes(period)) { + throw new Error('Invalid period. Must be one of: 7d, 30d, 90d'); + } + + return this.analyticsService.getAnalytics(period); + } +} diff --git a/backend/cmmty/analytics/analytics.module.ts b/backend/cmmty/analytics/analytics.module.ts new file mode 100644 index 0000000..cd42b39 --- /dev/null +++ b/backend/cmmty/analytics/analytics.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsController } from './analytics.controller'; +import { Document } from '../../src/documents/entities/document.entity'; +import { User } from '../../src/users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Document, User])], + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/backend/cmmty/analytics/analytics.service.spec.ts b/backend/cmmty/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..c198cd9 --- /dev/null +++ b/backend/cmmty/analytics/analytics.service.spec.ts @@ -0,0 +1,269 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Document } from '../../src/documents/entities/document.entity'; +import { User } from '../../src/users/entities/user.entity'; +import { DocumentStatus } from '../../src/documents/entities/document.entity'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let documentRepository: Repository; + let userRepository: Repository; + + const mockDocumentRepository = { + createQueryBuilder: jest.fn(), + }; + + const mockUserRepository = { + createQueryBuilder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(Document), + useValue: mockDocumentRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + documentRepository = module.get>( + getRepositoryToken(Document), + ); + userRepository = module.get>( + getRepositoryToken(User), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getAnalytics', () => { + it('should return analytics data for 30d period by default', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getMany: jest.fn(), + }; + + mockDocumentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + mockQueryBuilder.getRawMany + .mockResolvedValueOnce([ + { date: '2023-01-01', count: '5' }, + { date: '2023-01-02', count: '3' }, + ]) + .mockResolvedValueOnce([ + { date: '2023-01-01', count: '2' }, + { date: '2023-01-02', count: '4' }, + ]); + + mockQueryBuilder.getRawMany.mockResolvedValue([ + { date: '2023-01-01', count: '1' }, + { date: '2023-01-02', count: '2' }, + ]); + + const mockDocuments = [ + { + id: '1', + riskScore: 0.5, + riskFlags: ['flag1', 'flag2'], + }, + { + id: '2', + riskScore: 0.7, + riskFlags: ['flag1'], + }, + ]; + + mockQueryBuilder.getMany.mockResolvedValue(mockDocuments); + + const result = await service.getAnalytics('30d'); + + expect(result).toHaveProperty('uploads'); + expect(result).toHaveProperty('verifications'); + expect(result).toHaveProperty('newUsers'); + expect(result).toHaveProperty('averageRiskScore'); + expect(result).toHaveProperty('topRiskFlags'); + expect(result.averageRiskScore).toBe(0.6); + }); + + it('should handle 7d period', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getMany: jest.fn(), + }; + + mockDocumentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + mockQueryBuilder.getRawMany.mockResolvedValue([]); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await service.getAnalytics('7d'); + + expect(result).toBeDefined(); + expect(result.uploads).toEqual([]); + expect(result.verifications).toEqual([]); + expect(result.newUsers).toEqual([]); + }); + + it('should handle 90d period', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getMany: jest.fn(), + }; + + mockDocumentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + mockQueryBuilder.getRawMany.mockResolvedValue([]); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await service.getAnalytics('90d'); + + expect(result).toBeDefined(); + }); + }); + + describe('getDateRange', () => { + it('should return correct date range for 7d', () => { + const { startDate, endDate } = (service as any).getDateRange('7d'); + + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + expect(diffDays).toBe(7); + }); + + it('should return correct date range for 30d', () => { + const { startDate, endDate } = (service as any).getDateRange('30d'); + + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + expect(diffDays).toBe(30); + }); + + it('should return correct date range for 90d', () => { + const { startDate, endDate } = (service as any).getDateRange('90d'); + + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + expect(diffDays).toBe(90); + }); + }); + + describe('fillMissingDates', () => { + it('should fill missing dates with zero counts', () => { + const data = [ + { date: '2023-01-01', count: '5' }, + { date: '2023-01-03', count: '3' }, + ]; + + const startDate = new Date('2023-01-01'); + const endDate = new Date('2023-01-03'); + + const result = (service as any).fillMissingDates(data, startDate, endDate); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ date: '2023-01-01', count: 5 }); + expect(result[1]).toEqual({ date: '2023-01-02', count: 0 }); + expect(result[2]).toEqual({ date: '2023-01-03', count: 3 }); + }); + + it('should handle empty data', () => { + const data = []; + const startDate = new Date('2023-01-01'); + const endDate = new Date('2023-01-02'); + + const result = (service as any).fillMissingDates(data, startDate, endDate); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ date: '2023-01-01', count: 0 }); + expect(result[1]).toEqual({ date: '2023-01-02', count: 0 }); + }); + }); + + describe('getRiskAnalytics', () => { + it('should calculate average risk score and top flags', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + + mockDocumentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const mockDocuments = [ + { + riskScore: 0.5, + riskFlags: ['flag1', 'flag2'], + }, + { + riskScore: 0.7, + riskFlags: ['flag1', 'flag3'], + }, + { + riskScore: 0.3, + riskFlags: ['flag2'], + }, + ]; + + mockQueryBuilder.getMany.mockResolvedValue(mockDocuments); + + const startDate = new Date(); + const endDate = new Date(); + + const result = await (service as any).getRiskAnalytics(startDate, endDate); + + expect(result.averageRiskScore).toBe(0.5); + expect(result.topRiskFlags).toHaveLength(3); + expect(result.topRiskFlags[0]).toEqual({ flag: 'flag1', count: 2 }); + expect(result.topRiskFlags[1]).toEqual({ flag: 'flag2', count: 2 }); + expect(result.topRiskFlags[2]).toEqual({ flag: 'flag3', count: 1 }); + }); + + it('should handle no documents', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + + mockDocumentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const startDate = new Date(); + const endDate = new Date(); + + const result = await (service as any).getRiskAnalytics(startDate, endDate); + + expect(result.averageRiskScore).toBe(0); + expect(result.topRiskFlags).toEqual([]); + }); + }); +}); diff --git a/backend/cmmty/analytics/analytics.service.ts b/backend/cmmty/analytics/analytics.service.ts new file mode 100644 index 0000000..050dda1 --- /dev/null +++ b/backend/cmmty/analytics/analytics.service.ts @@ -0,0 +1,195 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Document } from '../../src/documents/entities/document.entity'; +import { User } from '../../src/users/entities/user.entity'; +import { DocumentStatus } from '../../src/documents/entities/document.entity'; + +export interface TimeSeriesData { + date: string; + count: number; +} + +export interface AnalyticsData { + uploads: TimeSeriesData[]; + verifications: TimeSeriesData[]; + newUsers: TimeSeriesData[]; + averageRiskScore: number; + topRiskFlags: Array<{ flag: string; count: number }>; +} + +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(Document) + private readonly documentRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async getAnalytics(period: '7d' | '30d' | '90d'): Promise { + const { startDate, endDate } = this.getDateRange(period); + + const [uploads, verifications, newUsers, riskData] = await Promise.all([ + this.getUploadsByDay(startDate, endDate), + this.getVerificationsByDay(startDate, endDate), + this.getNewUsersByDay(startDate, endDate), + this.getRiskAnalytics(startDate, endDate), + ]); + + return { + uploads, + verifications, + newUsers, + averageRiskScore: riskData.averageRiskScore, + topRiskFlags: riskData.topRiskFlags, + }; + } + + private getDateRange(period: '7d' | '30d' | '90d'): { startDate: Date; endDate: Date } { + const endDate = new Date(); + const startDate = new Date(); + + switch (period) { + case '7d': + startDate.setDate(endDate.getDate() - 7); + break; + case '30d': + startDate.setDate(endDate.getDate() - 30); + break; + case '90d': + startDate.setDate(endDate.getDate() - 90); + break; + } + + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + return { startDate, endDate }; + } + + private async getUploadsByDay(startDate: Date, endDate: Date): Promise { + const result = await this.documentRepository + .createQueryBuilder('document') + .select([ + 'DATE(document.createdAt) as date', + 'COUNT(*) as count' + ]) + .where('document.createdAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .groupBy('DATE(document.createdAt)') + .orderBy('DATE(document.createdAt)', 'ASC') + .getRawMany(); + + return this.fillMissingDates(result, startDate, endDate); + } + + private async getVerificationsByDay(startDate: Date, endDate: Date): Promise { + const result = await this.documentRepository + .createQueryBuilder('document') + .select([ + 'DATE(document.updatedAt) as date', + 'COUNT(*) as count' + ]) + .where('document.status IN (:...statuses)', { + statuses: [DocumentStatus.VERIFIED, DocumentStatus.FLAGGED], + }) + .andWhere('document.updatedAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .groupBy('DATE(document.updatedAt)') + .orderBy('DATE(document.updatedAt)', 'ASC') + .getRawMany(); + + return this.fillMissingDates(result, startDate, endDate); + } + + private async getNewUsersByDay(startDate: Date, endDate: Date): Promise { + const result = await this.userRepository + .createQueryBuilder('user') + .select([ + 'DATE(user.createdAt) as date', + 'COUNT(*) as count' + ]) + .where('user.createdAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .groupBy('DATE(user.createdAt)') + .orderBy('DATE(user.createdAt)', 'ASC') + .getRawMany(); + + return this.fillMissingDates(result, startDate, endDate); + } + + private async getRiskAnalytics(startDate: Date, endDate: Date): Promise<{ + averageRiskScore: number; + topRiskFlags: Array<{ flag: string; count: number }>; + }> { + const documents = await this.documentRepository + .createQueryBuilder('document') + .where('document.riskScore IS NOT NULL') + .andWhere('document.createdAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .getMany(); + + if (documents.length === 0) { + return { + averageRiskScore: 0, + topRiskFlags: [], + }; + } + + const totalRiskScore = documents.reduce((sum, doc) => sum + (doc.riskScore || 0), 0); + const averageRiskScore = totalRiskScore / documents.length; + + const flagCounts = new Map(); + documents.forEach(doc => { + if (doc.riskFlags) { + doc.riskFlags.forEach(flag => { + flagCounts.set(flag, (flagCounts.get(flag) || 0) + 1); + }); + } + }); + + const topRiskFlags = Array.from(flagCounts.entries()) + .map(([flag, count]) => ({ flag, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return { + averageRiskScore: Math.round(averageRiskScore * 100) / 100, + topRiskFlags, + }; + } + + private fillMissingDates( + data: Array<{ date: string; count: string | number }>, + startDate: Date, + endDate: Date, + ): TimeSeriesData[] { + const dateMap = new Map(); + data.forEach(item => { + dateMap.set(item.date, typeof item.count === 'string' ? parseInt(item.count, 10) : item.count); + }); + + const result: TimeSeriesData[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + result.push({ + date: dateStr, + count: dateMap.get(dateStr) || 0, + }); + currentDate.setDate(currentDate.getDate() + 1); + } + + return result; + } +} diff --git a/backend/cmmty/bulk-upload/bulk-upload.controller.ts b/backend/cmmty/bulk-upload/bulk-upload.controller.ts new file mode 100644 index 0000000..d0b3fb4 --- /dev/null +++ b/backend/cmmty/bulk-upload/bulk-upload.controller.ts @@ -0,0 +1,56 @@ +import { + Controller, + Post, + Req, + UseGuards, + UseInterceptors, + UploadedFiles, + BadRequestException, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import * as multer from 'multer'; + +import { BulkUploadService, BulkUploadResult } from './bulk-upload.service'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { User } from '../../src/users/entities/user.entity'; + +const multerStorage = multer.memoryStorage(); + +interface AuthenticatedRequest extends Request { + user?: User; +} + +@Controller('documents') +export class BulkUploadController { + constructor( + private readonly bulkUploadService: BulkUploadService, + private readonly configService: ConfigService, + ) {} + + @Post('bulk') + @UseGuards(JwtAuthGuard) + @UseInterceptors( + FilesInterceptor('files', 10, { + storage: multerStorage, + }), + ) + async bulkUploadDocuments( + @UploadedFiles() files: Express.Multer.File[], + @Req() req: AuthenticatedRequest, + ): Promise { + if (!files || files.length === 0) { + throw new BadRequestException('At least one file is required'); + } + + const user = req.user; + if (!user) { + throw new BadRequestException('Authenticated user is required'); + } + + const uploadDir = this.configService.get('UPLOAD_DIR') || './uploads'; + + return this.bulkUploadService.processBulkUpload(files, user.id, uploadDir); + } +} diff --git a/backend/cmmty/bulk-upload/bulk-upload.module.ts b/backend/cmmty/bulk-upload/bulk-upload.module.ts new file mode 100644 index 0000000..a471b56 --- /dev/null +++ b/backend/cmmty/bulk-upload/bulk-upload.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BulkUploadService } from './bulk-upload.service'; +import { BulkUploadController } from './bulk-upload.controller'; +import { DocumentsModule } from '../../src/documents/documents.module'; +import { QueueModule } from '../../src/queue/queue.module'; + +@Module({ + imports: [ConfigModule, DocumentsModule, QueueModule], + controllers: [BulkUploadController], + providers: [BulkUploadService], + exports: [BulkUploadService], +}) +export class BulkUploadModule {} diff --git a/backend/cmmty/bulk-upload/bulk-upload.service.spec.ts b/backend/cmmty/bulk-upload/bulk-upload.service.spec.ts new file mode 100644 index 0000000..937ebaa --- /dev/null +++ b/backend/cmmty/bulk-upload/bulk-upload.service.spec.ts @@ -0,0 +1,305 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BulkUploadService, BulkUploadResult } from './bulk-upload.service'; +import { DocumentsService } from '../../src/documents/documents.service'; +import { QueueService } from '../../src/queue/queue.service'; +import { Document, DocumentStatus } from '../../src/documents/entities/document.entity'; +import { promises as fs } from 'fs'; + +jest.mock('fs', () => ({ + promises: { + mkdir: jest.fn(), + writeFile: jest.fn(), + }, +})); + +describe('BulkUploadService', () => { + let service: BulkUploadService; + let documentsService: DocumentsService; + let queueService: QueueService; + let mockFs: jest.Mocked; + + const mockDocumentsService = { + findByFileHash: jest.fn(), + create: jest.fn(), + }; + + const mockQueueService = { + enqueueAnalyze: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BulkUploadService, + { + provide: DocumentsService, + useValue: mockDocumentsService, + }, + { + provide: QueueService, + useValue: mockQueueService, + }, + ], + }).compile(); + + service = module.get(BulkUploadService); + documentsService = module.get(DocumentsService); + queueService = module.get(QueueService); + mockFs = fs as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('processBulkUpload', () => { + const userId = 'user-123'; + const uploadDir = './uploads'; + + it('should successfully process multiple files', async () => { + const files = [ + { + originalname: 'test1.pdf', + buffer: Buffer.from('test content 1'), + mimetype: 'application/pdf', + size: 1000, + }, + { + originalname: 'test2.png', + buffer: Buffer.from('test content 2'), + mimetype: 'image/png', + size: 2000, + }, + ] as Express.Multer.File[]; + + const mockDocument1 = { + id: 'doc-1', + title: 'test1.pdf', + status: DocumentStatus.PENDING, + } as Document; + + const mockDocument2 = { + id: 'doc-2', + title: 'test2.png', + status: DocumentStatus.PENDING, + } as Document; + + mockDocumentsService.findByFileHash.mockResolvedValue(null); + mockDocumentsService.create + .mockResolvedValueOnce(mockDocument1) + .mockResolvedValueOnce(mockDocument2); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + mockQueueService.enqueueAnalyze.mockResolvedValue(undefined); + + const result = await service.processBulkUpload(files, userId, uploadDir); + + expect(result.succeeded).toHaveLength(2); + expect(result.failed).toHaveLength(0); + expect(result.succeeded[0]).toEqual({ + id: 'doc-1', + title: 'test1.pdf', + status: DocumentStatus.PENDING, + }); + expect(result.succeeded[1]).toEqual({ + id: 'doc-2', + title: 'test2.png', + status: DocumentStatus.PENDING, + }); + expect(mockQueueService.enqueueAnalyze).toHaveBeenCalledTimes(2); + }); + + it('should handle partial failures', async () => { + const files = [ + { + originalname: 'valid.pdf', + buffer: Buffer.from('valid content'), + mimetype: 'application/pdf', + size: 1000, + }, + { + originalname: 'invalid.txt', + buffer: Buffer.from('invalid content'), + mimetype: 'text/plain', + size: 500, + }, + ] as Express.Multer.File[]; + + const mockDocument = { + id: 'doc-1', + title: 'valid.pdf', + status: DocumentStatus.PENDING, + } as Document; + + mockDocumentsService.findByFileHash.mockResolvedValue(null); + mockDocumentsService.create.mockResolvedValue(mockDocument); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + mockQueueService.enqueueAnalyze.mockResolvedValue(undefined); + + const result = await service.processBulkUpload(files, userId, uploadDir); + + expect(result.succeeded).toHaveLength(1); + expect(result.failed).toHaveLength(1); + expect(result.succeeded[0]).toEqual({ + id: 'doc-1', + title: 'valid.pdf', + status: DocumentStatus.PENDING, + }); + expect(result.failed[0]).toEqual({ + filename: 'invalid.txt', + error: expect.stringContaining('invalid MIME type'), + }); + }); + + it('should reject empty file array', async () => { + await expect( + service.processBulkUpload([], userId, uploadDir), + ).rejects.toThrow('At least one file is required'); + }); + + it('should reject too many files', async () => { + const files = Array.from({ length: 11 }, () => ({ + originalname: 'test.pdf', + buffer: Buffer.from('test'), + mimetype: 'application/pdf', + size: 1000, + })) as Express.Multer.File[]; + + await expect( + service.processBulkUpload(files, userId, uploadDir), + ).rejects.toThrow('Maximum 10 files allowed per batch'); + }); + + it('should handle existing files by hash', async () => { + const files = [ + { + originalname: 'existing.pdf', + buffer: Buffer.from('existing content'), + mimetype: 'application/pdf', + size: 1000, + }, + ] as Express.Multer.File[]; + + const existingDocument = { + id: 'existing-doc', + title: 'existing.pdf', + status: DocumentStatus.VERIFIED, + } as Document; + + mockDocumentsService.findByFileHash.mockResolvedValue(existingDocument); + + const result = await service.processBulkUpload(files, userId, uploadDir); + + expect(result.succeeded).toHaveLength(1); + expect(result.succeeded[0]).toEqual({ + id: 'existing-doc', + title: 'existing.pdf', + status: DocumentStatus.VERIFIED, + }); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(mockQueueService.enqueueAnalyze).not.toHaveBeenCalled(); + }); + }); + + describe('validateBatch', () => { + it('should pass validation for valid files', () => { + const files = [ + { + originalname: 'test.pdf', + mimetype: 'application/pdf', + size: 1000, + }, + { + originalname: 'test.png', + mimetype: 'image/png', + size: 2000, + }, + ] as Express.Multer.File[]; + + expect(() => service.validateBatch(files)).not.toThrow(); + }); + + it('should reject empty batch', () => { + expect(() => service.validateBatch([])).toThrow( + 'At least one file is required', + ); + }); + + it('should reject oversized files', () => { + const files = [ + { + originalname: 'large.pdf', + mimetype: 'application/pdf', + size: 25 * 1024 * 1024, // 25MB + }, + ] as Express.Multer.File[]; + + expect(() => service.validateBatch(files)).toThrow( + 'Files exceed maximum size of 20MB', + ); + }); + + it('should reject invalid MIME types', () => { + const files = [ + { + originalname: 'test.txt', + mimetype: 'text/plain', + size: 1000, + }, + ] as Express.Multer.File[]; + + expect(() => service.validateBatch(files)).toThrow( + 'Files have invalid MIME type', + ); + }); + + it('should reject too many files', () => { + const files = Array(11).fill({ + originalname: 'test.pdf', + mimetype: 'application/pdf', + size: 1000, + }) as Express.Multer.File[]; + + expect(() => service.validateBatch(files)).toThrow( + 'Maximum 10 files allowed per batch', + ); + }); + }); + + describe('validateFile', () => { + it('should pass validation for valid file', () => { + const file = { + originalname: 'test.pdf', + mimetype: 'application/pdf', + size: 1000, + } as Express.Multer.File; + + expect(() => (service as any).validateFile(file)).not.toThrow(); + }); + + it('should reject oversized file', () => { + const file = { + originalname: 'large.pdf', + mimetype: 'application/pdf', + size: 25 * 1024 * 1024, // 25MB + } as Express.Multer.File; + + expect(() => (service as any).validateFile(file)).toThrow( + 'File large.pdf exceeds maximum size of 20MB', + ); + }); + + it('should reject invalid MIME type', () => { + const file = { + originalname: 'test.txt', + mimetype: 'text/plain', + size: 1000, + } as Express.Multer.File; + + expect(() => (service as any).validateFile(file)).toThrow( + 'File test.txt has invalid MIME type', + ); + }); + }); +}); diff --git a/backend/cmmty/bulk-upload/bulk-upload.service.ts b/backend/cmmty/bulk-upload/bulk-upload.service.ts new file mode 100644 index 0000000..a666dc3 --- /dev/null +++ b/backend/cmmty/bulk-upload/bulk-upload.service.ts @@ -0,0 +1,147 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { DocumentsService } from '../../src/documents/documents.service'; +import { QueueService } from '../../src/queue/queue.service'; +import { DocumentStatus } from '../../src/documents/entities/document.entity'; +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import { extname, join } from 'path'; + +const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg']; +const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; +const MAX_FILES_PER_BATCH = 10; + +export interface BulkUploadResult { + succeeded: Array<{ + id: string; + title: string; + status: DocumentStatus; + }>; + failed: Array<{ + filename: string; + error: string; + }>; +} + +@Injectable() +export class BulkUploadService { + constructor( + private readonly documentsService: DocumentsService, + private readonly queueService: QueueService, + ) {} + + async processBulkUpload( + files: Express.Multer.File[], + userId: string, + uploadDir: string, + ): Promise { + if (files.length === 0) { + throw new BadRequestException('At least one file is required'); + } + + if (files.length > MAX_FILES_PER_BATCH) { + throw new BadRequestException(`Maximum ${MAX_FILES_PER_BATCH} files allowed per batch`); + } + + await fs.mkdir(uploadDir, { recursive: true }); + + const result: BulkUploadResult = { + succeeded: [], + failed: [], + }; + + for (const file of files) { + try { + const document = await this.processSingleFile(file, userId, uploadDir); + result.succeeded.push({ + id: document.id, + title: document.title, + status: document.status, + }); + } catch (error) { + result.failed.push({ + filename: file.originalname, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return result; + } + + private async processSingleFile( + file: Express.Multer.File, + userId: string, + uploadDir: string, + ) { + this.validateFile(file); + + const fileHash = createHash('sha256').update(file.buffer).digest('hex'); + const existing = await this.documentsService.findByFileHash(fileHash); + + if (existing) { + return existing; + } + + const extension = extname(file.originalname) || ''; + const filename = `${fileHash}${extension}`; + const targetPath = join(uploadDir, filename); + await fs.writeFile(targetPath, file.buffer); + + const document = await this.documentsService.create({ + ownerId: userId, + title: file.originalname, + filePath: targetPath, + fileHash, + fileSize: file.size, + mimeType: file.mimetype, + status: DocumentStatus.PENDING, + }); + + await this.queueService.enqueueAnalyze(document.id); + return document; + } + + private validateFile(file: Express.Multer.File): void { + if (!file) { + throw new BadRequestException('File is required'); + } + + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException( + `File ${file.originalname} has invalid MIME type. Only PDF, PNG, or JPEG files are allowed`, + ); + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File ${file.originalname} exceeds maximum size of 20MB`, + ); + } + } + + validateBatch(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('At least one file is required'); + } + + if (files.length > MAX_FILES_PER_BATCH) { + throw new BadRequestException(`Maximum ${MAX_FILES_PER_BATCH} files allowed per batch`); + } + + const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE_BYTES); + if (oversizedFiles.length > 0) { + const filenames = oversizedFiles.map(f => f.originalname).join(', '); + throw new BadRequestException( + `Files exceed maximum size of 20MB: ${filenames}`, + ); + } + + const invalidMimeFiles = files.filter(file => !ALLOWED_MIME_TYPES.includes(file.mimetype)); + if (invalidMimeFiles.length > 0) { + const filenames = invalidMimeFiles.map(f => f.originalname).join(', '); + throw new BadRequestException( + `Files have invalid MIME type. Only PDF, PNG, or JPEG files are allowed: ${filenames}`, + ); + } + } +} diff --git a/backend/cmmty/notification-prefs/dto/update-notification-prefs.dto.ts b/backend/cmmty/notification-prefs/dto/update-notification-prefs.dto.ts new file mode 100644 index 0000000..7fea8ef --- /dev/null +++ b/backend/cmmty/notification-prefs/dto/update-notification-prefs.dto.ts @@ -0,0 +1,31 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class UpdateNotificationPrefsDto { + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + riskAlert?: boolean; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + verificationComplete?: boolean; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) + weeklyDigest?: boolean; +} diff --git a/backend/cmmty/notification-prefs/notification-prefs.controller.ts b/backend/cmmty/notification-prefs/notification-prefs.controller.ts new file mode 100644 index 0000000..fbaac0c --- /dev/null +++ b/backend/cmmty/notification-prefs/notification-prefs.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Patch, Req, UseGuards, Body } from '@nestjs/common'; +import { NotificationPrefsService } from './notification-prefs.service'; +import { UpdateNotificationPrefsDto } from './dto/update-notification-prefs.dto'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { User } from '../../src/users/entities/user.entity'; + +interface AuthenticatedRequest extends Request { + user?: User; +} + +@Controller('users') +export class NotificationPrefsController { + constructor( + private readonly notificationPrefsService: NotificationPrefsService, + ) {} + + @Get('me/notification-preferences') + @UseGuards(JwtAuthGuard) + async getNotificationPreferences(@Req() req: AuthenticatedRequest) { + const userId = req.user?.id; + if (!userId) { + throw new Error('User ID not found in request'); + } + return this.notificationPrefsService.getPreferences(userId); + } + + @Patch('me/notification-preferences') + @UseGuards(JwtAuthGuard) + async updateNotificationPreferences( + @Req() req: AuthenticatedRequest, + @Body() updateDto: UpdateNotificationPrefsDto, + ) { + const userId = req.user?.id; + if (!userId) { + throw new Error('User ID not found in request'); + } + return this.notificationPrefsService.updatePreferences(userId, updateDto); + } +} diff --git a/backend/cmmty/notification-prefs/notification-prefs.entity.ts b/backend/cmmty/notification-prefs/notification-prefs.entity.ts new file mode 100644 index 0000000..fec6fdb --- /dev/null +++ b/backend/cmmty/notification-prefs/notification-prefs.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../src/users/entities/user.entity'; + +@Entity('notification_preferences') +export class NotificationPrefs { + @PrimaryColumn({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'risk_alert', default: true }) + riskAlert: boolean; + + @Column({ name: 'verification_complete', default: true }) + verificationComplete: boolean; + + @Column({ name: 'weekly_digest', default: true }) + weeklyDigest: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column(() => User, { primary: true }) + user: User; +} diff --git a/backend/cmmty/notification-prefs/notification-prefs.module.ts b/backend/cmmty/notification-prefs/notification-prefs.module.ts new file mode 100644 index 0000000..a9691af --- /dev/null +++ b/backend/cmmty/notification-prefs/notification-prefs.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationPrefsService } from './notification-prefs.service'; +import { NotificationPrefsController } from './notification-prefs.controller'; +import { NotificationPrefs } from './notification-prefs.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPrefs])], + controllers: [NotificationPrefsController], + providers: [NotificationPrefsService], + exports: [NotificationPrefsService], +}) +export class NotificationPrefsModule {} diff --git a/backend/cmmty/notification-prefs/notification-prefs.service.spec.ts b/backend/cmmty/notification-prefs/notification-prefs.service.spec.ts new file mode 100644 index 0000000..f9c2202 --- /dev/null +++ b/backend/cmmty/notification-prefs/notification-prefs.service.spec.ts @@ -0,0 +1,241 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPrefsService } from './notification-prefs.service'; +import { NotificationPrefs } from './notification-prefs.entity'; + +describe('NotificationPrefsService', () => { + let service: NotificationPrefsService; + let repository: Repository; + + const mockRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationPrefsService, + { + provide: getRepositoryToken(NotificationPrefs), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(NotificationPrefsService); + repository = module.get>( + getRepositoryToken(NotificationPrefs), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getPreferences', () => { + it('should return existing preferences', async () => { + const userId = 'user-123'; + const existingPrefs = { + userId, + riskAlert: true, + verificationComplete: false, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(existingPrefs); + + const result = await service.getPreferences(userId); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { userId } }); + expect(result).toEqual(existingPrefs); + }); + + it('should create default preferences when none exist', async () => { + const userId = 'user-123'; + const defaultPrefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(defaultPrefs); + mockRepository.save.mockResolvedValue(defaultPrefs); + + const result = await service.getPreferences(userId); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { userId } }); + expect(mockRepository.create).toHaveBeenCalledWith({ + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + }); + expect(mockRepository.save).toHaveBeenCalledWith(defaultPrefs); + expect(result).toEqual(defaultPrefs); + }); + }); + + describe('updatePreferences', () => { + it('should update existing preferences', async () => { + const userId = 'user-123'; + const existingPrefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const updateDto = { + riskAlert: false, + weeklyDigest: false, + }; + + const updatedPrefs = { + ...existingPrefs, + ...updateDto, + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(existingPrefs); + mockRepository.save.mockResolvedValue(updatedPrefs); + + const result = await service.updatePreferences(userId, updateDto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { userId } }); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + riskAlert: false, + verificationComplete: true, + weeklyDigest: false, + }), + ); + expect(result).toEqual(updatedPrefs); + }); + }); + + describe('shouldSendRiskAlert', () => { + it('should return true when risk alert preference is enabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendRiskAlert(userId); + + expect(result).toBe(true); + }); + + it('should return false when risk alert preference is disabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: false, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendRiskAlert(userId); + + expect(result).toBe(false); + }); + }); + + describe('shouldSendVerificationComplete', () => { + it('should return true when verification complete preference is enabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendVerificationComplete(userId); + + expect(result).toBe(true); + }); + + it('should return false when verification complete preference is disabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: true, + verificationComplete: false, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendVerificationComplete(userId); + + expect(result).toBe(false); + }); + }); + + describe('shouldSendWeeklyDigest', () => { + it('should return true when weekly digest preference is enabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendWeeklyDigest(userId); + + expect(result).toBe(true); + }); + + it('should return false when weekly digest preference is disabled', async () => { + const userId = 'user-123'; + const prefs = { + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(prefs); + + const result = await service.shouldSendWeeklyDigest(userId); + + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/cmmty/notification-prefs/notification-prefs.service.ts b/backend/cmmty/notification-prefs/notification-prefs.service.ts new file mode 100644 index 0000000..7069426 --- /dev/null +++ b/backend/cmmty/notification-prefs/notification-prefs.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPrefs } from './notification-prefs.entity'; +import { UpdateNotificationPrefsDto } from './dto/update-notification-prefs.dto'; + +@Injectable() +export class NotificationPrefsService { + constructor( + @InjectRepository(NotificationPrefs) + private readonly notificationPrefsRepository: Repository, + ) {} + + async getPreferences(userId: string): Promise { + let preferences = await this.notificationPrefsRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + preferences = await this.createDefaultPreferences(userId); + } + + return preferences; + } + + async updatePreferences( + userId: string, + updateDto: UpdateNotificationPrefsDto, + ): Promise { + const preferences = await this.getPreferences(userId); + + Object.assign(preferences, updateDto); + + return this.notificationPrefsRepository.save(preferences); + } + + private async createDefaultPreferences(userId: string): Promise { + const defaultPrefs = this.notificationPrefsRepository.create({ + userId, + riskAlert: true, + verificationComplete: true, + weeklyDigest: true, + }); + + return this.notificationPrefsRepository.save(defaultPrefs); + } + + async shouldSendRiskAlert(userId: string): Promise { + const prefs = await this.getPreferences(userId); + return prefs.riskAlert; + } + + async shouldSendVerificationComplete(userId: string): Promise { + const prefs = await this.getPreferences(userId); + return prefs.verificationComplete; + } + + async shouldSendWeeklyDigest(userId: string): Promise { + const prefs = await this.getPreferences(userId); + return prefs.weeklyDigest; + } +} diff --git a/backend/cmmty/two-factor-auth/two-factor-auth.controller.ts b/backend/cmmty/two-factor-auth/two-factor-auth.controller.ts new file mode 100644 index 0000000..55a3b4f --- /dev/null +++ b/backend/cmmty/two-factor-auth/two-factor-auth.controller.ts @@ -0,0 +1,134 @@ +import { + Controller, + Post, + Get, + Body, + Req, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { TwoFactorAuthService, TOTPSecret, TOTPEnableDto, TOTPVerifyDto } from './two-factor-auth.service'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { User } from '../../src/users/entities/user.entity'; + +interface AuthenticatedRequest extends Request { + user?: User; +} + +@Controller('auth/2fa') +@UseGuards(JwtAuthGuard) +export class TwoFactorAuthController { + constructor(private readonly twoFactorAuthService: TwoFactorAuthService) {} + + @Post('generate') + async generateTOTPSecret(@Req() req: AuthenticatedRequest): Promise { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is already enabled'); + } + + return this.twoFactorAuthService.generateTOTPSecret(user); + } + + @Post('enable') + async enableTwoFactor( + @Req() req: AuthenticatedRequest, + @Body() enableDto: TOTPEnableDto, + ): Promise<{ message: string; user: Partial }> { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is already enabled'); + } + + const updatedUser = await this.twoFactorAuthService.enableTwoFactor(user.id, enableDto); + + return { + message: 'Two-factor authentication enabled successfully', + user: { + id: updatedUser.id, + email: updatedUser.email, + twoFactorEnabled: updatedUser.twoFactorEnabled, + }, + }; + } + + @Post('verify') + async verifyTwoFactor( + @Req() req: AuthenticatedRequest, + @Body() verifyDto: TOTPVerifyDto, + ): Promise<{ valid: boolean; message: string }> { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (!user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const isValid = await this.twoFactorAuthService.verifyTwoFactor(user, verifyDto); + + return { + valid: isValid, + message: isValid ? 'Two-factor authentication verified' : 'Invalid TOTP token', + }; + } + + @Post('disable') + async disableTwoFactor(@Req() req: AuthenticatedRequest): Promise<{ message: string }> { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (!user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + await this.twoFactorAuthService.disableTwoFactor(user.id); + + return { + message: 'Two-factor authentication disabled successfully', + }; + } + + @Get('backup-codes') + async getBackupCodes(@Req() req: AuthenticatedRequest): Promise<{ backupCodes: string[] }> { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (!user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const backupCodes = this.twoFactorAuthService.parseBackupCodes(user.twoFactorBackupCodes || '[]'); + + return { backupCodes }; + } + + @Post('backup-codes/regenerate') + async regenerateBackupCodes(@Req() req: AuthenticatedRequest): Promise<{ backupCodes: string[] }> { + const user = req.user; + if (!user) { + throw new BadRequestException('User not found'); + } + + if (!user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const backupCodes = await this.twoFactorAuthService.regenerateBackupCodes(user.id); + + return { backupCodes }; + } +} diff --git a/backend/cmmty/two-factor-auth/two-factor-auth.module.ts b/backend/cmmty/two-factor-auth/two-factor-auth.module.ts new file mode 100644 index 0000000..cd022cf --- /dev/null +++ b/backend/cmmty/two-factor-auth/two-factor-auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TwoFactorAuthService } from './two-factor-auth.service'; +import { TwoFactorAuthController } from './two-factor-auth.controller'; +import { User } from '../../src/users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [TwoFactorAuthController], + providers: [TwoFactorAuthService], + exports: [TwoFactorAuthService], +}) +export class TwoFactorAuthModule {} diff --git a/backend/cmmty/two-factor-auth/two-factor-auth.service.spec.ts b/backend/cmmty/two-factor-auth/two-factor-auth.service.spec.ts new file mode 100644 index 0000000..5456223 --- /dev/null +++ b/backend/cmmty/two-factor-auth/two-factor-auth.service.spec.ts @@ -0,0 +1,315 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TwoFactorAuthService, TOTPSecret, TOTPEnableDto, TOTPVerifyDto } from './two-factor-auth.service'; +import { User } from '../../src/users/entities/user.entity'; +import * as speakeasy from 'speakeasy'; + +jest.mock('speakeasy', () => ({ + generateSecret: jest.fn(), + totp: { + verify: jest.fn(), + }, +})); + +describe('TwoFactorAuthService', () => { + let service: TwoFactorAuthService; + let userRepository: Repository; + let mockSpeakeasy: jest.Mocked; + + const mockUserRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TwoFactorAuthService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(TwoFactorAuthService); + userRepository = module.get>(getRepositoryToken(User)); + mockSpeakeasy = speakeasy as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateTOTPSecret', () => { + it('should generate TOTP secret with QR code URL and backup codes', () => { + const user = { + id: 'user-123', + email: 'test@example.com', + } as User; + + const mockSecret = { + base32: 'JBSWY3DPEHPK3PXP', + ascii: 'JBSWY3DPEHPK3PXP', + hex: '4a425357593344504548504b33505850', + otpauth_url: 'otpauth://totp/SMALDA%20(test%40example.com)?secret=JBSWY3DPEHPK3PXP&issuer=SMALDA', + google_auth_qr: 'google-auth-qr-url', + }; + + (mockSpeakeasy.generateSecret as jest.Mock).mockReturnValue(mockSecret); + + const result = service.generateTOTPSecret(user); + + expect(mockSpeakeasy.generateSecret).toHaveBeenCalledWith({ + name: `SMALDA (${user.email})`, + issuer: 'SMALDA', + length: 32, + }); + + expect(result).toEqual({ + secret: 'JBSWY3DPEHPK3PXP', + qrCodeUrl: 'otpauth://totp/SMALDA%20(test%40example.com)?secret=JBSWY3DPEHPK3PXP&issuer=SMALDA', + backupCodes: expect.any(Array), + }); + + expect(result.backupCodes).toHaveLength(10); + expect(result.backupCodes.every(code => typeof code === 'string' && code.length === 8)).toBe(true); + }); + }); + + describe('enableTwoFactor', () => { + const userId = 'user-123'; + const enableDto: TOTPEnableDto = { + secret: 'JBSWY3DPEHPK3PXP', + token: '123456', + }; + + it('should enable two-factor authentication with valid token', async () => { + const user = { + id: userId, + email: 'test@example.com', + twoFactorEnabled: false, + } as User; + + const updatedUser = { + ...user, + twoFactorEnabled: true, + twoFactorSecret: enableDto.secret, + } as User; + + mockUserRepository.findOne.mockResolvedValue(user); + mockUserRepository.findOne.mockResolvedValue(updatedUser); + (mockSpeakeasy.totp.verify as jest.Mock).mockReturnValue(true); + + const result = await service.enableTwoFactor(userId, enableDto); + + expect(mockSpeakeasy.totp.verify).toHaveBeenCalledWith({ + secret: enableDto.secret, + encoding: 'base32', + token: enableDto.token, + window: 2, + }); + + expect(mockUserRepository.update).toHaveBeenCalledWith(userId, { + twoFactorSecret: enableDto.secret, + twoFactorEnabled: true, + twoFactorBackupCodes: expect.any(String), + }); + + expect(result).toEqual(updatedUser); + }); + + it('should throw error if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.enableTwoFactor(userId, enableDto)).rejects.toThrow('User not found'); + }); + + it('should throw error if TOTP token is invalid', async () => { + const user = { + id: userId, + email: 'test@example.com', + } as User; + + mockUserRepository.findOne.mockResolvedValue(user); + (mockSpeakeasy.totp.verify as jest.Mock).mockReturnValue(false); + + await expect(service.enableTwoFactor(userId, enableDto)).rejects.toThrow('Invalid TOTP token'); + }); + }); + + describe('verifyTwoFactor', () => { + const user = { + id: 'user-123', + email: 'test@example.com', + twoFactorEnabled: true, + twoFactorSecret: 'JBSWY3DPEHPK3PXP', + twoFactorBackupCodes: JSON.stringify(['ABCDEF12', 'GHIJKLMN']), + } as User; + + const verifyDto: TOTPVerifyDto = { + token: '123456', + }; + + it('should verify TOTP token successfully', async () => { + (mockSpeakeasy.totp.verify as jest.Mock).mockReturnValue(true); + + const result = await service.verifyTwoFactor(user, verifyDto); + + expect(mockSpeakeasy.totp.verify).toHaveBeenCalledWith({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: verifyDto.token, + window: 2, + }); + + expect(result).toBe(true); + }); + + it('should verify backup code successfully', async () => { + const backupCodeDto: TOTPVerifyDto = { + token: 'ABCDEF12', + }; + + (mockSpeakeasy.totp.verify as jest.Mock).mockReturnValue(false); + mockUserRepository.update.mockResolvedValue(undefined); + + const result = await service.verifyTwoFactor(user, backupCodeDto); + + expect(result).toBe(true); + expect(mockUserRepository.update).toHaveBeenCalledWith(user.id, { + twoFactorBackupCodes: JSON.stringify(['GHIJKLMN']), + }); + }); + + it('should throw error if two-factor is not enabled', async () => { + const userWithout2FA = { + ...user, + twoFactorEnabled: false, + } as User; + + await expect(service.verifyTwoFactor(userWithout2FA, verifyDto)).rejects.toThrow( + 'Two-factor authentication is not enabled', + ); + }); + + it('should return false for invalid token and backup code', async () => { + const invalidDto: TOTPVerifyDto = { + token: '999999', + }; + + (mockSpeakeasy.totp.verify as jest.Mock).mockReturnValue(false); + + const result = await service.verifyTwoFactor(user, invalidDto); + + expect(result).toBe(false); + }); + }); + + describe('disableTwoFactor', () => { + const userId = 'user-123'; + + it('should disable two-factor authentication', async () => { + const user = { + id: userId, + email: 'test@example.com', + twoFactorEnabled: true, + } as User; + + const updatedUser = { + ...user, + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: null, + } as User; + + mockUserRepository.findOne.mockResolvedValue(user); + mockUserRepository.findOne.mockResolvedValue(updatedUser); + + const result = await service.disableTwoFactor(userId); + + expect(mockUserRepository.update).toHaveBeenCalledWith(userId, { + twoFactorSecret: null, + twoFactorEnabled: false, + twoFactorBackupCodes: null, + }); + + expect(result).toEqual(updatedUser); + }); + + it('should throw error if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.disableTwoFactor(userId)).rejects.toThrow('User not found'); + }); + }); + + describe('regenerateBackupCodes', () => { + const userId = 'user-123'; + + it('should regenerate backup codes for user with 2FA enabled', async () => { + const user = { + id: userId, + email: 'test@example.com', + twoFactorEnabled: true, + } as User; + + mockUserRepository.findOne.mockResolvedValue(user); + mockUserRepository.update.mockResolvedValue(undefined); + + const result = await service.regenerateBackupCodes(userId); + + expect(mockUserRepository.update).toHaveBeenCalledWith(userId, { + twoFactorBackupCodes: expect.any(String), + }); + + expect(result).toHaveLength(10); + expect(result.every(code => typeof code === 'string' && code.length === 8)).toBe(true); + }); + + it('should throw error if user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.regenerateBackupCodes(userId)).rejects.toThrow( + 'Two-factor authentication is not enabled', + ); + }); + + it('should throw error if 2FA is not enabled', async () => { + const user = { + id: userId, + email: 'test@example.com', + twoFactorEnabled: false, + } as User; + + mockUserRepository.findOne.mockResolvedValue(user); + + await expect(service.regenerateBackupCodes(userId)).rejects.toThrow( + 'Two-factor authentication is not enabled', + ); + }); + }); + + describe('parseBackupCodes', () => { + it('should parse valid JSON array', () => { + const codes = JSON.stringify(['ABCDEF12', 'GHIJKLMN']); + const result = service.parseBackupCodes(codes); + + expect(result).toEqual(['ABCDEF12', 'GHIJKLMN']); + }); + + it('should return empty array for invalid JSON', () => { + const invalidJson = 'invalid json'; + const result = service.parseBackupCodes(invalidJson); + + expect(result).toEqual([]); + }); + + it('should return empty array for empty string', () => { + const result = service.parseBackupCodes(''); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/cmmty/two-factor-auth/two-factor-auth.service.ts b/backend/cmmty/two-factor-auth/two-factor-auth.service.ts new file mode 100644 index 0000000..c3fbf93 --- /dev/null +++ b/backend/cmmty/two-factor-auth/two-factor-auth.service.ts @@ -0,0 +1,144 @@ +import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../src/users/entities/user.entity'; +import * as speakeasy from 'speakeasy'; + +export interface TOTPSecret { + secret: string; + qrCodeUrl: string; + backupCodes: string[]; +} + +export interface TOTPEnableDto { + secret: string; + token: string; +} + +export interface TOTPVerifyDto { + token: string; +} + +@Injectable() +export class TwoFactorAuthService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + generateTOTPSecret(user: User): TOTPSecret { + const secret = speakeasy.generateSecret({ + name: `SMALDA (${user.email})`, + issuer: 'SMALDA', + length: 32, + }); + + const backupCodes = this.generateBackupCodes(); + + return { + secret: secret.base32, + qrCodeUrl: secret.otpauth_url, + backupCodes, + }; + } + + async enableTwoFactor(userId: string, enableDto: TOTPEnableDto): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + const verified = speakeasy.totp.verify({ + secret: enableDto.secret, + encoding: 'base32', + token: enableDto.token, + window: 2, + }); + + if (!verified) { + throw new UnauthorizedException('Invalid TOTP token'); + } + + await this.userRepository.update(userId, { + twoFactorSecret: enableDto.secret, + twoFactorEnabled: true, + twoFactorBackupCodes: JSON.stringify(this.generateBackupCodes()), + }); + + return this.userRepository.findOne({ where: { id: userId } }); + } + + async verifyTwoFactor(user: User, verifyDto: TOTPVerifyDto): Promise { + if (!user.twoFactorEnabled || !user.twoFactorSecret) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: verifyDto.token, + window: 2, + }); + + if (!verified) { + const backupCodes = this.parseBackupCodes(user.twoFactorBackupCodes as string); + const codeIndex = backupCodes.indexOf(verifyDto.token); + + if (codeIndex !== -1) { + backupCodes.splice(codeIndex, 1); + await this.userRepository.update(user.id, { + twoFactorBackupCodes: JSON.stringify(backupCodes), + }); + return true; + } + } + + return verified; + } + + async disableTwoFactor(userId: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + await this.userRepository.update(userId, { + twoFactorSecret: null, + twoFactorEnabled: false, + twoFactorBackupCodes: null, + }); + + return this.userRepository.findOne({ where: { id: userId } }); + } + + private generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < 10; i++) { + codes.push(Math.random().toString(36).substring(2, 10).toUpperCase()); + } + return codes; + } + + public parseBackupCodes(codes: string): string[] { + try { + return JSON.parse(codes); + } catch { + return []; + } + } + + async regenerateBackupCodes(userId: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user || !user.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const newBackupCodes = this.generateBackupCodes(); + + await this.userRepository.update(userId, { + twoFactorBackupCodes: JSON.stringify(newBackupCodes), + }); + + return newBackupCodes; + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 839cc86..70b9471 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@types/speakeasy": "^2.0.10", "axios": "^1.13.5", "bcrypt": "^6.0.0", "bullmq": "^5.67.0", @@ -41,6 +42,7 @@ "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", "stellar-sdk": "^13.3.0", "typeorm": "^0.3.25", "winston": "^3.19.0" @@ -2312,7 +2314,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.19.tgz", "integrity": "sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2360,7 +2361,6 @@ "integrity": "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2444,7 +2444,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2644,7 +2643,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -2770,7 +2768,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3075,7 +3072,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3230,7 +3226,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3368,6 +3363,15 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3463,7 +3467,6 @@ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -3863,7 +3866,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3913,7 +3915,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4688,7 +4689,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4900,7 +4900,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -5166,15 +5165,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6154,7 +6151,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6211,7 +6207,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7210,6 +7205,7 @@ "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -7906,7 +7902,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9427,6 +9422,7 @@ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", "license": "MIT", + "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -9872,7 +9868,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10052,7 +10047,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -10318,7 +10312,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10584,7 +10577,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", "license": "MIT", - "peer": true, "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", @@ -10949,7 +10941,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11247,6 +11238,24 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/speakeasy/node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -12039,7 +12048,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12205,7 +12213,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -12431,7 +12438,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12809,6 +12815,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -12827,6 +12834,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12841,6 +12849,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -12851,6 +12860,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -12918,7 +12928,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/backend/package.json b/backend/package.json index bc027b1..aceb410 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@types/speakeasy": "^2.0.10", "axios": "^1.13.5", "bcrypt": "^6.0.0", "bullmq": "^5.67.0", @@ -52,6 +53,7 @@ "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", "stellar-sdk": "^13.3.0", "typeorm": "^0.3.25", "winston": "^3.19.0" @@ -61,9 +63,9 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", "@types/bcrypt": "^6.0.0", - "@types/multer": "^1.4.11", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/nodemailer": "^7.0.5", "@types/passport-github2": "^1.2.9", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3775e0c..c19f248 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,11 @@ import { RiskAssessmentModule } from './risk-assessment/risk-assessment.module'; import { StellarModule } from './stellar/stellar.module'; import { UsersModule } from './users/users.module'; import { VerificationModule } from './verification/verification.module'; +import { join } from 'path'; +import { NotificationPrefsModule } from '../cmmty/notification-prefs/notification-prefs.module'; +import { AnalyticsModule } from '../cmmty/analytics/analytics.module'; +import { BulkUploadModule } from '../cmmty/bulk-upload/bulk-upload.module'; +import { TwoFactorAuthModule } from '../cmmty/two-factor-auth/two-factor-auth.module'; @Module({ imports: [ @@ -50,6 +55,10 @@ import { VerificationModule } from './verification/verification.module'; VerificationModule, MailModule, QueueModule, + NotificationPrefsModule, + AnalyticsModule, + BulkUploadModule, + TwoFactorAuthModule, ], controllers: [AppController], providers: [AppService, LoggerMiddleware], diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts index 4e7ef6c..de137ca 100644 --- a/backend/src/mail/mail.module.ts +++ b/backend/src/mail/mail.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { MailService } from './mail.service'; +import { NotificationPrefsModule } from '../../cmmty/notification-prefs/notification-prefs.module'; +import { UsersModule } from '../users/users.module'; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, NotificationPrefsModule, UsersModule], providers: [MailService], exports: [MailService], }) diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index a1cb233..0ee1cda 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import nodemailer, { SendMailOptions, Transporter } from 'nodemailer'; +import { UsersService } from '../users/users.service'; +import { NotificationPrefsService } from '../../cmmty/notification-prefs/notification-prefs.service'; @Injectable() export class MailService { @@ -8,7 +10,11 @@ export class MailService { private readonly transporter: Transporter | null; private readonly from?: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly usersService: UsersService, + private readonly notificationPrefsService: NotificationPrefsService, + ) { const host = this.configService.get('MAIL_HOST'); const port = Number(this.configService.get('MAIL_PORT')); const user = this.configService.get('MAIL_USER'); @@ -50,6 +56,18 @@ export class MailService { } async sendVerificationComplete(to: string, documentTitle: string, txHash: string): Promise { + const user = await this.usersService.findByEmail(to); + if (!user) { + this.logger.warn(`User not found for email: ${to}`); + return; + } + + const shouldSend = await this.notificationPrefsService.shouldSendVerificationComplete(user.id); + if (!shouldSend) { + this.logger.log(`User ${user.id} has opted out of verification complete emails`); + return; + } + await this.sendMail({ to, subject: 'Document Verification Complete', @@ -62,6 +80,18 @@ export class MailService { } async sendRiskAlert(to: string, documentTitle: string, flags: string[]): Promise { + const user = await this.usersService.findByEmail(to); + if (!user) { + this.logger.warn(`User not found for email: ${to}`); + return; + } + + const shouldSend = await this.notificationPrefsService.shouldSendRiskAlert(user.id); + if (!shouldSend) { + this.logger.log(`User ${user.id} has opted out of risk alert emails`); + return; + } + const flagList = flags.map((flag) => `
  • ${flag}
  • `).join(''); await this.sendMail({ to, diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index ee83765..134ae2e 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -36,6 +36,15 @@ export class User { @Column({ name: 'is_verified', default: false }) isVerified: boolean; + @Column({ name: 'two_factor_enabled', default: false }) + twoFactorEnabled: boolean; + + @Column({ name: 'two_factor_secret', nullable: true }) + twoFactorSecret?: string; + + @Column({ name: 'two_factor_backup_codes', type: 'text', nullable: true }) + twoFactorBackupCodes?: string; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date;