Encrypting two factor secret in the DB, adding env for secret used to encrypt, and creating/verifying totp codes.

This commit is contained in:
Bradley Shellnut 2024-11-19 19:47:00 -08:00
parent 3204b0b28b
commit 6e67b2d4e1
29 changed files with 915 additions and 869 deletions

View file

@ -10,6 +10,7 @@ DATABASE_PASSWORD='postgres'
DATABASE_HOST='localhost' DATABASE_HOST='localhost'
DATABASE_PORT=5432 DATABASE_PORT=5432
DATABASE_DB='postgres' DATABASE_DB='postgres'
ENCRYPTION_KEY=""
REDIS_URL='redis://127.0.0.1:6379/0' REDIS_URL='redis://127.0.0.1:6379/0'

5
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"recommendations": [
"inlang.vs-code-extension"
]
}

View file

@ -32,7 +32,7 @@
"linter": { "enabled": true, "rules": { "recommended": true } }, "linter": { "enabled": true, "rules": { "recommended": true } },
"javascript": { "javascript": {
"formatter": { "formatter": {
"jsxQuoteStyle": "double", "jsxQuoteStyle": "single",
"quoteProperties": "asNeeded", "quoteProperties": "asNeeded",
"trailingCommas": "all", "trailingCommas": "all",
"indentStyle": "space", "indentStyle": "space",

View file

@ -27,7 +27,7 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@melt-ui/pp": "^0.3.2", "@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0", "@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.48.2", "@playwright/test": "^1.49.0",
"@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/enhanced-img": "^0.3.10", "@sveltejs/enhanced-img": "^0.3.10",
"@sveltejs/kit": "^2.8.1", "@sveltejs/kit": "^2.8.1",
@ -63,8 +63,8 @@
"svelte-sequential-preprocessor": "^2.0.2", "svelte-sequential-preprocessor": "^2.0.2",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-flash-message": "^2.4.4", "sveltekit-flash-message": "^2.4.4",
"sveltekit-superforms": "^2.20.0", "sveltekit-superforms": "^2.20.1",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.15",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.19.2", "tsx": "^4.19.2",
@ -88,6 +88,7 @@
"@needle-di/core": "^0.8.4", "@needle-di/core": "^0.8.4",
"@neondatabase/serverless": "^0.9.5", "@neondatabase/serverless": "^0.9.5",
"@node-rs/argon2": "^1.8.3", "@node-rs/argon2": "^1.8.3",
"@oslojs/binary": "^1.0.0",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@oslojs/jwt": "^0.2.0", "@oslojs/jwt": "^0.2.0",
@ -100,13 +101,13 @@
"@sveltejs/adapter-vercel": "^5.4.7", "@sveltejs/adapter-vercel": "^5.4.7",
"@types/feather-icons": "^4.29.4", "@types/feather-icons": "^4.29.4",
"boardgamegeekclient": "^1.9.1", "boardgamegeekclient": "^1.9.1",
"bullmq": "^5.25.6", "bullmq": "^5.27.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^11.0.7", "dotenv-expand": "^11.0.7",
"drizzle-orm": "^0.36.1", "drizzle-orm": "^0.36.3",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",

View file

@ -34,7 +34,7 @@ importers:
version: 3.5.6 version: 3.5.6
'@lucia-auth/adapter-drizzle': '@lucia-auth/adapter-drizzle':
specifier: ^1.1.0 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': '@lukeed/uuid':
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
@ -47,6 +47,9 @@ importers:
'@node-rs/argon2': '@node-rs/argon2':
specifier: ^1.8.3 specifier: ^1.8.3
version: 1.8.3 version: 1.8.3
'@oslojs/binary':
specifier: ^1.0.0
version: 1.0.0
'@oslojs/crypto': '@oslojs/crypto':
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
@ -84,8 +87,8 @@ importers:
specifier: ^1.9.1 specifier: ^1.9.1
version: 1.9.1 version: 1.9.1
bullmq: bullmq:
specifier: ^5.25.6 specifier: ^5.27.0
version: 5.25.6 version: 5.27.0
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -102,11 +105,11 @@ importers:
specifier: ^11.0.7 specifier: ^11.0.7
version: 11.0.7 version: 11.0.7
drizzle-orm: drizzle-orm:
specifier: ^0.36.1 specifier: ^0.36.3
version: 0.36.1(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5) version: 0.36.3(@neondatabase/serverless@0.9.5)(@types/pg@8.11.10)(pg@8.13.1)(postgres@3.4.5)
drizzle-zod: drizzle-zod:
specifier: ^0.5.1 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: feather-icons:
specifier: ^4.29.2 specifier: ^4.29.2
version: 4.29.2 version: 4.29.2
@ -184,10 +187,10 @@ importers:
version: 2.5.4 version: 2.5.4
tailwind-variants: tailwind-variants:
specifier: ^0.2.1 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: tailwindcss-animate:
specifier: ^1.0.7 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: tsyringe:
specifier: ^4.8.0 specifier: ^4.8.0
version: 4.8.0 version: 4.8.0
@ -208,8 +211,8 @@ importers:
specifier: ^0.83.0 specifier: ^0.83.0
version: 0.83.0(svelte@5.0.0-next.175) version: 0.83.0(svelte@5.0.0-next.175)
'@playwright/test': '@playwright/test':
specifier: ^1.48.2 specifier: ^1.49.0
version: 1.48.2 version: 1.49.0
'@sveltejs/adapter-auto': '@sveltejs/adapter-auto':
specifier: ^3.3.1 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))) 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 version: 0.27.2
formsnap: formsnap:
specifier: ^1.0.1 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: just-clone:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.2.0 version: 6.2.0
@ -316,11 +319,11 @@ importers:
specifier: ^2.4.4 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) 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: sveltekit-superforms:
specifier: ^2.20.0 specifier: ^2.20.1
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) 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: tailwindcss:
specifier: ^3.4.14 specifier: ^3.4.15
version: 3.4.14(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) version: 3.4.15(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))
ts-node: ts-node:
specifier: ^10.9.2 specifier: ^10.9.2
version: 10.9.2(@types/node@20.17.6)(typescript@5.6.3) version: 10.9.2(@types/node@20.17.6)(typescript@5.6.3)
@ -353,11 +356,11 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@ark/schema@0.10.0': '@ark/schema@0.23.0':
resolution: {integrity: sha512-zpfXwWLOzj9aUK+dXQ6aleJAOgle4/WrHDop5CMX2M88dFQ85NdH8O0v0pvMAQnfFcaQAZ/nVDYLlBJsFc09XA==} resolution: {integrity: sha512-406Zx0te3ICd7PkGise4XIxOfmjFzK64tEuiN5rmJDg14AqhySXygMk8QcHqHORDJ7VXhel7J41iduw8eyiFPg==}
'@ark/util@0.10.0': '@ark/util@0.23.0':
resolution: {integrity: sha512-uK+9VU5doGMYOoOZVE+XaSs1vYACoaEJdrDkuBx26S4X7y3ChyKsPnIg/9pIw2vUySph1GkAXbvBnfVE2GmXgQ==} resolution: {integrity: sha512-2mb24N2leQENRh+zPqnlRJzFFf8Xr7BT+/4MJN46/G8C45davpqFfcqvOw0ZlXrjQpBi8H+ZqDQsi95lN/9oVg==}
'@asteasolutions/zod-to-openapi@7.1.2': '@asteasolutions/zod-to-openapi@7.1.2':
resolution: {integrity: sha512-tuDcV4aGAlY4eaZ8Qmf1efPL33hwJKdpCSbI6vJqXU5Wkz9IIyCrb3u3fExZyyMGzmLKcJH+CHI5UKvNBPlyjg==} resolution: {integrity: sha512-tuDcV4aGAlY4eaZ8Qmf1efPL33hwJKdpCSbI6vJqXU5Wkz9IIyCrb3u3fExZyyMGzmLKcJH+CHI5UKvNBPlyjg==}
@ -1398,10 +1401,20 @@ packages:
peerDependencies: peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 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': '@eslint-community/regexpp@4.11.1':
resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} 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': '@eslint/eslintrc@2.1.4':
resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -2199,8 +2212,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@playwright/test@1.48.2': '@playwright/test@1.49.0':
resolution: {integrity: sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==} resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
@ -2560,8 +2573,8 @@ packages:
resolution: {integrity: sha512-hg4ekaB5Y2zh+IWzBiC/WCDWrIfpVnKu/ubUvelKlidc/VbulsexoFRw5kJGHZenPVI5YzNnDeTdYSALkTV7jQ==} resolution: {integrity: sha512-hg4ekaB5Y2zh+IWzBiC/WCDWrIfpVnKu/ubUvelKlidc/VbulsexoFRw5kJGHZenPVI5YzNnDeTdYSALkTV7jQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@vinejs/vine@1.8.0': '@vinejs/vine@2.1.0':
resolution: {integrity: sha512-Qq3XxbA26jzqS9ICifkqzT399lMQZ2fWtqeV3luI2as+UIK7qDifJFU2Q4W3q3IB5VXoWxgwAZSZEO0em9I/qQ==} resolution: {integrity: sha512-09aJ2OauxpblqiNqd8qC9RAzzm5SV6fTqZhE4e25j4cM7fmNoXRTjM7Oo8llFADMO4eSA44HqYEO3mkRRYdbYw==}
engines: {node: '>=18.16.0'} engines: {node: '>=18.16.0'}
'@vitest/expect@1.6.0': '@vitest/expect@1.6.0':
@ -2685,8 +2698,8 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
arktype@2.0.0-rc.8: arktype@2.0.0-rc.23:
resolution: {integrity: sha512-ByrqjptsavUCUL9ptts6BUL2LCNkVZyniOdaBw76dlBQ6gYIhYSeycuuj4gRFwcAafszOnAPD2fAqHK7bbo/Zw==} resolution: {integrity: sha512-P0e40t3J4rc3xRHzPjzyOK1CgdgKswQJOFBgFLuehSiGcjAuRx6p/9lDVPzXZ62m7q5yRUqFiX8ovN5FjWQjMQ==}
array-flatten@1.1.1: array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
@ -2788,8 +2801,8 @@ packages:
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bullmq@5.25.6: bullmq@5.27.0:
resolution: {integrity: sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==} resolution: {integrity: sha512-DZWrjDLkecZZ1/43h/SkG6CxU8nO/Lq/0svVoQdw33ksUCGfccgjbvCa/cuxHP/OvhxlTAA0cO3dBOoaT7sRFQ==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@ -2966,6 +2979,10 @@ packages:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-blank-pseudo@6.0.2: css-blank-pseudo@6.0.2:
resolution: {integrity: sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==} resolution: {integrity: sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==}
engines: {node: ^14 || ^16 || >=18} engines: {node: ^14 || ^16 || >=18}
@ -3133,8 +3150,8 @@ packages:
resolution: {integrity: sha512-F6cFZ1wxa9XzFyeeQsp/0/lIzUbDuQjS8/njpYBDWa+wdWmXuY+Z/X2hHFK/9PGHZkv3c9mER+mVWfKlp/B6Vw==} resolution: {integrity: sha512-F6cFZ1wxa9XzFyeeQsp/0/lIzUbDuQjS8/njpYBDWa+wdWmXuY+Z/X2hHFK/9PGHZkv3c9mER+mVWfKlp/B6Vw==}
hasBin: true hasBin: true
drizzle-orm@0.36.1: drizzle-orm@0.36.3:
resolution: {integrity: sha512-F4hbimnMEhyWzDowQB4xEuVJJWXLHZYD7FYwvo8RImY+N7pStGqsbfmT95jDbec1s4qKmQbiuxEDZY90LRrfIw==} resolution: {integrity: sha512-ffQB7CcyCTvQBK6xtRLMl/Jsd5xFTBs+UTHrgs1hbk68i5TPkbsoCPbKEwiEsQZfq2I7VH632XJpV1g7LS2H9Q==}
peerDependencies: peerDependencies:
'@aws-sdk/client-rds-data': '>=3' '@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=3' '@cloudflare/workers-types': '>=3'
@ -3155,7 +3172,7 @@ packages:
'@xata.io/client': '*' '@xata.io/client': '*'
better-sqlite3: '>=7' better-sqlite3: '>=7'
bun-types: '*' bun-types: '*'
expo-sqlite: '>=13.2.0' expo-sqlite: '>=14.0.0'
knex: '*' knex: '*'
kysely: '*' kysely: '*'
mysql2: '>=2' mysql2: '>=2'
@ -3465,8 +3482,8 @@ packages:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
flatted@3.3.1: flatted@3.3.2:
resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
focus-trap@7.6.0: focus-trap@7.6.0:
resolution: {integrity: sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==} resolution: {integrity: sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==}
@ -4355,13 +4372,13 @@ packages:
pkg-types@1.2.0: pkg-types@1.2.0:
resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
playwright-core@1.48.2: playwright-core@1.49.0:
resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
playwright@1.48.2: playwright@1.49.0:
resolution: {integrity: sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==} resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
@ -5129,8 +5146,8 @@ packages:
'@sveltejs/kit': 1.x || 2.x '@sveltejs/kit': 1.x || 2.x
svelte: 3.x || 4.x || >=5.0.0-next.51 svelte: 3.x || 4.x || >=5.0.0-next.51
sveltekit-superforms@2.20.0: sveltekit-superforms@2.20.1:
resolution: {integrity: sha512-5HyA6THKFBHEmJinZ/klu2/0jYr9ElSaXMYc5EO9ptP3x1wQPWVXYl59sMcaSrIjWUlPpayGxVppCyu+x/o4WA==} resolution: {integrity: sha512-GPGPp4pf/v7fZ0iS3ddC1AO3Ti10oAgsVD95WZ6nPh6/L894pMsL8JN67/Lz0hSIRUXk8k35BjCIJB69z+KI/Q==}
peerDependencies: peerDependencies:
'@sveltejs/kit': 1.x || 2.x '@sveltejs/kit': 1.x || 2.x
svelte: 3.x || 4.x || >=5.0.0-next.51 svelte: 3.x || 4.x || >=5.0.0-next.51
@ -5152,8 +5169,8 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders' tailwindcss: '>=3.0.0 || insiders'
tailwindcss@3.4.14: tailwindcss@3.4.15:
resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
@ -5353,8 +5370,8 @@ packages:
valibot@0.31.1: valibot@0.31.1:
resolution: {integrity: sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q==} resolution: {integrity: sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q==}
valibot@0.41.0: valibot@1.0.0-beta.6:
resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} resolution: {integrity: sha512-x9ObzhqDCWFaWOa6Zri1mbFcc8OIIKP7cQtD9JauKt5pJFhpJkvAXT+49bFKjoVikiKVk7m33mXgUJb/Wfknmw==}
peerDependencies: peerDependencies:
typescript: '>=5' typescript: '>=5'
peerDependenciesMeta: peerDependenciesMeta:
@ -5556,12 +5573,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.5 '@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
'@ark/schema@0.10.0': '@ark/schema@0.23.0':
dependencies: dependencies:
'@ark/util': 0.10.0 '@ark/util': 0.23.0
optional: true optional: true
'@ark/util@0.10.0': '@ark/util@0.23.0':
optional: true optional: true
'@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8)': '@asteasolutions/zod-to-openapi@7.1.2(zod@3.23.8)':
@ -6248,8 +6265,15 @@ snapshots:
eslint: 8.57.1 eslint: 8.57.1
eslint-visitor-keys: 3.4.3 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.11.1': {}
'@eslint-community/regexpp@4.12.1': {}
'@eslint/eslintrc@2.1.4': '@eslint/eslintrc@2.1.4':
dependencies: dependencies:
ajv: 6.12.6 ajv: 6.12.6
@ -6604,9 +6628,9 @@ snapshots:
dependencies: dependencies:
typescript: 5.2.2 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: 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 lucia: 3.2.0
'@lukeed/csprng@1.1.0': {} '@lukeed/csprng@1.1.0': {}
@ -7104,9 +7128,9 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@playwright/test@1.48.2': '@playwright/test@1.49.0':
dependencies: dependencies:
playwright: 1.48.2 playwright: 1.49.0
'@polka/url@1.0.0-next.28': {} '@polka/url@1.0.0-next.28': {}
@ -7493,7 +7517,7 @@ snapshots:
'@vinejs/compiler@2.5.0': '@vinejs/compiler@2.5.0':
optional: true optional: true
'@vinejs/vine@1.8.0': '@vinejs/vine@2.1.0':
dependencies: dependencies:
'@poppinss/macroable': 1.0.3 '@poppinss/macroable': 1.0.3
'@types/validator': 13.12.2 '@types/validator': 13.12.2
@ -7551,9 +7575,9 @@ snapshots:
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.12.1
acorn-jsx@5.3.2(acorn@8.12.1): acorn-jsx@5.3.2(acorn@8.14.0):
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.14.0
acorn-typescript@1.4.13(acorn@8.12.1): acorn-typescript@1.4.13(acorn@8.12.1):
dependencies: dependencies:
@ -7623,10 +7647,10 @@ snapshots:
aria-query@5.3.2: {} aria-query@5.3.2: {}
arktype@2.0.0-rc.8: arktype@2.0.0-rc.23:
dependencies: dependencies:
'@ark/schema': 0.10.0 '@ark/schema': 0.23.0
'@ark/util': 0.10.0 '@ark/util': 0.23.0
optional: true optional: true
array-flatten@1.1.1: {} array-flatten@1.1.1: {}
@ -7742,7 +7766,7 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
bullmq@5.25.6: bullmq@5.27.0:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
ioredis: 5.4.1 ioredis: 5.4.1
@ -7915,6 +7939,12 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 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): css-blank-pseudo@6.0.2(postcss@8.4.49):
dependencies: dependencies:
postcss: 8.4.49 postcss: 8.4.49
@ -8032,16 +8062,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: optionalDependencies:
'@neondatabase/serverless': 0.9.5 '@neondatabase/serverless': 0.9.5
'@types/pg': 8.11.10 '@types/pg': 8.11.10
pg: 8.13.1 pg: 8.13.1
postgres: 3.4.5 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: 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 zod: 3.23.8
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
@ -8238,8 +8268,8 @@ snapshots:
eslint@8.57.1: eslint@8.57.1:
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1)
'@eslint-community/regexpp': 4.11.1 '@eslint-community/regexpp': 4.12.1
'@eslint/eslintrc': 2.1.4 '@eslint/eslintrc': 2.1.4
'@eslint/js': 8.57.1 '@eslint/js': 8.57.1
'@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/config-array': 0.13.0
@ -8248,7 +8278,7 @@ snapshots:
'@ungap/structured-clone': 1.2.0 '@ungap/structured-clone': 1.2.0
ajv: 6.12.6 ajv: 6.12.6
chalk: 4.1.2 chalk: 4.1.2
cross-spawn: 7.0.3 cross-spawn: 7.0.6
debug: 4.3.7 debug: 4.3.7
doctrine: 3.0.0 doctrine: 3.0.0
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
@ -8283,8 +8313,8 @@ snapshots:
espree@9.6.1: espree@9.6.1:
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.14.0
acorn-jsx: 5.3.2(acorn@8.12.1) acorn-jsx: 5.3.2(acorn@8.14.0)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
esquery@1.6.0: esquery@1.6.0:
@ -8444,11 +8474,11 @@ snapshots:
flat-cache@3.2.0: flat-cache@3.2.0:
dependencies: dependencies:
flatted: 3.3.1 flatted: 3.3.2
keyv: 4.5.4 keyv: 4.5.4
rimraf: 3.0.2 rimraf: 3.0.2
flatted@3.3.1: {} flatted@3.3.2: {}
focus-trap@7.6.0: focus-trap@7.6.0:
dependencies: dependencies:
@ -8467,11 +8497,11 @@ snapshots:
combined-stream: 1.0.8 combined-stream: 1.0.8
mime-types: 2.1.35 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: dependencies:
nanoid: 5.0.7 nanoid: 5.0.7
svelte: 5.0.0-next.175 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: {} forwarded@0.2.0: {}
@ -9297,11 +9327,11 @@ snapshots:
mlly: 1.7.1 mlly: 1.7.1
pathe: 1.1.2 pathe: 1.1.2
playwright-core@1.48.2: {} playwright-core@1.49.0: {}
playwright@1.48.2: playwright@1.49.0:
dependencies: dependencies:
playwright-core: 1.48.2 playwright-core: 1.49.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 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)) '@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 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: 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)) '@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 devalue: 5.1.1
@ -10132,14 +10162,14 @@ snapshots:
'@gcornut/valibot-json-schema': 0.31.0 '@gcornut/valibot-json-schema': 0.31.0
'@sinclair/typebox': 0.32.35 '@sinclair/typebox': 0.32.35
'@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.1) '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.1)
'@vinejs/vine': 1.8.0 '@vinejs/vine': 2.1.0
arktype: 2.0.0-rc.8 arktype: 2.0.0-rc.23
class-validator: 0.14.1 class-validator: 0.14.1
effect: 3.9.2 effect: 3.9.2
joi: 17.13.3 joi: 17.13.3
json-schema-to-ts: 3.1.1 json-schema-to-ts: 3.1.1
superstruct: 2.0.2 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 yup: 1.4.0
zod: 3.23.8 zod: 3.23.8
zod-to-json-schema: 3.23.5(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-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: dependencies:
tailwind-merge: 2.5.4 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: 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: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
arg: 5.0.2 arg: 5.0.2
@ -10175,7 +10205,7 @@ snapshots:
micromatch: 4.0.8 micromatch: 4.0.8
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.0 picocolors: 1.1.1
postcss: 8.4.49 postcss: 8.4.49
postcss-import: 15.1.0(postcss@8.4.49) postcss-import: 15.1.0(postcss@8.4.49)
postcss-js: 4.0.1(postcss@8.4.49) postcss-js: 4.0.1(postcss@8.4.49)
@ -10352,7 +10382,7 @@ snapshots:
valibot@0.31.1: valibot@0.31.1:
optional: true optional: true
valibot@0.41.0(typescript@5.6.3): valibot@1.0.0-beta.6(typescript@5.6.3):
optionalDependencies: optionalDependencies:
typescript: 5.6.3 typescript: 5.6.3
optional: true optional: true

View file

@ -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"
}
}
]

