Add Device Trust Management system

Implements device trust management with ability to view, trust, and remove devices:

Device Trust Features:
- DeviceTrustService for managing DeviceRegistry entries
- List all user devices with trust status
- List trusted devices only
- Trust/untrust specific devices
- Remove devices completely
- Remove all devices except current one
- Get device counts (total, trusted, untrusted)
- Device information includes fingerprint, platform, last seen, trust status

API Endpoints:
- GET /api/v1/auth/devices - List all user devices
- GET /api/v1/auth/devices/trusted - List trusted devices only
- GET /api/v1/auth/devices/count - Get device counts
- POST /api/v1/auth/devices/:deviceId/trust - Mark device as trusted
- DELETE /api/v1/auth/devices/:deviceId/trust - Revoke device trust
- DELETE /api/v1/auth/devices/:deviceId - Remove device completely
- DELETE /api/v1/auth/devices - Remove all devices except current

Implementation Details:
- Prevents removing current device (security)
- Warns when revoking trust from current device
- Cascade deletes refresh tokens when device is removed
- Marks current device for UI indication
- Orders devices by last seen (most recent first)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 21:04:51 +00:00
parent c0cade0a82
commit 6044df7ae8
3 changed files with 352 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<DeviceRegistry>,
) {}
/**
* Get all devices for a user
*/
async getUserDevices(
userId: string,
currentDeviceId?: string,
): Promise<DeviceInfo[]> {
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<DeviceInfo[]> {
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<boolean> {
const device = await this.deviceRepository.findOne({
where: {
id: deviceId,
user: { id: userId },
},
});
return device?.trusted || false;
}
}