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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user