From 6e67b2d4e1cfca30262d32279321f0ca8c10124d Mon Sep 17 00:00:00 2001 From: Bradley Shellnut Date: Tue, 19 Nov 2024 19:47:00 -0800 Subject: [PATCH] Encrypting two factor secret in the DB, adding env for secret used to encrypt, and creating/verifying totp codes. --- .env.example | 1 + .vscode/extensions.json | 5 + biome.json | 2 +- package.json | 11 +- pnpm-lock.yaml | 194 +++++---- src/lib/data.json | 18 - src/lib/server/api/common/config.ts | 3 + src/lib/server/api/common/env.ts | 71 ++-- src/lib/server/api/common/types/config.ts | 5 + .../server/api/controllers/iam.controller.ts | 2 +- .../server/api/controllers/mfa.controller.ts | 168 ++++---- .../api/controllers/signup.controller.ts | 2 +- .../postgres/tables/collections.table.ts | 42 +- src/lib/server/api/dtos/create-totp.dto.ts | 7 + .../server/api/dtos/signin-username.dto.ts | 12 +- .../repositories/recovery-codes.repository.ts | 8 +- .../server/api/services/encryption.service.ts | 46 +++ src/lib/server/api/services/iam.service.ts | 110 ++--- .../api/services/loginrequest.service.ts | 160 ++++---- .../api/services/recovery-codes.service.ts | 5 + src/lib/server/api/services/totp.service.ts | 89 +++-- src/lib/validations/auth.ts | 46 +-- .../mfa/recovery-codes/+page.server.ts | 2 +- .../security/mfa/totp/+page.server.ts | 273 +++++++------ .../settings/security/mfa/totp/+page.svelte | 7 +- .../settings/security/mfa/totp/schemas.ts | 3 +- src/routes/(auth)/login/+page.server.ts | 97 ++--- src/routes/(auth)/totp/+page.server.ts | 375 +++++++----------- src/routes/(auth)/totp/+page.svelte | 20 +- 29 files changed, 915 insertions(+), 869 deletions(-) create mode 100644 .vscode/extensions.json delete mode 100644 src/lib/data.json create mode 100644 src/lib/server/api/dtos/create-totp.dto.ts create mode 100644 src/lib/server/api/services/encryption.service.ts diff --git a/.env.example b/.env.example index 3b2e6f6..6ec97bf 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ DATABASE_PASSWORD='postgres' DATABASE_HOST='localhost' DATABASE_PORT=5432 DATABASE_DB='postgres' +ENCRYPTION_KEY="" REDIS_URL='redis://127.0.0.1:6379/0' diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..116d685 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "inlang.vs-code-extension" + ] +} \ No newline at end of file diff --git a/biome.json b/biome.json index 966a391..4caa522 100644 --- a/biome.json +++ b/biome.json @@ -32,7 +32,7 @@ "linter": { "enabled": true, "rules": { "recommended": true } }, "javascript": { "formatter": { - "jsxQuoteStyle": "double", + "jsxQuoteStyle": "single", "quoteProperties": "asNeeded", "trailingCommas": "all", "indentStyle": "space", diff --git a/package.json b/package.json index 3e4e2f7..769c313 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@faker-js/faker": "^8.4.1", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.83.0", - "@playwright/test": "^1.48.2", + "@playwright/test": "^1.49.0", "@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/enhanced-img": "^0.3.10", "@sveltejs/kit": "^2.8.1", @@ -63,8 +63,8 @@ "svelte-sequential-preprocessor": "^2.0.2", "svelte-sonner": "^0.3.28", "sveltekit-flash-message": "^2.4.4", - "sveltekit-superforms": "^2.20.0", - "tailwindcss": "^3.4.14", + "sveltekit-superforms": "^2.20.1", + "tailwindcss": "^3.4.15", "ts-node": "^10.9.2", "tslib": "^2.8.1", "tsx": "^4.19.2", @@ -88,6 +88,7 @@ "@needle-di/core": "^0.8.4", "@neondatabase/serverless": "^0.9.5", "@node-rs/argon2": "^1.8.3", + "@oslojs/binary": "^1.0.0", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@oslojs/jwt": "^0.2.0", @@ -100,13 +101,13 @@ "@sveltejs/adapter-vercel": "^5.4.7", "@types/feather-icons": "^4.29.4", "boardgamegeekclient": "^1.9.1", - "bullmq": "^5.25.6", + "bullmq": "^5.27.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cookie": "^1.0.1", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.7", - "drizzle-orm": "^0.36.1", + "drizzle-orm": "^0.36.3", "drizzle-zod": "^0.5.1", "feather-icons": "^4.29.2", "handlebars": "^4.7.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f7aee8..b082905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.5.6 '@lucia-auth/adapter-drizzle': specifier: ^1.1.0 - version: 1.1.0(drizzle-orm@0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(lucia@3.2.0) + version: 1.1.0(drizzle-orm@0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(lucia@3.2.0) '@lukeed/uuid': specifier: ^2.0.1 version: 2.0.1 @@ -47,6 +47,9 @@ importers: '@node-rs/argon2': specifier: ^1.8.3 version: 1.8.3 + '@oslojs/binary': + specifier: ^1.0.0 + version: 1.0.0 '@oslojs/crypto': specifier: ^1.0.1 version: 1.0.1 @@ -84,8 +87,8 @@ importers: specifier: ^1.9.1 version: 1.9.1 bullmq: - specifier: ^5.25.6 - version: 5.25.6 + specifier: ^5.27.0 + version: 5.27.0 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -102,11 +105,11 @@ importers: specifier: ^11.0.7 version: 11.0.7 drizzle-orm: - specifier: ^0.36.1 - version: 0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) + specifier: ^0.36.3 + version: 0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(zod@3.23.8) feather-icons: specifier: ^4.29.2 version: 4.29.2 @@ -184,10 +187,10 @@ importers: version: 2.5.4 tailwind-variants: specifier: ^0.2.1 - version: 0.2.1(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) + version: 0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) + version: 1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) tsyringe: specifier: ^4.8.0 version: 4.8.0 @@ -208,8 +211,8 @@ importers: specifier: ^0.83.0 version: 0.83.0(svelte@5.0.0-next.175) '@playwright/test': - specifier: ^1.48.2 - version: 1.48.2 + specifier: ^1.49.0 + version: 1.49.0 '@sveltejs/adapter-auto': specifier: ^3.3.1 version: 3.3.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6))) @@ -254,7 +257,7 @@ importers: version: 0.27.2 formsnap: specifier: ^1.0.1 - version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.0(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3)) + version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3)) just-clone: specifier: ^6.2.0 version: 6.2.0 @@ -316,11 +319,11 @@ importers: specifier: ^2.4.4 version: 2.4.4(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175) sveltekit-superforms: - specifier: ^2.20.0 - version: 2.20.0(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3) + specifier: ^2.20.1 + version: 2.20.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3) tailwindcss: - specifier: ^3.4.14 - version: 3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) + specifier: ^3.4.15 + version: 3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.17.6)(typescript@5.6.3) @@ -353,11 +356,11 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@ark/schema@0.10.0': - resolution: {integrity: sha512-zpfXwWLOzj9aUK+dXQ6aleJAOgle4/WrHDop5CMX2M88dFQ85NdH8O0v0pvMAQnfFcaQAZ/nVDYLlBJsFc09XA==} + '@ark/schema@0.23.0': + resolution: {integrity: sha512-406Zx0te3ICd7PkGise4XIxOfmjFzK64tEuiN5rmJDg14AqhySXygMk8QcHqHORDJ7VXhel7J41iduw8eyiFPg==} - '@ark/util@0.10.0': - resolution: {integrity: sha512-uK+9VU5doGMYOoOZVE+XaSs1vYACoaEJdrDkuBx26S4X7y3ChyKsPnIg/9pIw2vUySph1GkAXbvBnfVE2GmXgQ==} + '@ark/util@0.23.0': + resolution: {integrity: sha512-2mb24N2leQENRh+zPqnlRJzFFf8Xr7BT+/4MJN46/G8C45davpqFfcqvOw0ZlXrjQpBi8H+ZqDQsi95lN/9oVg==} '@asteasolutions/zod-to-openapi@7.1.2': resolution: {integrity: sha512-tuDcV4aGAlY4eaZ8Qmf1efPL33hwJKdpCSbI6vJqXU5Wkz9IIyCrb3u3fExZyyMGzmLKcJH+CHI5UKvNBPlyjg==} @@ -1398,10 +1401,20 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.11.1': resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2199,8 +2212,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.48.2': - resolution: {integrity: sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==} + '@playwright/test@1.49.0': + resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==} engines: {node: '>=18'} hasBin: true @@ -2560,8 +2573,8 @@ packages: resolution: {integrity: sha512-hg4ekaB5Y2zh+IWzBiC/WCDWrIfpVnKu/ubUvelKlidc/VbulsexoFRw5kJGHZenPVI5YzNnDeTdYSALkTV7jQ==} engines: {node: '>=18.0.0'} - '@vinejs/vine@1.8.0': - resolution: {integrity: sha512-Qq3XxbA26jzqS9ICifkqzT399lMQZ2fWtqeV3luI2as+UIK7qDifJFU2Q4W3q3IB5VXoWxgwAZSZEO0em9I/qQ==} + '@vinejs/vine@2.1.0': + resolution: {integrity: sha512-09aJ2OauxpblqiNqd8qC9RAzzm5SV6fTqZhE4e25j4cM7fmNoXRTjM7Oo8llFADMO4eSA44HqYEO3mkRRYdbYw==} engines: {node: '>=18.16.0'} '@vitest/expect@1.6.0': @@ -2685,8 +2698,8 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arktype@2.0.0-rc.8: - resolution: {integrity: sha512-ByrqjptsavUCUL9ptts6BUL2LCNkVZyniOdaBw76dlBQ6gYIhYSeycuuj4gRFwcAafszOnAPD2fAqHK7bbo/Zw==} + arktype@2.0.0-rc.23: + resolution: {integrity: sha512-P0e40t3J4rc3xRHzPjzyOK1CgdgKswQJOFBgFLuehSiGcjAuRx6p/9lDVPzXZ62m7q5yRUqFiX8ovN5FjWQjMQ==} array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -2788,8 +2801,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bullmq@5.25.6: - resolution: {integrity: sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==} + bullmq@5.27.0: + resolution: {integrity: sha512-DZWrjDLkecZZ1/43h/SkG6CxU8nO/Lq/0svVoQdw33ksUCGfccgjbvCa/cuxHP/OvhxlTAA0cO3dBOoaT7sRFQ==} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -2966,6 +2979,10 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-blank-pseudo@6.0.2: resolution: {integrity: sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==} engines: {node: ^14 || ^16 || >=18} @@ -3133,8 +3150,8 @@ packages: resolution: {integrity: sha512-F6cFZ1wxa9XzFyeeQsp/0/lIzUbDuQjS8/njpYBDWa+wdWmXuY+Z/X2hHFK/9PGHZkv3c9mER+mVWfKlp/B6Vw==} hasBin: true - drizzle-orm@0.36.1: - resolution: {integrity: sha512-F4hbimnMEhyWzDowQB4xEuVJJWXLHZYD7FYwvo8RImY+N7pStGqsbfmT95jDbec1s4qKmQbiuxEDZY90LRrfIw==} + drizzle-orm@0.36.3: + resolution: {integrity: sha512-ffQB7CcyCTvQBK6xtRLMl/Jsd5xFTBs+UTHrgs1hbk68i5TPkbsoCPbKEwiEsQZfq2I7VH632XJpV1g7LS2H9Q==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=3' @@ -3155,7 +3172,7 @@ packages: '@xata.io/client': '*' better-sqlite3: '>=7' bun-types: '*' - expo-sqlite: '>=13.2.0' + expo-sqlite: '>=14.0.0' knex: '*' kysely: '*' mysql2: '>=2' @@ -3465,8 +3482,8 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} focus-trap@7.6.0: resolution: {integrity: sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==} @@ -4355,13 +4372,13 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} - playwright-core@1.48.2: - resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} + playwright-core@1.49.0: + resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} engines: {node: '>=18'} hasBin: true - playwright@1.48.2: - resolution: {integrity: sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==} + playwright@1.49.0: + resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} engines: {node: '>=18'} hasBin: true @@ -5129,8 +5146,8 @@ packages: '@sveltejs/kit': 1.x || 2.x svelte: 3.x || 4.x || >=5.0.0-next.51 - sveltekit-superforms@2.20.0: - resolution: {integrity: sha512-5HyA6THKFBHEmJinZ/klu2/0jYr9ElSaXMYc5EO9ptP3x1wQPWVXYl59sMcaSrIjWUlPpayGxVppCyu+x/o4WA==} + sveltekit-superforms@2.20.1: + resolution: {integrity: sha512-GPGPp4pf/v7fZ0iS3ddC1AO3Ti10oAgsVD95WZ6nPh6/L894pMsL8JN67/Lz0hSIRUXk8k35BjCIJB69z+KI/Q==} peerDependencies: '@sveltejs/kit': 1.x || 2.x svelte: 3.x || 4.x || >=5.0.0-next.51 @@ -5152,8 +5169,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' - tailwindcss@3.4.14: - resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + tailwindcss@3.4.15: + resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==} engines: {node: '>=14.0.0'} hasBin: true @@ -5353,8 +5370,8 @@ packages: valibot@0.31.1: resolution: {integrity: sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q==} - valibot@0.41.0: - resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} + valibot@1.0.0-beta.6: + resolution: {integrity: sha512-x9ObzhqDCWFaWOa6Zri1mbFcc8OIIKP7cQtD9JauKt5pJFhpJkvAXT+49bFKjoVikiKVk7m33mXgUJb/Wfknmw==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -5556,12 +5573,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@ark/schema@0.10.0': + '@ark/schema@0.23.0': dependencies: - '@ark/util': 0.10.0 + '@ark/util': 0.23.0 optional: true - '@ark/util@0.10.0': + '@ark/util@0.23.0': optional: true '@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8)': @@ -6248,8 +6265,15 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.11.1': {} + '@eslint-community/regexpp@4.12.1': {} + '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 @@ -6604,9 +6628,9 @@ snapshots: dependencies: typescript: 5.2.2 - '@lucia-auth/adapter-drizzle@1.1.0(drizzle-orm@0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(lucia@3.2.0)': + '@lucia-auth/adapter-drizzle@1.1.0(drizzle-orm@0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(lucia@3.2.0)': dependencies: - drizzle-orm: 0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) + drizzle-orm: 0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) lucia: 3.2.0 '@lukeed/csprng@1.1.0': {} @@ -7104,9 +7128,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.48.2': + '@playwright/test@1.49.0': dependencies: - playwright: 1.48.2 + playwright: 1.49.0 '@polka/url@1.0.0-next.28': {} @@ -7493,7 +7517,7 @@ snapshots: '@vinejs/compiler@2.5.0': optional: true - '@vinejs/vine@1.8.0': + '@vinejs/vine@2.1.0': dependencies: '@poppinss/macroable': 1.0.3 '@types/validator': 13.12.2 @@ -7551,9 +7575,9 @@ snapshots: dependencies: acorn: 8.12.1 - acorn-jsx@5.3.2(acorn@8.12.1): + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: - acorn: 8.12.1 + acorn: 8.14.0 acorn-typescript@1.4.13(acorn@8.12.1): dependencies: @@ -7623,10 +7647,10 @@ snapshots: aria-query@5.3.2: {} - arktype@2.0.0-rc.8: + arktype@2.0.0-rc.23: dependencies: - '@ark/schema': 0.10.0 - '@ark/util': 0.10.0 + '@ark/schema': 0.23.0 + '@ark/util': 0.23.0 optional: true array-flatten@1.1.1: {} @@ -7742,7 +7766,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bullmq@5.25.6: + bullmq@5.27.0: dependencies: cron-parser: 4.9.0 ioredis: 5.4.1 @@ -7915,6 +7939,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-blank-pseudo@6.0.2(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -8032,16 +8062,16 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5): + drizzle-orm@0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5): optionalDependencies: '@neondatabase/serverless': 0.9.5 '@types/pg': 8.11.10 pg: 8.13.1 postgres: 3.4.5 - drizzle-zod@0.5.1(drizzle-orm@0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(zod@3.23.8): + drizzle-zod@0.5.1(drizzle-orm@0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5))(zod@3.23.8): dependencies: - drizzle-orm: 0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) + drizzle-orm: 0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) zod: 3.23.8 eastasianwidth@0.2.0: {} @@ -8238,8 +8268,8 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.11.1 + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 '@humanwhocodes/config-array': 0.13.0 @@ -8248,7 +8278,7 @@ snapshots: '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 debug: 4.3.7 doctrine: 3.0.0 escape-string-regexp: 4.0.0 @@ -8283,8 +8313,8 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) eslint-visitor-keys: 3.4.3 esquery@1.6.0: @@ -8444,11 +8474,11 @@ snapshots: flat-cache@3.2.0: dependencies: - flatted: 3.3.1 + flatted: 3.3.2 keyv: 4.5.4 rimraf: 3.0.2 - flatted@3.3.1: {} + flatted@3.3.2: {} focus-trap@7.6.0: dependencies: @@ -8467,11 +8497,11 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.0(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3)): + formsnap@1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3)): dependencies: nanoid: 5.0.7 svelte: 5.0.0-next.175 - sveltekit-superforms: 2.20.0(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3) + sveltekit-superforms: 2.20.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3) forwarded@0.2.0: {} @@ -9297,11 +9327,11 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 - playwright-core@1.48.2: {} + playwright-core@1.49.0: {} - playwright@1.48.2: + playwright@1.49.0: dependencies: - playwright-core: 1.48.2 + playwright-core: 1.49.0 optionalDependencies: fsevents: 2.3.2 @@ -10118,7 +10148,7 @@ snapshots: '@sveltejs/kit': 2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)) svelte: 5.0.0-next.175 - sveltekit-superforms@2.20.0(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3): + sveltekit-superforms@2.20.1(@sveltejs/kit@2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(@types/json-schema@7.0.15)(svelte@5.0.0-next.175)(typescript@5.6.3): dependencies: '@sveltejs/kit': 2.8.1(@sveltejs/vite-plugin-svelte@4.0.0-next.7(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)))(svelte@5.0.0-next.175)(vite@5.4.11(@types/node@20.17.6)) devalue: 5.1.1 @@ -10132,14 +10162,14 @@ snapshots: '@gcornut/valibot-json-schema': 0.31.0 '@sinclair/typebox': 0.32.35 '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.1) - '@vinejs/vine': 1.8.0 - arktype: 2.0.0-rc.8 + '@vinejs/vine': 2.1.0 + arktype: 2.0.0-rc.23 class-validator: 0.14.1 effect: 3.9.2 joi: 17.13.3 json-schema-to-ts: 3.1.1 superstruct: 2.0.2 - valibot: 0.41.0(typescript@5.6.3) + valibot: 1.0.0-beta.6(typescript@5.6.3) yup: 1.4.0 zod: 3.23.8 zod-to-json-schema: 3.23.5(zod@3.23.8) @@ -10151,16 +10181,16 @@ snapshots: tailwind-merge@2.5.4: {} - tailwind-variants@0.2.1(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))): + tailwind-variants@0.2.1(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))): dependencies: tailwind-merge: 2.5.4 - tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) + tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))): dependencies: - tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) + tailwindcss: 3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) - tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)): + tailwindcss@3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -10175,7 +10205,7 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.1.0 + picocolors: 1.1.1 postcss: 8.4.49 postcss-import: 15.1.0(postcss@8.4.49) postcss-js: 4.0.1(postcss@8.4.49) @@ -10352,7 +10382,7 @@ snapshots: valibot@0.31.1: optional: true - valibot@0.41.0(typescript@5.6.3): + valibot@1.0.0-beta.6(typescript@5.6.3): optionalDependencies: typescript: 5.6.3 optional: true diff --git a/src/lib/data.json b/src/lib/data.json deleted file mode 100644 index 5deffc5..0000000 --- a/src/lib/data.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "user": { - "firstName": "John", - "lastName": "Doe", - "email": "johndoe@example.com", - "username": "johndoe" - } - }, - { - "user": { - "firstName": "Jane", - "lastName": "Doe", - "email": "janedoe@example.com", - "username": "janedoe" - } - } -] diff --git a/src/lib/server/api/common/config.ts b/src/lib/server/api/common/config.ts index b8c497c..50b55ae 100644 --- a/src/lib/server/api/common/config.ts +++ b/src/lib/server/api/common/config.ts @@ -21,4 +21,7 @@ export const config: Config = { migrating: env.DB_MIGRATING, seeding: env.DB_SEEDING, }, + security: { + encryptionKey: env.ENCRYPTION_KEY, + } }; diff --git a/src/lib/server/api/common/env.ts b/src/lib/server/api/common/env.ts index d7cec7c..d8df40b 100644 --- a/src/lib/server/api/common/env.ts +++ b/src/lib/server/api/common/env.ts @@ -1,39 +1,40 @@ -import {config} from 'dotenv'; -import {expand} from 'dotenv-expand'; -import {z, type ZodError} from 'zod'; +import { config } from 'dotenv'; +import { expand } from 'dotenv-expand'; +import { z, type ZodError } from 'zod'; expand(config()); const stringBoolean = z.coerce - .string() - .transform((val) => { - return val === 'true'; - }) - .default('false'); + .string() + .transform((val) => { + return val === 'true'; + }) + .default('false'); const EnvSchema = z.object({ - DATABASE_USER: z.string(), - DATABASE_PASSWORD: z.string(), - DATABASE_HOST: z.string(), - DATABASE_PORT: z.coerce.number(), - DATABASE_DB: z.string(), - DB_MIGRATING: stringBoolean, - DB_SEEDING: stringBoolean, - DOMAIN: z.string(), - GITHUB_CLIENT_ID: z.string(), - GITHUB_CLIENT_SECRET: z.string(), - GOOGLE_CLIENT_ID: z.string(), - GOOGLE_CLIENT_SECRET: z.string(), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), - NODE_ENV: z.string().default('development'), - ORIGIN: z.string(), - PUBLIC_SITE_NAME: z.string(), - PUBLIC_SITE_URL: z.string(), - PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'), - PUBLIC_UMAMI_ID: z.string(), - PUBLIC_UMAMI_URL: z.string(), - REDIS_URL: z.string(), - TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000), + DATABASE_USER: z.string(), + DATABASE_PASSWORD: z.string(), + DATABASE_HOST: z.string(), + DATABASE_PORT: z.coerce.number(), + DATABASE_DB: z.string(), + DB_MIGRATING: stringBoolean, + DB_SEEDING: stringBoolean, + DOMAIN: z.string(), + ENCRYPTION_KEY: z.string(), + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + NODE_ENV: z.string().default('development'), + ORIGIN: z.string(), + PUBLIC_SITE_NAME: z.string(), + PUBLIC_SITE_URL: z.string(), + PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'), + PUBLIC_UMAMI_ID: z.string(), + PUBLIC_UMAMI_URL: z.string(), + REDIS_URL: z.string(), + TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000), }); export type env = z.infer; @@ -41,12 +42,12 @@ export type env = z.infer; let env: env; try { - env = EnvSchema.parse(process.env); + env = EnvSchema.parse(process.env); } catch (e) { - const error = e as ZodError; - console.error('❌ Missing required values in .env:\n'); - console.error(error.flatten().fieldErrors); - process.exit(1); + const error = e as ZodError; + console.error('❌ Missing required values in .env:\n'); + console.error(error.flatten().fieldErrors); + process.exit(1); } export default env; diff --git a/src/lib/server/api/common/types/config.ts b/src/lib/server/api/common/types/config.ts index c746233..0ba89f3 100644 --- a/src/lib/server/api/common/types/config.ts +++ b/src/lib/server/api/common/types/config.ts @@ -5,6 +5,7 @@ export interface Config { // storage: StorageConfig redis: RedisConfig; postgres: PostgresConfig; + security: SecurityConfig; } interface ApiConfig { @@ -33,3 +34,7 @@ interface PostgresConfig { migrating: boolean; seeding: boolean; } + +interface SecurityConfig { + encryptionKey: string; +} \ No newline at end of file diff --git a/src/lib/server/api/controllers/iam.controller.ts b/src/lib/server/api/controllers/iam.controller.ts index 3815883..30dbe12 100644 --- a/src/lib/server/api/controllers/iam.controller.ts +++ b/src/lib/server/api/controllers/iam.controller.ts @@ -79,7 +79,7 @@ export class IamController extends Controller { try { await this.iamService.updatePassword(user.id, { password, confirm_password }); await this.sessionsService.invalidateSession(user.id); - await this.loginRequestService.createUserSession(user.id, c.req, undefined); + await this.loginRequestService.createUserSession(user.id, c.req, false); const sessionCookie = createBlankSessionTokenCookie(); setSessionCookie(c, sessionCookie); return c.json({ status: 'success' }); diff --git a/src/lib/server/api/controllers/mfa.controller.ts b/src/lib/server/api/controllers/mfa.controller.ts index 3ecdbd0..23af75b 100644 --- a/src/lib/server/api/controllers/mfa.controller.ts +++ b/src/lib/server/api/controllers/mfa.controller.ts @@ -1,74 +1,106 @@ -import {StatusCodes} from '$lib/constants/status-codes'; -import {Controller} from '$lib/server/api/common/types/controller'; -import {verifyTotpDto} from '$lib/server/api/dtos/verify-totp.dto'; -import {RecoveryCodesService} from '$lib/server/api/services/recovery-codes.service'; -import {TotpService} from '$lib/server/api/services/totp.service'; -import {UsersService} from '$lib/server/api/services/users.service'; -import {zValidator} from '@hono/zod-validator'; +import { StatusCodes } from '$lib/constants/status-codes'; +import { Controller } from '$lib/server/api/common/types/controller'; +import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto'; +import { RecoveryCodesService } from '$lib/server/api/services/recovery-codes.service'; +import { TotpService } from '$lib/server/api/services/totp.service'; +import { UsersService } from '$lib/server/api/services/users.service'; +import { zValidator } from '@hono/zod-validator'; import { inject, injectable } from '@needle-di/core'; -import {CredentialsType} from '../databases/postgres/tables'; -import {requireAuth} from '../middleware/require-auth.middleware'; +import { CredentialsType } from '../databases/postgres/tables'; +import { requireAuth } from '../middleware/require-auth.middleware'; +import { createTwoFactorSchema } from '../dtos/create-totp.dto'; +import { decodeBase64 } from '@oslojs/encoding'; +import { LoginRequestsService } from '../services/loginrequest.service'; +import { cookieExpiresAt, createSessionTokenCookie, setSessionCookie } from '../common/utils/cookies'; @injectable() export class MfaController extends Controller { - constructor( - private recoveryCodesService = inject(RecoveryCodesService), - private totpService = inject(TotpService), - private usersService = inject(UsersService), - ) { - super(); - } + constructor( + private loginRequestService = inject(LoginRequestsService), + private recoveryCodesService = inject(RecoveryCodesService), + private totpService = inject(TotpService), + private usersService = inject(UsersService), + ) { + super(); + } - routes() { - return this.controller - .get('/totp', requireAuth, async (c) => { - const user = c.var.user; - const totpCredential = await this.totpService.findOneByUserId(user.id); - return c.json({ totpCredential }); - }) - .post('/totp', requireAuth, async (c) => { - const user = c.var.user; - const totpCredential = await this.totpService.create(user.id); - return c.json({ totpCredential }); - }) - .delete('/totp', requireAuth, async (c) => { - const user = c.var.user; - try { - await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP); - await this.recoveryCodesService.deleteAllRecoveryCodesByUserId(user.id); - await this.usersService.updateUser(user.id, { mfa_enabled: false }); - console.log('TOTP deleted'); - return c.body(null, StatusCodes.NO_CONTENT); - } catch (e) { - console.error(e); - return c.status(StatusCodes.INTERNAL_SERVER_ERROR); - } - }) - .get('/totp/recoveryCodes', requireAuth, async (c) => { - const user = c.var.user; - // You can only view recovery codes once and that is on creation - const existingCodes = await this.recoveryCodesService.findAllRecoveryCodesByUserId(user.id); - if (existingCodes && existingCodes.length > 0) { - console.log('Recovery Codes found', existingCodes); - return c.json({ recoveryCodes: existingCodes }); - } - const recoveryCodes = await this.recoveryCodesService.createRecoveryCodes(user.id); - return c.json({ recoveryCodes }); - }) - .post('/totp/verify', requireAuth, zValidator('json', verifyTotpDto), async (c) => { - try { - const user = c.var.user; - const { code } = c.req.valid('json'); - const verified = await this.totpService.verify(user.id, code); - if (verified) { - await this.usersService.updateUser(user.id, { mfa_enabled: true }); - return c.json({}, StatusCodes.OK); - } - return c.json('Invalid code', StatusCodes.BAD_REQUEST); - } catch (e) { - console.error(e); - return c.status(StatusCodes.INTERNAL_SERVER_ERROR); - } - }); - } + routes() { + return this.controller + .get('/totp', requireAuth, async (c) => { + const user = c.var.user; + const totpCredential = await this.totpService.findOneByUserId(user.id); + return c.json({ totpCredential }); + }) + .post('/totp', requireAuth, zValidator('json', createTwoFactorSchema), async (c) => { + const user = c.var.user; + const { key } = c.req.valid('json'); + const totpCredential = await this.totpService.create(user.id, decodeBase64(key)); + if (totpCredential) { + await this.usersService.updateUser(user.id, { mfa_enabled: true }); + return c.json({ totpCredential }); + } + return c.status(StatusCodes.INTERNAL_SERVER_ERROR); + }) + .delete('/totp', requireAuth, async (c) => { + const user = c.var.user; + try { + await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP); + await this.recoveryCodesService.deleteAllRecoveryCodesByUserId(user.id); + await this.usersService.updateUser(user.id, { mfa_enabled: false }); + console.log('TOTP deleted'); + return c.body(null, StatusCodes.NO_CONTENT); + } catch (e) { + console.error(e); + return c.status(StatusCodes.INTERNAL_SERVER_ERROR); + } + }) + .get('/totp/recoveryCodes', requireAuth, async (c) => { + const user = c.var.user; + // You can only view recovery codes once and that is on creation + const existingCodes = await this.recoveryCodesService.findAllRecoveryCodesByUserId(user.id); + if (existingCodes && existingCodes.length > 0) { + console.log('Recovery Codes found', existingCodes); + // Filter out codes that are not used and only return the code + const codes = existingCodes.filter(code => !code.used).map(code => code.code); + return c.json({ recoveryCodes: codes }); + } + const recoveryCodes = await this.recoveryCodesService.createRecoveryCodes(user.id); + return c.json({ recoveryCodes }); + }) + .post('/totp/recoveryCodes', requireAuth, zValidator('json', verifyTotpDto), async (c) => { + try { + const user = c.var.user; + const { code } = c.req.valid('json'); + c.var.logger.info(`Verifying code ${code} for user ${user.id}`); + const verified = await this.recoveryCodesService.verify(user.id, code); + if (verified) { + return c.json({}, StatusCodes.OK); + } + return c.json('Invalid code', StatusCodes.BAD_REQUEST); + } catch (e) { + console.error(e); + return c.status(StatusCodes.INTERNAL_SERVER_ERROR); + } + }) + .post('/totp/verify', requireAuth, zValidator('json', verifyTotpDto), async (c) => { + try { + const user = c.var.user; + const { code } = c.req.valid('json'); + c.var.logger.info(`Verifying code ${code} for user ${user.id}`); + const verified = await this.totpService.verify(user.id, code); + if (verified) { + await this.usersService.updateUser(user.id, { mfa_enabled: true }); + const session = await this.loginRequestService.createUserSession(user.id, c.req, true); + const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); + console.log('set cookie', sessionCookie); + setSessionCookie(c, sessionCookie); + return c.json({}, StatusCodes.OK); + } + return c.json('Invalid code', StatusCodes.BAD_REQUEST); + } catch (e) { + console.error(e); + return c.status(StatusCodes.INTERNAL_SERVER_ERROR); + } + }); + } } diff --git a/src/lib/server/api/controllers/signup.controller.ts b/src/lib/server/api/controllers/signup.controller.ts index 7a837d9..ac6cb9c 100644 --- a/src/lib/server/api/controllers/signup.controller.ts +++ b/src/lib/server/api/controllers/signup.controller.ts @@ -34,7 +34,7 @@ export class SignupController extends Controller { return c.body('Failed to create user', 500); } - const session = await this.loginRequestService.createUserSession(user.id, c.req, undefined); + const session = await this.loginRequestService.createUserSession(user.id, c.req, false); const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt); console.log('set cookie', sessionCookie); setSessionCookie(c, sessionCookie); diff --git a/src/lib/server/api/databases/postgres/tables/collections.table.ts b/src/lib/server/api/databases/postgres/tables/collections.table.ts index 1b2afcd..1fd76a7 100644 --- a/src/lib/server/api/databases/postgres/tables/collections.table.ts +++ b/src/lib/server/api/databases/postgres/tables/collections.table.ts @@ -1,29 +1,29 @@ -import {createId as cuid2} from '@paralleldrive/cuid2'; -import {type InferSelectModel, relations} from 'drizzle-orm'; -import {pgTable, text, uuid} from 'drizzle-orm/pg-core'; -import {createSelectSchema} from 'drizzle-zod'; -import {timestamps} from '../../../common/utils/table'; -import {collection_items} from './collectionItems.table'; -import {usersTable} from './users.table'; +import { createId as cuid2 } from '@paralleldrive/cuid2'; +import { type InferSelectModel, relations } from 'drizzle-orm'; +import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { createSelectSchema } from 'drizzle-zod'; +import { timestamps } from '../../../common/utils/table'; +import { collection_items } from './collectionItems.table'; +import { usersTable } from './users.table'; export const collections = pgTable('collections', { - id: uuid().primaryKey().defaultRandom(), - cuid: text() - .unique() - .$defaultFn(() => cuid2()), - user_id: uuid() - .notNull() - .references(() => usersTable.id, { onDelete: 'cascade' }), - name: text().notNull().default('My Collection'), - ...timestamps, + id: uuid().primaryKey().defaultRandom(), + cuid: text() + .unique() + .$defaultFn(() => cuid2()), + user_id: uuid() + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + name: text().notNull().default('My Collection'), + ...timestamps, }); export const collection_relations = relations(collections, ({ one, many }) => ({ - user: one(usersTable, { - fields: [collections.user_id], - references: [usersTable.id], - }), - collection_items: many(collection_items), + user: one(usersTable, { + fields: [collections.user_id], + references: [usersTable.id], + }), + collection_items: many(collection_items), })); export const selectCollectionSchema = createSelectSchema(collections); diff --git a/src/lib/server/api/dtos/create-totp.dto.ts b/src/lib/server/api/dtos/create-totp.dto.ts new file mode 100644 index 0000000..87dadb7 --- /dev/null +++ b/src/lib/server/api/dtos/create-totp.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const createTwoFactorSchema = z.object({ + key: z.string({ required_error: 'Secret Data is required' }).length(28, { message: 'Secret Data must be 28 characters' }).trim(), +}); + +export type CreateTwoFactorDto = z.infer; diff --git a/src/lib/server/api/dtos/signin-username.dto.ts b/src/lib/server/api/dtos/signin-username.dto.ts index 080f522..664be8c 100644 --- a/src/lib/server/api/dtos/signin-username.dto.ts +++ b/src/lib/server/api/dtos/signin-username.dto.ts @@ -1,12 +1,8 @@ -import {z} from "zod"; +import { z } from 'zod'; export const signinUsernameDto = z.object({ - username: z - .string() - .trim() - .min(3, { message: 'Must be at least 3 characters' }) - .max(50, { message: 'Must be less than 50 characters' }), - password: z.string({ required_error: 'Password is required' }).trim(), + username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }), + password: z.string({ required_error: 'Password is required' }).trim(), }); -export type SigninUsernameDto = z.infer; \ No newline at end of file +export type SigninUsernameDto = z.infer; diff --git a/src/lib/server/api/repositories/recovery-codes.repository.ts b/src/lib/server/api/repositories/recovery-codes.repository.ts index 3f019f6..f9d602e 100644 --- a/src/lib/server/api/repositories/recovery-codes.repository.ts +++ b/src/lib/server/api/repositories/recovery-codes.repository.ts @@ -1,6 +1,6 @@ import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository'; import {DrizzleService} from '$lib/server/api/services/drizzle.service'; -import {eq, type InferInsertModel} from 'drizzle-orm'; +import {and, eq, type InferInsertModel} from 'drizzle-orm'; import {inject, injectable} from '@needle-di/core'; import {recoveryCodesTable} from '../databases/postgres/tables'; @@ -20,6 +20,12 @@ export class RecoveryCodesRepository { }); } + async findAllNotUsedByUserId(userId: string, db = this.drizzle.db) { + return db.query.recoveryCodesTable.findMany({ + where: and(eq(recoveryCodesTable.userId, userId), eq(recoveryCodesTable.used, false)), + }); + } + async deleteAllByUserId(userId: string, db = this.drizzle.db) { return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId)); } diff --git a/src/lib/server/api/services/encryption.service.ts b/src/lib/server/api/services/encryption.service.ts new file mode 100644 index 0000000..c44f2f1 --- /dev/null +++ b/src/lib/server/api/services/encryption.service.ts @@ -0,0 +1,46 @@ +import { decodeBase64 } from '@oslojs/encoding'; +import { createCipheriv, createDecipheriv } from 'crypto'; +import { DynamicBuffer } from '@oslojs/binary'; +import { injectable } from '@needle-di/core'; +import { config } from '../common/config'; + +@injectable() +export class EncryptionService { + private encryptionKey: Uint8Array; + + constructor() { + this.encryptionKey = decodeBase64(config.security.encryptionKey); + } + + encrypt(data: Uint8Array): Uint8Array { + const iv = new Uint8Array(16); + crypto.getRandomValues(iv); + const cipher = createCipheriv('aes-128-gcm', this.encryptionKey, iv); + const encrypted = new DynamicBuffer(0); + encrypted.write(iv); + encrypted.write(cipher.update(data)); + encrypted.write(cipher.final()); + encrypted.write(cipher.getAuthTag()); + return encrypted.bytes(); + } + + encryptString(data: string): Uint8Array { + return this.encrypt(new TextEncoder().encode(data)); + } + + decrypt(encrypted: Uint8Array): Uint8Array { + if (encrypted.byteLength < 33) { + throw new Error('Invalid data'); + } + const decipher = createDecipheriv('aes-128-gcm', this.encryptionKey, encrypted.slice(0, 16)); + decipher.setAuthTag(encrypted.slice(encrypted.byteLength - 16)); + const decrypted = new DynamicBuffer(0); + decrypted.write(decipher.update(encrypted.slice(16, encrypted.byteLength - 16))); + decrypted.write(decipher.final()); + return decrypted.bytes(); + } + + decryptToString(data: Uint8Array): string { + return new TextDecoder().decode(this.decrypt(data)); + } +} diff --git a/src/lib/server/api/services/iam.service.ts b/src/lib/server/api/services/iam.service.ts index 19c018f..c10fcda 100644 --- a/src/lib/server/api/services/iam.service.ts +++ b/src/lib/server/api/services/iam.service.ts @@ -1,68 +1,68 @@ -import type {ChangePasswordDto} from '$lib/server/api/dtos/change-password.dto'; -import type {UpdateEmailDto} from '$lib/server/api/dtos/update-email.dto'; -import type {UpdateProfileDto} from '$lib/server/api/dtos/update-profile.dto'; -import type {VerifyPasswordDto} from '$lib/server/api/dtos/verify-password.dto'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import {UsersService} from '$lib/server/api/services/users.service'; -import {inject, injectable} from '@needle-di/core'; +import type { ChangePasswordDto } from '$lib/server/api/dtos/change-password.dto'; +import type { UpdateEmailDto } from '$lib/server/api/dtos/update-email.dto'; +import type { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; +import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'; +import { SessionsService } from '$lib/server/api/services/sessions.service'; +import { UsersService } from '$lib/server/api/services/users.service'; +import { inject, injectable } from '@needle-di/core'; @injectable() export class IamService { - constructor( - private readonly sessionsService = inject(SessionsService), - private readonly usersService = inject(UsersService), - ) {} + constructor( + private readonly sessionsService = inject(SessionsService), + private readonly usersService = inject(UsersService), + ) {} - async logout(sessionId: string) { - return this.sessionsService.invalidateSession(sessionId); - } + async logout(sessionId: string) { + return this.sessionsService.invalidateSession(sessionId); + } - async updateProfile(userId: string, data: UpdateProfileDto) { - const user = await this.usersService.findOneById(userId); - if (!user) { - return { - error: 'User not found', - }; - } + async updateProfile(userId: string, data: UpdateProfileDto) { + const user = await this.usersService.findOneById(userId); + if (!user) { + return { + error: 'User not found', + }; + } - const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username); - if (existingUserForNewUsername && existingUserForNewUsername.id !== user.id) { - return { - error: 'Username already in use', - }; - } + const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username); + if (existingUserForNewUsername && existingUserForNewUsername.id !== user.id) { + return { + error: 'Username already in use', + }; + } - return this.usersService.updateUser(user.id, { - first_name: data.firstName, - last_name: data.lastName, - username: data.username !== user.username ? data.username : user.username, - }); - } + return this.usersService.updateUser(user.id, { + first_name: data.firstName, + last_name: data.lastName, + username: data.username !== user.username ? data.username : user.username, + }); + } - async updateEmail(userId: string, data: UpdateEmailDto) { - const { email } = data; + async updateEmail(userId: string, data: UpdateEmailDto) { + const { email } = data; - const existingUserEmail = await this.usersService.findOneByEmail(email); - if (existingUserEmail && existingUserEmail.id !== userId) { - return null; - } + const existingUserEmail = await this.usersService.findOneByEmail(email); + if (existingUserEmail && existingUserEmail.id !== userId) { + return null; + } - return this.usersService.updateUser(userId, { - email, - }); - } + return this.usersService.updateUser(userId, { + email, + }); + } - async updatePassword(userId: string, data: ChangePasswordDto) { - const { password } = data; - await this.usersService.updatePassword(userId, password); - } + async updatePassword(userId: string, data: ChangePasswordDto) { + const { password } = data; + await this.usersService.updatePassword(userId, password); + } - async verifyPassword(userId: string, data: VerifyPasswordDto) { - const user = await this.usersService.findOneById(userId); - if (!user) { - return null; - } - const { password } = data; - return this.usersService.verifyPassword(userId, { password }); - } + async verifyPassword(userId: string, data: VerifyPasswordDto) { + const user = await this.usersService.findOneById(userId); + if (!user) { + return null; + } + const { password } = data; + return this.usersService.verifyPassword(userId, { password }); + } } diff --git a/src/lib/server/api/services/loginrequest.service.ts b/src/lib/server/api/services/loginrequest.service.ts index 714de1b..2f92e49 100644 --- a/src/lib/server/api/services/loginrequest.service.ts +++ b/src/lib/server/api/services/loginrequest.service.ts @@ -1,97 +1,97 @@ -import type {SigninUsernameDto} from '$lib/server/api/dtos/signin-username.dto'; -import {SessionsService} from '$lib/server/api/services/sessions.service'; -import type {HonoRequest} from 'hono'; -import {inject, injectable} from '@needle-di/core'; -import {BadRequest} from '../common/exceptions'; -import type {Credentials} from '../databases/postgres/tables'; -import {CredentialsRepository} from '../repositories/credentials.repository'; -import {UsersRepository} from '../repositories/users.repository'; -import {MailerService} from './mailer.service'; -import {TokensService} from './tokens.service'; -import {DrizzleService} from "$lib/server/api/services/drizzle.service"; +import type { SigninUsernameDto } from '$lib/server/api/dtos/signin-username.dto'; +import { SessionsService } from '$lib/server/api/services/sessions.service'; +import type { HonoRequest } from 'hono'; +import { inject, injectable } from '@needle-di/core'; +import { BadRequest } from '../common/exceptions'; +import type { Credentials } from '../databases/postgres/tables'; +import { CredentialsRepository } from '../repositories/credentials.repository'; +import { UsersRepository } from '../repositories/users.repository'; +import { MailerService } from './mailer.service'; +import { TokensService } from './tokens.service'; +import { DrizzleService } from '$lib/server/api/services/drizzle.service'; @injectable() export class LoginRequestsService { - constructor( - private sessionsService = inject(SessionsService), - private drizzleService = inject(DrizzleService) , - private tokensService = inject(TokensService) , - private mailerService = inject(MailerService) , - private usersRepository = inject(UsersRepository) , - private credentialsRepository = inject(CredentialsRepository) , - ) {} + constructor( + private sessionsService = inject(SessionsService), + private drizzleService = inject(DrizzleService), + private tokensService = inject(TokensService), + private mailerService = inject(MailerService), + private usersRepository = inject(UsersRepository), + private credentialsRepository = inject(CredentialsRepository), + ) {} - // async create(data: RegisterEmailDto) { - // // generate a token, expiry date, and hash - // const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm'); - // // save the login request to the database - ensuring we save the hashedToken - // await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry }); - // // send the login request email - // await this.mailerService.sendLoginRequest({ - // to: data.email, - // props: { token: token } - // }); - // } + // async create(data: RegisterEmailDto) { + // // generate a token, expiry date, and hash + // const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm'); + // // save the login request to the database - ensuring we save the hashedToken + // await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry }); + // // send the login request email + // await this.mailerService.sendLoginRequest({ + // to: data.email, + // props: { token: token } + // }); + // } - async verify(data: SigninUsernameDto, req: HonoRequest) { - const requestIpAddress = req.header('x-real-ip'); - const requestIpCountry = req.header('x-vercel-ip-country'); - const existingUser = await this.usersRepository.findOneByUsername(data.username); + async verify(data: SigninUsernameDto, req: HonoRequest) { + const requestIpAddress = req.header('X-Forwarded-For'); + const requestIpCountry = req.header('x-vercel-ip-country'); + const existingUser = await this.usersRepository.findOneByUsername(data.username); - if (!existingUser) { - throw BadRequest('User not found'); - } + if (!existingUser) { + throw BadRequest('User not found'); + } - const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id); + const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id); - if (!credential) { - throw BadRequest('Invalid credentials'); - } + if (!credential) { + throw BadRequest('Invalid credentials'); + } - if (!(await this.tokensService.verifyHashedToken(credential.secret_data, data.password))) { - throw BadRequest('Invalid credentials'); - } + if (!(await this.tokensService.verifyHashedToken(credential.secret_data, data.password))) { + throw BadRequest('Invalid credentials'); + } - const totpCredentials = await this.credentialsRepository.findTOTPCredentialsByUserId(existingUser.id); + const totpCredentials = await this.credentialsRepository.findTOTPCredentialsByUserId(existingUser.id); - return await this.createUserSession(existingUser.id, req, totpCredentials); - } + return await this.createUserSession(existingUser.id, req, !!totpCredentials && totpCredentials.secret_data !== null && totpCredentials.secret_data !== ''); + } - async createUserSession(existingUserId: string, req: HonoRequest, totpCredentials: Credentials | undefined) { - const requestIpAddress = req.header('x-real-ip'); - const requestIpCountry = req.header('x-vercel-ip-country'); - return this.sessionsService.createSession( - this.sessionsService.generateSessionToken(), - existingUserId, - requestIpCountry || 'unknown', - requestIpAddress || 'unknown', - !!totpCredentials && totpCredentials?.secret_data !== null && totpCredentials?.secret_data !== '', - false, - ); - } + async createUserSession(existingUserId: string, req: HonoRequest, twoFactorAuthEnabled: boolean) { + const requestIpAddress = req.header('X-Forwarded-For'); + const requestIpCountry = req.header('x-vercel-ip-country'); + return this.sessionsService.createSession( + this.sessionsService.generateSessionToken(), + existingUserId, + requestIpCountry || 'unknown', + requestIpAddress || 'unknown', + twoFactorAuthEnabled, + false, + ); + } - // Create a new user and send a welcome email - or other onboarding process - private async handleNewUserRegistration(email: string) { - const newUser = await this.usersRepository.create({ email, verified: true }); - // this.mailerService.sendWelcome({ to: email, props: null }); - // TODO: add whatever onboarding process or extra data you need here - return newUser; - } + // Create a new user and send a welcome email - or other onboarding process + private async handleNewUserRegistration(email: string) { + const newUser = await this.usersRepository.create({ email, verified: true }); + // this.mailerService.sendWelcome({ to: email, props: null }); + // TODO: add whatever onboarding process or extra data you need here + return newUser; + } - // Fetch a valid request from the database, verify the token and burn the request if it is valid - // private async fetchValidRequest(email: string, token: string) { - // return await this.db.transaction(async (trx) => { - // // fetch the login request - // const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email) - // if (!loginRequest) return null; + // Fetch a valid request from the database, verify the token and burn the request if it is valid + // private async fetchValidRequest(email: string, token: string) { + // return await this.db.transaction(async (trx) => { + // // fetch the login request + // const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email) + // if (!loginRequest) return null; - // // check if the token is valid - // const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token); - // if (!isValidRequest) return null + // // check if the token is valid + // const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token); + // if (!isValidRequest) return null - // // if the token is valid, burn the request - // await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id); - // return loginRequest - // }) - // } + // // if the token is valid, burn the request + // await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id); + // return loginRequest + // }) + // } } diff --git a/src/lib/server/api/services/recovery-codes.service.ts b/src/lib/server/api/services/recovery-codes.service.ts index 6bb08d6..f642083 100644 --- a/src/lib/server/api/services/recovery-codes.service.ts +++ b/src/lib/server/api/services/recovery-codes.service.ts @@ -29,6 +29,11 @@ export class RecoveryCodesService { return [] } + async verify(userId: string, code: string) { + const recoveryCodes = await this.recoveryCodesRepository.findAllNotUsedByUserId(userId); + return recoveryCodes.find(recoveryCode => this.hashingService.verify(recoveryCode.code, code)) + } + async deleteAllRecoveryCodesByUserId(userId: string) { return this.recoveryCodesRepository.deleteAllByUserId(userId) } diff --git a/src/lib/server/api/services/totp.service.ts b/src/lib/server/api/services/totp.service.ts index abf1dbf..8aaea80 100644 --- a/src/lib/server/api/services/totp.service.ts +++ b/src/lib/server/api/services/totp.service.ts @@ -1,52 +1,57 @@ -import {CredentialsRepository} from '$lib/server/api/repositories/credentials.repository'; -import {decodeHex, encodeHexLowerCase} from '@oslojs/encoding'; -import {verifyTOTP} from '@oslojs/otp'; -import {inject, injectable} from '@needle-di/core'; -import type {CredentialsType} from '../databases/postgres/tables'; +import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository'; +import { inject, injectable } from '@needle-di/core'; +import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; +import { generateTOTP, verifyTOTP } from '@oslojs/otp'; +import type { CredentialsType } from '../databases/postgres/tables'; +import { EncryptionService } from './encryption.service'; @injectable() export class TotpService { - constructor(private credentialsRepository = inject(CredentialsRepository)) {} + constructor( + private credentialsRepository = inject(CredentialsRepository), + private encryptionService = inject(EncryptionService) + ) {} - async findOneByUserId(userId: string) { - return this.credentialsRepository.findTOTPCredentialsByUserId(userId); - } + async findOneByUserId(userId: string) { + return this.credentialsRepository.findTOTPCredentialsByUserId(userId); + } - async findOneByUserIdOrThrow(userId: string) { - const credential = await this.findOneByUserId(userId); - if (!credential) { - throw new Error('TOTP credential not found'); - } - return credential; - } + async findOneByUserIdOrThrow(userId: string) { + const credential = await this.findOneByUserId(userId); + if (!credential) { + throw new Error('TOTP credential not found'); + } + return credential; + } - async create(userId: string) { - const secret = new Uint8Array(20); - try { - return await this.credentialsRepository.create({ - user_id: userId, - secret_data: encodeHexLowerCase(crypto.getRandomValues(secret)), - type: 'totp', - }); - } catch (e) { - console.error(e); - return null; - } - } + async create(userId: string, key: Uint8Array) { + try { + return await this.credentialsRepository.create({ + user_id: userId, + secret_data: encodeBase64(this.encryptionService.encrypt(key)), + type: 'totp', + }); + } catch (e) { + console.error(e); + return null; + } + } - async deleteOneByUserId(userId: string) { - return this.credentialsRepository.deleteByUserId(userId); - } + async deleteOneByUserId(userId: string) { + return this.credentialsRepository.deleteByUserId(userId); + } - async deleteOneByUserIdAndType(userId: string, type: CredentialsType) { - return this.credentialsRepository.deleteByUserIdAndType(userId, type); - } + async deleteOneByUserIdAndType(userId: string, type: CredentialsType) { + return this.credentialsRepository.deleteByUserIdAndType(userId, type); + } - async verify(userId: string, code: string) { - const credential = await this.credentialsRepository.findTOTPCredentialsByUserId(userId); - if (!credential) { - throw new Error('TOTP credential not found'); - } - return verifyTOTP(decodeHex(credential.secret_data), 30, 6, code); - } + async verify(userId: string, code: string) { + const credential = await this.credentialsRepository.findTOTPCredentialsByUserId(userId); + console.log(`TOTP credential: ${JSON.stringify(credential)}`); + if (!credential) { + throw new Error('TOTP credential not found'); + } + const secret = this.encryptionService.decrypt(decodeBase64(credential.secret_data)); + return verifyTOTP(secret, 30, 6, code); + } } diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index ec0f468..438e836 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -1,41 +1,37 @@ -import {refinePasswords} from './account'; -import {userSchema} from './zod-schemas'; -import {z} from 'zod'; +import { z } from 'zod'; +import { refinePasswords } from './account'; +import { userSchema } from './zod-schemas'; export const signUpSchema = userSchema - .pick({ - firstName: true, - lastName: true, - email: true, - username: true, - password: true, - confirm_password: true, - }) - .superRefine(async ({ confirm_password, password }, ctx) => { - return await refinePasswords(confirm_password, password, ctx); - }); + .pick({ + firstName: true, + lastName: true, + email: true, + username: true, + password: true, + confirm_password: true, + }) + .superRefine(async ({ confirm_password, password }, ctx) => { + return await refinePasswords(confirm_password, password, ctx); + }); export const signInSchema = z.object({ - username: z - .string() - .trim() - .min(3, { message: 'Must be at least 3 characters' }) - .max(50, { message: 'Must be less than 50 characters' }), - password: z.string({ required_error: 'Password is required' }).trim(), + username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }), + password: z.string({ required_error: 'Password is required' }).trim(), }); export const totpSchema = z.object({ - totpToken: z.string().trim().min(6).max(6), + code: z.string().trim().min(6).max(6), }); export const recoveryCodeSchema = z.object({ - recoveryCode: z.string().trim().min(10).max(10), + recoveryCode: z.string().trim().min(10).max(10), }); export const resetPasswordEmailSchema = z.object({ - email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }), + email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }), }); export const resetPasswordTokenSchema = z.object({ - resetToken: z.string().trim().min(6).max(6), -}); \ No newline at end of file + resetToken: z.string().trim().min(6).max(6), +}); diff --git a/src/routes/(app)/(protected)/settings/security/mfa/recovery-codes/+page.server.ts b/src/routes/(app)/(protected)/settings/security/mfa/recovery-codes/+page.server.ts index b0d0649..d321997 100644 --- a/src/routes/(app)/(protected)/settings/security/mfa/recovery-codes/+page.server.ts +++ b/src/routes/(app)/(protected)/settings/security/mfa/recovery-codes/+page.server.ts @@ -24,5 +24,5 @@ export const load: PageServerLoad = async (event) => { }; } - redirect(302, '/profile', { message: 'Two-Factor Authentication is not enabled', type: 'error' }, event); + redirect(302, '/settings/profile', { message: 'Two-Factor Authentication is not enabled', type: 'error' }, event); }; diff --git a/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.server.ts b/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.server.ts index f0fc2ba..bd5eeed 100644 --- a/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.server.ts +++ b/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.server.ts @@ -1,7 +1,7 @@ import { notSignedInMessage } from '$lib/flashMessages'; import env from '$lib/server/api/common/env'; -import { decodeHex, encodeBase32 } from '@oslojs/encoding'; -import { createTOTPKeyURI } from '@oslojs/otp'; +import { decodeBase64, encodeBase32NoPadding, encodeBase64 } from '@oslojs/encoding'; +import { createTOTPKeyURI, verifyTOTP } from '@oslojs/otp'; import { type Actions, fail } from '@sveltejs/kit'; import kebabCase from 'just-kebab-case'; import QRCode from 'qrcode'; @@ -12,163 +12,160 @@ import type { PageServerLoad } from '../../$types'; import { addTwoFactorSchema, removeTwoFactorSchema } from './schemas'; export const load: PageServerLoad = async (event) => { - const { locals } = event; + const { locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); - const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema)); - // const addAuthNFactorForm = await superValidate(event, zod(addAuthNFactorSchema)); + const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); + const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema)); - const { data, error } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); - if (error || !data) { - return fail(500, { - addTwoFactorForm, - }); - } - const { totpCredential } = data; - if (totpCredential && authedUser.mfa_enabled) { - return { - addTwoFactorForm, - removeTwoFactorForm, - twoFactorEnabled: true, - recoveryCodes: [], - totpUri: '', - qrCode: '', - }; - } + const { data: twoFactorCredentials, error: twoFactorCredentialsError } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); + if (twoFactorCredentials?.totpCredential) { + return { + addTwoFactorForm, + removeTwoFactorForm, + twoFactorEnabled: true, + recoveryCodes: [], + keyURI: '', + secret: '', + qrCode: '', + }; + } - if (totpCredential && !authedUser.mfa_enabled) { - await locals.api.mfa.totp.$delete().then(locals.parseApiResponse); - } + const issuer = kebabCase(env.PUBLIC_SITE_NAME); + const accountName = authedUser.email || authedUser.username; + const totpKey = new Uint8Array(20); + crypto.getRandomValues(totpKey); + const encodedTOTPKey = encodeBase64(totpKey); + const intervalInSeconds = 30; + const digits = 6; - const issuer = kebabCase(env.PUBLIC_SITE_NAME); - const accountName = authedUser.email || authedUser.username; - const { data: createdTotpData, error: createdTotpError } = await locals.api.mfa.totp.$post().then(locals.parseApiResponse); + const keyURI = createTOTPKeyURI(issuer, accountName, totpKey, intervalInSeconds, digits); + console.log('keyURI', keyURI); - if (createdTotpError || !createdTotpData) { - return fail(500, { - addTwoFactorForm, - }); - } - - const { totpCredential: createdTotpCredentials } = createdTotpData; - // pass the website's name and the user identifier (e.g. email, username) - if (!createdTotpCredentials?.secret_data) { - return fail(500, { - addTwoFactorForm, - }); - } - const decodedHexSecret = decodeHex(createdTotpCredentials.secret_data); - const secret = encodeBase32(decodedHexSecret); - const intervalInSeconds = 30; - const digits = 6; - - const totpUri = createTOTPKeyURI(issuer, accountName, decodedHexSecret, intervalInSeconds, digits); - - addTwoFactorForm.data = { - password: '', - two_factor_code: '', - }; - return { - addTwoFactorForm, - removeTwoFactorForm, - twoFactorEnabled: false, - recoveryCodes: [], - totpUri, - qrCode: await QRCode.toDataURL(totpUri), - secret, - }; + addTwoFactorForm.data = { + password: '', + code: '', + key: encodedTOTPKey, + }; + return { + addTwoFactorForm, + removeTwoFactorForm, + twoFactorEnabled: false, + recoveryCodes: [], + keyURI, + secret: encodeBase32NoPadding(totpKey), + qrCode: await QRCode.toDataURL(keyURI), + }; }; export const actions: Actions = { - enableTotp: async (event) => { - const { locals } = event; + enableTotp: async (event) => { + const { locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); + const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); - if (!addTwoFactorForm.valid) { - return fail(400, { - addTwoFactorForm, - }); - } + if (!addTwoFactorForm.valid) { + return fail(400, { + addTwoFactorForm, + }); + } - const { error: verifyPasswordError } = await locals.api.me.verify.password - .$post({ - json: { password: addTwoFactorForm.data.password }, - }) - .then(locals.parseApiResponse); + const { error: verifyPasswordError } = await locals.api.me.verify.password + .$post({ + json: { password: addTwoFactorForm.data.password }, + }) + .then(locals.parseApiResponse); - if (verifyPasswordError) { - console.log(verifyPasswordError); - return setError(addTwoFactorForm, 'password', 'Your password is incorrect'); - } + if (verifyPasswordError) { + console.log(verifyPasswordError); + return setError(addTwoFactorForm, 'password', 'Your password is incorrect'); + } - if (addTwoFactorForm.data.two_factor_code === '') { - return setError(addTwoFactorForm, 'two_factor_code', 'Please enter a code'); - } + if (addTwoFactorForm.data.code === '') { + return setError(addTwoFactorForm, 'code', 'Please enter a code'); + } - const twoFactorCode = addTwoFactorForm.data.two_factor_code; - const { error: verifyTotpError } = await locals.api.mfa.totp.verify - .$post({ - json: { code: twoFactorCode }, - }) - .then(locals.parseApiResponse); - if (verifyTotpError) { - return setError(addTwoFactorForm, 'two_factor_code', 'Invalid code'); - } + const twoFactorCode = addTwoFactorForm.data.code; + const encodedKey = addTwoFactorForm.data.key; - redirect(302, '/settings/security/mfa/recovery-codes'); - }, - disableTotp: async (event) => { - const { locals } = event; + let key: Uint8Array; + try { + key = decodeBase64(encodedKey); + } catch { + return fail(400, { + message: 'Invalid key', + }); + } + if (key.byteLength !== 20) { + return fail(400, { + message: 'Invalid key', + }); + } + if (!verifyTOTP(key, 30, 6, twoFactorCode)) { + return setError(addTwoFactorForm, 'code', 'Invalid code'); + } - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const { error: createTotpError } = await locals.api.mfa.totp + .$post({ + json: { key: encodeBase64(key) }, + }) + .then(locals.parseApiResponse); + if (createTotpError) { + return setError(addTwoFactorForm, 'code', 'Invalid code'); + } - const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema)); + redirect(302, '/settings/security/mfa/recovery-codes'); + }, + disableTotp: async (event) => { + const { locals } = event; - if (!removeTwoFactorForm.valid) { - return fail(400, { - removeTwoFactorForm, - }); - } - const { error: verifyPasswordError } = await locals.api.me.verify.password - .$post({ - json: { password: removeTwoFactorForm.data.password }, - }) - .then(locals.parseApiResponse); + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - if (verifyPasswordError) { - console.log(verifyPasswordError); - return setError(removeTwoFactorForm, 'password', 'Your password is incorrect'); - } + const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema)); - const { error: deleteTotpError } = await locals.api.mfa.totp.$delete().then(locals.parseApiResponse); - if (deleteTotpError) { - return fail(500, { - removeTwoFactorForm, - }); - } + if (!removeTwoFactorForm.valid) { + return fail(400, { + removeTwoFactorForm, + }); + } + const { error: verifyPasswordError } = await locals.api.me.verify.password + .$post({ + json: { password: removeTwoFactorForm.data.password }, + }) + .then(locals.parseApiResponse); - redirect( - 302, - '/settings/security/mfa', - { - type: 'success', - message: 'Two-Factor Authentication has been disabled.', - }, - event, - ); - }, + if (verifyPasswordError) { + console.log(verifyPasswordError); + return setError(removeTwoFactorForm, 'password', 'Your password is incorrect'); + } + + const { error: deleteTotpError } = await locals.api.mfa.totp.$delete().then(locals.parseApiResponse); + if (deleteTotpError) { + return fail(500, { + removeTwoFactorForm, + }); + } + + redirect( + 302, + '/settings/security/mfa', + { + type: 'success', + message: 'Two-Factor Authentication has been disabled.', + }, + event, + ); + }, }; diff --git a/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.svelte b/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.svelte index 9ea6f89..57ca61d 100644 --- a/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.svelte +++ b/src/routes/(app)/(protected)/settings/security/mfa/totp/+page.svelte @@ -9,7 +9,7 @@ import { addTwoFactorSchema, removeTwoFactorSchema } from './schemas'; const { data } = $props(); -const { qrCode, secret, twoFactorEnabled, recoveryCodes } = data; +const { qrCode, twoFactorEnabled, recoveryCodes, secret } = data; const addTwoFactorForm = superForm(data.addTwoFactorForm, { taintedMessage: null, @@ -52,10 +52,10 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov

Please scan the following QR Code

QR Code
- + Enter Code - + This is the code from your authenticator app. @@ -68,6 +68,7 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov Please enter your current password. + Submit
diff --git a/src/routes/(app)/(protected)/settings/security/mfa/totp/schemas.ts b/src/routes/(app)/(protected)/settings/security/mfa/totp/schemas.ts index c03dbd2..611b71a 100644 --- a/src/routes/(app)/(protected)/settings/security/mfa/totp/schemas.ts +++ b/src/routes/(app)/(protected)/settings/security/mfa/totp/schemas.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; export const addTwoFactorSchema = z.object({ password: z.string({ required_error: 'Current Password is required' }), - two_factor_code: z.string({ required_error: 'Two Factor Code is required' }).trim(), + code: z.string({ required_error: 'Two Factor Code is required' }).trim(), + key: z.string({ required_error: 'Secret Data is required' }).length(28).trim(), }); export type AddTwoFactorSchema = typeof addTwoFactorSchema; diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 9653d0c..301a605 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -7,64 +7,69 @@ import { setError, superValidate } from 'sveltekit-superforms/server'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { - const { locals } = event; + const { locals } = event; - const authedUser = await locals.getAuthedUser(); + const authedUser = await locals.getAuthedUser(); - if (authedUser) { - console.log('user already signed in'); - const message = { type: 'success', message: 'You are already signed in' } as const; - throw redirect('/', message, event); - // redirect(302, '/', message, event) - } - const form = await superValidate(event, zod(signinUsernameDto)); + if (authedUser) { + console.log('user already signed in'); + const message = { type: 'success', message: 'You are already signed in' } as const; + throw redirect('/', message, event); + // redirect(302, '/', message, event) + } + const form = await superValidate(event, zod(signinUsernameDto)); - return { - form, - }; + return { + form, + }; }; export const actions: Actions = { - default: async (event) => { - const { locals } = event; + default: async (event) => { + const { locals } = event; - const authedUser = await locals.getAuthedUser(); + const authedUser = await locals.getAuthedUser(); - if (authedUser) { - const message = { type: 'success', message: 'You are already signed in' } as const; - throw redirect('/', message, event); - } + if (authedUser) { + const message = { type: 'success', message: 'You are already signed in' } as const; + throw redirect('/', message, event); + } - const form = await superValidate(event, zod(signinUsernameDto)); + const form = await superValidate(event, zod(signinUsernameDto)); - const { error } = await locals.api.login.$post({ json: form.data }).then(locals.parseApiResponse); - if (error) { - return setError(form, 'username', error); - } + const { error } = await locals.api.login.$post({ json: form.data }).then(locals.parseApiResponse); + if (error) { + return setError(form, 'username', error); + } - if (!form.valid) { - form.data.password = ''; - return fail(400, { - form, - }); - } + if (!form.valid) { + form.data.password = ''; + return fail(400, { + form, + }); + } - form.data.username = ''; - form.data.password = ''; + form.data.username = ''; + form.data.password = ''; - redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); + const { error: totpCredentialError, data } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); + if (totpCredentialError || !data) { + return setError(form, 'username', totpCredentialError ?? 'Something went wrong. Please try again.'); + } - // if ( - // twoFactorDetails?.enabled && - // twoFactorDetails?.secret !== null && - // twoFactorDetails?.secret !== '' - // ) { - // console.log('redirecting to TOTP page'); - // const message = { type: 'success', message: 'Please enter your TOTP code.' } as const; - // redirect(302, '/totp', message, event); - // } else { - // const message = { type: 'success', message: 'Signed In!' } as const; - // redirect(302, '/', message, event); - // } - }, + const { totpCredential } = data; + console.log('totpCredential', totpCredential); + if (!totpCredential) { + const message = { type: 'success', message: 'Signed In!' } as const; + redirect(302, '/', message, event); + } else if (totpCredential?.type === 'totp' && totpCredential?.secret_data && totpCredential?.secret_data !== '') { + console.log('redirecting to TOTP page'); + const message = { type: 'success', message: 'Please enter your TOTP code.' } as const; + redirect(302, '/totp', message, event); + } else { + return setError(form, 'username', 'Something went wrong. Please try again.'); + } + + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); + }, }; diff --git a/src/routes/(auth)/totp/+page.server.ts b/src/routes/(auth)/totp/+page.server.ts index 568a690..098279a 100644 --- a/src/routes/(auth)/totp/+page.server.ts +++ b/src/routes/(auth)/totp/+page.server.ts @@ -1,270 +1,191 @@ import { notSignedInMessage } from '$lib/flashMessages'; import env from '$lib/server/api/common/env'; +import { twoFactorTable, usersTable } from '$lib/server/api/databases/postgres/tables'; import { db } from '$lib/server/api/packages/drizzle'; import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth'; +import { updateProfileFormSchema } from '$routes/(app)/(protected)/settings/profile/schemas'; import { type Actions, fail } from '@sveltejs/kit'; import { eq } from 'drizzle-orm'; import { redirect } from 'sveltekit-flash-message/server'; import { zod } from 'sveltekit-superforms/adapters'; -import { superValidate } from 'sveltekit-superforms/server'; -import { twoFactorTable, usersTable } from '../../../lib/server/api/databases/postgres/tables'; +import { message, setError, superValidate } from 'sveltekit-superforms/server'; import type { PageServerLoad, RequestEvent } from './$types'; export const load: PageServerLoad = async (event) => { - const { locals } = event; + const { locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const dbUser = await db.query.usersTable.findFirst({ - where: eq(usersTable.username, authedUser.username), - }); + const { data } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); + if (!data) { + throw redirect(302, '/login', notSignedInMessage, event); + } + const { totpCredential } = data; + if (!totpCredential) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const twoFactorDetails = await db.query.twoFactorTable.findFirst({ - where: eq(twoFactorTable.userId, authedUser.id), - }); - - if (!twoFactorDetails || !twoFactorDetails.enabled) { - const message = { - type: 'error', - message: 'Two factor authentication is not enabled', - } as const; - redirect(302, '/login', message, event); - } - - let twoFactorInitiatedTime = twoFactorDetails.initiatedTime; - if (twoFactorInitiatedTime === null) { - console.log('twoFactorInitiatedTime is null'); - twoFactorInitiatedTime = new Date(); - console.log('twoFactorInitiatedTime', twoFactorInitiatedTime); - await db.update(twoFactorTable).set({ initiatedTime: twoFactorInitiatedTime }).where(eq(twoFactorTable.userId, dbUser!.id!)); - } - - // Check if two factor started less than TWO_FACTOR_TIMEOUT - // const totpElapsed = totpTimeElapsed(twoFactorInitiatedTime) - // if (totpElapsed) { - // console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', totpElapsed, env.TWO_FACTOR_TIMEOUT) - // await lucia.invalidateSession(session!.id!) - // const sessionCookie = lucia.createBlankSessionCookie() - // cookies.set(sessionCookie.name, sessionCookie.value, { - // path: '.', - // ...sessionCookie.attributes, - // }) - // const message = { type: 'error', message: 'Two factor authentication has expired' } as const - // redirect(302, '/login', message, event) - // } - // - // const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated - // - // console.log('session', session) - // console.log('isTwoFactorAuthenticated', isTwoFactorAuthenticated) - - // if (isTwoFactorAuthenticated && twoFactorDetails?.enabled && twoFactorDetails?.secret !== '') { - // const message = { type: 'success', message: 'You are already signed in' } as const - // throw redirect('/', message, event) - // } - - return { - totpForm: await superValidate(event, zod(totpSchema)), - recoveryCodeForm: await superValidate(event, zod(recoveryCodeSchema)), - }; + return { + totpForm: await superValidate(event, zod(totpSchema)), + recoveryCodeForm: await superValidate(event, zod(recoveryCodeSchema)), + }; }; export const actions: Actions = { - validateTotp: async (event) => { - const { locals } = event; + validateTotp: async (event) => { + const { locals } = event; - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const { dbUser, twoFactorDetails } = await validateUserData(event, locals); + const { data: totpData } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); + if (!totpData) { + throw redirect(302, '/login', notSignedInMessage, event); + } + const { totpCredential } = totpData; + if (!totpCredential) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const totpForm = await superValidate(event, zod(totpSchema)); + const totpForm = await superValidate(event, zod(totpSchema)); - if (!totpForm.valid) { - totpForm.data.totpToken = ''; - return fail(400, { totpForm }); - } + if (!totpForm.valid) { + totpForm.data.code = ''; + return fail(400, { totpForm }); + } - // let sessionCookie - // const totpToken = totpForm?.data?.totpToken - // - // const twoFactorSecretPopulated = twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null - // if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !totpToken) { - // return fail(400, { totpForm }) - // } else if (twoFactorSecretPopulated && totpToken) { - // // Check if two factor started less than TWO_FACTOR_TIMEOUT - // const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date()) - // if (totpElapsed) { - // await lucia.invalidateSession(session!.id!) - // const sessionCookie = lucia.createBlankSessionCookie() - // cookies.set(sessionCookie.name, sessionCookie.value, { - // path: '.', - // ...sessionCookie.attributes, - // }) - // const message = { - // type: 'error', - // message: 'Two factor authentication has expired', - // } as const - // redirect(302, '/login', message, event) - // } - // - // console.log('totpToken', totpToken) - // const validOTP = await new TOTPController().verify(totpToken, decodeHex(twoFactorDetails.secret ?? '')) - // console.log('validOTP', validOTP) - // - // if (!validOTP) { - // console.log('invalid TOTP code') - // totpForm.data.totpToken = '' - // return setError(totpForm, 'totpToken', 'Invalid code.') - // } - // } - // console.log('ip', locals.ip) - // console.log('country', locals.country) - // await lucia.invalidateSession(session.id) - // const newSession = await lucia.createSession(dbUser.id, { - // ip_country: locals.country, - // ip_address: locals.ip, - // twoFactorAuthEnabled: true, - // isTwoFactorAuthenticated: true, - // }) - // console.log('logging in session', newSession) - // sessionCookie = lucia.createSessionCookie(newSession.id) - // console.log('logging in session cookie', sessionCookie) - // - // console.log('setting session cookie', sessionCookie) - // event.cookies.set(sessionCookie.name, sessionCookie.value, { - // path: '.', - // ...sessionCookie.attributes, - // }) - // - // totpForm.data.totpToken = '' - // const message = { type: 'success', message: 'Signed In!' } as const - redirect(302, '/', message, event); - }, - validateRecoveryCode: async (event) => { - const { cookies, locals } = event; + const { error: totpVerifyError } = await locals.api.mfa.totp.verify.$post({ json: { code: totpForm.data.code } }).then(locals.parseApiResponse); + if (totpVerifyError) { + return setError(totpForm, 'code', totpVerifyError); + } - const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); - } + console.log('Successfully logged in'); + return message(totpForm, { type: 'success', message: 'Successfully logged in!' }); + }, + validateRecoveryCode: async (event) => { + const { cookies, locals } = event; - const { dbUser, twoFactorDetails } = await validateUserData(event, locals); + const authedUser = await locals.getAuthedUser(); + if (!authedUser) { + throw redirect(302, '/login', notSignedInMessage, event); + } - const recoveryCodeForm = await superValidate(event, zod(recoveryCodeSchema)); - if (!recoveryCodeForm.valid) { - return fail(400, { - form: recoveryCodeForm, - }); - } + const { dbUser, twoFactorDetails } = await validateUserData(event, locals); - // let sessionCookie - // const recoveryCode = recoveryCodeForm?.data?.recoveryCode - // - // const twoFactorSecretPopulated = twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null - // if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !recoveryCode) { - // return fail(400, { recoveryCodeForm }) - // } else if (twoFactorSecretPopulated && recoveryCode) { - // // Check if two factor started less than TWO_FACTOR_TIMEOUT - // const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date()) - // if (totpElapsed) { - // await lucia.invalidateSession(session!.id!) - // const sessionCookie = lucia.createBlankSessionCookie() - // cookies.set(sessionCookie.name, sessionCookie.value, { - // path: '.', - // ...sessionCookie.attributes, - // }) - // const message = { - // type: 'error', - // message: 'Two factor authentication has expired', - // } as const - // redirect(302, '/login', message, event) - // } - // - // console.log('recoveryCode', recoveryCode) - // - // console.log('Check for recovery codes') - // const usedRecoveryCode = await checkRecoveryCode(recoveryCode, dbUser.id) - // if (!usedRecoveryCode) { - // console.log('invalid recovery code') - // recoveryCodeForm.data.recoveryCode = '' - // return setError(recoveryCodeForm, 'recoveryCode', 'Invalid code.') - // } - // } - // console.log('ip', locals.ip) - // console.log('country', locals.country) - // await lucia.invalidateSession(session.id) - // const newSession = await lucia.createSession(dbUser.id, { - // ip_country: locals.country, - // ip_address: locals.ip, - // twoFactorAuthEnabled: true, - // isTwoFactorAuthenticated: true, - // }) - // console.log('logging in session', newSession) - // sessionCookie = lucia.createSessionCookie(newSession.id) - // console.log('logging in session cookie', sessionCookie) - // - // console.log('setting session cookie', sessionCookie) - // event.cookies.set(sessionCookie.name, sessionCookie.value, { - // path: '.', - // ...sessionCookie.attributes, - // }) + const recoveryCodeForm = await superValidate(event, zod(recoveryCodeSchema)); + if (!recoveryCodeForm.valid) { + return fail(400, { + form: recoveryCodeForm, + }); + } - recoveryCodeForm.data.recoveryCode = ''; - const message = { type: 'success', message: 'Signed In!' } as const; - redirect(302, '/', message, event); - }, + // let sessionCookie + // const recoveryCode = recoveryCodeForm?.data?.recoveryCode + // + // const twoFactorSecretPopulated = twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null + // if (twoFactorDetails.enabled && !twoFactorSecretPopulated && !recoveryCode) { + // return fail(400, { recoveryCodeForm }) + // } else if (twoFactorSecretPopulated && recoveryCode) { + // // Check if two factor started less than TWO_FACTOR_TIMEOUT + // const totpElapsed = totpTimeElapsed(twoFactorDetails.initiatedTime ?? new Date()) + // if (totpElapsed) { + // await lucia.invalidateSession(session!.id!) + // const sessionCookie = lucia.createBlankSessionCookie() + // cookies.set(sessionCookie.name, sessionCookie.value, { + // path: '.', + // ...sessionCookie.attributes, + // }) + // const message = { + // type: 'error', + // message: 'Two factor authentication has expired', + // } as const + // redirect(302, '/login', message, event) + // } + // + // console.log('recoveryCode', recoveryCode) + // + // console.log('Check for recovery codes') + // const usedRecoveryCode = await checkRecoveryCode(recoveryCode, dbUser.id) + // if (!usedRecoveryCode) { + // console.log('invalid recovery code') + // recoveryCodeForm.data.recoveryCode = '' + // return setError(recoveryCodeForm, 'recoveryCode', 'Invalid code.') + // } + // } + // console.log('ip', locals.ip) + // console.log('country', locals.country) + // await lucia.invalidateSession(session.id) + // const newSession = await lucia.createSession(dbUser.id, { + // ip_country: locals.country, + // ip_address: locals.ip, + // twoFactorAuthEnabled: true, + // isTwoFactorAuthenticated: true, + // }) + // console.log('logging in session', newSession) + // sessionCookie = lucia.createSessionCookie(newSession.id) + // console.log('logging in session cookie', sessionCookie) + // + // console.log('setting session cookie', sessionCookie) + // event.cookies.set(sessionCookie.name, sessionCookie.value, { + // path: '.', + // ...sessionCookie.attributes, + // }) + + recoveryCodeForm.data.recoveryCode = ''; + const message = { type: 'success', message: 'Signed In!' } as const; + redirect(302, '/', message, event); + }, }; async function validateUserData(event: RequestEvent, locals: App.Locals) { - const { user, session } = locals; + const { user, session } = locals; - if (!user || !session) { - throw fail(401); - } + if (!user || !session) { + throw fail(401); + } - const dbUser = await db.query.usersTable.findFirst({ - where: eq(usersTable.username, user.username), - }); + const dbUser = await db.query.usersTable.findFirst({ + where: eq(usersTable.username, user.username), + }); - if (!dbUser) { - throw fail(401); - } + if (!dbUser) { + throw fail(401); + } - const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated; - const twoFactorDetails = await db.query.twoFactorTable.findFirst({ - where: eq(twoFactorTable.userId, dbUser!.id!), - }); + const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated; + const twoFactorDetails = await db.query.twoFactorTable.findFirst({ + where: eq(twoFactorTable.userId, dbUser!.id!), + }); - if (!twoFactorDetails) { - const message = { type: 'error', message: 'Unable to process request' } as const; - throw redirect(302, '/login', message, event); - } + if (!twoFactorDetails) { + const message = { type: 'error', message: 'Unable to process request' } as const; + throw redirect(302, '/login', message, event); + } - if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') { - const message = { type: 'success', message: 'You are already signed in' } as const; - throw redirect('/', message, event); - } - return { dbUser, twoFactorDetails }; + if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') { + const message = { type: 'success', message: 'You are already signed in' } as const; + throw redirect('/', message, event); + } + return { dbUser, twoFactorDetails }; } function totpTimeElapsed(initiatedTime: Date) { - if (initiatedTime === null || initiatedTime === undefined) { - return true; - } + if (initiatedTime === null || initiatedTime === undefined) { + return true; + } - const timeElapsed = Date.now() - initiatedTime.getTime(); - console.log('Time elapsed', timeElapsed); - if (timeElapsed > env.TWO_FACTOR_TIMEOUT) { - console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', timeElapsed, env.TWO_FACTOR_TIMEOUT); - return true; - } - return false; + const timeElapsed = Date.now() - initiatedTime.getTime(); + console.log('Time elapsed', timeElapsed); + if (timeElapsed > env.TWO_FACTOR_TIMEOUT) { + console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', timeElapsed, env.TWO_FACTOR_TIMEOUT); + return true; + } + return false; } // async function checkRecoveryCode(recoveryCode: string, userId: string) { diff --git a/src/routes/(auth)/totp/+page.svelte b/src/routes/(auth)/totp/+page.svelte index e524a1a..a6b13c6 100644 --- a/src/routes/(auth)/totp/+page.svelte +++ b/src/routes/(auth)/totp/+page.svelte @@ -11,15 +11,15 @@ import { superForm } from 'sveltekit-superforms/client'; const { data } = $props(); const superTotpForm = superForm(data.totpForm, { - resetForm: false, - validators: zodClient(totpSchema), + resetForm: false, + validators: zodClient(totpSchema), }); const superRecoveryCodeForm = superForm(data.recoveryCodeForm, { - validators: zodClient(recoveryCodeSchema), - resetForm: false, - validationMethod: 'oninput', - delayMs: 0, + validators: zodClient(recoveryCodeSchema), + resetForm: false, + validationMethod: 'oninput', + delayMs: 0, }); let showRecoveryCode = $state(false); @@ -40,20 +40,20 @@ const { form: recoveryCodeFormData, enhance: recoveryCodeEnhance } = superRecove {#if !showRecoveryCode} {@render totpForm()} - + {:else} {@render recoveryCodeForm()} - + {/if} {#snippet totpForm()}
- + TOTP Code - +