View file

@ -21,4 +21,7 @@ export const config: Config = {
migrating: env.DB_MIGRATING, migrating: env.DB_MIGRATING,
seeding: env.DB_SEEDING, seeding: env.DB_SEEDING,
}, },
security: {
encryptionKey: env.ENCRYPTION_KEY,
}
}; };

View file

@ -1,39 +1,40 @@
import {config} from 'dotenv'; import { config } from 'dotenv';
import {expand} from 'dotenv-expand'; import { expand } from 'dotenv-expand';
import {z, type ZodError} from 'zod'; import { z, type ZodError } from 'zod';
expand(config()); expand(config());
const stringBoolean = z.coerce const stringBoolean = z.coerce
.string() .string()
.transform((val) => { .transform((val) => {
return val === 'true'; return val === 'true';
}) })
.default('false'); .default('false');
const EnvSchema = z.object({ const EnvSchema = z.object({
DATABASE_USER: z.string(), DATABASE_USER: z.string(),
DATABASE_PASSWORD: z.string(), DATABASE_PASSWORD: z.string(),
DATABASE_HOST: z.string(), DATABASE_HOST: z.string(),
DATABASE_PORT: z.coerce.number(), DATABASE_PORT: z.coerce.number(),
DATABASE_DB: z.string(), DATABASE_DB: z.string(),
DB_MIGRATING: stringBoolean, DB_MIGRATING: stringBoolean,
DB_SEEDING: stringBoolean, DB_SEEDING: stringBoolean,
DOMAIN: z.string(), DOMAIN: z.string(),
GITHUB_CLIENT_ID: z.string(), ENCRYPTION_KEY: z.string(),
GITHUB_CLIENT_SECRET: z.string(), GITHUB_CLIENT_ID: z.string(),
GOOGLE_CLIENT_ID: z.string(), GITHUB_CLIENT_SECRET: z.string(),
GOOGLE_CLIENT_SECRET: z.string(), GOOGLE_CLIENT_ID: z.string(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), GOOGLE_CLIENT_SECRET: z.string(),
NODE_ENV: z.string().default('development'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
ORIGIN: z.string(), NODE_ENV: z.string().default('development'),
PUBLIC_SITE_NAME: z.string(), ORIGIN: z.string(),
PUBLIC_SITE_URL: z.string(), PUBLIC_SITE_NAME: z.string(),
PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'), PUBLIC_SITE_URL: z.string(),
PUBLIC_UMAMI_ID: z.string(), PUBLIC_UMAMI_DO_NOT_TRACK: z.string().default('true'),
PUBLIC_UMAMI_URL: z.string(), PUBLIC_UMAMI_ID: z.string(),
REDIS_URL: z.string(), PUBLIC_UMAMI_URL: z.string(),
TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000), REDIS_URL: z.string(),
TWO_FACTOR_TIMEOUT: z.coerce.number().default(300000),
}); });
export type env = z.infer<typeof EnvSchema>; export type env = z.infer<typeof EnvSchema>;
@ -41,12 +42,12 @@ export type env = z.infer<typeof EnvSchema>;
let env: env; let env: env;
try { try {
env = EnvSchema.parse(process.env); env = EnvSchema.parse(process.env);
} catch (e) { } catch (e) {
const error = e as ZodError; const error = e as ZodError;
console.error('❌ Missing required values in .env:\n'); console.error('❌ Missing required values in .env:\n');
console.error(error.flatten().fieldErrors); console.error(error.flatten().fieldErrors);
process.exit(1); process.exit(1);
} }
export default env; export default env;

