From dddb82579ff4ea46ca625f66dbadcb07329cfcfa Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 1 Oct 2025 22:24:19 +0000 Subject: [PATCH] Add WebAuthn biometric authentication backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create webauthn_credentials table migration (V011) - Add WebAuthnCredential entity for storing biometric credentials - Implement BiometricAuthService with SimpleWebAuthn v13 API - Add 8 biometric auth endpoints (register/verify/credentials/manage) - Add loginWithExternalAuth method to AuthService for biometric login - Support Face ID, Touch ID, Windows Hello, and Android biometrics - Store challenges in-memory (can be moved to Redis in production) - Include credential management (list, delete, rename) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../maternal-app-backend/package-lock.json | 238 +++++++++++++ .../maternal-app-backend/package.json | 1 + .../V011_add_webauthn_credentials.sql | 37 ++ .../src/modules/auth/auth.controller.ts | 100 ++++++ .../src/modules/auth/auth.module.ts | 8 +- .../src/modules/auth/auth.service.ts | 45 +++ .../modules/auth/biometric-auth.service.ts | 333 ++++++++++++++++++ .../entities/webauthn-credential.entity.ts | 45 +++ 8 files changed, 804 insertions(+), 3 deletions(-) create mode 100644 maternal-app/maternal-app-backend/src/database/migrations/V011_add_webauthn_credentials.sql create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/auth/entities/webauthn-credential.entity.ts diff --git a/maternal-app/maternal-app-backend/package-lock.json b/maternal-app/maternal-app-backend/package-lock.json index cae3a57..8b150e6 100644 --- a/maternal-app/maternal-app-backend/package-lock.json +++ b/maternal-app/maternal-app-backend/package-lock.json @@ -27,6 +27,7 @@ "@nestjs/websockets": "^10.4.20", "@sentry/node": "^10.17.0", "@sentry/profiling-node": "^10.17.0", + "@simplewebauthn/server": "^13.2.1", "@types/pdfkit": "^0.17.3", "@types/qrcode": "^1.5.5", "axios": "^1.12.2", @@ -2209,6 +2210,12 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3435,6 +3442,12 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -4528,6 +4541,162 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz", + "integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.5.0.tgz", + "integrity": "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-x509-attr": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz", + "integrity": "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz", + "integrity": "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz", + "integrity": "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-pkcs8": "^2.5.0", + "@peculiar/asn1-rsa": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz", + "integrity": "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz", + "integrity": "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-pfx": "^2.5.0", + "@peculiar/asn1-pkcs8": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-x509-attr": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz", + "integrity": "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz", + "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz", + "integrity": "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz", + "integrity": "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz", + "integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-csr": "^2.5.0", + "@peculiar/asn1-ecc": "^2.5.0", + "@peculiar/asn1-pkcs9": "^2.5.0", + "@peculiar/asn1-rsa": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4856,6 +5025,25 @@ "node": ">=18" } }, + "node_modules/@simplewebauthn/server": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.1.tgz", + "integrity": "sha512-Inmfye5opZXe3HI0GaksqBnQiM7glcNySoG6DH1GgkO1Lh9dvuV4XSV9DK02DReUVX39HpcDob9nxHELjECoQw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/x509": "^1.13.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -6842,6 +7030,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -12782,6 +12984,24 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -14727,6 +14947,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/maternal-app/maternal-app-backend/package.json b/maternal-app/maternal-app-backend/package.json index 9220820..fcb7ed5 100644 --- a/maternal-app/maternal-app-backend/package.json +++ b/maternal-app/maternal-app-backend/package.json @@ -39,6 +39,7 @@ "@nestjs/websockets": "^10.4.20", "@sentry/node": "^10.17.0", "@sentry/profiling-node": "^10.17.0", + "@simplewebauthn/server": "^13.2.1", "@types/pdfkit": "^0.17.3", "@types/qrcode": "^1.5.5", "axios": "^1.12.2", diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V011_add_webauthn_credentials.sql b/maternal-app/maternal-app-backend/src/database/migrations/V011_add_webauthn_credentials.sql new file mode 100644 index 0000000..2fde51c --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V011_add_webauthn_credentials.sql @@ -0,0 +1,37 @@ +-- Migration V011: Add WebAuthn credentials table for biometric authentication +-- Description: Creates table to store WebAuthn/FIDO2 credentials for Face ID, Touch ID, Windows Hello, etc. +-- Date: 2025-10-01 + +-- Create webauthn_credentials table +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter BIGINT NOT NULL DEFAULT 0, + device_type VARCHAR(50), + transports TEXT[], + backed_up BOOLEAN DEFAULT false, + authenticator_attachment VARCHAR(20), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP WITH TIME ZONE, + friendly_name VARCHAR(100), + + CONSTRAINT fk_webauthn_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Create indexes for performance +CREATE INDEX idx_webauthn_user_id ON webauthn_credentials(user_id); +CREATE INDEX idx_webauthn_credential_id ON webauthn_credentials(credential_id); +CREATE INDEX idx_webauthn_last_used ON webauthn_credentials(last_used); + +-- Add comments for documentation +COMMENT ON TABLE webauthn_credentials IS 'Stores WebAuthn/FIDO2 credentials for biometric authentication (Face ID, Touch ID, Windows Hello, Android biometrics)'; +COMMENT ON COLUMN webauthn_credentials.credential_id IS 'Base64url-encoded credential ID from authenticator'; +COMMENT ON COLUMN webauthn_credentials.public_key IS 'Base64url-encoded public key for signature verification'; +COMMENT ON COLUMN webauthn_credentials.counter IS 'Signature counter to prevent credential replay attacks'; +COMMENT ON COLUMN webauthn_credentials.device_type IS 'Device type from user agent (e.g., iPhone, Android, Windows)'; +COMMENT ON COLUMN webauthn_credentials.transports IS 'Supported transports (internal, nfc, ble, usb)'; +COMMENT ON COLUMN webauthn_credentials.backed_up IS 'Whether credential is backed up (synced across devices)'; +COMMENT ON COLUMN webauthn_credentials.authenticator_attachment IS 'platform (built-in) or cross-platform (external key)'; +COMMENT ON COLUMN webauthn_credentials.friendly_name IS 'User-assigned name for the credential (e.g., "iPhone 15 Pro")'; diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts index 4ede2fd..9009038 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts @@ -19,6 +19,7 @@ import { PasswordResetService } from './password-reset.service'; import { MFAService } from './mfa.service'; import { SessionService } from './session.service'; import { DeviceTrustService } from './device-trust.service'; +import { BiometricAuthService } from './biometric-auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -37,6 +38,7 @@ export class AuthController { private readonly mfaService: MFAService, private readonly sessionService: SessionService, private readonly deviceTrustService: DeviceTrustService, + private readonly biometricAuthService: BiometricAuthService, ) {} @Public() @@ -321,4 +323,102 @@ export class AuthController { const currentDeviceId = request.user?.deviceId; return await this.deviceTrustService.removeAllDevices(user.userId, currentDeviceId); } + + // ==================== Biometric Authentication Endpoints ==================== + + @UseGuards(JwtAuthGuard) + @Post('biometric/register/options') + @HttpCode(HttpStatus.OK) + async getBiometricRegistrationOptions(@CurrentUser() user: any, @Body() body: { friendlyName?: string }) { + return await this.biometricAuthService.generateRegistrationOptions({ + userId: user.userId, + friendlyName: body.friendlyName, + }); + } + + @UseGuards(JwtAuthGuard) + @Post('biometric/register/verify') + @HttpCode(HttpStatus.OK) + async verifyBiometricRegistration( + @CurrentUser() user: any, + @Body() body: { response: any; friendlyName?: string }, + ) { + return await this.biometricAuthService.verifyRegistrationResponse( + user.userId, + body.response, + body.friendlyName, + ); + } + + @Public() + @Post('biometric/authenticate/options') + @HttpCode(HttpStatus.OK) + async getBiometricAuthenticationOptions(@Body() body: { email?: string }) { + return await this.biometricAuthService.generateAuthenticationOptions({ + email: body.email, + }); + } + + @Public() + @Post('biometric/authenticate/verify') + @HttpCode(HttpStatus.OK) + async verifyBiometricAuthentication( + @Body() body: { response: any; email?: string; deviceInfo?: { deviceId: string; platform: string } }, + @Ip() ipAddress: string, + @Headers('user-agent') userAgent: string, + ) { + const deviceInfo = body.deviceInfo || { + deviceId: body.response.id.substring(0, 10), // Use credential ID as device ID + platform: userAgent, + }; + + return await this.biometricAuthService.authenticateWithBiometric(body.response, deviceInfo, body.email); + } + + @UseGuards(JwtAuthGuard) + @Get('biometric/credentials') + @HttpCode(HttpStatus.OK) + async getBiometricCredentials(@CurrentUser() user: any) { + const credentials = await this.biometricAuthService.getUserCredentials(user.userId); + return { + success: true, + credentials: credentials.map((cred) => ({ + id: cred.id, + friendlyName: cred.friendlyName, + deviceType: cred.deviceType, + createdAt: cred.createdAt, + lastUsed: cred.lastUsed, + backedUp: cred.backedUp, + })), + }; + } + + @UseGuards(JwtAuthGuard) + @Delete('biometric/credentials/:credentialId') + @HttpCode(HttpStatus.OK) + async deleteBiometricCredential(@CurrentUser() user: any, @Param('credentialId') credentialId: string) { + return await this.biometricAuthService.deleteCredential(user.userId, credentialId); + } + + @UseGuards(JwtAuthGuard) + @Patch('biometric/credentials/:credentialId') + @HttpCode(HttpStatus.OK) + async updateBiometricCredentialName( + @CurrentUser() user: any, + @Param('credentialId') credentialId: string, + @Body() body: { friendlyName: string }, + ) { + return await this.biometricAuthService.updateCredentialName(user.userId, credentialId, body.friendlyName); + } + + @UseGuards(JwtAuthGuard) + @Get('biometric/has-credentials') + @HttpCode(HttpStatus.OK) + async hasBiometricCredentials(@CurrentUser() user: any) { + const hasCredentials = await this.biometricAuthService.hasCredentials(user.userId); + return { + success: true, + hasCredentials, + }; + } } \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts index 4dbc8bd..9b8c050 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts @@ -9,9 +9,11 @@ import { PasswordResetService } from './password-reset.service'; import { MFAService } from './mfa.service'; import { SessionService } from './session.service'; import { DeviceTrustService } from './device-trust.service'; +import { BiometricAuthService } from './biometric-auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { CommonModule } from '../../common/common.module'; +import { WebAuthnCredential } from './entities/webauthn-credential.entity'; import { User, DeviceRegistry, @@ -23,7 +25,7 @@ import { @Module({ imports: [ - TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]), + TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember, WebAuthnCredential]), PassportModule, CommonModule, JwtModule.registerAsync({ @@ -38,7 +40,7 @@ import { }), ], controllers: [AuthController], - providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, JwtStrategy, LocalStrategy], - exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService], + providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, BiometricAuthService, JwtStrategy, LocalStrategy], + exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, BiometricAuthService], }) export class AuthModule {} \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts index 50d3ff3..ff42c65 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts @@ -415,4 +415,49 @@ export class AuthService { expiresIn: 3600, // 1 hour in seconds }; } + + /** + * Login with external authentication (e.g., biometric, OAuth) + * Skips password validation but performs device registration and token generation + */ + async loginWithExternalAuth( + user: User, + deviceInfo: { deviceId: string; platform: string }, + ): Promise { + // Register or update device + const device = await this.registerDevice(user.id, deviceInfo.deviceId, deviceInfo.platform); + + // Generate JWT tokens + const tokens = await this.generateTokens(user, device.id); + + // Audit log for biometric login + await this.auditService.log({ + userId: user.id, + action: 'LOGIN_BIOMETRIC', + resourceType: 'AUTH', + resourceId: user.id, + metadata: { + deviceId: device.deviceFingerprint, + platform: device.platform, + }, + }); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + user: { + id: user.id, + email: user.email, + name: user.name, + phone: user.phone, + locale: user.locale, + timezone: user.timezone, + emailVerified: user.emailVerified, + createdAt: user.createdAt, + familyMemberships: user.familyMemberships, + preferences: user.preferences, + }, + }; + } } \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.ts new file mode 100644 index 0000000..68bafa5 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/biometric-auth.service.ts @@ -0,0 +1,333 @@ +import { Injectable, BadRequestException, UnauthorizedException, NotFoundException, Inject, forwardRef } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type VerifiedRegistrationResponse, + type VerifiedAuthenticationResponse, + type PublicKeyCredentialCreationOptionsJSON, + type PublicKeyCredentialRequestOptionsJSON, +} from '@simplewebauthn/server'; +import { WebAuthnCredential } from './entities/webauthn-credential.entity'; +import { User } from '../../database/entities/user.entity'; +import { AuthService } from './auth.service'; + +interface BiometricRegistrationOptions { + userId: string; + friendlyName?: string; +} + +interface BiometricAuthenticationOptions { + email?: string; +} + +@Injectable() +export class BiometricAuthService { + // Replace with your actual domain in production + private readonly rpName = 'Maternal Care App'; + private readonly rpID = process.env.RP_ID || 'localhost'; + private readonly origin = process.env.ORIGIN || 'http://localhost:3000'; + + // Store challenges temporarily (use Redis in production) + private challenges = new Map(); + + constructor( + @InjectRepository(WebAuthnCredential) + private webauthnCredentialRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @Inject(forwardRef(() => AuthService)) + private authService: AuthService, + ) {} + + /** + * Generate registration options for a new biometric credential + */ + async generateRegistrationOptions( + options: BiometricRegistrationOptions, + ): Promise { + const { userId, friendlyName } = options; + + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get existing credentials to exclude + const existingCredentials = await this.webauthnCredentialRepository.find({ + where: { userId }, + }); + + const opts = await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userName: user.email, + userDisplayName: user.name, + attestationType: 'none', + excludeCredentials: existingCredentials.map((cred) => ({ + id: cred.credentialId, + transports: cred.transports as any[], + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + authenticatorAttachment: 'platform', // Prefer built-in authenticators (Face ID, Touch ID) + }, + }); + + // Store challenge for verification + this.challenges.set(userId, opts.challenge); + + return opts; + } + + /** + * Verify registration response and save credential + */ + async verifyRegistrationResponse( + userId: string, + response: any, + friendlyName?: string, + ): Promise<{ success: boolean; credentialId: string; message: string }> { + const expectedChallenge = this.challenges.get(userId); + if (!expectedChallenge) { + throw new BadRequestException('Challenge not found or expired'); + } + + let verification: VerifiedRegistrationResponse; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + }); + } catch (error) { + throw new BadRequestException(`Verification failed: ${error.message}`); + } + + if (!verification.verified || !verification.registrationInfo) { + throw new BadRequestException('Registration verification failed'); + } + + const { credential, aaguid, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + // Save credential to database + const credentialEntity = this.webauthnCredentialRepository.create({ + userId, + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey).toString('base64url'), + counter: credential.counter, + deviceType: credentialDeviceType, + transports: credential.transports, + backedUp: credentialBackedUp, + authenticatorAttachment: response.authenticatorAttachment, + friendlyName: friendlyName || this.generateDefaultName(credentialDeviceType), + }); + + await this.webauthnCredentialRepository.save(credentialEntity); + + // Clear challenge + this.challenges.delete(userId); + + return { + success: true, + credentialId: credentialEntity.id, + message: 'Biometric credential registered successfully', + }; + } + + /** + * Generate authentication options for login + */ + async generateAuthenticationOptions( + options?: BiometricAuthenticationOptions, + ): Promise { + let allowCredentials: Array<{ id: string; transports?: any[] }> = []; + + // If email provided, get user's credentials + if (options?.email) { + const user = await this.userRepository.findOne({ where: { email: options.email } }); + if (user) { + const credentials = await this.webauthnCredentialRepository.find({ + where: { userId: user.id }, + }); + + allowCredentials = credentials.map((cred) => ({ + id: cred.credentialId, + transports: cred.transports, + })); + } + } + + const opts = await generateAuthenticationOptions({ + rpID: this.rpID, + allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined, + userVerification: 'preferred', + }); + + // Store challenge (use credential ID or email as key) + const challengeKey = options?.email || 'anonymous'; + this.challenges.set(challengeKey, opts.challenge); + + return opts; + } + + /** + * Verify authentication response + */ + async verifyAuthenticationResponse( + response: any, + email?: string, + ): Promise<{ success: boolean; user: User; message: string }> { + const credentialId = response.id; + + // Find credential + const credential = await this.webauthnCredentialRepository.findOne({ + where: { credentialId }, + relations: ['user'], + }); + + if (!credential) { + throw new UnauthorizedException('Credential not found'); + } + + const challengeKey = email || 'anonymous'; + const expectedChallenge = this.challenges.get(challengeKey); + if (!expectedChallenge) { + throw new BadRequestException('Challenge not found or expired'); + } + + let verification: VerifiedAuthenticationResponse; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + credential: { + id: credential.credentialId, + publicKey: Uint8Array.from(Buffer.from(credential.publicKey, 'base64url')), + counter: Number(credential.counter), + transports: credential.transports as any[], + }, + }); + } catch (error) { + throw new UnauthorizedException(`Authentication failed: ${error.message}`); + } + + if (!verification.verified) { + throw new UnauthorizedException('Authentication verification failed'); + } + + // Update counter and last used + credential.counter = verification.authenticationInfo.newCounter; + credential.lastUsed = new Date(); + await this.webauthnCredentialRepository.save(credential); + + // Clear challenge + this.challenges.delete(challengeKey); + + return { + success: true, + user: credential.user, + message: 'Authentication successful', + }; + } + + /** + * Get all credentials for a user + */ + async getUserCredentials(userId: string): Promise { + return this.webauthnCredentialRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Delete a credential + */ + async deleteCredential(userId: string, credentialId: string): Promise<{ success: boolean; message: string }> { + const credential = await this.webauthnCredentialRepository.findOne({ + where: { id: credentialId, userId }, + }); + + if (!credential) { + throw new NotFoundException('Credential not found'); + } + + await this.webauthnCredentialRepository.remove(credential); + + return { + success: true, + message: 'Credential deleted successfully', + }; + } + + /** + * Update credential friendly name + */ + async updateCredentialName( + userId: string, + credentialId: string, + friendlyName: string, + ): Promise<{ success: boolean; message: string }> { + const credential = await this.webauthnCredentialRepository.findOne({ + where: { id: credentialId, userId }, + }); + + if (!credential) { + throw new NotFoundException('Credential not found'); + } + + credential.friendlyName = friendlyName; + await this.webauthnCredentialRepository.save(credential); + + return { + success: true, + message: 'Credential name updated successfully', + }; + } + + /** + * Check if user has biometric credentials + */ + async hasCredentials(userId: string): Promise { + const count = await this.webauthnCredentialRepository.count({ + where: { userId }, + }); + return count > 0; + } + + /** + * Authenticate with biometric and return full auth response with tokens + */ + async authenticateWithBiometric( + response: any, + deviceInfo: { deviceId: string; platform: string }, + email?: string, + ): Promise { + // Verify biometric authentication + const verifyResult = await this.verifyAuthenticationResponse(response, email); + + // Use AuthService to complete login (register device, generate tokens) + return await this.authService.loginWithExternalAuth(verifyResult.user, deviceInfo); + } + + /** + * Generate default credential name based on device type + */ + private generateDefaultName(deviceType?: string): string { + const timestamp = new Date().toLocaleDateString(); + if (deviceType === 'singleDevice') { + return `Device - ${timestamp}`; + } else if (deviceType === 'multiDevice') { + return `Synced Device - ${timestamp}`; + } + return `Biometric Device - ${timestamp}`; + } +} diff --git a/maternal-app/maternal-app-backend/src/modules/auth/entities/webauthn-credential.entity.ts b/maternal-app/maternal-app-backend/src/modules/auth/entities/webauthn-credential.entity.ts new file mode 100644 index 0000000..f31452b --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/entities/webauthn-credential.entity.ts @@ -0,0 +1,45 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm'; +import { User } from '../../../database/entities/user.entity'; + +@Entity('webauthn_credentials') +export class WebAuthnCredential { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ name: 'credential_id', unique: true }) + credentialId: string; + + @Column({ name: 'public_key' }) + publicKey: string; + + @Column({ name: 'counter', type: 'bigint', default: 0 }) + counter: number; + + @Column({ name: 'device_type', nullable: true }) + deviceType?: string; + + @Column({ name: 'transports', type: 'text', array: true, nullable: true }) + transports?: string[]; + + @Column({ name: 'backed_up', default: false }) + backedUp: boolean; + + @Column({ name: 'authenticator_attachment', nullable: true }) + authenticatorAttachment?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @Column({ name: 'last_used', type: 'timestamp with time zone', nullable: true }) + lastUsed?: Date; + + @Column({ name: 'friendly_name', nullable: true }) + friendlyName?: string; +}