Browse Source

webauthn implementation

Dr-Swopt 6 months ago
parent
commit
63fd8bbc77

File diff suppressed because it is too large
+ 465 - 248
package-lock.json


+ 27 - 25
package.json

@@ -20,45 +20,47 @@
     "test:e2e": "jest --config ./test/jest-e2e.json"
   },
   "dependencies": {
-    "@nestjs/common": "^11.0.1",
-    "@nestjs/core": "^11.0.1",
+    "@nestjs/common": "^11.1.3",
+    "@nestjs/core": "^11.1.3",
     "@nestjs/jwt": "^11.0.0",
     "@nestjs/passport": "^11.0.5",
-    "@nestjs/platform-express": "^11.0.1",
+    "@nestjs/platform-express": "^11.1.3",
     "@nestjs/platform-socket.io": "^11.1.3",
     "@nestjs/websockets": "^11.1.3",
+    "@simplewebauthn/server": "^13.1.1",
     "bcrypt": "^6.0.0",
+    "express-session": "^1.18.1",
     "passport": "^0.7.0",
     "passport-jwt": "^4.0.1",
     "reflect-metadata": "^0.2.2",
-    "rxjs": "^7.8.1"
+    "rxjs": "^7.8.2"
   },
   "devDependencies": {
-    "@eslint/eslintrc": "^3.2.0",
-    "@eslint/js": "^9.18.0",
-    "@nestjs/cli": "^11.0.0",
-    "@nestjs/schematics": "^11.0.0",
-    "@nestjs/testing": "^11.0.1",
-    "@swc/cli": "^0.6.0",
-    "@swc/core": "^1.10.7",
-    "@types/express": "^5.0.0",
-    "@types/jest": "^29.5.14",
-    "@types/node": "^22.10.7",
-    "@types/supertest": "^6.0.2",
-    "eslint": "^9.18.0",
-    "eslint-config-prettier": "^10.0.1",
-    "eslint-plugin-prettier": "^5.2.2",
-    "globals": "^16.0.0",
-    "jest": "^29.7.0",
-    "prettier": "^3.4.2",
+    "@eslint/eslintrc": "^3.3.1",
+    "@eslint/js": "^9.30.0",
+    "@nestjs/cli": "^11.0.7",
+    "@nestjs/schematics": "^11.0.5",
+    "@nestjs/testing": "^11.1.3",
+    "@swc/cli": "^0.7.7",
+    "@swc/core": "^1.12.7",
+    "@types/express": "^5.0.3",
+    "@types/jest": "^30.0.0",
+    "@types/node": "^24.0.7",
+    "@types/supertest": "^6.0.3",
+    "eslint": "^9.30.0",
+    "eslint-config-prettier": "^10.1.5",
+    "eslint-plugin-prettier": "^5.5.1",
+    "globals": "^16.2.0",
+    "jest": "^30.0.3",
+    "prettier": "^3.6.2",
     "source-map-support": "^0.5.21",
-    "supertest": "^7.0.0",
-    "ts-jest": "^29.2.5",
+    "supertest": "^7.1.1",
+    "ts-jest": "^29.4.0",
     "ts-loader": "^9.5.2",
     "ts-node": "^10.9.2",
     "tsconfig-paths": "^4.2.0",
-    "typescript": "^5.7.3",
-    "typescript-eslint": "^8.20.0"
+    "typescript": "^5.8.3",
+    "typescript-eslint": "^8.35.0"
   },
   "jest": {
     "moduleFileExtensions": [

+ 0 - 7
src/app.controller.ts

@@ -13,11 +13,4 @@ export class AppController {
     return this.service.getHello();
   }
 
-  @Get(`server`)
-  @UseGuards(JwtAuthGuard)
-  getServerAPIurl(): string {
-    this.logger.log(`attempted acquisition`)
-    return serverConfig.exposedUrl
-  }
-
 }

+ 1 - 2
src/app.module.ts

@@ -3,11 +3,10 @@ import { AppController } from './app.controller';
 import { GeneralService } from './services/general.service';
 import { AuthModule } from './auth/auth.module';
 import { AttendanceModule } from './attendance/attendance.module';
-import { SocketGateway } from './gateway/socket.gateway';
 import { PaymentModule } from './payment/payment.module';
 
 @Module({
-  imports: [AuthModule, AttendanceModule, PaymentModule, SocketGateway],
+  imports: [AuthModule, AttendanceModule, PaymentModule],
   controllers: [AppController],
   providers: [GeneralService],
 })

+ 4 - 2
src/attendance/attendance.module.ts

@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
 import { AttendanceController } from './attendance.controller';
 import { AttendanceService } from 'src/services/attendance.service';
 import { SocketGateway } from 'src/gateway/socket.gateway';
+import { SocketModule } from 'src/gateway/socket.module';
 
 @Module({
   controllers: [AttendanceController],
+  imports: [SocketModule],
   exports: [], // if you have services to share with other modules
-  providers: [AttendanceService, SocketGateway]
+  providers: [AttendanceService]
 })
-export class AttendanceModule {}
+export class AttendanceModule { }

+ 227 - 3
src/auth/auth.controller.ts

@@ -1,11 +1,18 @@
-import { Body, Controller, Logger, Post } from '@nestjs/common';
+import { BadRequestException, Body, ConflictException, Controller, Get, Logger, NotFoundException, Post, Session, UseGuards } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { AuthenticatorTransportFuture } from '@simplewebauthn/server';
+import { isoBase64URL } from '@simplewebauthn/server/helpers';
+import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
+import { serverConfig } from 'src/config';
 import { LoginPayload, User } from 'src/interface/interface';
 import { AuthService } from 'src/services/auth.service';
+import { UsersService } from 'src/services/user.service';
+import { WebauthnService } from 'src/services/webauthn.service';
 
 @Controller('auth')
 export class AuthController {
     private logger: Logger = new Logger(`Auth Controller`)
-    constructor(private authService: AuthService) { }
+    constructor(private authService: AuthService, private webauthnService: WebauthnService, private usersService: UsersService, private jwtService: JwtService) { }
 
     @Post('register')
     async register(@Body() body: { name: string, email: string; password: string }) {
@@ -13,7 +20,7 @@ export class AuthController {
         let user: User = body
         return this.authService.register(user);
     }
-    
+
     @Post('login')
     login(@Body() body: { email?: string; password?: string }) {
         this.logger.log(`logging in ${body.email}`)
@@ -23,4 +30,221 @@ export class AuthController {
             return this.authService.login(body as LoginPayload);
         }
     }
+
+    @Post('webauthn-register-options')
+    async getRegistrationOptions(
+        @Body() body: { username: string },
+        @Session() session: Record<string, any>,
+    ) {
+        this.logger.log(`Registring options...`)
+        if (!body.username?.trim()) {
+            throw new BadRequestException('Missing or empty username');
+        }
+
+        const user = await this.getUserFromDb(body.username);
+        if (!user) {
+            throw new NotFoundException('User not found');
+        }
+
+        const options = await this.webauthnService.generateRegistrationOptions(user);
+        session.challenge = options.challenge;
+        return options;
+    }
+
+
+    @Post('webauthn-register')
+    async registerCredential(
+        @Body() body: any,
+        @Session() session: Record<string, any>,
+    ) {
+        this.logger.log(`Registring ${body.name}`)
+        const expectedChallenge = session.challenge;
+        if (!expectedChallenge) {
+            throw new BadRequestException('Missing challenge in session');
+        }
+
+        // 1. Verify the registration response
+        const verification = await this.webauthnService.verifyRegistrationResponse(body, expectedChallenge);
+        const { verified, registrationInfo } = verification;
+
+        if (!verified || !registrationInfo) {
+            throw new BadRequestException('Registration verification failed');
+        }
+
+        // 2. Check if user exists
+        let user = await this.usersService.findByEmail(body.email);
+
+        if (!user) {
+            // 👇 Create new user if not found
+            user = await this.usersService.createUser({
+                name: body.name,
+                email: body.email,
+                password: '', // or null/undefined if you're not using passwords for passkey
+            });
+        } else {
+            // 👇 Optional: prevent duplicate registration
+            if (user.devices?.length) {
+                throw new ConflictException('User already registered with WebAuthn');
+            }
+        }
+
+        // 3. Sanitize transports
+        const rawTransports = body.response?.transports ?? [];
+        const allowedTransports: AuthenticatorTransport[] = ['usb', 'ble', 'nfc', 'internal'];
+
+        const transports = (rawTransports as AuthenticatorTransportFuture[]).filter(
+            (t): t is AuthenticatorTransport => allowedTransports.includes(t as AuthenticatorTransport),
+        );
+
+        // Optional: Log dropped transports
+        const dropped = rawTransports.filter(
+            t => !allowedTransports.includes(t as AuthenticatorTransport),
+        );
+        if (dropped.length) {
+            console.warn('Dropped unsupported transports:', dropped);
+        }
+
+        // 4. Store device
+        user.devices = user.devices || [];
+        user.devices.push({
+            credentialID: isoBase64URL.toBuffer(registrationInfo.credential.id),
+            credentialPublicKey: registrationInfo.credential.publicKey,
+            counter: registrationInfo.credential.counter,
+            transports, // ✅ now correct
+        });
+
+        // 5. Create JWT and return
+        const payload = { sub: user.id, name: user.name, email: user.email };
+        const token = this.jwtService.sign(payload);
+        let response = {
+            verified: true,
+            access_token: token,
+            name: body.name,
+        };
+
+        return response
+    }
+
+    @Post('webauthn-login-options')
+    async getLoginOptions(@Body() body: { email: string }, @Session() session: Record<string, any>) {
+        if (!body.email) throw new BadRequestException('Missing email');
+
+        const user = await this.usersService.findByEmail(body.email);
+        if (!user || !user.devices?.length) {
+            throw new NotFoundException('User or devices not found');
+        }
+
+        const options = await this.webauthnService.generateAuthenticationOptions(user.devices);
+        session.challenge = options.challenge;
+        session.email = user.email;
+        return options;
+    }
+
+
+    @Post('webauthn-login')
+    async verifyLogin(@Body() body: any, @Session() session: Record<string, any>) {
+        const expectedChallenge = session.challenge;
+        const email = session.email;
+
+        if (!expectedChallenge || !email) {
+            throw new BadRequestException('Missing challenge or email in session');
+        }
+
+        const user = await this.usersService.findByEmail(email);
+        if (!user || !user.devices?.length) {
+            throw new NotFoundException('User or devices not found');
+        }
+
+        const device = user.devices.find(
+            d => isoBase64URL.fromBuffer(d.credentialID) === body.id || body.rawId === isoBase64URL.fromBuffer(d.credentialID),
+        );
+
+        if (!device) {
+            throw new NotFoundException('Device not registered');
+        }
+
+        const verification = await this.webauthnService.verifyAuthenticationResponse(
+            body,
+            expectedChallenge,
+            device.credentialPublicKey,
+            isoBase64URL.fromBuffer(device.credentialID),
+            device.counter,
+        );
+
+        if (!verification.verified) {
+            throw new BadRequestException('Authentication failed');
+        }
+
+        // Optional: update device counter
+        device.counter = verification.authenticationInfo.newCounter;
+
+        await this.usersService.updateUser(user);
+
+        const payload = { sub: user.id, name: user.name, email: user.email };
+        const token = this.jwtService.sign(payload);
+
+        return {
+            verified: true,
+            access_token: token,
+            name: user.name,
+        };
+    }
+
+
+    @Post('auth-options')
+    async getAuthenticationOptions(@Body() body: any, @Session() session: Record<string, any>) {
+        const devices = await this.getDevicesFromDb(body.userID);
+        const options = await this.webauthnService.generateAuthenticationOptions(devices);
+        session.challenge = options.challenge;
+        return options;
+    }
+
+    @Post('webauthn-authenticate')
+    async verifyAuthentication(@Body() body: any, @Session() session: Record<string, any>) {
+        const user = await this.getUserFromDb(body.userID);
+        const device = await this.getDeviceByCredentialID(body.rawId);
+
+        const verification = await this.webauthnService.verifyAuthenticationResponse(
+            body,
+            session.challenge,
+            device.credentialPublicKey,
+            device.credentialID,
+            device.counter,
+        );
+
+        if (verification.verified) {
+            // Update counter in DB
+        }
+
+        return verification;
+    }
+
+    @Get(`server`)
+    @UseGuards(JwtAuthGuard)
+    getServerAPIurl(): string {
+        this.logger.log(`attempted acquisition`)
+        return serverConfig.exposedUrl
+    }
+
+
+    // -- Mock DB lookups (replace with your DB calls) --
+    async getUserFromDb(userID: string) {
+        return {
+            id: userID,
+            username: `${userID}@user.com`,
+            devices: [], // Array of previously registered devices
+        };
+    }
+
+    async getDevicesFromDb(userID: string) {
+        return []; // Retrieve from DB
+    }
+
+    async getDeviceByCredentialID(id: string) {
+        return {
+            credentialID: id,
+            credentialPublicKey: new Uint8Array(), // from DB
+            counter: 0,
+        };
+    }
 }

+ 4 - 2
src/auth/auth.module.ts

@@ -1,13 +1,15 @@
 import { Module } from '@nestjs/common';
 import { AuthController } from './auth.controller';
 import { JwtModule } from '@nestjs/jwt';
-import { UsersService } from 'src/services/user.service';
 import { AuthService } from 'src/services/auth.service';
 import { PassportModule } from '@nestjs/passport';
 import { JwtStrategy } from './jwt.strategy';
+import { UsersModule } from 'src/users/users.module';
+import { WebauthnService } from 'src/services/webauthn.service';
 
 @Module({
   imports: [
+    UsersModule,
     PassportModule,
     JwtModule.register({
       secret: 'dev-secret', // Use ENV later
@@ -15,6 +17,6 @@ import { JwtStrategy } from './jwt.strategy';
     }),
   ],
   controllers: [AuthController],
-  providers: [AuthService, UsersService, JwtStrategy],
+  providers: [AuthService, JwtStrategy, WebauthnService],
 })
 export class AuthModule {}

+ 1 - 1
src/config.ts

@@ -1,3 +1,3 @@
 export const serverConfig = {
-    exposedUrl: `https://a654-124-13-232-72.ngrok-free.app`
+    exposedUrl: `https://b8c8-115-132-229-66.ngrok-free.app`
 }

+ 9 - 0
src/gateway/socket.module.ts

@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { SocketGateway } from "./socket.gateway";
+
+// socket.module.ts
+@Module({
+  providers: [SocketGateway],
+  exports: [SocketGateway], // ← important
+})
+export class SocketModule {}

+ 23 - 6
src/interface/interface.ts

@@ -1,13 +1,23 @@
-export interface RegisteredUser extends User {
-    id: string,
+// Represents a single WebAuthn credential device
+export interface WebAuthnDevice {
+    credentialID: Uint8Array;
+    credentialPublicKey: Uint8Array;
+    counter: number;
+    transports?: AuthenticatorTransport[];
 }
 
+// Your existing User interface (basic registration fields)
 export interface User {
-    name: string,
-    email: string,
-    password: string
-} 
+    name: string;
+    email: string;
+    password: string;
+}
 
+// RegisteredUser now supports both password and WebAuthn logins
+export interface RegisteredUser extends User {
+    id: string;
+    devices?: WebAuthnDevice[]; // Optional array of registered WebAuthn credentials
+}
 export interface LoginPayload {
     email: string,
     password: string
@@ -22,4 +32,11 @@ export interface PaymentPayload {
     name: string
     date: Date
     verified: boolean
+}
+
+export interface Message {
+    time: Date
+    event: string,
+    source: string
+    payload: any
 }

+ 44 - 4
src/main.ts

@@ -1,13 +1,53 @@
 import { NestFactory } from '@nestjs/core';
 import { AppModule } from './app.module';
 import { serverConfig } from './config';
+import * as session from 'express-session';
+import { join } from 'path';
+import { NestExpressApplication } from '@nestjs/platform-express';
+import * as fs from 'fs';
 
 async function bootstrap() {
-  const app = await NestFactory.create(AppModule);
-  app.enableCors(); // ← enable CORS globally
+  const app = await NestFactory.create<NestExpressApplication>(AppModule);
+  app.enableCors();
 
-  await app.listen(process.env.PORT ?? 3000);
+  // ✅ API prefix
+  app.setGlobalPrefix('api'); // <<==== All controllers now under /api/*
+
+  const angularDistPath = join(__dirname, '..', '..', 'web-app', 'dist', 'mobile-auth-web-app', 'browser');
+  const indexPath = join(angularDistPath, 'index.html');
+
+  console.log('✅ Angular path:', angularDistPath);
+  console.log('✅ index.html exists:', fs.existsSync(indexPath));
+
+  // ✅ Now serve Angular static files
+  app.useStaticAssets(angularDistPath);
+  app.setBaseViewsDir(angularDistPath);
+  app.setViewEngine('html');
+
+  app.use(
+    session({
+      secret: 'your-secret',
+      resave: false,
+      saveUninitialized: false,
+    }),
+  );
 
-  console.log(`Server started at localhost:3000 $$ Exposed at ${serverConfig.exposedUrl}`)
+  // ✅ Angular fallback: only for non-API, non-static requests
+  app.use((req, res, next) => {
+    const isStaticAsset = req.url.includes('.');
+    const isApiCall = req.url.startsWith('/api') || req.method !== 'GET';
+
+    if (isStaticAsset || isApiCall) {
+      return next();
+    }
+
+    console.log('📦 Angular fallback hit for:', req.url);
+    res.sendFile(indexPath);
+  });
+
+  await app.listen(process.env.PORT ?? 3000);
+  console.log(`🚀 Server running at http://localhost:3000`);
+  console.log(`🌐 Exposed at: ${serverConfig.exposedUrl}`);
 }
+
 bootstrap();

+ 3 - 2
src/payment/payment.module.ts

@@ -1,11 +1,12 @@
 import { Module } from '@nestjs/common';
 import { PaymentController } from './payment.controller';
 import { PaymentService } from 'src/services/payment.service';
-import { SocketGateway } from 'src/gateway/socket.gateway';
+import { SocketModule } from 'src/gateway/socket.module';
 
 @Module({
   controllers: [PaymentController],
+  imports: [SocketModule],
   exports: [], // if you have services to share with other modules
-  providers: [PaymentService, SocketGateway]
+  providers: [PaymentService]
 })
 export class PaymentModule {}

+ 19 - 0
src/services/user.service.ts

@@ -59,4 +59,23 @@ export class UsersService {
         });
     }
 
+    public async findById(id: string): Promise<RegisteredUser | undefined> {
+        return this.users.find(user => user.id === id);
+    }
+
+    public getAllUsers(): Omit<RegisteredUser, 'password'>[] {
+        return this.users.map(({ password, ...rest }) => rest);
+    }
+
+    public async updateUser(updatedUser: RegisteredUser): Promise<void> {
+        const index = this.users.findIndex(user => user.id === updatedUser.id);
+
+        if (index === -1) {
+            throw new Error(`User with ID ${updatedUser.id} not found`);
+        }
+
+        this.users[index] = updatedUser;
+        this.logger.log(`User ${updatedUser.id} updated successfully`);
+    }
+
 }

