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:
238
maternal-app/maternal-app-backend/package-lock.json
generated
238
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user