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 { PasswordResetService } from './password-reset.service';
|
||||||
import { MFAService } from './mfa.service';
|
import { MFAService } from './mfa.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { DeviceTrustService } from './device-trust.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
@@ -35,6 +36,7 @@ export class AuthController {
|
|||||||
private readonly passwordResetService: PasswordResetService,
|
private readonly passwordResetService: PasswordResetService,
|
||||||
private readonly mfaService: MFAService,
|
private readonly mfaService: MFAService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly deviceTrustService: DeviceTrustService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -240,4 +242,83 @@ export class AuthController {
|
|||||||
activeSessionCount: count,
|
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 { PasswordResetService } from './password-reset.service';
|
||||||
import { MFAService } from './mfa.service';
|
import { MFAService } from './mfa.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { DeviceTrustService } from './device-trust.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { CommonModule } from '../../common/common.module';
|
import { CommonModule } from '../../common/common.module';
|
||||||
@@ -37,7 +38,7 @@ import {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, PasswordResetService, MFAService, SessionService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService, PasswordResetService, MFAService, SessionService],
|
exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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