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",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
"@sentry/node": "^10.17.0",
|
"@sentry/node": "^10.17.0",
|
||||||
"@sentry/profiling-node": "^10.17.0",
|
"@sentry/profiling-node": "^10.17.0",
|
||||||
|
"@simplewebauthn/server": "^13.2.1",
|
||||||
"@types/pdfkit": "^0.17.3",
|
"@types/pdfkit": "^0.17.3",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"axios": "^1.12.2",
|
"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"
|
"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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"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"
|
"@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": {
|
"node_modules/@ljharb/through": {
|
||||||
"version": "2.3.14",
|
"version": "2.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz",
|
"resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz",
|
||||||
@@ -4528,6 +4541,162 @@
|
|||||||
"@noble/hashes": "^1.1.5"
|
"@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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -4856,6 +5025,25 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.27.8",
|
"version": "0.27.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||||
@@ -6842,6 +7030,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/async-function": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||||
@@ -12782,6 +12984,24 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
@@ -14727,6 +14947,24 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@nestjs/websockets": "^10.4.20",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
"@sentry/node": "^10.17.0",
|
"@sentry/node": "^10.17.0",
|
||||||
"@sentry/profiling-node": "^10.17.0",
|
"@sentry/profiling-node": "^10.17.0",
|
||||||
|
"@simplewebauthn/server": "^13.2.1",
|
||||||
"@types/pdfkit": "^0.17.3",
|
"@types/pdfkit": "^0.17.3",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"axios": "^1.12.2",
|
"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 { MFAService } from './mfa.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
import { DeviceTrustService } from './device-trust.service';
|
import { DeviceTrustService } from './device-trust.service';
|
||||||
|
import { BiometricAuthService } from './biometric-auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
@@ -37,6 +38,7 @@ export class AuthController {
|
|||||||
private readonly mfaService: MFAService,
|
private readonly mfaService: MFAService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
private readonly deviceTrustService: DeviceTrustService,
|
private readonly deviceTrustService: DeviceTrustService,
|
||||||
|
private readonly biometricAuthService: BiometricAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -321,4 +323,102 @@ export class AuthController {
|
|||||||
const currentDeviceId = request.user?.deviceId;
|
const currentDeviceId = request.user?.deviceId;
|
||||||
return await this.deviceTrustService.removeAllDevices(user.userId, currentDeviceId);
|
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 { MFAService } from './mfa.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
import { DeviceTrustService } from './device-trust.service';
|
import { DeviceTrustService } from './device-trust.service';
|
||||||
|
import { BiometricAuthService } from './biometric-auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { CommonModule } from '../../common/common.module';
|
import { CommonModule } from '../../common/common.module';
|
||||||
|
import { WebAuthnCredential } from './entities/webauthn-credential.entity';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
DeviceRegistry,
|
DeviceRegistry,
|
||||||
@@ -23,7 +25,7 @@ import {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]),
|
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember, WebAuthnCredential]),
|
||||||
PassportModule,
|
PassportModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
@@ -38,7 +40,7 @@ import {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, BiometricAuthService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService],
|
exports: [AuthService, PasswordResetService, MFAService, SessionService, DeviceTrustService, BiometricAuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -415,4 +415,49 @@ export class AuthService {
|
|||||||
expiresIn: 3600, // 1 hour in seconds
|
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