From 0ffbac341f58125f9b19986c3e5bc74102df9e01 Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Sat, 27 Jun 2026 11:37:40 +0100 Subject: [PATCH] feat: add WebAuthn biometric check-in endpoints --- backend/src/users/entities/user.entity.ts | 6 + .../providers/biometric-auth.provider.ts | 115 ++++++++++++++++++ .../workspace-tracking.controller.ts | 56 +++++++++ .../workspace-tracking.module.ts | 11 +- .../workspace-tracking.service.ts | 52 +++++++- 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 backend/src/workspace-tracking/providers/biometric-auth.provider.ts diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 4a72a951..895389ca 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -119,6 +119,12 @@ export class User { @Column({ type: 'jsonb', nullable: true }) totpBackupCodes?: string[]; + @Column({ nullable: true, type: 'varchar' }) + credentialId?: string; + + @Column({ nullable: true, type: 'text' }) + credentialPublicKey?: string; + @Column({ type: 'enum', enum: MembershipStatus, diff --git a/backend/src/workspace-tracking/providers/biometric-auth.provider.ts b/backend/src/workspace-tracking/providers/biometric-auth.provider.ts new file mode 100644 index 00000000..8afadcc4 --- /dev/null +++ b/backend/src/workspace-tracking/providers/biometric-auth.provider.ts @@ -0,0 +1,115 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { User } from '../../users/entities/user.entity'; + +@Injectable() +export class BiometricAuthProvider { + private challenges: Map = + new Map(); + + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + generateRegistrationChallenge(userId: string): { challenge: string } { + const challenge = crypto.randomBytes(32).toString('base64url'); + this.challenges.set(`reg:${userId}`, { + challenge, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + return { challenge }; + } + + async verifyRegistration( + userId: string, + response: { + challenge: string; + credentialId: string; + credentialPublicKey: string; + }, + ): Promise<{ message: string }> { + const key = `reg:${userId}`; + const stored = this.challenges.get(key); + if (!stored || stored.challenge !== response.challenge) { + throw new BadRequestException('Invalid or expired challenge'); + } + if (new Date() > stored.expiresAt) { + this.challenges.delete(key); + throw new BadRequestException('Challenge has expired'); + } + + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + if (!user) { + throw new NotFoundException('User not found'); + } + + user.credentialId = response.credentialId; + user.credentialPublicKey = response.credentialPublicKey; + await this.usersRepository.save(user); + this.challenges.delete(key); + + return { message: 'Biometric credential registered successfully' }; + } + + async generateAuthenticationChallenge( + userId: string, + ): Promise<{ challenge: string; credentialId: string }> { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + if (!user || !user.credentialId) { + throw new BadRequestException('No biometric credential registered'); + } + + const challenge = crypto.randomBytes(32).toString('base64url'); + this.challenges.set(`auth:${userId}`, { + challenge, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + return { challenge, credentialId: user.credentialId }; + } + + async verifyAuthentication( + userId: string, + response: { challenge: string; credentialId: string }, + ): Promise { + const key = `auth:${userId}`; + const stored = this.challenges.get(key); + if (!stored || stored.challenge !== response.challenge) { + throw new BadRequestException('Invalid or expired challenge'); + } + if (new Date() > stored.expiresAt) { + this.challenges.delete(key); + throw new BadRequestException('Challenge has expired'); + } + + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + if (!user || user.credentialId !== response.credentialId) { + throw new BadRequestException('Invalid credential'); + } + + this.challenges.delete(key); + } + + async validateCredential( + userId: string, + credentialId: string, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId, credentialId }, + }); + return user ?? null; + } +} diff --git a/backend/src/workspace-tracking/workspace-tracking.controller.ts b/backend/src/workspace-tracking/workspace-tracking.controller.ts index 58b05156..3a2007f6 100644 --- a/backend/src/workspace-tracking/workspace-tracking.controller.ts +++ b/backend/src/workspace-tracking/workspace-tracking.controller.ts @@ -102,4 +102,60 @@ export class WorkspaceTrackingController { ); return { message: 'Recent logs retrieved', data }; } + + @Post('biometric/register/challenge') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Generate WebAuthn registration challenge' }) + async biometricRegisterChallenge(@GetCurrentUser('id') userId: string) { + const data = + await this.workspaceTrackingService.generateRegistrationChallenge( + userId, + ); + return { message: 'Registration challenge generated', data }; + } + + @Post('biometric/register/verify') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Verify and store WebAuthn credential' }) + async biometricRegisterVerify( + @Body() body: { challenge: string; credentialId: string; credentialPublicKey: string }, + @GetCurrentUser('id') userId: string, + ) { + const data = await this.workspaceTrackingService.verifyRegistration( + userId, + body, + ); + return { message: data.message, data }; + } + + @Post('biometric/check-in') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Biometric check-in using stored credential' }) + async biometricCheckIn( + @Body() dto: CheckInDto & { credentialId: string }, + @GetCurrentUser('id') userId: string, + ) { + const data = await this.workspaceTrackingService.biometricCheckIn( + dto, + userId, + ); + return { message: 'Biometric check-in successful', data }; + } + + @Post('biometric/check-out/:logId') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Biometric check-out using stored credential' }) + @HttpCode(HttpStatus.OK) + async biometricCheckOut( + @Param('logId', ParseUUIDPipe) logId: string, + @Body() body: { credentialId: string }, + @GetCurrentUser('id') userId: string, + ) { + const data = await this.workspaceTrackingService.biometricCheckOut( + logId, + userId, + body, + ); + return { message: 'Biometric check-out successful', data }; + } } diff --git a/backend/src/workspace-tracking/workspace-tracking.module.ts b/backend/src/workspace-tracking/workspace-tracking.module.ts index e42d28b8..9fdf39e4 100644 --- a/backend/src/workspace-tracking/workspace-tracking.module.ts +++ b/backend/src/workspace-tracking/workspace-tracking.module.ts @@ -2,15 +2,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceLog } from './entities/workspace-log.entity'; import { Workspace } from '../workspaces/entities/workspace.entity'; +import { User } from '../users/entities/user.entity'; import { WorkspaceTrackingService } from './workspace-tracking.service'; import { WorkspaceTrackingController } from './workspace-tracking.controller'; import { CheckInProvider } from './providers/check-in.provider'; import { OccupancyProvider } from './providers/occupancy.provider'; +import { BiometricAuthProvider } from './providers/biometric-auth.provider'; @Module({ - imports: [TypeOrmModule.forFeature([WorkspaceLog, Workspace])], + imports: [TypeOrmModule.forFeature([WorkspaceLog, Workspace, User])], controllers: [WorkspaceTrackingController], - providers: [WorkspaceTrackingService, CheckInProvider, OccupancyProvider], + providers: [ + WorkspaceTrackingService, + CheckInProvider, + OccupancyProvider, + BiometricAuthProvider, + ], exports: [WorkspaceTrackingService], }) export class WorkspaceTrackingModule {} diff --git a/backend/src/workspace-tracking/workspace-tracking.service.ts b/backend/src/workspace-tracking/workspace-tracking.service.ts index 6b9cd1f5..c8344ec4 100644 --- a/backend/src/workspace-tracking/workspace-tracking.service.ts +++ b/backend/src/workspace-tracking/workspace-tracking.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; import { CheckInProvider } from './providers/check-in.provider'; import { OccupancyProvider } from './providers/occupancy.provider'; +import { BiometricAuthProvider } from './providers/biometric-auth.provider'; import { CheckInDto } from './dto/check-in.dto'; import { OccupancyQueryDto } from './dto/occupancy-query.dto'; @@ -9,6 +10,7 @@ export class WorkspaceTrackingService { constructor( private readonly checkInProvider: CheckInProvider, private readonly occupancyProvider: OccupancyProvider, + private readonly biometricAuthProvider: BiometricAuthProvider, ) {} checkIn(dto: CheckInDto, userId: string) { @@ -34,4 +36,52 @@ export class WorkspaceTrackingService { getRecentLogs(workspaceId?: string, limit?: number) { return this.occupancyProvider.getRecentLogs(workspaceId, limit); } + + generateRegistrationChallenge(userId: string) { + return this.biometricAuthProvider.generateRegistrationChallenge(userId); + } + + async verifyRegistration( + userId: string, + response: { + challenge: string; + credentialId: string; + credentialPublicKey: string; + }, + ) { + return this.biometricAuthProvider.verifyRegistration(userId, response); + } + + async generateBiometricAuthChallenge(userId: string) { + return this.biometricAuthProvider.generateAuthenticationChallenge(userId); + } + + async biometricCheckIn( + dto: CheckInDto & { credentialId: string }, + userId: string, + ) { + const user = await this.biometricAuthProvider.validateCredential( + userId, + dto.credentialId, + ); + if (!user) { + throw new BadRequestException('Invalid biometric credential'); + } + return this.checkInProvider.checkIn(dto, userId); + } + + async biometricCheckOut( + logId: string, + userId: string, + body: { credentialId: string }, + ) { + const user = await this.biometricAuthProvider.validateCredential( + userId, + body.credentialId, + ); + if (!user) { + throw new BadRequestException('Invalid biometric credential'); + } + return this.checkInProvider.checkOut(logId, userId); + } }