+ 124 - 0
src/services/webauthn.service.ts

@@ -0,0 +1,124 @@
+// src/webauthn/webauthn.service.ts
+import {
+    generateRegistrationOptions,
+    verifyRegistrationResponse,
+    generateAuthenticationOptions,
+    verifyAuthenticationResponse,
+    VerifiedRegistrationResponse,
+    VerifiedAuthenticationResponse,
+    RegistrationResponseJSON,
+} from '@simplewebauthn/server';
+import { Injectable, Logger } from '@nestjs/common';
+import { isoUint8Array } from '@simplewebauthn/server/helpers';
+import { isoBase64URL } from '@simplewebauthn/server/helpers';
+
+@Injectable()
+export class WebauthnService {
+    private logger: Logger = new Logger(`WebAuthn`)
+    private rpName = 'MyApp';
+    private rpID = 'b8c8-115-132-229-66.ngrok-free.app'; // replace with your domain in production
+    private origin = 'https://b8c8-115-132-229-66.ngrok-free.app'; // your frontend origin
+
+    public devices: any[] = []; // In-memory device store
+
+    constructor() {
+        this.logger.log(`Web Authn service instantiated...`)
+    }
+
+    async generateRegistrationOptions(user: {
+        id: string;
+        username: string;
+        devices: {
+            credentialID: Uint8Array;
+            transports?: AuthenticatorTransport[];
+        }[];
+    }) {
+        return generateRegistrationOptions({
+            rpName: this.rpName,
+            rpID: this.rpID,
+            userID: isoUint8Array.fromUTF8String(user.id),
+            userName: user.username,
+            timeout: 60000,
+            attestationType: 'none',
+            excludeCredentials: user.devices.map(dev => ({
+                id: isoBase64URL.fromBuffer(dev.credentialID),
+                type: 'public-key',
+                transports: dev.transports,
+            })),
+            authenticatorSelection: {
+                userVerification: 'preferred',
+                residentKey: 'preferred',
+            },
+        });
+    }
+
+    async verifyRegistrationResponse(
+        responseBody: RegistrationResponseJSON,
+        expectedChallenge: string,
+    ): Promise<VerifiedRegistrationResponse> {
+        this.logger.log('Verifying registration response...');
+        this.logger.debug(JSON.stringify(responseBody, null, 2));
+
+        return verifyRegistrationResponse({
+            response: responseBody,
+            expectedChallenge,
+            expectedOrigin: this.origin,
+            expectedRPID: this.rpID,
+        });
+    }
+
+    async generateAuthenticationOptions(registeredDevices: {
+        credentialID: Uint8Array;
+        transports?: AuthenticatorTransport[];
+    }[]) {
+        return generateAuthenticationOptions({
+            rpID: this.rpID,
+            timeout: 60000,
+            allowCredentials: registeredDevices.map(dev => ({
+                id: isoBase64URL.fromBuffer(dev.credentialID),
+                type: 'public-key',
+                transports: dev.transports,
+            })),
+            userVerification: 'preferred',
+        });
+    }
+
+    async verifyAuthenticationResponse(
+        responseBody: any,
+        expectedChallenge: string,
+        credentialPublicKey: Uint8Array,
+        credentialID: string, // stored as base64url in DB
+        counter: number,
+    ): Promise<VerifiedAuthenticationResponse> {
+        return verifyAuthenticationResponse({
+            response: responseBody,
+            expectedChallenge,
+            expectedOrigin: this.origin,
+            expectedRPID: this.rpID,
+            credential: {
+                id: credentialID, // must be base64url string
+                publicKey: credentialPublicKey,
+                counter,
+            },
+        });
+    }
+
+    storeDeviceInfo(deviceInfo: any): void {
+        this.devices.push(deviceInfo)
+        console.log(this.devices)
+    }
+
+    findDeviceByCredentialID(
+        devices: {
+            credentialID: Uint8Array;
+            credentialPublicKey: Uint8Array;
+            counter: number;
+            transports?: AuthenticatorTransport[];
+        }[],
+        credentialID: string, // base64url
+    ) {
+        return devices.find(dev =>
+            isoBase64URL.fromBuffer(dev.credentialID) === credentialID
+        );
+    }
+}