View file

@ -5,6 +5,7 @@ export interface Config {
// storage: StorageConfig // storage: StorageConfig
redis: RedisConfig; redis: RedisConfig;
postgres: PostgresConfig; postgres: PostgresConfig;
security: SecurityConfig;
} }
interface ApiConfig { interface ApiConfig {
@ -33,3 +34,7 @@ interface PostgresConfig {
migrating: boolean; migrating: boolean;
seeding: boolean; seeding: boolean;
} }
interface SecurityConfig {
encryptionKey: string;
}

View file

@ -79,7 +79,7 @@ export class IamController extends Controller {
try { try {
await this.iamService.updatePassword(user.id, { password, confirm_password }); await this.iamService.updatePassword(user.id, { password, confirm_password });
await this.sessionsService.invalidateSession(user.id); 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(); const sessionCookie = createBlankSessionTokenCookie();
setSessionCookie(c, sessionCookie); setSessionCookie(c, sessionCookie);
return c.json({ status: 'success' }); return c.json({ status: 'success' });

View file

@ -1,74 +1,106 @@
import {StatusCodes} from '$lib/constants/status-codes'; import { StatusCodes } from '$lib/constants/status-codes';
import {Controller} from '$lib/server/api/common/types/controller'; import { Controller } from '$lib/server/api/common/types/controller';
import {verifyTotpDto} from '$lib/server/api/dtos/verify-totp.dto'; import { verifyTotpDto } from '$lib/server/api/dtos/verify-totp.dto';
import {RecoveryCodesService} from '$lib/server/api/services/recovery-codes.service'; import { RecoveryCodesService } from '$lib/server/api/services/recovery-codes.service';
import {TotpService} from '$lib/server/api/services/totp.service'; import { TotpService } from '$lib/server/api/services/totp.service';
import {UsersService} from '$lib/server/api/services/users.service'; import { UsersService } from '$lib/server/api/services/users.service';
import {zValidator} from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { inject, injectable } from '@needle-di/core'; import { inject, injectable } from '@needle-di/core';
import {CredentialsType} from '../databases/postgres/tables'; import { CredentialsType } from '../databases/postgres/tables';
import {requireAuth} from '../middleware/require-auth.middleware'; 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() @injectable()
export class MfaController extends Controller { export class MfaController extends Controller {
constructor( constructor(
private recoveryCodesService = inject(RecoveryCodesService), private loginRequestService = inject(LoginRequestsService),
private totpService = inject(TotpService), private recoveryCodesService = inject(RecoveryCodesService),
private usersService = inject(UsersService), private totpService = inject(TotpService),
) { private usersService = inject(UsersService),
super(); ) {
} super();
}
routes() { routes() {
return this.controller return this.controller
.get('/totp', requireAuth, async (c) => { .get('/totp', requireAuth, async (c) => {
const user = c.var.user; const user = c.var.user;
const totpCredential = await this.totpService.findOneByUserId(user.id); const totpCredential = await this.totpService.findOneByUserId(user.id);
return c.json({ totpCredential }); return c.json({ totpCredential });
}) })
.post('/totp', requireAuth, async (c) => { .post('/totp', requireAuth, zValidator('json', createTwoFactorSchema), async (c) => {
const user = c.var.user; const user = c.var.user;
const totpCredential = await this.totpService.create(user.id); const { key } = c.req.valid('json');
return c.json({ totpCredential }); const totpCredential = await this.totpService.create(user.id, decodeBase64(key));
}) if (totpCredential) {
.delete('/totp', requireAuth, async (c) => { await this.usersService.updateUser(user.id, { mfa_enabled: true });
const user = c.var.user; return c.json({ totpCredential });
try { }
await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP); return c.status(StatusCodes.INTERNAL_SERVER_ERROR);
await this.recoveryCodesService.deleteAllRecoveryCodesByUserId(user.id); })
await this.usersService.updateUser(user.id, { mfa_enabled: false }); .delete('/totp', requireAuth, async (c) => {
console.log('TOTP deleted'); const user = c.var.user;
return c.body(null, StatusCodes.NO_CONTENT); try {
} catch (e) { await this.totpService.deleteOneByUserIdAndType(user.id, CredentialsType.TOTP);
console.error(e); await this.recoveryCodesService.deleteAllRecoveryCodesByUserId(user.id);
return c.status(StatusCodes.INTERNAL_SERVER_ERROR); await this.usersService.updateUser(user.id, { mfa_enabled: false });
} console.log('TOTP deleted');
}) return c.body(null, StatusCodes.NO_CONTENT);
.get('/totp/recoveryCodes', requireAuth, async (c) => { } catch (e) {
const user = c.var.user; console.error(e);
// You can only view recovery codes once and that is on creation return c.status(StatusCodes.INTERNAL_SERVER_ERROR);
const existingCodes = await this.recoveryCodesService.findAllRecoveryCodesByUserId(user.id); }
if (existingCodes && existingCodes.length > 0) { })
console.log('Recovery Codes found', existingCodes); .get('/totp/recoveryCodes', requireAuth, async (c) => {
return c.json({ recoveryCodes: existingCodes }); const user = c.var.user;
} // You can only view recovery codes once and that is on creation
const recoveryCodes = await this.recoveryCodesService.createRecoveryCodes(user.id); const existingCodes = await this.recoveryCodesService.findAllRecoveryCodesByUserId(user.id);
return c.json({ recoveryCodes }); if (existingCodes && existingCodes.length > 0) {
}) console.log('Recovery Codes found', existingCodes);
.post('/totp/verify', requireAuth, zValidator('json', verifyTotpDto), async (c) => { // Filter out codes that are not used and only return the code
try { const codes = existingCodes.filter(code => !code.used).map(code => code.code);
const user = c.var.user; return c.json({ recoveryCodes: codes });
const { code } = c.req.valid('json'); }
const verified = await this.totpService.verify(user.id, code); const recoveryCodes = await this.recoveryCodesService.createRecoveryCodes(user.id);
if (verified) { return c.json({ recoveryCodes });
await this.usersService.updateUser(user.id, { mfa_enabled: true }); })
return c.json({}, StatusCodes.OK); .post('/totp/recoveryCodes', requireAuth, zValidator('json', verifyTotpDto), async (c) => {
} try {
return c.json('Invalid code', StatusCodes.BAD_REQUEST); const user = c.var.user;
} catch (e) { const { code } = c.req.valid('json');
console.error(e); c.var.logger.info(`Verifying code ${code} for user ${user.id}`);
return c.status(StatusCodes.INTERNAL_SERVER_ERROR); 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);
}
});
}
} }

View file

@ -34,7 +34,7 @@ export class SignupController extends Controller {
return c.body('Failed to create user', 500); 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); const sessionCookie = createSessionTokenCookie(session.id, cookieExpiresAt);
console.log('set cookie', sessionCookie); console.log('set cookie', sessionCookie);
setSessionCookie(c, sessionCookie); setSessionCookie(c, sessionCookie);

View file

@ -1,29 +1,29 @@
import {createId as cuid2} from '@paralleldrive/cuid2'; import { createId as cuid2 } from '@paralleldrive/cuid2';
import {type InferSelectModel, relations} from 'drizzle-orm'; import { type InferSelectModel, relations } from 'drizzle-orm';
import {pgTable, text, uuid} from 'drizzle-orm/pg-core'; import { pgTable, text, uuid } from 'drizzle-orm/pg-core';
import {createSelectSchema} from 'drizzle-zod'; import { createSelectSchema } from 'drizzle-zod';
import {timestamps} from '../../../common/utils/table'; import { timestamps } from '../../../common/utils/table';
import {collection_items} from './collectionItems.table'; import { collection_items } from './collectionItems.table';
import {usersTable} from './users.table'; import { usersTable } from './users.table';
export const collections = pgTable('collections', { export const collections = pgTable('collections', {
id: uuid().primaryKey().defaultRandom(), id: uuid().primaryKey().defaultRandom(),
cuid: text() cuid: text()
.unique() .unique()
.$defaultFn(() => cuid2()), .$defaultFn(() => cuid2()),
user_id: uuid() user_id: uuid()
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: 'cascade' }), .references(() => usersTable.id, { onDelete: 'cascade' }),
name: text().notNull().default('My Collection'), name: text().notNull().default('My Collection'),
...timestamps, ...timestamps,
}); });
export const collection_relations = relations(collections, ({ one, many }) => ({ export const collection_relations = relations(collections, ({ one, many }) => ({
user: one(usersTable, { user: one(usersTable, {
fields: [collections.user_id], fields: [collections.user_id],
references: [usersTable.id], references: [usersTable.id],
}), }),
collection_items: many(collection_items), collection_items: many(collection_items),
})); }));
export const selectCollectionSchema = createSelectSchema(collections); export const selectCollectionSchema = createSelectSchema(collections);

View file

@ -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<typeof createTwoFactorSchema>;

View file

@ -1,12 +1,8 @@
import {z} from "zod"; import { z } from 'zod';
export const signinUsernameDto = z.object({ export const signinUsernameDto = z.object({
username: z username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
.string() password: z.string({ required_error: 'Password is required' }).trim(),
.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<typeof signinUsernameDto>; export type SigninUsernameDto = z.infer<typeof signinUsernameDto>;

View file

@ -1,6 +1,6 @@
import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository'; import {takeFirstOrThrow} from '$lib/server/api/common/utils/repository';
import {DrizzleService} from '$lib/server/api/services/drizzle.service'; 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 {inject, injectable} from '@needle-di/core';
import {recoveryCodesTable} from '../databases/postgres/tables'; 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) { async deleteAllByUserId(userId: string, db = this.drizzle.db) {
return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId)); return db.delete(recoveryCodesTable).where(eq(recoveryCodesTable.userId, userId));
} }

View file

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

View file

@ -1,68 +1,68 @@
import type {ChangePasswordDto} from '$lib/server/api/dtos/change-password.dto'; import type { ChangePasswordDto } from '$lib/server/api/dtos/change-password.dto';
import type {UpdateEmailDto} from '$lib/server/api/dtos/update-email.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 { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import type {VerifyPasswordDto} from '$lib/server/api/dtos/verify-password.dto'; import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto';
import {SessionsService} from '$lib/server/api/services/sessions.service'; import { SessionsService } from '$lib/server/api/services/sessions.service';
import {UsersService} from '$lib/server/api/services/users.service'; import { UsersService } from '$lib/server/api/services/users.service';
import {inject, injectable} from '@needle-di/core'; import { inject, injectable } from '@needle-di/core';
@injectable() @injectable()
export class IamService { export class IamService {
constructor( constructor(
private readonly sessionsService = inject(SessionsService), private readonly sessionsService = inject(SessionsService),
private readonly usersService = inject(UsersService), private readonly usersService = inject(UsersService),
) {} ) {}
async logout(sessionId: string) { async logout(sessionId: string) {
return this.sessionsService.invalidateSession(sessionId); return this.sessionsService.invalidateSession(sessionId);
} }
async updateProfile(userId: string, data: UpdateProfileDto) { async updateProfile(userId: string, data: UpdateProfileDto) {
const user = await this.usersService.findOneById(userId); const user = await this.usersService.findOneById(userId);
if (!user) { if (!user) {
return { return {
error: 'User not found', error: 'User not found',
}; };
} }
const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username); const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username);
if (existingUserForNewUsername && existingUserForNewUsername.id !== user.id) { if (existingUserForNewUsername && existingUserForNewUsername.id !== user.id) {
return { return {
error: 'Username already in use', error: 'Username already in use',
}; };
} }
return this.usersService.updateUser(user.id, { return this.usersService.updateUser(user.id, {
first_name: data.firstName, first_name: data.firstName,
last_name: data.lastName, last_name: data.lastName,
username: data.username !== user.username ? data.username : user.username, username: data.username !== user.username ? data.username : user.username,
}); });
} }
async updateEmail(userId: string, data: UpdateEmailDto) { async updateEmail(userId: string, data: UpdateEmailDto) {
const { email } = data; const { email } = data;
const existingUserEmail = await this.usersService.findOneByEmail(email); const existingUserEmail = await this.usersService.findOneByEmail(email);
if (existingUserEmail && existingUserEmail.id !== userId) { if (existingUserEmail && existingUserEmail.id !== userId) {
return null; return null;
} }
return this.usersService.updateUser(userId, { return this.usersService.updateUser(userId, {
email, email,
}); });
} }
async updatePassword(userId: string, data: ChangePasswordDto) { async updatePassword(userId: string, data: ChangePasswordDto) {
const { password } = data; const { password } = data;
await this.usersService.updatePassword(userId, password); await this.usersService.updatePassword(userId, password);
} }
async verifyPassword(userId: string, data: VerifyPasswordDto) { async verifyPassword(userId: string, data: VerifyPasswordDto) {
const user = await this.usersService.findOneById(userId); const user = await this.usersService.findOneById(userId);
if (!user) { if (!user) {
return null; return null;
} }
const { password } = data; const { password } = data;
return this.usersService.verifyPassword(userId, { password }); return this.usersService.verifyPassword(userId, { password });
} }
} }

View file

@ -1,97 +1,97 @@
import type {SigninUsernameDto} from '$lib/server/api/dtos/signin-username.dto'; import type { SigninUsernameDto } from '$lib/server/api/dtos/signin-username.dto';
import {SessionsService} from '$lib/server/api/services/sessions.service'; import { SessionsService } from '$lib/server/api/services/sessions.service';
import type {HonoRequest} from 'hono'; import type { HonoRequest } from 'hono';
import {inject, injectable} from '@needle-di/core'; import { inject, injectable } from '@needle-di/core';
import {BadRequest} from '../common/exceptions'; import { BadRequest } from '../common/exceptions';
import type {Credentials} from '../databases/postgres/tables'; import type { Credentials } from '../databases/postgres/tables';
import {CredentialsRepository} from '../repositories/credentials.repository'; import { CredentialsRepository } from '../repositories/credentials.repository';
import {UsersRepository} from '../repositories/users.repository'; import { UsersRepository } from '../repositories/users.repository';
import {MailerService} from './mailer.service'; import { MailerService } from './mailer.service';
import {TokensService} from './tokens.service'; import { TokensService } from './tokens.service';
import {DrizzleService} from "$lib/server/api/services/drizzle.service"; import { DrizzleService } from '$lib/server/api/services/drizzle.service';
@injectable() @injectable()
export class LoginRequestsService { export class LoginRequestsService {
constructor( constructor(
private sessionsService = inject(SessionsService), private sessionsService = inject(SessionsService),
private drizzleService = inject(DrizzleService) , private drizzleService = inject(DrizzleService),
private tokensService = inject(TokensService) , private tokensService = inject(TokensService),
private mailerService = inject(MailerService) , private mailerService = inject(MailerService),
private usersRepository = inject(UsersRepository) , private usersRepository = inject(UsersRepository),
private credentialsRepository = inject(CredentialsRepository) , private credentialsRepository = inject(CredentialsRepository),
) {} ) {}
// async create(data: RegisterEmailDto) { // async create(data: RegisterEmailDto) {
// // generate a token, expiry date, and hash // // generate a token, expiry date, and hash
// const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm'); // const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
// // save the login request to the database - ensuring we save the hashedToken // // save the login request to the database - ensuring we save the hashedToken
// await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry }); // await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
// // send the login request email // // send the login request email
// await this.mailerService.sendLoginRequest({ // await this.mailerService.sendLoginRequest({
// to: data.email, // to: data.email,
// props: { token: token } // props: { token: token }
// }); // });
// } // }
async verify(data: SigninUsernameDto, req: HonoRequest) { async verify(data: SigninUsernameDto, req: HonoRequest) {
const requestIpAddress = req.header('x-real-ip'); const requestIpAddress = req.header('X-Forwarded-For');
const requestIpCountry = req.header('x-vercel-ip-country'); const requestIpCountry = req.header('x-vercel-ip-country');
const existingUser = await this.usersRepository.findOneByUsername(data.username); const existingUser = await this.usersRepository.findOneByUsername(data.username);
if (!existingUser) { if (!existingUser) {
throw BadRequest('User not found'); throw BadRequest('User not found');
} }
const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id); const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id);
if (!credential) { if (!credential) {
throw BadRequest('Invalid credentials'); throw BadRequest('Invalid credentials');
} }
if (!(await this.tokensService.verifyHashedToken(credential.secret_data, data.password))) { if (!(await this.tokensService.verifyHashedToken(credential.secret_data, data.password))) {
throw BadRequest('Invalid credentials'); 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) { async createUserSession(existingUserId: string, req: HonoRequest, twoFactorAuthEnabled: boolean) {
const requestIpAddress = req.header('x-real-ip'); const requestIpAddress = req.header('X-Forwarded-For');
const requestIpCountry = req.header('x-vercel-ip-country'); const requestIpCountry = req.header('x-vercel-ip-country');
return this.sessionsService.createSession( return this.sessionsService.createSession(
this.sessionsService.generateSessionToken(), this.sessionsService.generateSessionToken(),
existingUserId, existingUserId,
requestIpCountry || 'unknown', requestIpCountry || 'unknown',
requestIpAddress || 'unknown', requestIpAddress || 'unknown',
!!totpCredentials && totpCredentials?.secret_data !== null && totpCredentials?.secret_data !== '', twoFactorAuthEnabled,
false, false,
); );
} }
// Create a new user and send a welcome email - or other onboarding process // Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) { private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true }); const newUser = await this.usersRepository.create({ email, verified: true });
// this.mailerService.sendWelcome({ to: email, props: null }); // this.mailerService.sendWelcome({ to: email, props: null });
// TODO: add whatever onboarding process or extra data you need here // TODO: add whatever onboarding process or extra data you need here
return newUser; return newUser;
} }
// Fetch a valid request from the database, verify the token and burn the request if it is valid // 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) { // private async fetchValidRequest(email: string, token: string) {
// return await this.db.transaction(async (trx) => { // return await this.db.transaction(async (trx) => {
// // fetch the login request // // fetch the login request
// const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email) // const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
// if (!loginRequest) return null; // if (!loginRequest) return null;
// // check if the token is valid // // check if the token is valid
// const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token); // const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
// if (!isValidRequest) return null // if (!isValidRequest) return null
// // if the token is valid, burn the request // // if the token is valid, burn the request
// await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id); // await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
// return loginRequest // return loginRequest
// }) // })
// } // }
} }

View file

@ -29,6 +29,11 @@ export class RecoveryCodesService {
return [] 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) { async deleteAllRecoveryCodesByUserId(userId: string) {
return this.recoveryCodesRepository.deleteAllByUserId(userId) return this.recoveryCodesRepository.deleteAllByUserId(userId)
} }

View file

@ -1,52 +1,57 @@
import {CredentialsRepository} from '$lib/server/api/repositories/credentials.repository'; import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository';
import {decodeHex, encodeHexLowerCase} from '@oslojs/encoding'; import { inject, injectable } from '@needle-di/core';
import {verifyTOTP} from '@oslojs/otp'; import { decodeBase64, encodeBase64 } from "@oslojs/encoding";
import {inject, injectable} from '@needle-di/core'; import { generateTOTP, verifyTOTP } from '@oslojs/otp';
import type {CredentialsType} from '../databases/postgres/tables'; import type { CredentialsType } from '../databases/postgres/tables';
import { EncryptionService } from './encryption.service';
@injectable() @injectable()
export class TotpService { export class TotpService {
constructor(private credentialsRepository = inject(CredentialsRepository)) {} constructor(
private credentialsRepository = inject(CredentialsRepository),
private encryptionService = inject(EncryptionService)
) {}
async findOneByUserId(userId: string) { async findOneByUserId(userId: string) {
return this.credentialsRepository.findTOTPCredentialsByUserId(userId); return this.credentialsRepository.findTOTPCredentialsByUserId(userId);
} }
async findOneByUserIdOrThrow(userId: string) { async findOneByUserIdOrThrow(userId: string) {
const credential = await this.findOneByUserId(userId); const credential = await this.findOneByUserId(userId);
if (!credential) { if (!credential) {
throw new Error('TOTP credential not found'); throw new Error('TOTP credential not found');
} }
return credential; return credential;
} }
async create(userId: string) { async create(userId: string, key: Uint8Array) {
const secret = new Uint8Array(20); try {
try { return await this.credentialsRepository.create({
return await this.credentialsRepository.create({ user_id: userId,
user_id: userId, secret_data: encodeBase64(this.encryptionService.encrypt(key)),
secret_data: encodeHexLowerCase(crypto.getRandomValues(secret)), type: 'totp',
type: 'totp', });
}); } catch (e) {
} catch (e) { console.error(e);
console.error(e); return null;
return null; }
} }
}
async deleteOneByUserId(userId: string) { async deleteOneByUserId(userId: string) {
return this.credentialsRepository.deleteByUserId(userId); return this.credentialsRepository.deleteByUserId(userId);
} }
async deleteOneByUserIdAndType(userId: string, type: CredentialsType) { async deleteOneByUserIdAndType(userId: string, type: CredentialsType) {
return this.credentialsRepository.deleteByUserIdAndType(userId, type); return this.credentialsRepository.deleteByUserIdAndType(userId, type);
} }
async verify(userId: string, code: string) { async verify(userId: string, code: string) {
const credential = await this.credentialsRepository.findTOTPCredentialsByUserId(userId); const credential = await this.credentialsRepository.findTOTPCredentialsByUserId(userId);
if (!credential) { console.log(`TOTP credential: ${JSON.stringify(credential)}`);
throw new Error('TOTP credential not found'); if (!credential) {
} throw new Error('TOTP credential not found');
return verifyTOTP(decodeHex(credential.secret_data), 30, 6, code); }
} const secret = this.encryptionService.decrypt(decodeBase64(credential.secret_data));
return verifyTOTP(secret, 30, 6, code);
}
} }

View file

@ -1,41 +1,37 @@
import {refinePasswords} from './account'; import { z } from 'zod';
import {userSchema} from './zod-schemas'; import { refinePasswords } from './account';
import {z} from 'zod'; import { userSchema } from './zod-schemas';
export const signUpSchema = userSchema export const signUpSchema = userSchema
.pick({ .pick({
firstName: true, firstName: true,
lastName: true, lastName: true,
email: true, email: true,
username: true, username: true,
password: true, password: true,
confirm_password: true, confirm_password: true,
}) })
.superRefine(async ({ confirm_password, password }, ctx) => { .superRefine(async ({ confirm_password, password }, ctx) => {
return await refinePasswords(confirm_password, password, ctx); return await refinePasswords(confirm_password, password, ctx);
}); });
export const signInSchema = z.object({ export const signInSchema = z.object({
username: z username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
.string() password: z.string({ required_error: 'Password is required' }).trim(),
.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({ 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({ 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({ 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({ export const resetPasswordTokenSchema = z.object({
resetToken: z.string().trim().min(6).max(6), resetToken: z.string().trim().min(6).max(6),
}); });

View file

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

View file

@ -1,7 +1,7 @@
import { notSignedInMessage } from '$lib/flashMessages'; import { notSignedInMessage } from '$lib/flashMessages';
import env from '$lib/server/api/common/env'; import env from '$lib/server/api/common/env';
import { decodeHex, encodeBase32 } from '@oslojs/encoding'; import { decodeBase64, encodeBase32NoPadding, encodeBase64 } from '@oslojs/encoding';
import { createTOTPKeyURI } from '@oslojs/otp'; import { createTOTPKeyURI, verifyTOTP } from '@oslojs/otp';
import { type Actions, fail } from '@sveltejs/kit'; import { type Actions, fail } from '@sveltejs/kit';
import kebabCase from 'just-kebab-case'; import kebabCase from 'just-kebab-case';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@ -12,163 +12,160 @@ import type { PageServerLoad } from '../../$types';
import { addTwoFactorSchema, removeTwoFactorSchema } from './schemas'; import { addTwoFactorSchema, removeTwoFactorSchema } from './schemas';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (!authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); throw redirect(302, '/login', notSignedInMessage, event);
} }
const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema));
const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema)); const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema));
// const addAuthNFactorForm = await superValidate(event, zod(addAuthNFactorSchema));
const { data, error } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse); const { data: twoFactorCredentials, error: twoFactorCredentialsError } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse);
if (error || !data) { if (twoFactorCredentials?.totpCredential) {
return fail(500, { return {
addTwoFactorForm, addTwoFactorForm,
}); removeTwoFactorForm,
} twoFactorEnabled: true,
const { totpCredential } = data; recoveryCodes: [],
if (totpCredential && authedUser.mfa_enabled) { keyURI: '',
return { secret: '',
addTwoFactorForm, qrCode: '',
removeTwoFactorForm, };
twoFactorEnabled: true, }
recoveryCodes: [],
totpUri: '',
qrCode: '',
};
}
if (totpCredential && !authedUser.mfa_enabled) { const issuer = kebabCase(env.PUBLIC_SITE_NAME);
await locals.api.mfa.totp.$delete().then(locals.parseApiResponse); 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 keyURI = createTOTPKeyURI(issuer, accountName, totpKey, intervalInSeconds, digits);
const accountName = authedUser.email || authedUser.username; console.log('keyURI', keyURI);
const { data: createdTotpData, error: createdTotpError } = await locals.api.mfa.totp.$post().then(locals.parseApiResponse);
if (createdTotpError || !createdTotpData) { addTwoFactorForm.data = {
return fail(500, { password: '',
addTwoFactorForm, code: '',
}); key: encodedTOTPKey,
} };
return {
const { totpCredential: createdTotpCredentials } = createdTotpData; addTwoFactorForm,
// pass the website's name and the user identifier (e.g. email, username) removeTwoFactorForm,
if (!createdTotpCredentials?.secret_data) { twoFactorEnabled: false,
return fail(500, { recoveryCodes: [],
addTwoFactorForm, keyURI,
}); secret: encodeBase32NoPadding(totpKey),
} qrCode: await QRCode.toDataURL(keyURI),
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,
};
}; };
export const actions: Actions = { export const actions: Actions = {
enableTotp: async (event) => { enableTotp: async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (!authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); throw redirect(302, '/login', notSignedInMessage, event);
} }
const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema)); const addTwoFactorForm = await superValidate(event, zod(addTwoFactorSchema));
if (!addTwoFactorForm.valid) { if (!addTwoFactorForm.valid) {
return fail(400, { return fail(400, {
addTwoFactorForm, addTwoFactorForm,
}); });
} }
const { error: verifyPasswordError } = await locals.api.me.verify.password const { error: verifyPasswordError } = await locals.api.me.verify.password
.$post({ .$post({
json: { password: addTwoFactorForm.data.password }, json: { password: addTwoFactorForm.data.password },
}) })
.then(locals.parseApiResponse); .then(locals.parseApiResponse);
if (verifyPasswordError) { if (verifyPasswordError) {
console.log(verifyPasswordError); console.log(verifyPasswordError);
return setError(addTwoFactorForm, 'password', 'Your password is incorrect'); return setError(addTwoFactorForm, 'password', 'Your password is incorrect');
} }
if (addTwoFactorForm.data.two_factor_code === '') { if (addTwoFactorForm.data.code === '') {
return setError(addTwoFactorForm, 'two_factor_code', 'Please enter a code'); return setError(addTwoFactorForm, 'code', 'Please enter a code');
} }
const twoFactorCode = addTwoFactorForm.data.two_factor_code; const twoFactorCode = addTwoFactorForm.data.code;
const { error: verifyTotpError } = await locals.api.mfa.totp.verify const encodedKey = addTwoFactorForm.data.key;
.$post({
json: { code: twoFactorCode },
})
.then(locals.parseApiResponse);
if (verifyTotpError) {
return setError(addTwoFactorForm, 'two_factor_code', 'Invalid code');
}
redirect(302, '/settings/security/mfa/recovery-codes'); let key: Uint8Array;
}, try {
disableTotp: async (event) => { key = decodeBase64(encodedKey);
const { locals } = event; } 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(); const { error: createTotpError } = await locals.api.mfa.totp
if (!authedUser) { .$post({
throw redirect(302, '/login', notSignedInMessage, event); 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) { const authedUser = await locals.getAuthedUser();
return fail(400, { if (!authedUser) {
removeTwoFactorForm, throw redirect(302, '/login', notSignedInMessage, event);
}); }
}
const { error: verifyPasswordError } = await locals.api.me.verify.password
.$post({
json: { password: removeTwoFactorForm.data.password },
})
.then(locals.parseApiResponse);
if (verifyPasswordError) { const removeTwoFactorForm = await superValidate(event, zod(removeTwoFactorSchema));
console.log(verifyPasswordError);
return setError(removeTwoFactorForm, 'password', 'Your password is incorrect');
}
const { error: deleteTotpError } = await locals.api.mfa.totp.$delete().then(locals.parseApiResponse); if (!removeTwoFactorForm.valid) {
if (deleteTotpError) { return fail(400, {
return fail(500, { removeTwoFactorForm,
removeTwoFactorForm, });
}); }
} const { error: verifyPasswordError } = await locals.api.me.verify.password
.$post({
json: { password: removeTwoFactorForm.data.password },
})
.then(locals.parseApiResponse);
redirect( if (verifyPasswordError) {
302, console.log(verifyPasswordError);
'/settings/security/mfa', return setError(removeTwoFactorForm, 'password', 'Your password is incorrect');
{ }
type: 'success',
message: 'Two-Factor Authentication has been disabled.', const { error: deleteTotpError } = await locals.api.mfa.totp.$delete().then(locals.parseApiResponse);
}, if (deleteTotpError) {
event, return fail(500, {
); removeTwoFactorForm,
}, });
}
redirect(
302,
'/settings/security/mfa',
{
type: 'success',
message: 'Two-Factor Authentication has been disabled.',
},
event,
);
},
}; };

