commit 25cc3970951ce4f894bc8039f330f7cdd29b3e4f Author: pilcrowOnPaper Date: Thu Oct 3 18:50:34 2024 +0900 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..95768c3 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +ENCRYPTION_KEY="" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ed75f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +sqlite.db \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ab78a95 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..76b985d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": true, + "trailingComma": "none", + "printWidth": 120, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b346d8 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Email and password example with 2FA and WebAuthn in SvelteKit + +Built with SQLite. + +- Password checks with HaveIBeenPwned +- Sign in with passkeys +- Email verification +- 2FA with TOTP +- 2FA recovery codes +- 2FA with passkeys and security keys +- Password reset with 2FA +- Login throttling and rate limiting + +Emails are just logged to the console. Rate limiting is implemented using JavaScript `Map`. + +## Initialize project + +Create `sqlite.db` and run `setup.sql`. + +``` +sqlite3 sqlite.db +``` + +Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`. + +```bash +ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA==" +``` + +> You can use OpenSSL to quickly generate a secure key. +> +> ```bash +> openssl rand --base64 16 +> ``` + +Run the application: + +``` +pnpm dev +``` + +## Notes + +- We do not consider user enumeration to be a real vulnerability so please don't open issues on it. If you really need to prevent it, just don't use emails. +- This example does not handle unexpected errors gracefully. +- There are some major code duplications (specifically for 2FA) to keep the codebase simple. +- TODO: Passkeys will only work when hosted on `localhost:5173`. Update the host and origin values before deploying. +- TODO: You may need to rewrite some queries and use transactions to avoid race conditions when using MySQL, Postgres, etc. +- TODO: This project relies on the `X-Forwarded-For` header for getting the client's IP address. +- TODO: Logging should be implemented. diff --git a/package.json b/package.json new file mode 100644 index 0000000..365914c --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "example-sveltekit-email-password-webauthn", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/better-sqlite3": "^7.6.11", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^4.2.7", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.3" + }, + "type": "module", + "dependencies": { + "@node-rs/argon2": "^1.8.3", + "@oslojs/binary": "^1.0.0", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "@oslojs/otp": "^1.0.0", + "@oslojs/webauthn": "^1.0.0", + "@pilcrowjs/db-query": "^0.0.2", + "@pilcrowjs/object-parser": "^0.0.4", + "better-sqlite3": "^11.3.0", + "uqr": "^0.1.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d926de6 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1543 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@node-rs/argon2': + specifier: ^1.8.3 + version: 1.8.3 + '@oslojs/binary': + specifier: ^1.0.0 + version: 1.0.0 + '@oslojs/crypto': + specifier: ^1.0.1 + version: 1.0.1 + '@oslojs/encoding': + specifier: ^1.1.0 + version: 1.1.0 + '@oslojs/otp': + specifier: ^1.0.0 + version: 1.0.0 + '@oslojs/webauthn': + specifier: ^1.0.0 + version: 1.0.0 + '@pilcrowjs/db-query': + specifier: ^0.0.2 + version: 0.0.2 + '@pilcrowjs/object-parser': + specifier: ^0.0.4 + version: 0.0.4 + better-sqlite3: + specifier: ^11.3.0 + version: 11.3.0 + uqr: + specifier: ^0.1.2 + version: 0.1.2 + devDependencies: + '@sveltejs/adapter-auto': + specifier: ^3.0.0 + version: 3.2.5(@sveltejs/kit@2.6.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4))) + '@sveltejs/kit': + specifier: ^2.0.0 + version: 2.6.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + '@sveltejs/vite-plugin-svelte': + specifier: ^3.0.0 + version: 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + '@types/better-sqlite3': + specifier: ^7.6.11 + version: 7.6.11 + prettier: + specifier: ^3.1.1 + version: 3.3.3 + prettier-plugin-svelte: + specifier: ^3.1.2 + version: 3.2.7(prettier@3.3.3)(svelte@4.2.19) + svelte: + specifier: ^4.2.7 + version: 4.2.19 + svelte-check: + specifier: ^4.0.0 + version: 4.0.4(svelte@4.2.19)(typescript@5.6.2) + typescript: + specifier: ^5.0.0 + version: 5.6.2 + vite: + specifier: ^5.0.3 + version: 5.4.8(@types/node@22.7.4) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@emnapi/core@1.2.0': + resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==} + + '@emnapi/runtime@1.2.0': + resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} + + '@emnapi/wasi-threads@1.0.1': + resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@napi-rs/wasm-runtime@0.2.5': + resolution: {integrity: sha512-kwUxR7J9WLutBbulqg1dfOrMTwhMdXLdcGUhcbCcGwnPLt3gz19uHVdwH1syKVDbE022ZS2vZxOWflFLS0YTjw==} + + '@node-rs/argon2-android-arm-eabi@1.8.3': + resolution: {integrity: sha512-JFZPlNM0A8Og+Tncb8UZsQrhEMlbHBXPsT3hRoKImzVmTmq28Os0ucFWow0AACp2coLHBSydXH3Dh0lZup3rWw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@1.8.3': + resolution: {integrity: sha512-zaf8P3T92caeW2xnMA7P1QvRA4pIt/04oilYP44XlTCtMye//vwXDMeK53sl7dvYiJKnzAWDRx41k8vZvpZazg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@1.8.3': + resolution: {integrity: sha512-DV/IbmLGdNXBtXb5o2UI5ba6kvqXqPAJgmMOTUCuHeBSp992GlLHdfU4rzGu0dNrxudBnunNZv+crd0YdEQSUA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@1.8.3': + resolution: {integrity: sha512-YMjmBGFZhLfYjfQ2gll9A+BZu/zAMV7lWZIbKxb7ZgEofILQwuGmExjDtY3Jplido/6leCEdpmlk2oIsME00LA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@1.8.3': + resolution: {integrity: sha512-Hq3Rj5Yb2RolTG/luRPnv+XiGCbi5nAK25Pc8ou/tVapwX+iktEm/NXbxc5zsMxraYVkCvfdwBjweC5O+KqCGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@1.8.3': + resolution: {integrity: sha512-x49l8RgzKoG0/V0IXa5rrEl1TcJEc936ctlYFvqcunSOyowZ6kiWtrp1qrbOR8gbaNILl11KTF52vF6+h8UlEQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@1.8.3': + resolution: {integrity: sha512-gJesam/qA63reGkb9qJ2TjFSLBtY41zQh2oei7nfnYsmVQPuHHWItJxEa1Bm21SPW53gZex4jFJbDIgj0+PxIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-arm64-musl@1.8.3': + resolution: {integrity: sha512-7O6kQdSKzB4Tjx/EBa8zKIxnmLkQE8VdJgPm6Ksrpn+ueo0mx2xf76fIDnbbTCtm3UbB+y+FkTo2wLA7tOqIKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-x64-gnu@1.8.3': + resolution: {integrity: sha512-OBH+EFG7BGjFyldaao2H2gSCLmjtrrwf420B1L+lFn7JLW9UAjsIPFKAcWsYwPa/PwYzIge9Y7SGcpqlsSEX0w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-linux-x64-musl@1.8.3': + resolution: {integrity: sha512-bDbMuyekIxZaN7NaX+gHVkOyABB8bcMEJYeRPW1vCXKHj3brJns1wiUFSxqeUXreupifNVJlQfPt1Y5B/vFXgQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-wasm32-wasi@1.8.3': + resolution: {integrity: sha512-NBf2cMCDbNKMzp13Pog8ZPmI0M9U4Ak5b95EUjkp17kdKZFds12dwW67EMnj7Zy+pRqby2QLECaWebDYfNENTg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@1.8.3': + resolution: {integrity: sha512-AHpPo7UbdW5WWjwreVpgFSY0o1RY4A7cUFaqDXZB2OqEuyrhMxBdZct9PX7PQKI18D85pLsODnR+gvVuTwJ6rQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@1.8.3': + resolution: {integrity: sha512-bqzn2rcQkEwCINefhm69ttBVVkgHJb/V03DdBKsPFtiX6H47axXKz62d1imi26zFXhOEYxhKbu3js03GobJOLw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@1.8.3': + resolution: {integrity: sha512-ILlrRThdbp5xNR5gwYM2ic1n/vG5rJ8dQZ+YMRqksl+lnTJ/6FDe5BOyIhiPtiDwlCiCtUA+1NxpDB9KlUCAIA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@1.8.3': + resolution: {integrity: sha512-sf/QAEI59hsMEEE2J8vO4hKrXrv4Oplte3KI2N4MhMDYpytH0drkVfErmHBfWFZxxIEK03fX1WsBNswS2nIZKg==} + engines: {node: '>= 10'} + + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/cbor@1.0.0': + resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} + + '@oslojs/crypto@1.0.0': + resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@1.0.0': + resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/otp@1.0.0': + resolution: {integrity: sha512-w/vZfoVsFCCcmsmsXVsIMoWbvr1IZmQ9BsDZwdePSpe8rFKMD1Knd+05iJr415adXkFVyu0tYxgrLPYMynNtXQ==} + + '@oslojs/webauthn@1.0.0': + resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} + + '@pilcrowjs/db-query@0.0.2': + resolution: {integrity: sha512-d1iARoIxeUL2cTGhJe4JPhp/n1sXtgnM1mL7elrfsKjdwwjWTDyPDtVcGQy6W7RvrtZ40Wh0pdeYdBnboQjewg==} + + '@pilcrowjs/object-parser@0.0.4': + resolution: {integrity: sha512-mBy3FMv2lvl/sZX/q03wvl3Km8FWg7kbrqQ/qMxK49uZcBssD76Js5k+o7VuCDJI8SNvsrbIX8y6vclx7bWeSg==} + + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + + '@rollup/rollup-android-arm-eabi@4.24.0': + resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.24.0': + resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.24.0': + resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.24.0': + resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': + resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.24.0': + resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.24.0': + resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.24.0': + resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': + resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.24.0': + resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.24.0': + resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.24.0': + resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.24.0': + resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.24.0': + resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.24.0': + resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.24.0': + resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} + cpu: [x64] + os: [win32] + + '@sveltejs/adapter-auto@3.2.5': + resolution: {integrity: sha512-27LR+uKccZ62lgq4N/hvyU2G+hTP9fxWEAfnZcl70HnyfAjMSsGk1z/SjAPXNCD1mVJIE7IFu3TQ8cQ/UH3c0A==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.6.1': + resolution: {integrity: sha512-QFlch3GPGZYidYhdRAub0fONw8UTguPICFHUSPxNkA/jdlU1p6C6yqq19J1QWdxIHS2El/ycDCGrHb3EAiMNqg==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 + + '@sveltejs/vite-plugin-svelte-inspector@2.1.0': + resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@sveltejs/vite-plugin-svelte@3.1.2': + resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@tybys/wasm-util@0.9.0': + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + + '@types/better-sqlite3@7.6.11': + resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/node@22.7.4': + resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.3.0: + resolution: {integrity: sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + devalue@5.1.1: + resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fdir@6.4.0: + resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.11: + resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + node-abi@3.68.0: + resolution: {integrity: sha512-7vbj10trelExNjFSBm5kTvZXXa7pZyKWx9RCKIyqe6I9Ev3IzGpQoqBP3a+cOdxY+pWj6VkP28n/2wWysBHD/A==} + engines: {node: '>=10'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} + engines: {node: '>=10'} + hasBin: true + + prettier-plugin-svelte@3.2.7: + resolution: {integrity: sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.0.1: + resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} + engines: {node: '>= 14.16.0'} + + rollup@4.24.0: + resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.0: + resolution: {integrity: sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + svelte-check@4.0.4: + resolution: {integrity: sha512-AcHWIPuZb1mh/jKoIrww0ebBPpAvwWd1bfXCnwC2dx4OkydNMaiG//+Xnry91RJMHFH7CiE+6Y2p332DRIaOXQ==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-hmr@0.16.0: + resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + + svelte@4.2.19: + resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} + engines: {node: '>=16'} + + tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.8: + resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@emnapi/core@1.2.0': + dependencies: + '@emnapi/wasi-threads': 1.0.1 + tslib: 2.7.0 + optional: true + + '@emnapi/runtime@1.2.0': + dependencies: + tslib: 2.7.0 + optional: true + + '@emnapi/wasi-threads@1.0.1': + dependencies: + tslib: 2.7.0 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@napi-rs/wasm-runtime@0.2.5': + dependencies: + '@emnapi/core': 1.2.0 + '@emnapi/runtime': 1.2.0 + '@tybys/wasm-util': 0.9.0 + optional: true + + '@node-rs/argon2-android-arm-eabi@1.8.3': + optional: true + + '@node-rs/argon2-android-arm64@1.8.3': + optional: true + + '@node-rs/argon2-darwin-arm64@1.8.3': + optional: true + + '@node-rs/argon2-darwin-x64@1.8.3': + optional: true + + '@node-rs/argon2-freebsd-x64@1.8.3': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@1.8.3': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@1.8.3': + optional: true + + '@node-rs/argon2-linux-arm64-musl@1.8.3': + optional: true + + '@node-rs/argon2-linux-x64-gnu@1.8.3': + optional: true + + '@node-rs/argon2-linux-x64-musl@1.8.3': + optional: true + + '@node-rs/argon2-wasm32-wasi@1.8.3': + dependencies: + '@napi-rs/wasm-runtime': 0.2.5 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@1.8.3': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@1.8.3': + optional: true + + '@node-rs/argon2-win32-x64-msvc@1.8.3': + optional: true + + '@node-rs/argon2@1.8.3': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 1.8.3 + '@node-rs/argon2-android-arm64': 1.8.3 + '@node-rs/argon2-darwin-arm64': 1.8.3 + '@node-rs/argon2-darwin-x64': 1.8.3 + '@node-rs/argon2-freebsd-x64': 1.8.3 + '@node-rs/argon2-linux-arm-gnueabihf': 1.8.3 + '@node-rs/argon2-linux-arm64-gnu': 1.8.3 + '@node-rs/argon2-linux-arm64-musl': 1.8.3 + '@node-rs/argon2-linux-x64-gnu': 1.8.3 + '@node-rs/argon2-linux-x64-musl': 1.8.3 + '@node-rs/argon2-wasm32-wasi': 1.8.3 + '@node-rs/argon2-win32-arm64-msvc': 1.8.3 + '@node-rs/argon2-win32-ia32-msvc': 1.8.3 + '@node-rs/argon2-win32-x64-msvc': 1.8.3 + + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/cbor@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/crypto@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@1.0.0': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/otp@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + '@oslojs/crypto': 1.0.0 + '@oslojs/encoding': 1.0.0 + + '@oslojs/webauthn@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + '@oslojs/cbor': 1.0.0 + '@oslojs/crypto': 1.0.0 + '@oslojs/encoding': 1.0.0 + + '@pilcrowjs/db-query@0.0.2': {} + + '@pilcrowjs/object-parser@0.0.4': {} + + '@polka/url@1.0.0-next.28': {} + + '@rollup/rollup-android-arm-eabi@4.24.0': + optional: true + + '@rollup/rollup-android-arm64@4.24.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.24.0': + optional: true + + '@rollup/rollup-darwin-x64@4.24.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.24.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.24.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.24.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.24.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.24.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.24.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.24.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.24.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.24.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.24.0': + optional: true + + '@sveltejs/adapter-auto@3.2.5(@sveltejs/kit@2.6.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))': + dependencies: + '@sveltejs/kit': 2.6.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + import-meta-resolve: 4.1.0 + + '@sveltejs/kit@2.6.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 5.1.1 + esm-env: 1.0.0 + import-meta-resolve: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.11 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.7.0 + sirv: 2.0.4 + svelte: 4.2.19 + tiny-glob: 0.2.9 + vite: 5.4.8(@types/node@22.7.4) + + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + debug: 4.3.7 + svelte: 4.2.19 + vite: 5.4.8(@types/node@22.7.4) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)))(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) + debug: 4.3.7 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.11 + svelte: 4.2.19 + svelte-hmr: 0.16.0(svelte@4.2.19) + vite: 5.4.8(@types/node@22.7.4) + vitefu: 0.2.5(vite@5.4.8(@types/node@22.7.4)) + transitivePeerDependencies: + - supports-color + + '@tybys/wasm-util@0.9.0': + dependencies: + tslib: 2.7.0 + optional: true + + '@types/better-sqlite3@7.6.11': + dependencies: + '@types/node': 22.7.4 + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.6': {} + + '@types/node@22.7.4': + dependencies: + undici-types: 6.19.8 + + acorn@8.12.1: {} + + aria-query@5.3.2: {} + + axobject-query@4.1.0: {} + + base64-js@1.5.1: {} + + better-sqlite3@11.3.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.2 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chokidar@4.0.1: + dependencies: + readdirp: 4.0.1 + + chownr@1.1.4: {} + + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + acorn: 8.12.1 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + cookie@0.6.0: {} + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + detect-libc@2.0.3: {} + + devalue@5.1.1: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esm-env@1.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + expand-template@2.0.3: {} + + fdir@6.4.0: {} + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + globalyzer@0.1.0: {} + + globrex@0.1.2: {} + + ieee754@1.2.1: {} + + import-meta-resolve@4.1.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-reference@3.0.2: + dependencies: + '@types/estree': 1.0.6 + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + magic-string@0.30.11: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + mdn-data@2.0.30: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mri@1.2.0: {} + + mrmime@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.7: {} + + napi-build-utils@1.0.2: {} + + node-abi@3.68.0: + dependencies: + semver: 7.6.3 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.6 + estree-walker: 3.0.3 + is-reference: 3.0.2 + + picocolors@1.1.0: {} + + postcss@8.4.47: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + + prebuild-install@7.1.2: + dependencies: + detect-libc: 2.0.3 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.68.0 + pump: 3.0.2 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + + prettier-plugin-svelte@3.2.7(prettier@3.3.3)(svelte@4.2.19): + dependencies: + prettier: 3.3.3 + svelte: 4.2.19 + + prettier@3.3.3: {} + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.0.1: {} + + rollup@4.24.0: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.24.0 + '@rollup/rollup-android-arm64': 4.24.0 + '@rollup/rollup-darwin-arm64': 4.24.0 + '@rollup/rollup-darwin-x64': 4.24.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 + '@rollup/rollup-linux-arm-musleabihf': 4.24.0 + '@rollup/rollup-linux-arm64-gnu': 4.24.0 + '@rollup/rollup-linux-arm64-musl': 4.24.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 + '@rollup/rollup-linux-riscv64-gnu': 4.24.0 + '@rollup/rollup-linux-s390x-gnu': 4.24.0 + '@rollup/rollup-linux-x64-gnu': 4.24.0 + '@rollup/rollup-linux-x64-musl': 4.24.0 + '@rollup/rollup-win32-arm64-msvc': 4.24.0 + '@rollup/rollup-win32-ia32-msvc': 4.24.0 + '@rollup/rollup-win32-x64-msvc': 4.24.0 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.2.1: {} + + semver@7.6.3: {} + + set-cookie-parser@2.7.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + svelte-check@4.0.4(svelte@4.2.19)(typescript@5.6.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + chokidar: 4.0.1 + fdir: 6.4.0 + picocolors: 1.1.0 + sade: 1.8.1 + svelte: 4.2.19 + typescript: 5.6.2 + transitivePeerDependencies: + - picomatch + + svelte-hmr@0.16.0(svelte@4.2.19): + dependencies: + svelte: 4.2.19 + + svelte@4.2.19: + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + '@types/estree': 1.0.6 + acorn: 8.12.1 + aria-query: 5.3.2 + axobject-query: 4.1.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.11 + periscopic: 3.1.0 + + tar-fs@2.1.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + + totalist@3.0.1: {} + + tslib@2.7.0: + optional: true + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.6.2: {} + + undici-types@6.19.8: {} + + uqr@0.1.2: {} + + util-deprecate@1.0.2: {} + + vite@5.4.8(@types/node@22.7.4): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.24.0 + optionalDependencies: + '@types/node': 22.7.4 + fsevents: 2.3.3 + + vitefu@0.2.5(vite@5.4.8(@types/node@22.7.4)): + optionalDependencies: + vite: 5.4.8(@types/node@22.7.4) + + wrappy@1.0.2: {} diff --git a/setup.sql b/setup.sql new file mode 100644 index 0000000..ac81d95 --- /dev/null +++ b/setup.sql @@ -0,0 +1,56 @@ +CREATE TABLE user ( + id INTEGER NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + email_verified INTEGER NOT NULL DEFAULT 0, + recovery_code BLOB NOT NULL +); + +CREATE TABLE session ( + id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + expires_at INTEGER NOT NULL, + two_factor_verified INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE email_verification_request ( + id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + email TEXT NOT NULL, + code TEXT NOT NULL, + expires_at INTEGER NOT NULL, + email_verified INTEGER NOT NULL NOT NULL DEFAULT 0 +); + +CREATE TABLE password_reset_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + email TEXT NOT NULL, + code TEXT NOT NULL, + expires_at INTEGER NOT NULL, + email_verified INTEGER NOT NULL NOT NULL DEFAULT 0, + two_factor_verified INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE totp_credential ( + id INTEGER NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES user(id), + key BLOB NOT NULL +); + +CREATE TABLE passkey_credential ( + id BLOB NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + name TEXT NOT NULL, + algorithm INTEGER NOT NULL, + public_key BLOB NOT NULL +); + +CREATE TABLE security_key_credential ( + id BLOB NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + name TEXT NOT NULL, + algorithm INTEGER NOT NULL, + public_key BLOB NOT NULL +); diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..883e1b5 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,16 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + user: import("$lib/server/user").User | null; + session: import("$lib/server/session").Session | null; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..9c0f794 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,49 @@ +import { RefillingTokenBucket } from "$lib/server/rate-limit"; +import { validateSessionToken, setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/server/session"; + +import type { Handle } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; + +const bucket = new RefillingTokenBucket(100, 1); + +const rateLimitHandle: Handle = async ({ event, resolve }) => { + // Note: Assumes X-Forwarded-For will always be defined. + const clientIP = event.request.headers.get("X-Forwarded-For"); + if (clientIP === null) { + return resolve(event); + } + let cost: number; + if (event.request.method === "GET" || event.request.method === "OPTIONS") { + cost = 1; + } else { + cost = 3; + } + if (!bucket.consume(clientIP, cost)) { + return new Response("Too many requests", { + status: 429 + }); + } + return resolve(event); +}; + +const authHandle: Handle = async ({ event, resolve }) => { + const token = event.cookies.get("session") ?? null; + if (token === null) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = validateSessionToken(token); + if (session !== null) { + setSessionTokenCookie(event, token, session.expiresAt); + } else { + deleteSessionTokenCookie(event); + } + + event.locals.session = session; + event.locals.user = user; + return resolve(event); +}; + +export const handle = sequence(rateLimitHandle, authHandle); diff --git a/src/lib/client/webauthn.ts b/src/lib/client/webauthn.ts new file mode 100644 index 0000000..28f8141 --- /dev/null +++ b/src/lib/client/webauthn.ts @@ -0,0 +1,14 @@ +import { decodeBase64 } from "@oslojs/encoding"; +import { ObjectParser } from "@pilcrowjs/object-parser"; + +export async function createChallenge(): Promise { + const response = await fetch("/api/webauthn/challenge", { + method: "POST" + }); + if (!response.ok) { + throw new Error("Failed to create challenge"); + } + const result = await response.json(); + const parser = new ObjectParser(result); + return decodeBase64(parser.getString("challenge")); +} diff --git a/src/lib/server/2fa.ts b/src/lib/server/2fa.ts new file mode 100644 index 0000000..728d8e5 --- /dev/null +++ b/src/lib/server/2fa.ts @@ -0,0 +1,75 @@ +import { db } from "./db"; +import { generateRandomRecoveryCode } from "./utils"; +import { ExpiringTokenBucket } from "./rate-limit"; +import { decryptToString, encryptString } from "./encryption"; + +import type { User } from "./user"; + +export const recoveryCodeBucket = new ExpiringTokenBucket(3, 60 * 60); + +export function resetUser2FAWithRecoveryCode(userId: number, recoveryCode: string): boolean { + // Note: In Postgres and MySQL, these queries should be done in a transaction using SELECT FOR UPDATE + const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); + if (row === null) { + return false; + } + const encryptedRecoveryCode = row.bytes(0); + const userRecoveryCode = decryptToString(encryptedRecoveryCode); + if (recoveryCode !== userRecoveryCode) { + return false; + } + + const newRecoveryCode = generateRandomRecoveryCode(); + const encryptedNewRecoveryCode = encryptString(newRecoveryCode); + + try { + db.execute("BEGIN TRANSACTION", []); + // Compare old recovery code to ensure recovery code wasn't updated. + const result = db.execute("UPDATE user SET recovery_code = ? WHERE id = ? AND recovery_code = ?", [ + encryptedNewRecoveryCode, + userId, + encryptedRecoveryCode + ]); + if (result.changes < 1) { + db.execute("ROLLBACK", []); + return false; + } + db.execute("UPDATE session SET two_factor_verified = 0 WHERE user_id = ?", [userId]); + db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); + db.execute("DELETE FROM passkey_credential WHERE user_id = ?", [userId]); + db.execute("DELETE FROM security_key_credential WHERE user_id = ?", [userId]); + db.execute("COMMIT", []); + } catch (e) { + if (db.inTransaction()) { + db.execute("ROLLBACK", []); + } + throw e; + } + return true; +} + +export function get2FARedirect(user: User): string { + if (user.registeredPasskey) { + return "/2fa/passkey"; + } + if (user.registeredSecurityKey) { + return "/2fa/security-key"; + } + if (user.registeredTOTP) { + return "/2fa/totp"; + } + return "/2fa/setup"; +} + +export function getPasswordReset2FARedirect(user: User): string { + if (user.registeredPasskey) { + return "/reset-password/2fa/passkey"; + } + if (user.registeredSecurityKey) { + return "/reset-password/2fa/security-key"; + } + if (user.registeredTOTP) { + return "/reset-password/2fa/totp"; + } + return "/2fa/setup"; +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..cae21ab --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,35 @@ +import sqlite3 from "better-sqlite3"; +import { SyncDatabase } from "@pilcrowjs/db-query"; + +import type { SyncAdapter } from "@pilcrowjs/db-query"; + +const sqlite = sqlite3("sqlite.db"); + +const adapter: SyncAdapter = { + query: (statement: string, params: unknown[]): unknown[][] => { + const result = sqlite + .prepare(statement) + .raw() + .all(...params) as unknown[][]; + for (let i = 0; i < result.length; i++) { + for (let j = 0; j < result[i].length; j++) { + if (result[i][j] instanceof Buffer) { + result[i][j] = new Uint8Array(result[i][j] as Buffer); + } + } + } + return result as unknown[][]; + }, + execute: (statement: string, params: unknown[]): sqlite3.RunResult => { + const result = sqlite.prepare(statement).run(...params); + return result; + } +}; + +class Database extends SyncDatabase { + public inTransaction(): boolean { + return sqlite.inTransaction; + } +} + +export const db = new Database(adapter); diff --git a/src/lib/server/email-verification.ts b/src/lib/server/email-verification.ts new file mode 100644 index 0000000..3d6e4ea --- /dev/null +++ b/src/lib/server/email-verification.ts @@ -0,0 +1,100 @@ +import { generateRandomOTP } from "./utils"; +import { db } from "./db"; +import { ExpiringTokenBucket } from "./rate-limit"; +import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; + +import type { RequestEvent } from "@sveltejs/kit"; + +export function getEmailVerificationRequest(id: string): EmailVerificationRequest | null { + const row = db.queryOne("SELECT id, user_id, code, email, expires_at FROM email_verification_request WHERE id = ?", [ + id + ]); + if (row === null) { + return row; + } + const request: EmailVerificationRequest = { + id: row.string(0), + userId: row.number(1), + code: row.string(2), + email: row.string(3), + expiresAt: new Date(row.number(4) * 1000) + }; + return request; +} + +export function createEmailVerificationRequest(userId: number, email: string): EmailVerificationRequest { + deleteUserEmailVerificationRequest(userId); + const idBytes = new Uint8Array(20); + crypto.getRandomValues(idBytes); + const id = encodeBase32LowerCaseNoPadding(idBytes); + + const code = generateRandomOTP(); + const expiresAt = new Date(Date.now() + 1000 * 60 * 10); + db.queryOne( + "INSERT INTO email_verification_request (id, user_id, code, email, expires_at) VALUES (?, ?, ?, ?, ?) RETURNING id", + [id, userId, code, email, Math.floor(expiresAt.getTime() / 1000)] + ); + + const request: EmailVerificationRequest = { + id, + userId, + code, + email, + expiresAt + }; + return request; +} + +export function deleteUserEmailVerificationRequest(userId: number): void { + db.execute("DELETE FROM email_verification_request WHERE user_id = ?", [userId]); +} + +export function sendVerificationEmail(email: string, code: string): void { + console.log(`To ${email}: Your verification code is ${code}`); +} + +export function setEmailVerificationRequestCookie(event: RequestEvent, request: EmailVerificationRequest): void { + event.cookies.set("email_verification", request.id, { + httpOnly: true, + path: "/", + secure: import.meta.env.PROD, + sameSite: "lax", + expires: request.expiresAt + }); +} + +export function deleteEmailVerificationRequestCookie(event: RequestEvent): void { + event.cookies.set("email_verification", "", { + httpOnly: true, + path: "/", + secure: import.meta.env.PROD, + sameSite: "lax", + maxAge: 0 + }); +} + +export function getUserEmailVerificationRequestFromRequest(event: RequestEvent): EmailVerificationRequest | null { + if (event.locals.user === null) { + return null; + } + const id = event.cookies.get("email_verification") ?? null; + if (id === null) { + return null; + } + const request = getEmailVerificationRequest(id); + if (request !== null && request.userId !== event.locals.user.id) { + deleteEmailVerificationRequestCookie(event); + return null; + } + return request; +} + +export const sendVerificationEmailBucket = new ExpiringTokenBucket(3, 60 * 10); + +export interface EmailVerificationRequest { + id: string; + userId: number; + code: string; + email: string; + expiresAt: Date; +} diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..1b4dcf5 --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,13 @@ +import { db } from "./db"; + +export function verifyEmailInput(email: string): boolean { + return /^.+@.+\..+$/.test(email) && email.length < 256; +} + +export function checkEmailAvailability(email: string): boolean { + const row = db.queryOne("SELECT COUNT(*) FROM user WHERE email = ?", [email]); + if (row === null) { + throw new Error(); + } + return row.number(0) === 0; +} diff --git a/src/lib/server/encryption.ts b/src/lib/server/encryption.ts new file mode 100644 index 0000000..193f568 --- /dev/null +++ b/src/lib/server/encryption.ts @@ -0,0 +1,39 @@ +import { decodeBase64 } from "@oslojs/encoding"; +import { createCipheriv, createDecipheriv } from "crypto"; +import { DynamicBuffer } from "@oslojs/binary"; + +import { ENCRYPTION_KEY } from "$env/static/private"; + +const key = decodeBase64(ENCRYPTION_KEY); + +export function encrypt(data: Uint8Array): Uint8Array { + const iv = new Uint8Array(16); + crypto.getRandomValues(iv); + const cipher = createCipheriv("aes-128-gcm", key, 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(); +} + +export function encryptString(data: string): Uint8Array { + return encrypt(new TextEncoder().encode(data)); +} + +export function decrypt(encrypted: Uint8Array): Uint8Array { + if (encrypted.byteLength < 33) { + throw new Error("Invalid data"); + } + const decipher = createDecipheriv("aes-128-gcm", key, 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(); +} + +export function decryptToString(data: Uint8Array): string { + return new TextDecoder().decode(decrypt(data)); +} diff --git a/src/lib/server/password-reset.ts b/src/lib/server/password-reset.ts new file mode 100644 index 0000000..3aa7a70 --- /dev/null +++ b/src/lib/server/password-reset.ts @@ -0,0 +1,134 @@ +import { db } from "./db"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { generateRandomOTP } from "./utils"; +import { sha256 } from "@oslojs/crypto/sha2"; + +import type { RequestEvent } from "@sveltejs/kit"; +import type { User } from "./user"; + +export function createPasswordResetSession(token: string, userId: number, email: string): PasswordResetSession { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: PasswordResetSession = { + id: sessionId, + userId, + email, + expiresAt: new Date(Date.now() + 1000 * 60 * 10), + code: generateRandomOTP(), + emailVerified: false, + twoFactorVerified: false + }; + db.execute("INSERT INTO password_reset_session (id, user_id, email, code, expires_at) VALUES (?, ?, ?, ?, ?)", [ + session.id, + session.userId, + session.email, + session.code, + Math.floor(session.expiresAt.getTime() / 1000) + ]); + return session; +} + +export function validatePasswordResetSessionToken(token: string): PasswordResetSessionValidationResult { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const row = db.queryOne( + `SELECT password_reset_session.id, password_reset_session.user_id, password_reset_session.email, password_reset_session.code, password_reset_session.expires_at, password_reset_session.email_verified, password_reset_session.two_factor_verified, +user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM password_reset_session +INNER JOIN user ON password_reset_session.user_id = user.id +LEFT JOIN totp_credential ON user.id = totp_credential.user_id +LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id +LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id +WHERE password_reset_session.id = ?`, + [sessionId] + ); + if (row === null) { + return { session: null, user: null }; + } + const session: PasswordResetSession = { + id: row.string(0), + userId: row.number(1), + email: row.string(2), + code: row.string(3), + expiresAt: new Date(row.number(4) * 1000), + emailVerified: Boolean(row.number(5)), + twoFactorVerified: Boolean(row.number(6)) + }; + const user: User = { + id: row.number(7), + email: row.string(8), + username: row.string(9), + emailVerified: Boolean(row.number(10)), + registeredTOTP: Boolean(row.number(11)), + registeredPasskey: Boolean(row.number(12)), + registeredSecurityKey: Boolean(row.number(13)), + registered2FA: false + }; + if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { + user.registered2FA = true; + } + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM password_reset_session WHERE id = ?", [session.id]); + return { session: null, user: null }; + } + return { session, user }; +} + +export function setPasswordResetSessionAsEmailVerified(sessionId: string): void { + db.execute("UPDATE password_reset_session SET email_verified = 1 WHERE id = ?", [sessionId]); +} + +export function setPasswordResetSessionAs2FAVerified(sessionId: string): void { + db.execute("UPDATE password_reset_session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); +} + +export function invalidateUserPasswordResetSessions(userId: number): void { + db.execute("DELETE FROM password_reset_session WHERE user_id = ?", [userId]); +} + +export function validatePasswordResetSessionRequest(event: RequestEvent): PasswordResetSessionValidationResult { + const token = event.cookies.get("password_reset_session") ?? null; + if (token === null) { + return { session: null, user: null }; + } + const result = validatePasswordResetSessionToken(token); + if (result.session === null) { + deletePasswordResetSessionTokenCookie(event); + } + return result; +} + +export function setPasswordResetSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { + event.cookies.set("password_reset_session", token, { + expires: expiresAt, + sameSite: "lax", + httpOnly: true, + path: "/", + secure: !import.meta.env.DEV + }); +} + +export function deletePasswordResetSessionTokenCookie(event: RequestEvent): void { + event.cookies.set("password_reset_session", "", { + maxAge: 0, + sameSite: "lax", + httpOnly: true, + path: "/", + secure: !import.meta.env.DEV + }); +} + +export function sendPasswordResetEmail(email: string, code: string): void { + console.log(`To ${email}: Your reset code is ${code}`); +} + +export interface PasswordResetSession { + id: string; + userId: number; + email: string; + expiresAt: Date; + code: string; + emailVerified: boolean; + twoFactorVerified: boolean; +} + +export type PasswordResetSessionValidationResult = + | { session: PasswordResetSession; user: User } + | { session: null; user: null }; diff --git a/src/lib/server/password.ts b/src/lib/server/password.ts new file mode 100644 index 0000000..ce10c26 --- /dev/null +++ b/src/lib/server/password.ts @@ -0,0 +1,34 @@ +import { hash, verify } from "@node-rs/argon2"; +import { sha1 } from "@oslojs/crypto/sha1"; +import { encodeHexLowerCase } from "@oslojs/encoding"; + +export async function hashPassword(password: string): Promise { + return await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); +} + +export async function verifyPasswordHash(hash: string, password: string): Promise { + return await verify(hash, password); +} + +export async function verifyPasswordStrength(password: string): Promise { + if (password.length < 8 || password.length > 255) { + return false; + } + const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password))); + const hashPrefix = hash.slice(0, 5); + const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`); + const data = await response.text(); + const items = data.split("\n"); + for (const item of items) { + const hashSuffix = item.slice(0, 35).toLowerCase(); + if (hash === hashPrefix + hashSuffix) { + return false; + } + } + return true; +} diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..f7bae8b --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,150 @@ +export class RefillingTokenBucket<_Key> { + public max: number; + public refillIntervalSeconds: number; + + constructor(max: number, refillIntervalSeconds: number) { + this.max = max; + this.refillIntervalSeconds = refillIntervalSeconds; + } + + private storage = new Map<_Key, RefillBucket>(); + + public check(key: _Key, cost: number): boolean { + const bucket = this.storage.get(key) ?? null; + if (bucket === null) { + return true; + } + const now = Date.now(); + const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); + if (refill > 0) { + return Math.min(bucket.count + refill, this.max) >= cost; + } + return bucket.count >= cost; + } + + public consume(key: _Key, cost: number): boolean { + let bucket = this.storage.get(key) ?? null; + const now = Date.now(); + if (bucket === null) { + bucket = { + count: this.max - cost, + refilledAt: now + }; + this.storage.set(key, bucket); + return true; + } + const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); + if (refill > 0) { + bucket.count = Math.min(bucket.count + refill, this.max); + bucket.refilledAt = now; + } + if (bucket.count < cost) { + this.storage.set(key, bucket); + return false; + } + bucket.count -= cost; + this.storage.set(key, bucket); + return true; + } +} + +export class Throttler<_Key> { + public timeoutSeconds: number[]; + + private storage = new Map<_Key, ThrottlingCounter>(); + + constructor(timeoutSeconds: number[]) { + this.timeoutSeconds = timeoutSeconds; + } + + public consume(key: _Key): boolean { + let counter = this.storage.get(key) ?? null; + const now = Date.now(); + if (counter === null) { + counter = { + timeout: 0, + updatedAt: now + }; + this.storage.set(key, counter); + return true; + } + const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout] * 1000; + if (!allowed) { + return false; + } + counter.updatedAt = now; + counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1); + this.storage.set(key, counter); + return true; + } + + public reset(key: _Key): void { + this.storage.delete(key); + } +} + +export class ExpiringTokenBucket<_Key> { + public max: number; + public expiresInSeconds: number; + + private storage = new Map<_Key, ExpiringBucket>(); + + constructor(max: number, expiresInSeconds: number) { + this.max = max; + this.expiresInSeconds = expiresInSeconds; + } + + public check(key: _Key, cost: number): boolean { + const bucket = this.storage.get(key) ?? null; + const now = Date.now(); + if (bucket === null) { + return true; + } + if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { + return true; + } + return bucket.count >= cost; + } + + public consume(key: _Key, cost: number): boolean { + let bucket = this.storage.get(key) ?? null; + const now = Date.now(); + if (bucket === null) { + bucket = { + count: this.max - cost, + createdAt: now + }; + this.storage.set(key, bucket); + return true; + } + if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { + bucket.count = this.max; + } + if (bucket.count < cost) { + this.storage.set(key, bucket); + return false; + } + bucket.count -= cost; + this.storage.set(key, bucket); + return true; + } + + public reset(key: _Key): void { + this.storage.delete(key); + } +} + +interface RefillBucket { + count: number; + refilledAt: number; +} + +interface ExpiringBucket { + count: number; + createdAt: number; +} + +interface ThrottlingCounter { + timeout: number; + updatedAt: number; +} diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts new file mode 100644 index 0000000..b8bc77a --- /dev/null +++ b/src/lib/server/session.ts @@ -0,0 +1,124 @@ +import { db } from "./db"; +import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; + +import type { User } from "./user"; +import type { RequestEvent } from "@sveltejs/kit"; + +export function validateSessionToken(token: string): SessionValidationResult { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const row = db.queryOne( + ` +SELECT session.id, session.user_id, session.expires_at, session.two_factor_verified, user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM session +INNER JOIN user ON session.user_id = user.id +LEFT JOIN totp_credential ON session.user_id = totp_credential.user_id +LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id +LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id +WHERE session.id = ? +`, + [sessionId] + ); + + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row.string(0), + userId: row.number(1), + expiresAt: new Date(row.number(2) * 1000), + twoFactorVerified: Boolean(row.number(3)) + }; + const user: User = { + id: row.number(4), + email: row.string(5), + username: row.string(6), + emailVerified: Boolean(row.number(7)), + registeredTOTP: Boolean(row.number(8)), + registeredPasskey: Boolean(row.number(9)), + registeredSecurityKey: Boolean(row.number(10)), + registered2FA: false + }; + if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { + user.registered2FA = true; + } + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM session WHERE id = ?", [sessionId]); + return { session: null, user: null }; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + db.execute("UPDATE session SET expires_at = ? WHERE session.id = ?", [ + Math.floor(session.expiresAt.getTime() / 1000), + sessionId + ]); + } + return { session, user }; +} + +export function invalidateSession(sessionId: string): void { + db.execute("DELETE FROM session WHERE id = ?", [sessionId]); +} + +export function invalidateUserSessions(userId: number): void { + db.execute("DELETE FROM session WHERE user_id = ?", [userId]); +} + +export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { + event.cookies.set("session", token, { + httpOnly: true, + path: "/", + secure: import.meta.env.PROD, + sameSite: "lax", + expires: expiresAt + }); +} + +export function deleteSessionTokenCookie(event: RequestEvent): void { + event.cookies.set("session", "", { + httpOnly: true, + path: "/", + secure: import.meta.env.PROD, + sameSite: "lax", + maxAge: 0 + }); +} + +export function generateSessionToken(): string { + const tokenBytes = new Uint8Array(20); + crypto.getRandomValues(tokenBytes); + const token = encodeBase32LowerCaseNoPadding(tokenBytes); + return token; +} + +export function createSession(token: string, userId: number, flags: SessionFlags): Session { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + twoFactorVerified: flags.twoFactorVerified + }; + db.execute("INSERT INTO session (id, user_id, expires_at, two_factor_verified) VALUES (?, ?, ?, ?)", [ + session.id, + session.userId, + Math.floor(session.expiresAt.getTime() / 1000), + Number(session.twoFactorVerified) + ]); + return session; +} + +export function setSessionAs2FAVerified(sessionId: string): void { + db.execute("UPDATE session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); +} + +export interface SessionFlags { + twoFactorVerified: boolean; +} + +export interface Session extends SessionFlags { + id: string; + expiresAt: Date; + userId: number; +} + +type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; diff --git a/src/lib/server/totp.ts b/src/lib/server/totp.ts new file mode 100644 index 0000000..8e5e406 --- /dev/null +++ b/src/lib/server/totp.ts @@ -0,0 +1,37 @@ +import { db } from "./db"; +import { decrypt, encrypt } from "./encryption"; +import { ExpiringTokenBucket, RefillingTokenBucket } from "./rate-limit"; + +export const totpBucket = new ExpiringTokenBucket(5, 60 * 30); +export const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); + +export function getUserTOTPKey(userId: number): Uint8Array | null { + const row = db.queryOne("SELECT totp_credential.key FROM totp_credential WHERE user_id = ?", [userId]); + if (row === null) { + throw new Error("Invalid user ID"); + } + const encrypted = row.bytesNullable(0); + if (encrypted === null) { + return null; + } + return decrypt(encrypted); +} + +export function updateUserTOTPKey(userId: number, key: Uint8Array): void { + const encrypted = encrypt(key); + try { + db.execute("BEGIN TRANSACTION", []); + db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); + db.execute("INSERT INTO totp_credential (user_id, key) VALUES (?, ?)", [userId, encrypted]); + db.execute("COMMIT", []); + } catch (e) { + if (db.inTransaction()) { + db.execute("ROLLBACK", []); + } + throw e; + } +} + +export function deleteUserTOTPKey(userId: number): void { + db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); +} diff --git a/src/lib/server/user.ts b/src/lib/server/user.ts new file mode 100644 index 0000000..6a270bc --- /dev/null +++ b/src/lib/server/user.ts @@ -0,0 +1,108 @@ +import { db } from "./db"; +import { decryptToString, encryptString } from "./encryption"; +import { hashPassword } from "./password"; +import { generateRandomRecoveryCode } from "./utils"; + +export function verifyUsernameInput(username: string): boolean { + return username.length > 3 && username.length < 32 && username.trim() === username; +} + +export async function createUser(email: string, username: string, password: string): Promise { + const passwordHash = await hashPassword(password); + const recoveryCode = generateRandomRecoveryCode(); + const encryptedRecoveryCode = encryptString(recoveryCode); + const row = db.queryOne( + "INSERT INTO user (email, username, password_hash, recovery_code) VALUES (?, ?, ?, ?) RETURNING user.id", + [email, username, passwordHash, encryptedRecoveryCode] + ); + if (row === null) { + throw new Error("Unexpected error"); + } + const user: User = { + id: row.number(0), + username, + email, + emailVerified: false, + registeredTOTP: false, + registeredPasskey: false, + registeredSecurityKey: false, + registered2FA: false + }; + return user; +} + +export async function updateUserPassword(userId: number, password: string): Promise { + const passwordHash = await hashPassword(password); + db.execute("UPDATE user SET password_hash = ? WHERE id = ?", [passwordHash, userId]); +} + +export function updateUserEmailAndSetEmailAsVerified(userId: number, email: string): void { + db.execute("UPDATE user SET email = ?, email_verified = 1 WHERE id = ?", [email, userId]); +} + +export function setUserAsEmailVerifiedIfEmailMatches(userId: number, email: string): boolean { + const result = db.execute("UPDATE user SET email_verified = 1 WHERE id = ? AND email = ?", [userId, email]); + return result.changes > 0; +} + +export function getUserPasswordHash(userId: number): string { + const row = db.queryOne("SELECT password_hash FROM user WHERE id = ?", [userId]); + if (row === null) { + throw new Error("Invalid user ID"); + } + return row.string(0); +} + +export function getUserRecoverCode(userId: number): string { + const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); + if (row === null) { + throw new Error("Invalid user ID"); + } + return decryptToString(row.bytes(0)); +} + +export function resetUserRecoveryCode(userId: number): string { + const recoveryCode = generateRandomRecoveryCode(); + const encrypted = encryptString(recoveryCode); + db.execute("UPDATE user SET recovery_code = ? WHERE id = ?", [encrypted, userId]); + return recoveryCode; +} + +export function getUserFromEmail(email: string): User | null { + const row = db.queryOne( + `SELECT user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM user + LEFT JOIN totp_credential ON user.id = totp_credential.user_id + LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id + LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id + WHERE user.email = ?`, + [email] + ); + if (row === null) { + return null; + } + const user: User = { + id: row.number(0), + email: row.string(1), + username: row.string(2), + emailVerified: Boolean(row.number(3)), + registeredTOTP: Boolean(row.number(4)), + registeredPasskey: Boolean(row.number(5)), + registeredSecurityKey: Boolean(row.number(6)), + registered2FA: false + }; + if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { + user.registered2FA = true; + } + return user; +} + +export interface User { + id: number; + email: string; + username: string; + emailVerified: boolean; + registeredTOTP: boolean; + registeredSecurityKey: boolean; + registeredPasskey: boolean; + registered2FA: boolean; +} diff --git a/src/lib/server/utils.ts b/src/lib/server/utils.ts new file mode 100644 index 0000000..5af4b45 --- /dev/null +++ b/src/lib/server/utils.ts @@ -0,0 +1,15 @@ +import { encodeBase32UpperCaseNoPadding } from "@oslojs/encoding"; + +export function generateRandomOTP(): string { + const bytes = new Uint8Array(5); + crypto.getRandomValues(bytes); + const code = encodeBase32UpperCaseNoPadding(bytes); + return code; +} + +export function generateRandomRecoveryCode(): string { + const recoveryCodeBytes = new Uint8Array(10); + crypto.getRandomValues(recoveryCodeBytes); + const recoveryCode = encodeBase32UpperCaseNoPadding(recoveryCodeBytes); + return recoveryCode; +} diff --git a/src/lib/server/webauthn.ts b/src/lib/server/webauthn.ts new file mode 100644 index 0000000..c286f13 --- /dev/null +++ b/src/lib/server/webauthn.ts @@ -0,0 +1,145 @@ +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { db } from "./db"; + +const challengeBucket = new Set(); + +export function createWebAuthnChallenge(): Uint8Array { + const challenge = new Uint8Array(20); + crypto.getRandomValues(challenge); + const encoded = encodeHexLowerCase(challenge); + challengeBucket.add(encoded); + return challenge; +} + +export function verifyWebAuthnChallenge(challenge: Uint8Array): boolean { + const encoded = encodeHexLowerCase(challenge); + return challengeBucket.delete(encoded); +} + +export function getUserPasskeyCredentials(userId: number): WebAuthnUserCredential[] { + const rows = db.query("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE user_id = ?", [ + userId + ]); + const credentials: WebAuthnUserCredential[] = []; + for (const row of rows) { + const credential: WebAuthnUserCredential = { + id: row.bytes(0), + userId: row.number(1), + name: row.string(2), + algorithmId: row.number(3), + publicKey: row.bytes(4) + }; + credentials.push(credential); + } + return credentials; +} + +export function getPasskeyCredential(credentialId: Uint8Array): WebAuthnUserCredential | null { + const row = db.queryOne("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ?", [ + credentialId + ]); + if (row === null) { + return null; + } + const credential: WebAuthnUserCredential = { + id: row.bytes(0), + userId: row.number(1), + name: row.string(2), + algorithmId: row.number(3), + publicKey: row.bytes(4) + }; + return credential; +} + +export function getUserPasskeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { + const row = db.queryOne( + "SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ? AND user_id = ?", + [credentialId, userId] + ); + if (row === null) { + return null; + } + const credential: WebAuthnUserCredential = { + id: row.bytes(0), + userId: row.number(1), + name: row.string(2), + algorithmId: row.number(3), + publicKey: row.bytes(4) + }; + return credential; +} + +export function createPasskeyCredential(credential: WebAuthnUserCredential): void { + db.execute("INSERT INTO passkey_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ + credential.id, + credential.userId, + credential.name, + credential.algorithmId, + credential.publicKey + ]); +} + +export function deleteUserPasskeyCredential(userId: number, credentialId: Uint8Array): boolean { + const result = db.execute("DELETE FROM passkey_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); + return result.changes > 0; +} + +export function getUserSecurityKeyCredentials(userId: number): WebAuthnUserCredential[] { + const rows = db.query( + "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE user_id = ?", + [userId] + ); + const credentials: WebAuthnUserCredential[] = []; + for (const row of rows) { + const credential: WebAuthnUserCredential = { + id: row.bytes(0), + userId: row.number(1), + name: row.string(2), + algorithmId: row.number(3), + publicKey: row.bytes(4) + }; + credentials.push(credential); + } + return credentials; +} + +export function getUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { + const row = db.queryOne( + "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE id = ? AND user_id = ?", + [credentialId, userId] + ); + if (row === null) { + return null; + } + const credential: WebAuthnUserCredential = { + id: row.bytes(0), + userId: row.number(1), + name: row.string(2), + algorithmId: row.number(3), + publicKey: row.bytes(4) + }; + return credential; +} + +export function createSecurityKeyCredential(credential: WebAuthnUserCredential): void { + db.execute("INSERT INTO security_key_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ + credential.id, + credential.userId, + credential.name, + credential.algorithmId, + credential.publicKey + ]); +} + +export function deleteUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): boolean { + const result = db.execute("DELETE FROM security_key_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); + return result.changes > 0; +} + +export interface WebAuthnUserCredential { + id: Uint8Array; + userId: number; + name: string; + algorithmId: number; + publicKey: Uint8Array; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..7017d4f --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,5 @@ + + Email and password example with 2FA and WebAuthn in SvelteKit + + + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..55121be --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,38 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { deleteSessionTokenCookie, invalidateSession } from "$lib/server/session"; +import { get2FARedirect } from "$lib/server/2fa"; + +import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; + +export function load(event: PageServerLoadEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (!event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + return { + user: event.locals.user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + if (event.locals.session === null) { + return fail(401, { + message: "Not authenticated" + }); + } + invalidateSession(event.locals.session.id); + deleteSessionTokenCookie(event); + return redirect(302, "/login"); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..0acd9dd --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,18 @@ + + +
+ Home + Settings +
+
+

Hi {data.user.username}!

+
+ +
+
diff --git a/src/routes/2fa/+server.ts b/src/routes/2fa/+server.ts new file mode 100644 index 0000000..1e76059 --- /dev/null +++ b/src/routes/2fa/+server.ts @@ -0,0 +1,17 @@ +import { get2FARedirect } from "$lib/server/2fa"; +import { redirect } from "@sveltejs/kit"; + +import type { RequestEvent } from "./$types"; + +export function GET(event: RequestEvent): Response { + if (event.locals.session === null || event.locals.user === null) { + return redirect(302, "/login"); + } + if (event.locals.session.twoFactorVerified) { + return redirect(302, "/"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + return redirect(302, get2FARedirect(event.locals.user)); +} diff --git a/src/routes/2fa/passkey/+page.server.ts b/src/routes/2fa/passkey/+page.server.ts new file mode 100644 index 0000000..2015faf --- /dev/null +++ b/src/routes/2fa/passkey/+page.server.ts @@ -0,0 +1,28 @@ +import { redirect } from "@sveltejs/kit"; +import { get2FARedirect } from "$lib/server/2fa"; +import { getUserPasskeyCredentials } from "$lib/server/webauthn"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/"); + } + if (event.locals.session.twoFactorVerified) { + return redirect(302, "/"); + } + if (!event.locals.user.registeredPasskey) { + return redirect(302, get2FARedirect(event.locals.user)); + } + const credentials = getUserPasskeyCredentials(event.locals.user.id); + return { + credentials, + user: event.locals.user + }; +} diff --git a/src/routes/2fa/passkey/+page.svelte b/src/routes/2fa/passkey/+page.svelte new file mode 100644 index 0000000..039140d --- /dev/null +++ b/src/routes/2fa/passkey/+page.svelte @@ -0,0 +1,65 @@ + + +

Authenticate with passkeys

+
+ +

{message}

+
+Use recovery code + +{#if data.user.registeredTOTP} + Use authenticator apps +{/if} +{#if data.user.registeredSecurityKey} + Use security keys +{/if} diff --git a/src/routes/2fa/passkey/+server.ts b/src/routes/2fa/passkey/+server.ts new file mode 100644 index 0000000..62f0585 --- /dev/null +++ b/src/routes/2fa/passkey/+server.ts @@ -0,0 +1,152 @@ +import { + parseClientDataJSON, + coseAlgorithmES256, + ClientDataType, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData +} from "@oslojs/webauthn"; +import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn"; +import { setSessionAs2FAVerified } from "$lib/server/session"; +import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; + +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import type { RequestEvent } from "./$types"; + +export async function POST(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!event.locals.user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (!event.locals.user.registeredPasskey) { + return new Response("Forbidden", { + status: 403 + }); + } + + const data: unknown = await event.request.json(); + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return new Response("Invalid data", { + status: 400 + }); + } + if (!authenticatorData.userPresent) { + return new Response("Invalid data", { + status: 400 + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.type !== ClientDataType.Get) { + return new Response("Invalid data", { + status: 400 + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return new Response("Invalid data", { + status: 400 + }); + } + + const credential = getUserPasskeyCredential(event.locals.user.id, credentialId); + if (credential === null) { + return new Response("Invalid credential", { + status: 400 + }); + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); + } else { + return new Response("Internal error", { + status: 500 + }); + } + + if (!validSignature) { + return new Response("Invalid signature", { + status: 400 + }); + } + + setSessionAs2FAVerified(event.locals.session.id); + return new Response(null, { + status: 204 + }); +} diff --git a/src/routes/2fa/passkey/register/+page.server.ts b/src/routes/2fa/passkey/register/+page.server.ts new file mode 100644 index 0000000..44e12f2 --- /dev/null +++ b/src/routes/2fa/passkey/register/+page.server.ts @@ -0,0 +1,237 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { get2FARedirect } from "$lib/server/2fa"; +import { bigEndian } from "@oslojs/binary"; +import { + parseAttestationObject, + AttestationStatementFormat, + parseClientDataJSON, + coseAlgorithmES256, + coseEllipticCurveP256, + ClientDataType, + coseAlgorithmRS256 +} from "@oslojs/webauthn"; +import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, createPasskeyCredential, getUserPasskeyCredentials } from "$lib/server/webauthn"; +import { setSessionAs2FAVerified } from "$lib/server/session"; +import { RSAPublicKey } from "@oslojs/crypto/rsa"; +import { SqliteError } from "better-sqlite3"; + +import type { WebAuthnUserCredential } from "$lib/server/webauthn"; +import type { + AttestationStatement, + AuthenticatorData, + ClientData, + COSEEC2PublicKey, + COSERSAPublicKey +} from "@oslojs/webauthn"; +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + + const credentials = getUserPasskeyCredentials(event.locals.user.id); + + const credentialUserId = new Uint8Array(8); + bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0); + + return { + credentials, + credentialUserId, + user: event.locals.user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!event.locals.user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + + const formData = await event.request.formData(); + let name = formData.get("name"); + let encodedAttestationObject = formData.get("attestation_object"); + let encodedClientDataJSON = formData.get("client_data_json"); + if ( + typeof name !== "string" || + typeof encodedAttestationObject !== "string" || + typeof encodedClientDataJSON !== "string" + ) { + return fail(400, { + message: "Invalid or missing fields" + }); + } + + let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; + try { + attestationObjectBytes = decodeBase64(encodedAttestationObject); + clientDataJSON = decodeBase64(encodedClientDataJSON); + } catch { + return fail(400, { + message: "Invalid or missing fields" + }); + } + + let attestationStatement: AttestationStatement; + let authenticatorData: AuthenticatorData; + try { + let attestationObject = parseAttestationObject(attestationObjectBytes); + attestationStatement = attestationObject.attestationStatement; + authenticatorData = attestationObject.authenticatorData; + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (attestationStatement.format !== AttestationStatementFormat.None) { + return fail(400, { + message: "Invalid data" + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return fail(400, { + message: "Invalid data" + }); + } + if (!authenticatorData.userPresent || !authenticatorData.userVerified) { + return fail(400, { + message: "Invalid data" + }); + } + if (authenticatorData.credential === null) { + return fail(400, { + message: "Invalid data" + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (clientData.type !== ClientDataType.Create) { + return fail(400, { + message: "Invalid data" + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return fail(400, { + message: "Invalid data" + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return fail(400, { + message: "Invalid data" + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return fail(400, { + message: "Invalid data" + }); + } + + let credential: WebAuthnUserCredential; + if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { + let cosePublicKey: COSEEC2PublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.ec2(); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (cosePublicKey.curve !== coseEllipticCurveP256) { + return fail(400, { + message: "Unsupported algorithm" + }); + } + const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); + credential = { + id: authenticatorData.credential.id, + userId: event.locals.user.id, + algorithmId: coseAlgorithmES256, + name, + publicKey: encodedPublicKey + }; + } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { + let cosePublicKey: COSERSAPublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.rsa(); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); + credential = { + id: authenticatorData.credential.id, + userId: event.locals.user.id, + algorithmId: coseAlgorithmRS256, + name, + publicKey: encodedPublicKey + }; + } else { + return fail(400, { + message: "Unsupported algorithm" + }); + } + + // We don't have to worry about race conditions since queries are synchronous + const credentials = getUserPasskeyCredentials(event.locals.user.id); + if (credentials.length >= 5) { + return fail(400, { + message: "Too many credentials" + }); + } + + try { + createPasskeyCredential(credential); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { + return fail(400, { + message: "Invalid data" + }); + } + return fail(500, { + message: "Internal error" + }); + } + + if (!event.locals.session.twoFactorVerified) { + setSessionAs2FAVerified(event.locals.session.id); + } + + if (!event.locals.user.registered2FA) { + return redirect(302, "/recovery-code"); + } + return redirect(302, "/"); +} diff --git a/src/routes/2fa/passkey/register/+page.svelte b/src/routes/2fa/passkey/register/+page.svelte new file mode 100644 index 0000000..b363f62 --- /dev/null +++ b/src/routes/2fa/passkey/register/+page.svelte @@ -0,0 +1,75 @@ + + +

Register passkey

+ +
+ + + + + +

{form?.message ?? ""}

+
diff --git a/src/routes/2fa/reset/+page.server.ts b/src/routes/2fa/reset/+page.server.ts new file mode 100644 index 0000000..22029fe --- /dev/null +++ b/src/routes/2fa/reset/+page.server.ts @@ -0,0 +1,73 @@ +import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, RequestEvent } from "./$types"; + +export const actions: Actions = { + default: action +}; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (event.locals.session.twoFactorVerified) { + return redirect(302, "/"); + } + return {}; +} + +async function action(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!event.locals.user.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (!event.locals.user.registered2FA) { + return fail(403, { + message: "Forbidden" + }); + } + if (!recoveryCodeBucket.check(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Please enter your code" + }); + } + if (!recoveryCodeBucket.consume(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + const valid = resetUser2FAWithRecoveryCode(event.locals.user.id, code); + if (!valid) { + return fail(400, { + message: "Invalid recovery code" + }); + } + recoveryCodeBucket.reset(event.locals.user.id); + return redirect(302, "/2fa/setup"); +} diff --git a/src/routes/2fa/reset/+page.svelte b/src/routes/2fa/reset/+page.svelte new file mode 100644 index 0000000..d976f19 --- /dev/null +++ b/src/routes/2fa/reset/+page.svelte @@ -0,0 +1,15 @@ + + +

Recover your account

+
+ +
+ +

{form?.message ?? ""}

+
diff --git a/src/routes/2fa/security-key/+page.server.ts b/src/routes/2fa/security-key/+page.server.ts new file mode 100644 index 0000000..02f319d --- /dev/null +++ b/src/routes/2fa/security-key/+page.server.ts @@ -0,0 +1,28 @@ +import { redirect } from "@sveltejs/kit"; +import { get2FARedirect } from "$lib/server/2fa"; +import { getUserSecurityKeyCredentials } from "$lib/server/webauthn"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/"); + } + if (event.locals.session.twoFactorVerified) { + return redirect(302, "/"); + } + if (!event.locals.user.registeredSecurityKey) { + return redirect(302, get2FARedirect(event.locals.user)); + } + const credentials = getUserSecurityKeyCredentials(event.locals.user.id); + return { + credentials, + user: event.locals.user + }; +} diff --git a/src/routes/2fa/security-key/+page.svelte b/src/routes/2fa/security-key/+page.svelte new file mode 100644 index 0000000..453f2d2 --- /dev/null +++ b/src/routes/2fa/security-key/+page.svelte @@ -0,0 +1,65 @@ + + +

Authenticate with security keys

+
+ +

{message}

+
+Use recovery code + +{#if data.user.registeredTOTP} + Use authenticator apps +{/if} +{#if data.user.registeredPasskey} + Use passkeys +{/if} diff --git a/src/routes/2fa/security-key/+server.ts b/src/routes/2fa/security-key/+server.ts new file mode 100644 index 0000000..cbf9f8a --- /dev/null +++ b/src/routes/2fa/security-key/+server.ts @@ -0,0 +1,152 @@ +import { + parseClientDataJSON, + coseAlgorithmES256, + ClientDataType, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData +} from "@oslojs/webauthn"; +import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn"; +import { setSessionAs2FAVerified } from "$lib/server/session"; +import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; + +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import type { RequestEvent } from "./$types"; + +export async function POST(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!event.locals.user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (!event.locals.user.registeredSecurityKey) { + return new Response("Forbidden", { + status: 403 + }); + } + + const data: unknown = await event.request.json(); + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return new Response("Invalid data", { + status: 400 + }); + } + if (!authenticatorData.userPresent) { + return new Response("Invalid data", { + status: 400 + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.type !== ClientDataType.Get) { + return new Response("Invalid data", { + status: 400 + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return new Response("Invalid data", { + status: 400 + }); + } + + const credential = getUserSecurityKeyCredential(event.locals.user.id, credentialId); + if (credential === null) { + return new Response("Invalid credential", { + status: 400 + }); + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); + } else { + return new Response("Internal error", { + status: 500 + }); + } + + if (!validSignature) { + return new Response("Invalid signature", { + status: 400 + }); + } + + setSessionAs2FAVerified(event.locals.session.id); + return new Response(null, { + status: 204 + }); +} diff --git a/src/routes/2fa/security-key/register/+page.server.ts b/src/routes/2fa/security-key/register/+page.server.ts new file mode 100644 index 0000000..1ee033b --- /dev/null +++ b/src/routes/2fa/security-key/register/+page.server.ts @@ -0,0 +1,241 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { get2FARedirect } from "$lib/server/2fa"; +import { bigEndian } from "@oslojs/binary"; +import { + parseAttestationObject, + AttestationStatementFormat, + parseClientDataJSON, + coseAlgorithmES256, + coseEllipticCurveP256, + ClientDataType, + coseAlgorithmRS256 +} from "@oslojs/webauthn"; +import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + verifyWebAuthnChallenge, + createSecurityKeyCredential, + getUserSecurityKeyCredentials +} from "$lib/server/webauthn"; +import { setSessionAs2FAVerified } from "$lib/server/session"; +import { RSAPublicKey } from "@oslojs/crypto/rsa"; +import { SqliteError } from "better-sqlite3"; + +import type { WebAuthnUserCredential } from "$lib/server/webauthn"; +import type { + AttestationStatement, + AuthenticatorData, + ClientData, + COSEEC2PublicKey, + COSERSAPublicKey +} from "@oslojs/webauthn"; +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + + const credentials = getUserSecurityKeyCredentials(event.locals.user.id); + + const credentialUserId = new Uint8Array(8); + bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0); + + return { + credentials, + credentialUserId, + user: event.locals.user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!event.locals.user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + + const formData = await event.request.formData(); + let name = formData.get("name"); + let encodedAttestationObject = formData.get("attestation_object"); + let encodedClientDataJSON = formData.get("client_data_json"); + if ( + typeof name !== "string" || + typeof encodedAttestationObject !== "string" || + typeof encodedClientDataJSON !== "string" + ) { + return fail(400, { + message: "Invalid or missing fields" + }); + } + + let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; + try { + attestationObjectBytes = decodeBase64(encodedAttestationObject); + clientDataJSON = decodeBase64(encodedClientDataJSON); + } catch { + return fail(400, { + message: "Invalid or missing fields" + }); + } + + let attestationStatement: AttestationStatement; + let authenticatorData: AuthenticatorData; + try { + let attestationObject = parseAttestationObject(attestationObjectBytes); + attestationStatement = attestationObject.attestationStatement; + authenticatorData = attestationObject.authenticatorData; + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (attestationStatement.format !== AttestationStatementFormat.None) { + return fail(400, { + message: "Invalid data" + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return fail(400, { + message: "Invalid data" + }); + } + if (!authenticatorData.userPresent) { + return fail(400, { + message: "Invalid data" + }); + } + if (authenticatorData.credential === null) { + return fail(400, { + message: "Invalid data" + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (clientData.type !== ClientDataType.Create) { + return fail(400, { + message: "Invalid data" + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return fail(400, { + message: "Invalid data" + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return fail(400, { + message: "Invalid data" + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return fail(400, { + message: "Invalid data" + }); + } + + let credential: WebAuthnUserCredential; + if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { + let cosePublicKey: COSEEC2PublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.ec2(); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + if (cosePublicKey.curve !== coseEllipticCurveP256) { + return fail(400, { + message: "Unsupported algorithm" + }); + } + const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); + credential = { + id: authenticatorData.credential.id, + userId: event.locals.user.id, + algorithmId: coseAlgorithmES256, + name, + publicKey: encodedPublicKey + }; + } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { + let cosePublicKey: COSERSAPublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.rsa(); + } catch { + return fail(400, { + message: "Invalid data" + }); + } + const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); + credential = { + id: authenticatorData.credential.id, + userId: event.locals.user.id, + algorithmId: coseAlgorithmRS256, + name, + publicKey: encodedPublicKey + }; + } else { + return fail(400, { + message: "Unsupported algorithm" + }); + } + + // We don't have to worry about race conditions since queries are synchronous + const credentials = getUserSecurityKeyCredentials(event.locals.user.id); + if (credentials.length >= 5) { + return fail(400, { + message: "Too many credentials" + }); + } + + try { + createSecurityKeyCredential(credential); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { + return fail(400, { + message: "Invalid data" + }); + } + return fail(500, { + message: "Internal error" + }); + } + + if (!event.locals.session.twoFactorVerified) { + setSessionAs2FAVerified(event.locals.session.id); + } + + if (!event.locals.user.registered2FA) { + return redirect(302, "/recovery-code"); + } + return redirect(302, "/"); +} diff --git a/src/routes/2fa/security-key/register/+page.svelte b/src/routes/2fa/security-key/register/+page.svelte new file mode 100644 index 0000000..56f484a --- /dev/null +++ b/src/routes/2fa/security-key/register/+page.svelte @@ -0,0 +1,76 @@ + + +

Register security key

+ +
+ + + + + +

{form?.message ?? ""}

+
diff --git a/src/routes/2fa/setup/+page.server.ts b/src/routes/2fa/setup/+page.server.ts new file mode 100644 index 0000000..909e04b --- /dev/null +++ b/src/routes/2fa/setup/+page.server.ts @@ -0,0 +1,16 @@ +import { redirect } from "@sveltejs/kit"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (event.locals.user.registered2FA) { + return redirect(302, "/"); + } + return {}; +} diff --git a/src/routes/2fa/setup/+page.svelte b/src/routes/2fa/setup/+page.svelte new file mode 100644 index 0000000..2ec3b57 --- /dev/null +++ b/src/routes/2fa/setup/+page.svelte @@ -0,0 +1,6 @@ +

Set up two-factor authentication

+ diff --git a/src/routes/2fa/totp/+page.server.ts b/src/routes/2fa/totp/+page.server.ts new file mode 100644 index 0000000..85bf5a8 --- /dev/null +++ b/src/routes/2fa/totp/+page.server.ts @@ -0,0 +1,83 @@ +import { totpBucket, getUserTOTPKey } from "$lib/server/totp"; +import { fail, redirect } from "@sveltejs/kit"; +import { verifyTOTP } from "@oslojs/otp"; +import { setSessionAs2FAVerified } from "$lib/server/session"; + +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (event.locals.session.twoFactorVerified) { + return redirect(302, "/"); + } + return { + user: event.locals.user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!event.locals.user.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (!event.locals.user.registered2FA) { + return fail(403, { + message: "Forbidden" + }); + } + if (!totpBucket.check(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Enter your code" + }); + } + if (!totpBucket.consume(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + const totpKey = getUserTOTPKey(event.locals.user.id); + if (totpKey === null) { + return fail(403, { + message: "Forbidden" + }); + } + if (!verifyTOTP(totpKey, 30, 6, code)) { + return fail(400, { + message: "Invalid code" + }); + } + totpBucket.reset(event.locals.user.id); + setSessionAs2FAVerified(event.locals.session.id); + return redirect(302, "/"); +} diff --git a/src/routes/2fa/totp/+page.svelte b/src/routes/2fa/totp/+page.svelte new file mode 100644 index 0000000..871ecbf --- /dev/null +++ b/src/routes/2fa/totp/+page.svelte @@ -0,0 +1,24 @@ + + +

Two-factor authentication

+
+ +
+ +

{form?.message ?? ""}

+
+Use recovery code + +{#if data.user.registeredPasskey} + Use passkeys +{/if} +{#if data.user.registeredSecurityKey} + Use security keys +{/if} diff --git a/src/routes/2fa/totp/setup/+page.server.ts b/src/routes/2fa/totp/setup/+page.server.ts new file mode 100644 index 0000000..d242104 --- /dev/null +++ b/src/routes/2fa/totp/setup/+page.server.ts @@ -0,0 +1,106 @@ +import { createTOTPKeyURI, verifyTOTP } from "@oslojs/otp"; +import { fail, redirect } from "@sveltejs/kit"; +import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; +import { totpUpdateBucket, updateUserTOTPKey } from "$lib/server/totp"; +import { setSessionAs2FAVerified } from "$lib/server/session"; +import { renderSVG } from "uqr"; +import { get2FARedirect } from "$lib/server/2fa"; + +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + + const totpKey = new Uint8Array(20); + crypto.getRandomValues(totpKey); + const encodedTOTPKey = encodeBase64(totpKey); + const keyURI = createTOTPKeyURI("Demo", event.locals.user.username, totpKey, 30, 6); + const qrcode = renderSVG(keyURI); + return { + encodedTOTPKey, + qrcode + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!event.locals.user.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (!totpUpdateBucket.check(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + + const formData = await event.request.formData(); + const encodedKey = formData.get("key"); + const code = formData.get("code"); + if (typeof encodedKey !== "string" || typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Please enter your code" + }); + } + if (encodedKey.length !== 28) { + return fail(400, { + message: "Please enter your code" + }); + } + let key: Uint8Array; + try { + key = decodeBase64(encodedKey); + } catch { + return fail(400, { + message: "Invalid key" + }); + } + if (key.byteLength !== 20) { + return fail(400, { + message: "Invalid key" + }); + } + if (!totpUpdateBucket.consume(event.locals.user.id, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + if (!verifyTOTP(key, 30, 6, code)) { + return fail(400, { + message: "Invalid code" + }); + } + updateUserTOTPKey(event.locals.session.userId, key); + setSessionAs2FAVerified(event.locals.session.id); + if (!event.locals.user.registered2FA) { + return redirect(302, "/recovery-code"); + } + return redirect(302, "/"); +} diff --git a/src/routes/2fa/totp/setup/+page.svelte b/src/routes/2fa/totp/setup/+page.svelte new file mode 100644 index 0000000..47ff350 --- /dev/null +++ b/src/routes/2fa/totp/setup/+page.svelte @@ -0,0 +1,20 @@ + + +

Set up two-factor authentication

+
+ {@html data.qrcode} +
+
+ + +
+ +

{form?.message ?? ""}

+
diff --git a/src/routes/api/webauthn/challenge/+server.ts b/src/routes/api/webauthn/challenge/+server.ts new file mode 100644 index 0000000..7a74c86 --- /dev/null +++ b/src/routes/api/webauthn/challenge/+server.ts @@ -0,0 +1,19 @@ +import { createWebAuthnChallenge } from "$lib/server/webauthn"; +import { encodeBase64 } from "@oslojs/encoding"; +import { RefillingTokenBucket } from "$lib/server/rate-limit"; + +import type { RequestEvent } from "./$types"; + +const webauthnChallengeRateLimitBucket = new RefillingTokenBucket(30, 10); + +export async function POST(event: RequestEvent) { + // TODO: Assumes X-Forwarded-For is always included. + const clientIP = event.request.headers.get("X-Forwarded-For"); + if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) { + return new Response("Too many requests", { + status: 429 + }); + } + const challenge = createWebAuthnChallenge(); + return new Response(JSON.stringify({ challenge: encodeBase64(challenge) })); +} diff --git a/src/routes/forgot-password/+page.server.ts b/src/routes/forgot-password/+page.server.ts new file mode 100644 index 0000000..ee79a91 --- /dev/null +++ b/src/routes/forgot-password/+page.server.ts @@ -0,0 +1,71 @@ +import { verifyEmailInput } from "$lib/server/email"; +import { getUserFromEmail } from "$lib/server/user"; +import { + createPasswordResetSession, + invalidateUserPasswordResetSessions, + sendPasswordResetEmail, + setPasswordResetSessionTokenCookie +} from "$lib/server/password-reset"; +import { RefillingTokenBucket } from "$lib/server/rate-limit"; +import { generateSessionToken } from "$lib/server/session"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, RequestEvent } from "./$types"; + +const ipBucket = new RefillingTokenBucket(3, 60); +const userBucket = new RefillingTokenBucket(3, 60); + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + // TODO: Assumes X-Forwarded-For is always included. + const clientIP = event.request.headers.get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return fail(429, { + message: "Too many requests", + email: "" + }); + } + + const formData = await event.request.formData(); + const email = formData.get("email"); + if (typeof email !== "string") { + return fail(400, { + message: "Invalid or missing fields", + email: "" + }); + } + if (!verifyEmailInput(email)) { + return fail(400, { + message: "Invalid email", + email + }); + } + const user = getUserFromEmail(email); + if (user === null) { + return fail(400, { + message: "Account does not exist", + email + }); + } + if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { + return fail(400, { + message: "Too many requests", + email + }); + } + if (!userBucket.consume(user.id, 1)) { + return fail(400, { + message: "Too many requests", + email + }); + } + invalidateUserPasswordResetSessions(user.id); + const sessionToken = generateSessionToken(); + const session = createPasswordResetSession(sessionToken, user.id, user.email); + sendPasswordResetEmail(session.email, session.code); + setPasswordResetSessionTokenCookie(event, sessionToken, session.expiresAt); + return redirect(302, "/reset-password/verify-email"); +} diff --git a/src/routes/forgot-password/+page.svelte b/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..7360b04 --- /dev/null +++ b/src/routes/forgot-password/+page.svelte @@ -0,0 +1,16 @@ + + +

Forgot your password?

+
+ +
+ +

{form?.message ?? ""}

+
+Sign in diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..23cae36 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,108 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { verifyEmailInput } from "$lib/server/email"; +import { getUserFromEmail, getUserPasswordHash } from "$lib/server/user"; +import { RefillingTokenBucket, Throttler } from "$lib/server/rate-limit"; +import { verifyPasswordHash } from "$lib/server/password"; +import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; +import { get2FARedirect } from "$lib/server/2fa"; + +import type { SessionFlags } from "$lib/server/session"; +import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; + +export function load(event: PageServerLoadEvent) { + if (event.locals.session !== null && event.locals.user !== null) { + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (!event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + return redirect(302, "/"); + } + return {}; +} + +const throttler = new Throttler([0, 1, 2, 4, 8, 16, 30, 60, 180, 300]); +const ipBucket = new RefillingTokenBucket(20, 1); + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + // TODO: Assumes X-Forwarded-For is always included. + const clientIP = event.request.headers.get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return fail(429, { + message: "Too many requests", + email: "" + }); + } + + const formData = await event.request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + if (typeof email !== "string" || typeof password !== "string") { + return fail(400, { + message: "Invalid or missing fields", + email: "" + }); + } + if (email === "" || password === "") { + return fail(400, { + message: "Please enter your email and password.", + email + }); + } + if (!verifyEmailInput(email)) { + return fail(400, { + message: "Invalid email", + email + }); + } + const user = getUserFromEmail(email); + if (user === null) { + return fail(400, { + message: "Account does not exist", + email + }); + } + if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { + return fail(429, { + message: "Too many requests", + email: "" + }); + } + if (!throttler.consume(user.id)) { + return fail(429, { + message: "Too many requests", + email: "" + }); + } + const passwordHash = getUserPasswordHash(user.id); + const validPassword = await verifyPasswordHash(passwordHash, password); + if (!validPassword) { + return fail(400, { + message: "Invalid password", + email + }); + } + throttler.reset(user.id); + const sessionFlags: SessionFlags = { + twoFactorVerified: false + }; + const sessionToken = generateSessionToken(); + const session = createSession(sessionToken, user.id, sessionFlags); + setSessionTokenCookie(event, sessionToken, session.expiresAt); + + if (!user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + return redirect(302, get2FARedirect(user)); +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..0d87fc9 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,70 @@ + + +

Sign in

+
+ +
+ +
+ +

{form?.message ?? ""}

+
+
+ +

{passkeyErrorMessage}

+
+Create an account +Forgot password? diff --git a/src/routes/login/passkey/+server.ts b/src/routes/login/passkey/+server.ts new file mode 100644 index 0000000..2863b00 --- /dev/null +++ b/src/routes/login/passkey/+server.ts @@ -0,0 +1,142 @@ +import { + parseClientDataJSON, + coseAlgorithmES256, + ClientDataType, + parseAuthenticatorData, + createAssertionSignatureMessage, + coseAlgorithmRS256 +} from "@oslojs/webauthn"; +import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, getPasskeyCredential } from "$lib/server/webauthn"; +import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; + +import type { RequestEvent } from "./$types"; +import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; +import type { SessionFlags } from "$lib/server/session"; + +// Stricter rate limiting can be omitted here since creating challenges are rate-limited +export async function POST(context: RequestEvent): Promise { + const data: unknown = await context.request.json(); + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return new Response("Invalid data", { + status: 400 + }); + } + if (!authenticatorData.userPresent || !authenticatorData.userVerified) { + return new Response("Invalid data", { + status: 400 + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.type !== ClientDataType.Get) { + return new Response("Invalid data", { + status: 400 + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return new Response("Invalid data", { + status: 400 + }); + } + + const credential = getPasskeyCredential(credentialId); + if (credential === null) { + return new Response("Invalid credential", { + status: 400 + }); + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); + } else { + return new Response("Internal error", { + status: 500 + }); + } + + if (!validSignature) { + return new Response("Invalid signature", { + status: 400 + }); + } + const sessionFlags: SessionFlags = { + twoFactorVerified: true + }; + const sessionToken = generateSessionToken(); + const session = createSession(sessionToken, credential.userId, sessionFlags); + setSessionTokenCookie(context, sessionToken, session.expiresAt); + return new Response(null, { + status: 204 + }); +} diff --git a/src/routes/recovery-code/+page.server.ts b/src/routes/recovery-code/+page.server.ts new file mode 100644 index 0000000..3e39eae --- /dev/null +++ b/src/routes/recovery-code/+page.server.ts @@ -0,0 +1,24 @@ +import { getUserRecoverCode } from "$lib/server/user"; +import { redirect } from "@sveltejs/kit"; +import { get2FARedirect } from "$lib/server/2fa"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (!event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + const recoveryCode = getUserRecoverCode(event.locals.user.id); + return { + recoveryCode + }; +} diff --git a/src/routes/recovery-code/+page.svelte b/src/routes/recovery-code/+page.svelte new file mode 100644 index 0000000..787e9e4 --- /dev/null +++ b/src/routes/recovery-code/+page.svelte @@ -0,0 +1,10 @@ + + +

Recovery code

+

Your recovery code is: {data.recoveryCode}

+

You can use this recovery code if you lose access to your second factors.

+Next diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts new file mode 100644 index 0000000..4c93031 --- /dev/null +++ b/src/routes/reset-password/+page.server.ts @@ -0,0 +1,81 @@ +import { + deletePasswordResetSessionTokenCookie, + invalidateUserPasswordResetSessions, + validatePasswordResetSessionRequest +} from "$lib/server/password-reset"; +import { fail, redirect } from "@sveltejs/kit"; +import { verifyPasswordStrength } from "$lib/server/password"; +import { + createSession, + generateSessionToken, + invalidateUserSessions, + setSessionTokenCookie +} from "$lib/server/session"; +import { updateUserPassword } from "$lib/server/user"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; + +import type { Actions, RequestEvent } from "./$types"; +import type { SessionFlags } from "$lib/server/session"; + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (!session.emailVerified) { + return redirect(302, "/reset-password/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(302, getPasswordReset2FARedirect(user)); + } + return {}; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + const { session: passwordResetSession, user } = validatePasswordResetSessionRequest(event); + if (passwordResetSession === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!passwordResetSession.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (user.registered2FA && !passwordResetSession.twoFactorVerified) { + return fail(403, { + message: "Forbidden" + }); + } + const formData = await event.request.formData(); + const password = formData.get("password"); + if (typeof password !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + + const strongPassword = await verifyPasswordStrength(password); + if (!strongPassword) { + return fail(400, { + message: "Weak password" + }); + } + invalidateUserPasswordResetSessions(passwordResetSession.userId); + invalidateUserSessions(passwordResetSession.userId); + await updateUserPassword(passwordResetSession.userId, password); + + const sessionFlags: SessionFlags = { + twoFactorVerified: passwordResetSession.twoFactorVerified + }; + const sessionToken = generateSessionToken(); + const session = createSession(sessionToken, user.id, sessionFlags); + setSessionTokenCookie(event, sessionToken, session.expiresAt); + deletePasswordResetSessionTokenCookie(event); + return redirect(302, "/"); +} diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..3eee5ff --- /dev/null +++ b/src/routes/reset-password/+page.svelte @@ -0,0 +1,15 @@ + + +

Enter your new password

+
+ +
+ +

{form?.message ?? ""}

+
diff --git a/src/routes/reset-password/2fa/+server.ts b/src/routes/reset-password/2fa/+server.ts new file mode 100644 index 0000000..ea4ea2b --- /dev/null +++ b/src/routes/reset-password/2fa/+server.ts @@ -0,0 +1,16 @@ +import { redirect } from "@sveltejs/kit"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; +import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; + +import type { RequestEvent } from "./$types"; + +export async function GET(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null) { + return redirect(302, "/login"); + } + if (!user.registered2FA || session.twoFactorVerified) { + return redirect(302, "/reset-password"); + } + return redirect(302, getPasswordReset2FARedirect(user)); +} diff --git a/src/routes/reset-password/2fa/passkey/+page.server.ts b/src/routes/reset-password/2fa/passkey/+page.server.ts new file mode 100644 index 0000000..4830024 --- /dev/null +++ b/src/routes/reset-password/2fa/passkey/+page.server.ts @@ -0,0 +1,31 @@ +import { redirect } from "@sveltejs/kit"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; +import { getUserPasskeyCredentials } from "$lib/server/webauthn"; +import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (!session.emailVerified) { + return redirect(302, "/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect(302, "/reset-password"); + } + if (session.twoFactorVerified) { + return redirect(302, "/reset-password"); + } + if (!user.registeredPasskey) { + return redirect(302, getPasswordReset2FARedirect(user)); + } + const credentials = getUserPasskeyCredentials(user.id); + return { + user, + credentials + }; +} diff --git a/src/routes/reset-password/2fa/passkey/+page.svelte b/src/routes/reset-password/2fa/passkey/+page.svelte new file mode 100644 index 0000000..5d39748 --- /dev/null +++ b/src/routes/reset-password/2fa/passkey/+page.svelte @@ -0,0 +1,64 @@ + + +

Authenticate with passkeys

+
+ +

{message}

+
+Use recovery code +{#if data.user.registeredSecurityKey} + Use security keys +{/if} +{#if data.user.registeredTOTP} + Use authenticator apps +{/if} diff --git a/src/routes/reset-password/2fa/passkey/+server.ts b/src/routes/reset-password/2fa/passkey/+server.ts new file mode 100644 index 0000000..00d7f1b --- /dev/null +++ b/src/routes/reset-password/2fa/passkey/+server.ts @@ -0,0 +1,153 @@ +import { + parseClientDataJSON, + coseAlgorithmES256, + ClientDataType, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData +} from "@oslojs/webauthn"; +import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn"; +import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset"; + +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import type { RequestEvent } from "./$types"; + +export async function POST(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null || user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (!user.registeredPasskey) { + return new Response("Forbidden", { + status: 403 + }); + } + + const data: unknown = await event.request.json(); + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return new Response("Invalid data", { + status: 400 + }); + } + if (!authenticatorData.userPresent) { + return new Response("Invalid data", { + status: 400 + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.type !== ClientDataType.Get) { + return new Response("Invalid data", { + status: 400 + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return new Response("Invalid data", { + status: 400 + }); + } + + const credential = getUserPasskeyCredential(user.id, credentialId); + if (credential === null) { + return new Response("Invalid credential", { + status: 400 + }); + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); + } else { + return new Response("Internal error", { + status: 500 + }); + } + + if (!validSignature) { + return new Response("Invalid signature", { + status: 400 + }); + } + + setPasswordResetSessionAs2FAVerified(session.id); + return new Response(null, { + status: 204 + }); +} diff --git a/src/routes/reset-password/2fa/recovery-code/+page.server.ts b/src/routes/reset-password/2fa/recovery-code/+page.server.ts new file mode 100644 index 0000000..3b1b01f --- /dev/null +++ b/src/routes/reset-password/2fa/recovery-code/+page.server.ts @@ -0,0 +1,79 @@ +import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; +import { fail, redirect } from "@sveltejs/kit"; +import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa"; + +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (!session.emailVerified) { + return redirect(302, "/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect(302, "/reset-password"); + } + if (session.twoFactorVerified) { + return redirect(302, "/reset-password"); + } + return { + user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + const { session } = validatePasswordResetSessionRequest(event); + if (session === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!session.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + if (!recoveryCodeBucket.check(session.userId, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + if (session.twoFactorVerified) { + return fail(400, { + message: "Already verified" + }); + } + + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Please enter your code" + }); + } + if (!recoveryCodeBucket.consume(session.userId, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + const valid = resetUser2FAWithRecoveryCode(session.userId, code); + if (!valid) { + return fail(400, { + message: "Invalid code" + }); + } + recoveryCodeBucket.reset(session.userId); + return redirect(302, "/reset-password"); +} diff --git a/src/routes/reset-password/2fa/recovery-code/+page.svelte b/src/routes/reset-password/2fa/recovery-code/+page.svelte new file mode 100644 index 0000000..78352ab --- /dev/null +++ b/src/routes/reset-password/2fa/recovery-code/+page.svelte @@ -0,0 +1,25 @@ + + +

Use your recovery code

+
+ +
+ +

{form?.message ?? ""}

+
+{#if data.user.registeredSecurityKey} + Use security keys +{/if} +{#if data.user.registeredPasskey} + Use passkeys +{/if} +{#if data.user.registeredTOTP} + Use authenticator apps +{/if} diff --git a/src/routes/reset-password/2fa/security-key/+page.server.ts b/src/routes/reset-password/2fa/security-key/+page.server.ts new file mode 100644 index 0000000..94f0218 --- /dev/null +++ b/src/routes/reset-password/2fa/security-key/+page.server.ts @@ -0,0 +1,31 @@ +import { redirect } from "@sveltejs/kit"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; +import { getUserSecurityKeyCredentials } from "$lib/server/webauthn"; +import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; + +import type { RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (!session.emailVerified) { + return redirect(302, "/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect(302, "/reset-password"); + } + if (session.twoFactorVerified) { + return redirect(302, "/reset-password"); + } + if (!user.registeredSecurityKey) { + return redirect(302, getPasswordReset2FARedirect(user)); + } + const credentials = getUserSecurityKeyCredentials(user.id); + return { + credentials, + user + }; +} diff --git a/src/routes/reset-password/2fa/security-key/+page.svelte b/src/routes/reset-password/2fa/security-key/+page.svelte new file mode 100644 index 0000000..f132ff2 --- /dev/null +++ b/src/routes/reset-password/2fa/security-key/+page.svelte @@ -0,0 +1,64 @@ + + +

Authenticate with security keys

+
+ +

{message}

+
+Use recovery code +{#if data.user.registeredPasskey} + Use passkeys +{/if} +{#if data.user.registeredTOTP} + Use authenticator apps +{/if} diff --git a/src/routes/reset-password/2fa/security-key/+server.ts b/src/routes/reset-password/2fa/security-key/+server.ts new file mode 100644 index 0000000..b860bef --- /dev/null +++ b/src/routes/reset-password/2fa/security-key/+server.ts @@ -0,0 +1,153 @@ +import { + parseClientDataJSON, + coseAlgorithmES256, + ClientDataType, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData +} from "@oslojs/webauthn"; +import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn"; +import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset"; + +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import type { RequestEvent } from "./$types"; + +export async function POST(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null || user === null) { + return new Response("Not authenticated", { + status: 401 + }); + } + if (!user.emailVerified) { + return new Response("Forbidden", { + status: 403 + }); + } + if (!user.registeredSecurityKey) { + return new Response("Forbidden", { + status: 403 + }); + } + + const data: unknown = await event.request.json(); + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return new Response("Invalid or missing fields", { + status: 400 + }); + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update host + if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { + return new Response("Invalid data", { + status: 400 + }); + } + if (!authenticatorData.userPresent) { + return new Response("Invalid data", { + status: 400 + }); + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.type !== ClientDataType.Get) { + return new Response("Invalid data", { + status: 400 + }); + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return new Response("Invalid data", { + status: 400 + }); + } + // TODO: Update origin + if (clientData.origin !== "http://localhost:5173") { + return new Response("Invalid data", { + status: 400 + }); + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return new Response("Invalid data", { + status: 400 + }); + } + + const credential = getUserSecurityKeyCredential(user.id, credentialId); + if (credential === null) { + return new Response("Invalid credential", { + status: 400 + }); + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); + validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); + } else { + return new Response("Internal error", { + status: 500 + }); + } + + if (!validSignature) { + return new Response("Invalid signature", { + status: 400 + }); + } + + setPasswordResetSessionAs2FAVerified(session.id); + return new Response(null, { + status: 204 + }); +} diff --git a/src/routes/reset-password/2fa/totp/+page.server.ts b/src/routes/reset-password/2fa/totp/+page.server.ts new file mode 100644 index 0000000..52fc703 --- /dev/null +++ b/src/routes/reset-password/2fa/totp/+page.server.ts @@ -0,0 +1,85 @@ +import { verifyTOTP } from "@oslojs/otp"; +import { validatePasswordResetSessionRequest, setPasswordResetSessionAs2FAVerified } from "$lib/server/password-reset"; +import { totpBucket, getUserTOTPKey } from "$lib/server/totp"; +import { fail, redirect } from "@sveltejs/kit"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; + +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (!session.emailVerified) { + return redirect(302, "/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect(302, "/reset-password"); + } + if (session.twoFactorVerified) { + return redirect(302, "/reset-password"); + } + if (!user.registeredTOTP) { + return redirect(302, getPasswordReset2FARedirect(user)); + } + return { + user + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!totpBucket.check(session.userId, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + if (!user.registered2FA || session.twoFactorVerified || !session.emailVerified) { + return fail(403, { + message: "Forbidden" + }); + } + + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Please enter your code" + }); + } + const totpKey = getUserTOTPKey(session.userId); + if (totpKey === null) { + return fail(403, { + message: "Forbidden" + }); + } + if (!totpBucket.consume(session.userId, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + if (!verifyTOTP(totpKey, 30, 6, code)) { + return fail(400, { + message: "Invalid code" + }); + } + totpBucket.reset(session.userId); + setPasswordResetSessionAs2FAVerified(session.id); + return redirect(302, "/reset-password"); +} diff --git a/src/routes/reset-password/2fa/totp/+page.svelte b/src/routes/reset-password/2fa/totp/+page.svelte new file mode 100644 index 0000000..c615410 --- /dev/null +++ b/src/routes/reset-password/2fa/totp/+page.svelte @@ -0,0 +1,24 @@ + + +

Two-factor authentication

+

Enter the code in your authenticator app.

+
+ +
+ +

{form?.message ?? ""}

+
+Use recovery code +{#if data.user.registeredSecurityKey} + Use security keys +{/if} +{#if data.user.registeredPasskey} + Use passkeys +{/if} diff --git a/src/routes/reset-password/verify-email/+page.server.ts b/src/routes/reset-password/verify-email/+page.server.ts new file mode 100644 index 0000000..be9bd40 --- /dev/null +++ b/src/routes/reset-password/verify-email/+page.server.ts @@ -0,0 +1,84 @@ +import { + validatePasswordResetSessionRequest, + setPasswordResetSessionAsEmailVerified +} from "$lib/server/password-reset"; +import { ExpiringTokenBucket } from "$lib/server/rate-limit"; +import { setUserAsEmailVerifiedIfEmailMatches } from "$lib/server/user"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, RequestEvent } from "./$types"; +import { getPasswordReset2FARedirect } from "$lib/server/2fa"; + +const bucket = new ExpiringTokenBucket(5, 60 * 30); + +export async function load(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null) { + return redirect(302, "/forgot-password"); + } + if (session.emailVerified) { + if (!session.twoFactorVerified) { + return redirect(302, getPasswordReset2FARedirect(user)); + } + return redirect(302, "/reset-password"); + } + return { + email: session.email + }; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + const { session, user } = validatePasswordResetSessionRequest(event); + if (session === null) { + return fail(401, { + message: "Not authenticated" + }); + } + if (!bucket.check(session.userId, 1)) { + return fail(429, { + message: "Too many requests" + }); + } + + if (session.emailVerified) { + return fail(400, { + message: "Already verified" + }); + } + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + message: "Invalid or missing fields" + }); + } + if (code === "") { + return fail(400, { + message: "Please enter your code" + }); + } + if (!bucket.consume(session.userId, 1)) { + return fail(429, { message: "Too many requests" }); + } + if (code !== session.code) { + return fail(400, { + message: "Incorrect code" + }); + } + bucket.reset(session.userId); + setPasswordResetSessionAsEmailVerified(session.id); + const emailMatches = setUserAsEmailVerifiedIfEmailMatches(session.userId, session.email); + if (!emailMatches) { + return fail(400, { + message: "Please restart the process" + }); + } + if (!user.registered2FA) { + return redirect(302, "/reset-password"); + } + return redirect(302, getPasswordReset2FARedirect(user)); +} diff --git a/src/routes/reset-password/verify-email/+page.svelte b/src/routes/reset-password/verify-email/+page.svelte new file mode 100644 index 0000000..91c7a84 --- /dev/null +++ b/src/routes/reset-password/verify-email/+page.svelte @@ -0,0 +1,17 @@ + + +

Verify your email address

+

We sent an 8-digit code to {data.email}.

+
+ + + +

{form?.message ?? ""}

+
diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts new file mode 100644 index 0000000..2aefb99 --- /dev/null +++ b/src/routes/settings/+page.server.ts @@ -0,0 +1,277 @@ +import { + createEmailVerificationRequest, + sendVerificationEmail, + sendVerificationEmailBucket, + setEmailVerificationRequestCookie +} from "$lib/server/email-verification"; +import { fail, redirect } from "@sveltejs/kit"; +import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email"; +import { verifyPasswordHash, verifyPasswordStrength } from "$lib/server/password"; +import { getUserPasswordHash, getUserRecoverCode, resetUserRecoveryCode, updateUserPassword } from "$lib/server/user"; +import { + createSession, + generateSessionToken, + invalidateUserSessions, + setSessionTokenCookie +} from "$lib/server/session"; +import { + deleteUserPasskeyCredential, + deleteUserSecurityKeyCredential, + getUserPasskeyCredentials, + getUserSecurityKeyCredentials +} from "$lib/server/webauthn"; +import { decodeBase64 } from "@oslojs/encoding"; +import { get2FARedirect } from "$lib/server/2fa"; +import { deleteUserTOTPKey, totpUpdateBucket } from "$lib/server/totp"; + +import type { Actions, RequestEvent } from "./$types"; +import type { SessionFlags } from "$lib/server/session"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return redirect(302, "/login"); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + let recoveryCode: string | null = null; + if (event.locals.user.registered2FA) { + recoveryCode = getUserRecoverCode(event.locals.user.id); + } + const passkeyCredentials = getUserPasskeyCredentials(event.locals.user.id); + const securityKeyCredentials = getUserSecurityKeyCredentials(event.locals.user.id); + return { + recoveryCode, + user: event.locals.user, + passkeyCredentials, + securityKeyCredentials + }; +} + +export const actions: Actions = { + update_password: updatePasswordAction, + update_email: updateEmailAction, + disconnect_totp: disconnectTOTPAction, + delete_passkey: deletePasskeyAction, + delete_security_key: deleteSecurityKeyAction, + regenerate_recovery_code: regenerateRecoveryCodeAction +}; + +async function updatePasswordAction(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return fail(401, { + password: { + message: "Not authenticated" + } + }); + } + if (!event.locals.user.emailVerified) { + return fail(403, { + password: { + message: "Forbidden" + } + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403, { + password: { + message: "Forbidden" + } + }); + } + const formData = await event.request.formData(); + const password = formData.get("password"); + const newPassword = formData.get("new_password"); + if (typeof password !== "string" || typeof newPassword !== "string") { + return fail(400, { + password: { + message: "Invalid or missing fields" + } + }); + } + const strongPassword = await verifyPasswordStrength(newPassword); + if (!strongPassword) { + return fail(400, { + password: { + message: "Weak password" + } + }); + } + const passwordHash = getUserPasswordHash(event.locals.user.id); + const validPassword = await verifyPasswordHash(passwordHash, password); + if (!validPassword) { + return fail(400, { + password: { + message: "Incorrect password" + } + }); + } + invalidateUserSessions(event.locals.user.id); + await updateUserPassword(event.locals.user.id, newPassword); + + const sessionToken = generateSessionToken(); + const sessionFlags: SessionFlags = { + twoFactorVerified: event.locals.session.twoFactorVerified + }; + const session = createSession(sessionToken, event.locals.user.id, sessionFlags); + setSessionTokenCookie(event, sessionToken, session.expiresAt); + return { + password: { + message: "Updated password" + } + }; +} + +async function updateEmailAction(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401, { + email: { + message: "Not authenticated" + } + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403, { + email: { + message: "Forbidden" + } + }); + } + if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) { + return fail(429, { + email: { + message: "Too many requests" + } + }); + } + + const formData = await event.request.formData(); + const email = formData.get("email"); + if (typeof email !== "string") { + return fail(400, { + email: { + message: "Invalid or missing fields" + } + }); + } + if (email === "") { + return fail(400, { + email: { + message: "Please enter your email" + } + }); + } + if (!verifyEmailInput(email)) { + return fail(400, { + email: { + message: "Please enter a valid email" + } + }); + } + const emailAvailable = checkEmailAvailability(email); + if (!emailAvailable) { + return fail(400, { + email: { + message: "This email is already used" + } + }); + } + if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) { + return fail(429, { + email: { + message: "Too many requests" + } + }); + } + const verificationRequest = createEmailVerificationRequest(event.locals.user.id, email); + sendVerificationEmail(verificationRequest.email, verificationRequest.code); + setEmailVerificationRequestCookie(event, verificationRequest); + return redirect(302, "/verify-email"); +} + +async function disconnectTOTPAction(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401); + } + if (!event.locals.user.emailVerified) { + return fail(403); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403); + } + if (!totpUpdateBucket.consume(event.locals.user.id, 1)) { + return fail(429); + } + deleteUserTOTPKey(event.locals.user.id); + return {}; +} + +async function deletePasskeyAction(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return fail(401); + } + if (!event.locals.user.emailVerified) { + return fail(403); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403); + } + const formData = await event.request.formData(); + const encodedCredentialId = formData.get("credential_id"); + if (typeof encodedCredentialId !== "string") { + return fail(400); + } + let credentialId: Uint8Array; + try { + credentialId = decodeBase64(encodedCredentialId); + } catch { + return fail(400); + } + const deleted = deleteUserPasskeyCredential(event.locals.user.id, credentialId); + if (!deleted) { + return fail(400); + } + return {}; +} + +async function deleteSecurityKeyAction(event: RequestEvent) { + if (event.locals.user === null || event.locals.session === null) { + return fail(401); + } + if (!event.locals.user.emailVerified) { + return fail(403); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(403); + } + const formData = await event.request.formData(); + const encodedCredentialId = formData.get("credential_id"); + if (typeof encodedCredentialId !== "string") { + return fail(400); + } + let credentialId: Uint8Array; + try { + credentialId = decodeBase64(encodedCredentialId); + } catch { + return fail(400); + } + const deleted = deleteUserSecurityKeyCredential(event.locals.user.id, credentialId); + if (!deleted) { + return fail(400); + } + return {}; +} + +async function regenerateRecoveryCodeAction(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401); + } + if (!event.locals.user.emailVerified) { + return fail(403); + } + if (!event.locals.session.twoFactorVerified) { + return fail(403); + } + resetUserRecoveryCode(event.locals.session.userId); + return {}; +} diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..ee8e1d4 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,96 @@ + + +
+ Home + Settings +
+
+

Settings

+
+

Update email

+

Your email: {data.user.email}

+
+ +
+ +

{form?.email?.message ?? ""}

+
+
+
+

Update password

+
+ +
+ +
+ +

{form?.password?.message ?? ""}

+
+
+
+

Authenticator app

+ {#if data.user.registeredTOTP} + Update TOTP +
+ +
+ {:else} + Set up TOTP + {/if} +
+
+

Passkeys

+

Passkeys are WebAuthn credentials that validate your identity using your device.

+
    + {#each data.passkeyCredentials as credential} +
  • +

    {credential.name}

    +
    + + +
    +
  • + {/each} +
+ Add +
+
+

Security keys

+

Security keys are WebAuthn credentials that can only be used for two-factor authentication.

+
    + {#each data.securityKeyCredentials as credential} +
  • +

    {credential.name}

    +
    + + +
    +
  • + {/each} +
+ Add +
+ {#if data.recoveryCode !== null} +
+

Recovery code

+

Your recovery code is: {data.recoveryCode}}

+
+ +
+
+ {/if} +
diff --git a/src/routes/signup/+page.server.ts b/src/routes/signup/+page.server.ts new file mode 100644 index 0000000..5c48426 --- /dev/null +++ b/src/routes/signup/+page.server.ts @@ -0,0 +1,117 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email"; +import { createUser, verifyUsernameInput } from "$lib/server/user"; +import { RefillingTokenBucket } from "$lib/server/rate-limit"; +import { verifyPasswordStrength } from "$lib/server/password"; +import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; +import { + createEmailVerificationRequest, + sendVerificationEmail, + setEmailVerificationRequestCookie +} from "$lib/server/email-verification"; +import { get2FARedirect } from "$lib/server/2fa"; + +import type { SessionFlags } from "$lib/server/session"; +import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; + +const ipBucket = new RefillingTokenBucket(3, 10); + +export function load(event: PageServerLoadEvent) { + if (event.locals.session !== null && event.locals.user !== null) { + if (!event.locals.user.emailVerified) { + return redirect(302, "/verify-email"); + } + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + if (!event.locals.session.twoFactorVerified) { + return redirect(302, get2FARedirect(event.locals.user)); + } + return redirect(302, "/"); + } + return {}; +} + +export const actions: Actions = { + default: action +}; + +async function action(event: RequestEvent) { + // TODO: Assumes X-Forwarded-For is always included. + const clientIP = event.request.headers.get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return fail(429, { + message: "Too many requests", + email: "", + username: "" + }); + } + + const formData = await event.request.formData(); + const email = formData.get("email"); + const username = formData.get("username"); + const password = formData.get("password"); + if (typeof email !== "string" || typeof username !== "string" || typeof password !== "string") { + return fail(400, { + message: "Invalid or missing fields", + email: "", + username: "" + }); + } + if (email === "" || password === "" || username === "") { + return fail(400, { + message: "Please enter your username, email, and password", + email: "", + username: "" + }); + } + if (!verifyEmailInput(email)) { + return fail(400, { + message: "Invalid email", + email, + username + }); + } + const emailAvailable = checkEmailAvailability(email); + if (!emailAvailable) { + return fail(400, { + message: "Email is already used", + email, + username + }); + } + if (!verifyUsernameInput(username)) { + return fail(400, { + message: "Invalid username", + email, + username + }); + } + const strongPassword = await verifyPasswordStrength(password); + if (!strongPassword) { + return fail(400, { + message: "Weak password", + email, + username + }); + } + if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { + return fail(429, { + message: "Too many requests", + email, + username + }); + } + const user = await createUser(email, username, password); + const emailVerificationRequest = createEmailVerificationRequest(user.id, user.email); + sendVerificationEmail(emailVerificationRequest.email, emailVerificationRequest.code); + setEmailVerificationRequestCookie(event, emailVerificationRequest); + + const sessionFlags: SessionFlags = { + twoFactorVerified: false + }; + const sessionToken = generateSessionToken(); + const session = createSession(sessionToken, user.id, sessionFlags); + setSessionTokenCookie(event, sessionToken, session.expiresAt); + throw redirect(302, "/2fa/setup"); +} diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte new file mode 100644 index 0000000..275146f --- /dev/null +++ b/src/routes/signup/+page.svelte @@ -0,0 +1,35 @@ + + +

Create an account

+

Your username must be at least 3 characters long and your password must be at least 8 characters long.

+
+ +
+ +
+ +
+ +

{form?.message ?? ""}

+
+Sign in diff --git a/src/routes/verify-email/+page.server.ts b/src/routes/verify-email/+page.server.ts new file mode 100644 index 0000000..2fc7bd2 --- /dev/null +++ b/src/routes/verify-email/+page.server.ts @@ -0,0 +1,153 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { + createEmailVerificationRequest, + deleteEmailVerificationRequestCookie, + deleteUserEmailVerificationRequest, + getUserEmailVerificationRequestFromRequest, + sendVerificationEmail, + sendVerificationEmailBucket, + setEmailVerificationRequestCookie +} from "$lib/server/email-verification"; +import { invalidateUserPasswordResetSessions } from "$lib/server/password-reset"; +import { updateUserEmailAndSetEmailAsVerified } from "$lib/server/user"; +import { ExpiringTokenBucket } from "$lib/server/rate-limit"; + +import type { Actions, RequestEvent } from "./$types"; + +export async function load(event: RequestEvent) { + if (event.locals.user === null) { + return redirect(302, "/redirect"); + } + let verificationRequest = getUserEmailVerificationRequestFromRequest(event); + if (verificationRequest === null || Date.now() >= verificationRequest.expiresAt.getTime()) { + if (event.locals.user.emailVerified) { + return redirect(302, "/"); + } + // Note: We don't need rate limiting since it takes time before requests expire + verificationRequest = createEmailVerificationRequest(event.locals.user.id, event.locals.user.email); + sendVerificationEmail(verificationRequest.email, verificationRequest.code); + setEmailVerificationRequestCookie(event, verificationRequest); + } + return { + email: verificationRequest.email + }; +} + +const bucket = new ExpiringTokenBucket(5, 60 * 30); + +export const actions: Actions = { + verify: verifyCode, + resend: resendEmail +}; + +async function verifyCode(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(401); + } + if (!bucket.check(event.locals.user.id, 1)) { + return fail(429, { + verify: { + message: "Too many requests" + } + }); + } + + let verificationRequest = getUserEmailVerificationRequestFromRequest(event); + if (verificationRequest === null) { + return fail(401); + } + const formData = await event.request.formData(); + const code = formData.get("code"); + if (typeof code !== "string") { + return fail(400, { + verify: { + message: "Invalid or missing fields" + } + }); + } + if (code === "") { + return fail(400, { + verify: { + message: "Enter your code" + } + }); + } + if (!bucket.consume(event.locals.user.id, 1)) { + return fail(400, { + verify: { + message: "Too many requests" + } + }); + } + if (Date.now() >= verificationRequest.expiresAt.getTime()) { + verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email); + sendVerificationEmail(verificationRequest.email, verificationRequest.code); + return { + verify: { + message: "The verification code was expired. We sent another code to your inbox." + } + }; + } + if (verificationRequest.code !== code) { + return fail(400, { + verify: { + message: "Incorrect code." + } + }); + } + deleteUserEmailVerificationRequest(event.locals.user.id); + invalidateUserPasswordResetSessions(event.locals.user.id); + updateUserEmailAndSetEmailAsVerified(event.locals.user.id, verificationRequest.email); + deleteEmailVerificationRequestCookie(event); + if (!event.locals.user.registered2FA) { + return redirect(302, "/2fa/setup"); + } + return redirect(302, "/"); +} + +async function resendEmail(event: RequestEvent) { + if (event.locals.session === null || event.locals.user === null) { + return fail(401, { + resend: { + message: "Not authenticated" + } + }); + } + if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { + return fail(401, { + resend: { + message: "Forbidden" + } + }); + } + if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) { + return fail(429, { + resend: { + message: "Too many requests" + } + }); + } + + let verificationRequest = getUserEmailVerificationRequestFromRequest(event); + if (verificationRequest === null) { + return fail(401); + } + if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) { + return fail(429, { + resend: { + message: "Too many requests" + } + }); + } + verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email); + sendVerificationEmail(verificationRequest.email, verificationRequest.code); + setEmailVerificationRequestCookie(event, verificationRequest); + return { + resend: { + message: "A new code was sent to your inbox." + } + }; +} diff --git a/src/routes/verify-email/+page.svelte b/src/routes/verify-email/+page.svelte new file mode 100644 index 0000000..06415ee --- /dev/null +++ b/src/routes/verify-email/+page.svelte @@ -0,0 +1,22 @@ + + +

Verify your email address

+

We sent an 8-digit code to {data.email}.

+
+ + + +

{form?.verify?.message ?? ""}

+
+
+ +

{form?.resend?.message ?? ""}

+
+Change your email diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/static/favicon.png differ diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..578e765 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from "@sveltejs/adapter-auto"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc93cbd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..328b1f5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [sveltekit()] +});