Add WebAuthn biometric authentication backend

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-10-01 22:24:19 +00:00
parent dd33b4551d
commit dddb82579f
8 changed files with 804 additions and 3 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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")';

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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<AuthResponse> {
// 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,
},
};
}
}

View File

@@ -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<string, string>();
constructor(
@InjectRepository(WebAuthnCredential)
private webauthnCredentialRepository: Repository<WebAuthnCredential>,
@InjectRepository(User)
private userRepository: Repository<User>,
@Inject(forwardRef(() => AuthService))
private authService: AuthService,
) {}
/**
* Generate registration options for a new biometric credential
*/
async generateRegistrationOptions(
options: BiometricRegistrationOptions,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
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<PublicKeyCredentialRequestOptionsJSON> {
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<WebAuthnCredential[]> {
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<boolean> {
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<any> {
// 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}`;
}
}

View File

@@ -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;
}