View file

@ -9,7 +9,7 @@ import { addTwoFactorSchema, removeTwoFactorSchema } from './schemas';
const { data } = $props(); const { data } = $props();
const { qrCode, secret, twoFactorEnabled, recoveryCodes } = data; const { qrCode, twoFactorEnabled, recoveryCodes, secret } = data;
const addTwoFactorForm = superForm(data.addTwoFactorForm, { const addTwoFactorForm = superForm(data.addTwoFactorForm, {
taintedMessage: null, taintedMessage: null,
@ -52,10 +52,10 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov
<h2>Please scan the following QR Code</h2> <h2>Please scan the following QR Code</h2>
<img src={qrCode} alt="QR Code" /> <img src={qrCode} alt="QR Code" />
<form method="POST" action="?/enableTotp" use:addTwoFactorEnhance data-sveltekit-replacestate> <form method="POST" action="?/enableTotp" use:addTwoFactorEnhance data-sveltekit-replacestate>
<Form.Field form={addTwoFactorForm} name="two_factor_code"> <Form.Field form={addTwoFactorForm} name="code">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="code">Enter Code</Form.Label> <Form.Label for="code">Enter Code</Form.Label>
<PinInput {...attrs} bind:value={$addTwoFactorFormData.two_factor_code} /> <PinInput {...attrs} bind:value={$addTwoFactorFormData.code} />
</Form.Control> </Form.Control>
<Form.Description>This is the code from your authenticator app.</Form.Description> <Form.Description>This is the code from your authenticator app.</Form.Description>
<Form.FieldErrors /> <Form.FieldErrors />
@ -68,6 +68,7 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov
<Form.Description>Please enter your current password.</Form.Description> <Form.Description>Please enter your current password.</Form.Description>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>
<input name="key" type="hidden" value={$addTwoFactorFormData.key} hidden required />
<Form.Button>Submit</Form.Button> <Form.Button>Submit</Form.Button>
</form> </form>
<div class="mt-4"> <div class="mt-4">

View file

@ -2,7 +2,8 @@ import { z } from 'zod';
export const addTwoFactorSchema = z.object({ export const addTwoFactorSchema = z.object({
password: z.string({ required_error: 'Current Password is required' }), 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; export type AddTwoFactorSchema = typeof addTwoFactorSchema;

View file

@ -7,64 +7,69 @@ import { setError, superValidate } from 'sveltekit-superforms/server';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (authedUser) { if (authedUser) {
console.log('user already signed in'); console.log('user already signed in');
const message = { type: 'success', message: 'You are already signed in' } as const; const message = { type: 'success', message: 'You are already signed in' } as const;
throw redirect('/', message, event); throw redirect('/', message, event);
// redirect(302, '/', message, event) // redirect(302, '/', message, event)
} }
const form = await superValidate(event, zod(signinUsernameDto)); const form = await superValidate(event, zod(signinUsernameDto));
return { return {
form, form,
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
default: async (event) => { default: async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (authedUser) { if (authedUser) {
const message = { type: 'success', message: 'You are already signed in' } as const; const message = { type: 'success', message: 'You are already signed in' } as const;
throw redirect('/', message, event); 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); const { error } = await locals.api.login.$post({ json: form.data }).then(locals.parseApiResponse);
if (error) { if (error) {
return setError(form, 'username', error); return setError(form, 'username', error);
} }
if (!form.valid) { if (!form.valid) {
form.data.password = ''; form.data.password = '';
return fail(400, { return fail(400, {
form, form,
}); });
} }
form.data.username = ''; form.data.username = '';
form.data.password = ''; 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 ( const { totpCredential } = data;
// twoFactorDetails?.enabled && console.log('totpCredential', totpCredential);
// twoFactorDetails?.secret !== null && if (!totpCredential) {
// twoFactorDetails?.secret !== '' const message = { type: 'success', message: 'Signed In!' } as const;
// ) { redirect(302, '/', message, event);
// console.log('redirecting to TOTP page'); } else if (totpCredential?.type === 'totp' && totpCredential?.secret_data && totpCredential?.secret_data !== '') {
// const message = { type: 'success', message: 'Please enter your TOTP code.' } as const; console.log('redirecting to TOTP page');
// redirect(302, '/totp', message, event); const message = { type: 'success', message: 'Please enter your TOTP code.' } as const;
// } else { redirect(302, '/totp', message, event);
// const message = { type: 'success', message: 'Signed In!' } as const; } else {
// redirect(302, '/', message, event); return setError(form, 'username', 'Something went wrong. Please try again.');
// } }
},
redirect(StatusCodes.TEMPORARY_REDIRECT, '/');
},
}; };

View file

@ -1,270 +1,191 @@
import { notSignedInMessage } from '$lib/flashMessages'; import { notSignedInMessage } from '$lib/flashMessages';
import env from '$lib/server/api/common/env'; 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 { db } from '$lib/server/api/packages/drizzle';
import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth'; import { recoveryCodeSchema, totpSchema } from '$lib/validations/auth';
import { updateProfileFormSchema } from '$routes/(app)/(protected)/settings/profile/schemas';
import { type Actions, fail } from '@sveltejs/kit'; import { type Actions, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { redirect } from 'sveltekit-flash-message/server'; import { redirect } from 'sveltekit-flash-message/server';
import { zod } from 'sveltekit-superforms/adapters'; import { zod } from 'sveltekit-superforms/adapters';
import { superValidate } from 'sveltekit-superforms/server'; import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { twoFactorTable, usersTable } from '../../../lib/server/api/databases/postgres/tables';
import type { PageServerLoad, RequestEvent } from './$types'; import type { PageServerLoad, RequestEvent } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (!authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); throw redirect(302, '/login', notSignedInMessage, event);
} }
const dbUser = await db.query.usersTable.findFirst({ const { data } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse);
where: eq(usersTable.username, authedUser.username), 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({ return {
where: eq(twoFactorTable.userId, authedUser.id), totpForm: await superValidate(event, zod(totpSchema)),
}); recoveryCodeForm: await superValidate(event, zod(recoveryCodeSchema)),
};
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)),
};
}; };
export const actions: Actions = { export const actions: Actions = {
validateTotp: async (event) => { validateTotp: async (event) => {
const { locals } = event; const { locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (!authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); 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) { if (!totpForm.valid) {
totpForm.data.totpToken = ''; totpForm.data.code = '';
return fail(400, { totpForm }); return fail(400, { totpForm });
} }
// let sessionCookie const { error: totpVerifyError } = await locals.api.mfa.totp.verify.$post({ json: { code: totpForm.data.code } }).then(locals.parseApiResponse);
// const totpToken = totpForm?.data?.totpToken if (totpVerifyError) {
// return setError(totpForm, 'code', totpVerifyError);
// 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 authedUser = await locals.getAuthedUser(); console.log('Successfully logged in');
if (!authedUser) { return message(totpForm, { type: 'success', message: 'Successfully logged in!' });
throw redirect(302, '/login', notSignedInMessage, event); },
} 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)); const { dbUser, twoFactorDetails } = await validateUserData(event, locals);
if (!recoveryCodeForm.valid) {
return fail(400, {
form: recoveryCodeForm,
});
}
// let sessionCookie const recoveryCodeForm = await superValidate(event, zod(recoveryCodeSchema));
// const recoveryCode = recoveryCodeForm?.data?.recoveryCode if (!recoveryCodeForm.valid) {
// return fail(400, {
// const twoFactorSecretPopulated = twoFactorDetails.secret !== '' && twoFactorDetails.secret !== null form: recoveryCodeForm,
// 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 = ''; // let sessionCookie
const message = { type: 'success', message: 'Signed In!' } as const; // const recoveryCode = recoveryCodeForm?.data?.recoveryCode
redirect(302, '/', message, event); //
}, // 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) { async function validateUserData(event: RequestEvent, locals: App.Locals) {
const { user, session } = locals; const { user, session } = locals;
if (!user || !session) { if (!user || !session) {
throw fail(401); throw fail(401);
} }
const dbUser = await db.query.usersTable.findFirst({ const dbUser = await db.query.usersTable.findFirst({
where: eq(usersTable.username, user.username), where: eq(usersTable.username, user.username),
}); });
if (!dbUser) { if (!dbUser) {
throw fail(401); throw fail(401);
} }
const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated; const isTwoFactorAuthenticated = session?.isTwoFactorAuthenticated;
const twoFactorDetails = await db.query.twoFactorTable.findFirst({ const twoFactorDetails = await db.query.twoFactorTable.findFirst({
where: eq(twoFactorTable.userId, dbUser!.id!), where: eq(twoFactorTable.userId, dbUser!.id!),
}); });
if (!twoFactorDetails) { if (!twoFactorDetails) {
const message = { type: 'error', message: 'Unable to process request' } as const; const message = { type: 'error', message: 'Unable to process request' } as const;
throw redirect(302, '/login', message, event); throw redirect(302, '/login', message, event);
} }
if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') { if (isTwoFactorAuthenticated && twoFactorDetails.enabled && twoFactorDetails.secret !== '') {
const message = { type: 'success', message: 'You are already signed in' } as const; const message = { type: 'success', message: 'You are already signed in' } as const;
throw redirect('/', message, event); throw redirect('/', message, event);
} }
return { dbUser, twoFactorDetails }; return { dbUser, twoFactorDetails };
} }
function totpTimeElapsed(initiatedTime: Date) { function totpTimeElapsed(initiatedTime: Date) {
if (initiatedTime === null || initiatedTime === undefined) { if (initiatedTime === null || initiatedTime === undefined) {
return true; return true;
} }
const timeElapsed = Date.now() - initiatedTime.getTime(); const timeElapsed = Date.now() - initiatedTime.getTime();
console.log('Time elapsed', timeElapsed); console.log('Time elapsed', timeElapsed);
if (timeElapsed > env.TWO_FACTOR_TIMEOUT) { if (timeElapsed > env.TWO_FACTOR_TIMEOUT) {
console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', timeElapsed, env.TWO_FACTOR_TIMEOUT); console.log('Time elapsed was more than TWO_FACTOR_TIMEOUT', timeElapsed, env.TWO_FACTOR_TIMEOUT);
return true; return true;
} }
return false; return false;
} }
// async function checkRecoveryCode(recoveryCode: string, userId: string) { // async function checkRecoveryCode(recoveryCode: string, userId: string) {

