Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
115 changes: 115 additions & 0 deletions backend/src/workspace-tracking/providers/biometric-auth.provider.ts
Original file line number Diff line number Diff line change
@@ -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<string, { challenge: string; expiresAt: Date }> =
new Map();

constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}

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<void> {
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<User | null> {
const user = await this.usersRepository.findOne({
where: { id: userId, credentialId },
});
return user ?? null;
}
}
56 changes: 56 additions & 0 deletions backend/src/workspace-tracking/workspace-tracking.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
11 changes: 9 additions & 2 deletions backend/src/workspace-tracking/workspace-tracking.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
52 changes: 51 additions & 1 deletion backend/src/workspace-tracking/workspace-tracking.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) {
Expand All @@ -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);
}
}
Loading