+ 26 - 0
src/users/users.controller.ts

@@ -0,0 +1,26 @@
+import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
+import { UsersService } from 'src/services/user.service';
+import { RegisteredUser } from 'src/interface/interface';
+
+@Controller('users')
+export class UsersController {
+  constructor(private readonly usersService: UsersService) {}
+
+  // GET /users/:id → Fetch user by ID
+  @Get(':id')
+  async getUserById(@Param('id') id: string): Promise<Omit<RegisteredUser, 'password'>> {
+    const user = await this.usersService.findById(id);
+    if (!user) {
+      throw new NotFoundException(`User with ID ${id} not found`);
+    }
+
+    const { password, ...safeUser } = user;
+    return safeUser;
+  }
+
+  // GET /users → (Optional) List all users (for debugging)
+  @Get()
+  getAllUsers(): Omit<RegisteredUser, 'password'>[] {
+    return this.usersService.getAllUsers();
+  }
+}

+ 11 - 0
src/users/users.module.ts

@@ -0,0 +1,11 @@
+// src/services/users.module.ts
+import { Module } from '@nestjs/common';
+import { UsersService } from 'src/services/user.service';
+import { UsersController } from 'src/users/users.controller';
+
+@Module({
+  providers: [UsersService],
+  exports: [UsersService],
+  controllers: [UsersController],
+})
+export class UsersModule {}

Some files were not shown because too many files changed in this diff