View file

@ -11,15 +11,15 @@ import { superForm } from 'sveltekit-superforms/client';
const { data } = $props(); const { data } = $props();
const superTotpForm = superForm(data.totpForm, { const superTotpForm = superForm(data.totpForm, {
resetForm: false, resetForm: false,
validators: zodClient(totpSchema), validators: zodClient(totpSchema),
}); });
const superRecoveryCodeForm = superForm(data.recoveryCodeForm, { const superRecoveryCodeForm = superForm(data.recoveryCodeForm, {
validators: zodClient(recoveryCodeSchema), validators: zodClient(recoveryCodeSchema),
resetForm: false, resetForm: false,
validationMethod: 'oninput', validationMethod: 'oninput',
delayMs: 0, delayMs: 0,
}); });
let showRecoveryCode = $state(false); let showRecoveryCode = $state(false);
@ -40,20 +40,20 @@ const { form: recoveryCodeFormData, enhance: recoveryCodeEnhance } = superRecove
<Card.Content> <Card.Content>
{#if !showRecoveryCode} {#if !showRecoveryCode}
{@render totpForm()} {@render totpForm()}
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = true}>Show Recovery Code</Button> <Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = true}>Use Recovery Code</Button>
{:else} {:else}
{@render recoveryCodeForm()} {@render recoveryCodeForm()}
<Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = false}>Show TOTP Code</Button> <Button variant="link" class="text-secondary-foreground" on:click={() => showRecoveryCode = false}>Use TOTP Code</Button>
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{#snippet totpForm()} {#snippet totpForm()}
<form method="POST" action="?/validateTotp" use:totpEnhance> <form method="POST" action="?/validateTotp" use:totpEnhance>
<Form.Field class="form-field-container" form={superTotpForm} name="totpToken"> <Form.Field class="form-field-container" form={superTotpForm} name="code">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label>TOTP Code</Form.Label> <Form.Label>TOTP Code</Form.Label>
<PinInput {...attrs} bind:value={$totpFormData.totpToken} class="justify-evenly" /> <PinInput {...attrs} bind:value={$totpFormData.code} class="justify-evenly" />
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>