diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts index 47c1b1e..4ede2fd 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts @@ -18,6 +18,7 @@ import { AuthService } from './auth.service'; import { PasswordResetService } from './password-reset.service'; import { MFAService } from './mfa.service'; import { SessionService } from './session.service'; +import { DeviceTrustService } from './device-trust.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -35,6 +36,7 @@ export class AuthController { private readonly passwordResetService: PasswordResetService, private readonly mfaService: MFAService, private readonly sessionService: SessionService, + private readonly deviceTrustService: DeviceTrustService, ) {} @Public() @@ -240,4 +242,83 @@ export class AuthController { activeSessionCount: count, }; } + + // Device Trust Management Endpoints + @UseGuards(JwtAuthGuard) + @Get('devices') + @HttpCode(HttpStatus.OK) + async getDevices(@CurrentUser() user: any, @Req() request: any) { + const currentDeviceId = request.user?.deviceId; + const devices = await this.deviceTrustService.getUserDevices(user.userId, currentDeviceId); + return { + success: true, + devices, + totalCount: devices.length, + }; + } + + @UseGuards(JwtAuthGuard) + @Get('devices/trusted') + @HttpCode(HttpStatus.OK) + async getTrustedDevices(@CurrentUser() user: any) { + const devices = await this.deviceTrustService.getTrustedDevices(user.userId); + return { + success: true, + devices, + totalCount: devices.length, + }; + } + + @UseGuards(JwtAuthGuard) + @Get('devices/count') + @HttpCode(HttpStatus.OK) + async getDeviceCount(@CurrentUser() user: any) { + const counts = await this.deviceTrustService.getDeviceCount(user.userId); + return { + success: true, + ...counts, + }; + } + + @UseGuards(JwtAuthGuard) + @Post('devices/:deviceId/trust') + @HttpCode(HttpStatus.OK) + async trustDevice( + @CurrentUser() user: any, + @Param('deviceId') deviceId: string, + ) { + return await this.deviceTrustService.trustDevice(user.userId, deviceId); + } + + @UseGuards(JwtAuthGuard) + @Delete('devices/:deviceId/trust') + @HttpCode(HttpStatus.OK) + async revokeDeviceTrust( + @CurrentUser() user: any, + @Param('deviceId') deviceId: string, + @Req() request: any, + ) { + const currentDeviceId = request.user?.deviceId; + return await this.deviceTrustService.revokeDeviceTrust(user.userId, deviceId, currentDeviceId); + } + + @UseGuards(JwtAuthGuard) + @Delete('devices/:deviceId') + @HttpCode(HttpStatus.OK) + async removeDevice( + @CurrentUser() user: any, + @Param('deviceId') deviceId: string, + @Req() request: any, + ) { + const currentDeviceId = request.user?.deviceId; + return await this.deviceTrustService.removeDevice(user.userId, deviceId, currentDeviceId); + } + + @UseGuards(JwtAuthGuard) + @Delete('devices') + @HttpCode(HttpStatus.OK) + async removeAllDevices(@CurrentUser() user: any, @Req() request: any) { + const currentDeviceId = request.user?.deviceId; + return await this.deviceTrustService.removeAllDevices(user.userId, currentDeviceId); + } } \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts index 4f1719d..4dbc8bd 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts @@ -8,6 +8,7 @@ import { AuthService } from './auth.service'; import { PasswordResetService } from './password-reset.service'; import { MFAService } from './mfa.service'; import { SessionService } from './session.service'; +import { DeviceTrustService } from './device-trust.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { CommonModule } from '../../common/common.module'; @@ -37,7 +38,7 @@ import { }), ], controllers: [AuthController], - providers: [AuthService, PasswordResetService, MFAService, SessionService, JwtStrategy, LocalStrategy], - exports: [AuthService, PasswordResetService, MFAService, SessionService], + providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, JwtStrategy, LocalStrategy], + exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService], }) export class AuthModule {} \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.ts new file mode 100644 index 0000000..d4fadce --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/device-trust.service.ts @@ -0,0 +1,268 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DeviceRegistry } from '../../database/entities'; + +export interface DeviceInfo { + id: string; + deviceFingerprint: string; + platform?: string; + trusted: boolean; + lastSeen: Date; + isCurrent?: boolean; +} + +@Injectable() +export class DeviceTrustService { + private readonly logger = new Logger(DeviceTrustService.name); + + constructor( + @InjectRepository(DeviceRegistry) + private deviceRepository: Repository, + ) {} + + /** + * Get all devices for a user + */ + async getUserDevices( + userId: string, + currentDeviceId?: string, + ): Promise { + const devices = await this.deviceRepository.find({ + where: { + user: { id: userId }, + }, + order: { + lastSeen: 'DESC', + }, + }); + + return devices.map((device) => ({ + id: device.id, + deviceFingerprint: device.deviceFingerprint, + platform: device.platform, + trusted: device.trusted, + lastSeen: device.lastSeen, + isCurrent: device.id === currentDeviceId, + })); + } + + /** + * Get trusted devices only + */ + async getTrustedDevices(userId: string): Promise { + const devices = await this.deviceRepository.find({ + where: { + user: { id: userId }, + trusted: true, + }, + order: { + lastSeen: 'DESC', + }, + }); + + return devices.map((device) => ({ + id: device.id, + deviceFingerprint: device.deviceFingerprint, + platform: device.platform, + trusted: device.trusted, + lastSeen: device.lastSeen, + })); + } + + /** + * Trust a device + */ + async trustDevice( + userId: string, + deviceId: string, + ): Promise<{ success: boolean; message: string }> { + const device = await this.deviceRepository.findOne({ + where: { + id: deviceId, + user: { id: userId }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + if (device.trusted) { + return { + success: true, + message: 'Device is already trusted', + }; + } + + await this.deviceRepository.update(deviceId, { + trusted: true, + }); + + this.logger.log(`Device ${deviceId} marked as trusted for user ${userId}`); + + return { + success: true, + message: 'Device marked as trusted', + }; + } + + /** + * Revoke trust from a device + */ + async revokeDeviceTrust( + userId: string, + deviceId: string, + currentDeviceId?: string, + ): Promise<{ success: boolean; message: string }> { + const device = await this.deviceRepository.findOne({ + where: { + id: deviceId, + user: { id: userId }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Warn if revoking current device + if (currentDeviceId && device.id === currentDeviceId) { + this.logger.warn( + `User ${userId} is revoking trust from their current device ${deviceId}`, + ); + } + + await this.deviceRepository.update(deviceId, { + trusted: false, + }); + + this.logger.log(`Device trust revoked for device ${deviceId}, user ${userId}`); + + return { + success: true, + message: 'Device trust revoked', + }; + } + + /** + * Remove a device completely + */ + async removeDevice( + userId: string, + deviceId: string, + currentDeviceId?: string, + ): Promise<{ success: boolean; message: string }> { + const device = await this.deviceRepository.findOne({ + where: { + id: deviceId, + user: { id: userId }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + // Don't allow removing current device + if (currentDeviceId && device.id === currentDeviceId) { + throw new BadRequestException( + 'Cannot remove current device. Please use a different device to remove this one.', + ); + } + + // Note: This will cascade delete all refresh tokens associated with this device + await this.deviceRepository.delete(deviceId); + + this.logger.log(`Device ${deviceId} removed for user ${userId}`); + + return { + success: true, + message: 'Device removed successfully', + }; + } + + /** + * Remove all devices except current + */ + async removeAllDevices( + userId: string, + currentDeviceId?: string, + ): Promise<{ success: boolean; message: string; removedCount: number }> { + const queryBuilder = this.deviceRepository + .createQueryBuilder('device') + .where('device.userId = :userId', { userId }); + + // Exclude current device if provided + if (currentDeviceId) { + queryBuilder.andWhere('device.id != :currentDeviceId', { currentDeviceId }); + } + + const devicesToRemove = await queryBuilder.getMany(); + const removedCount = devicesToRemove.length; + + if (removedCount === 0) { + return { + success: true, + message: 'No other devices to remove', + removedCount: 0, + }; + } + + const deviceIds = devicesToRemove.map((d) => d.id); + await this.deviceRepository.delete(deviceIds); + + this.logger.log( + `Removed ${removedCount} devices for user ${userId}, kept current device`, + ); + + return { + success: true, + message: `Successfully removed ${removedCount} device(s)`, + removedCount, + }; + } + + /** + * Get device count for a user + */ + async getDeviceCount(userId: string): Promise<{ + total: number; + trusted: number; + untrusted: number; + }> { + const allDevices = await this.deviceRepository.find({ + where: { + user: { id: userId }, + }, + }); + + const trusted = allDevices.filter((d) => d.trusted).length; + const untrusted = allDevices.filter((d) => !d.trusted).length; + + return { + total: allDevices.length, + trusted, + untrusted, + }; + } + + /** + * Check if a device is trusted + */ + async isDeviceTrusted(userId: string, deviceId: string): Promise { + const device = await this.deviceRepository.findOne({ + where: { + id: deviceId, + user: { id: userId }, + }, + }); + + return device?.trusted || false; + } +}