From 25cc3970951ce4f894bc8039f330f7cdd29b3e4f Mon Sep 17 00:00:00 2001 From: pilcrowOnPaper Date: Thu, 3 Oct 2024 18:50:34 +0900 Subject: [PATCH] init --- .env.example | 1 + .gitignore | 23 + .npmrc | 1 + .prettierignore | 4 + .prettierrc | 7 + README.md | 50 + package.json | 39 + pnpm-lock.yaml | 1543 +++++++++++++++++ setup.sql | 56 + src/app.d.ts | 16 + src/app.html | 12 + src/hooks.server.ts | 49 + src/lib/client/webauthn.ts | 14 + src/lib/server/2fa.ts | 75 + src/lib/server/db.ts | 35 + src/lib/server/email-verification.ts | 100 ++ src/lib/server/email.ts | 13 + src/lib/server/encryption.ts | 39 + src/lib/server/password-reset.ts | 134 ++ src/lib/server/password.ts | 34 + src/lib/server/rate-limit.ts | 150 ++ src/lib/server/session.ts | 124 ++ src/lib/server/totp.ts | 37 + src/lib/server/user.ts | 108 ++ src/lib/server/utils.ts | 15 + src/lib/server/webauthn.ts | 145 ++ src/routes/+layout.svelte | 5 + src/routes/+page.server.ts | 38 + src/routes/+page.svelte | 18 + src/routes/2fa/+server.ts | 17 + src/routes/2fa/passkey/+page.server.ts | 28 + src/routes/2fa/passkey/+page.svelte | 65 + src/routes/2fa/passkey/+server.ts | 152 ++ .../2fa/passkey/register/+page.server.ts | 237 +++ src/routes/2fa/passkey/register/+page.svelte | 75 + src/routes/2fa/reset/+page.server.ts | 73 + src/routes/2fa/reset/+page.svelte | 15 + src/routes/2fa/security-key/+page.server.ts | 28 + src/routes/2fa/security-key/+page.svelte | 65 + src/routes/2fa/security-key/+server.ts | 152 ++ .../2fa/security-key/register/+page.server.ts | 241 +++ .../2fa/security-key/register/+page.svelte | 76 + src/routes/2fa/setup/+page.server.ts | 16 + src/routes/2fa/setup/+page.svelte | 6 + src/routes/2fa/totp/+page.server.ts | 83 + src/routes/2fa/totp/+page.svelte | 24 + src/routes/2fa/totp/setup/+page.server.ts | 106 ++ src/routes/2fa/totp/setup/+page.svelte | 20 + src/routes/api/webauthn/challenge/+server.ts | 19 + src/routes/forgot-password/+page.server.ts | 71 + src/routes/forgot-password/+page.svelte | 16 + src/routes/login/+page.server.ts | 108 ++ src/routes/login/+page.svelte | 70 + src/routes/login/passkey/+server.ts | 142 ++ src/routes/recovery-code/+page.server.ts | 24 + src/routes/recovery-code/+page.svelte | 10 + src/routes/reset-password/+page.server.ts | 81 + src/routes/reset-password/+page.svelte | 15 + src/routes/reset-password/2fa/+server.ts | 16 + .../2fa/passkey/+page.server.ts | 31 + .../reset-password/2fa/passkey/+page.svelte | 64 + .../reset-password/2fa/passkey/+server.ts | 153 ++ .../2fa/recovery-code/+page.server.ts | 79 + .../2fa/recovery-code/+page.svelte | 25 + .../2fa/security-key/+page.server.ts | 31 + .../2fa/security-key/+page.svelte | 64 + .../2fa/security-key/+server.ts | 153 ++ .../reset-password/2fa/totp/+page.server.ts | 85 + .../reset-password/2fa/totp/+page.svelte | 24 + .../verify-email/+page.server.ts | 84 + .../reset-password/verify-email/+page.svelte | 17 + src/routes/settings/+page.server.ts | 277 +++ src/routes/settings/+page.svelte | 96 + src/routes/signup/+page.server.ts | 117 ++ src/routes/signup/+page.svelte | 35 + src/routes/verify-email/+page.server.ts | 153 ++ src/routes/verify-email/+page.svelte | 22 + static/favicon.png | Bin 0 -> 1571 bytes svelte.config.js | 18 + tsconfig.json | 19 + vite.config.ts | 6 + 81 files changed, 6489 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 setup.sql create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/hooks.server.ts create mode 100644 src/lib/client/webauthn.ts create mode 100644 src/lib/server/2fa.ts create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/email-verification.ts create mode 100644 src/lib/server/email.ts create mode 100644 src/lib/server/encryption.ts create mode 100644 src/lib/server/password-reset.ts create mode 100644 src/lib/server/password.ts create mode 100644 src/lib/server/rate-limit.ts create mode 100644 src/lib/server/session.ts create mode 100644 src/lib/server/totp.ts create mode 100644 src/lib/server/user.ts create mode 100644 src/lib/server/utils.ts create mode 100644 src/lib/server/webauthn.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/2fa/+server.ts create mode 100644 src/routes/2fa/passkey/+page.server.ts create mode 100644 src/routes/2fa/passkey/+page.svelte create mode 100644 src/routes/2fa/passkey/+server.ts create mode 100644 src/routes/2fa/passkey/register/+page.server.ts create mode 100644 src/routes/2fa/passkey/register/+page.svelte create mode 100644 src/routes/2fa/reset/+page.server.ts create mode 100644 src/routes/2fa/reset/+page.svelte create mode 100644 src/routes/2fa/security-key/+page.server.ts create mode 100644 src/routes/2fa/security-key/+page.svelte create mode 100644 src/routes/2fa/security-key/+server.ts create mode 100644 src/routes/2fa/security-key/register/+page.server.ts create mode 100644 src/routes/2fa/security-key/register/+page.svelte create mode 100644 src/routes/2fa/setup/+page.server.ts create mode 100644 src/routes/2fa/setup/+page.svelte create mode 100644 src/routes/2fa/totp/+page.server.ts create mode 100644 src/routes/2fa/totp/+page.svelte create mode 100644 src/routes/2fa/totp/setup/+page.server.ts create mode 100644 src/routes/2fa/totp/setup/+page.svelte create mode 100644 src/routes/api/webauthn/challenge/+server.ts create mode 100644 src/routes/forgot-password/+page.server.ts create mode 100644 src/routes/forgot-password/+page.svelte create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/login/passkey/+server.ts create mode 100644 src/routes/recovery-code/+page.server.ts create mode 100644 src/routes/recovery-code/+page.svelte create mode 100644 src/routes/reset-password/+page.server.ts create mode 100644 src/routes/reset-password/+page.svelte create mode 100644 src/routes/reset-password/2fa/+server.ts create mode 100644 src/routes/reset-password/2fa/passkey/+page.server.ts create mode 100644 src/routes/reset-password/2fa/passkey/+page.svelte create mode 100644 src/routes/reset-password/2fa/passkey/+server.ts create mode 100644 src/routes/reset-password/2fa/recovery-code/+page.server.ts create mode 100644 src/routes/reset-password/2fa/recovery-code/+page.svelte create mode 100644 src/routes/reset-password/2fa/security-key/+page.server.ts create mode 100644 src/routes/reset-password/2fa/security-key/+page.svelte create mode 100644 src/routes/reset-password/2fa/security-key/+server.ts create mode 100644 src/routes/reset-password/2fa/totp/+page.server.ts create mode 100644 src/routes/reset-password/2fa/totp/+page.svelte create mode 100644 src/routes/reset-password/verify-email/+page.server.ts create mode 100644 src/routes/reset-password/verify-email/+page.svelte create mode 100644 src/routes/settings/+page.server.ts create mode 100644 src/routes/settings/+page.svelte create mode 100644 src/routes/signup/+page.server.ts create mode 100644 src/routes/signup/+page.svelte create mode 100644 src/routes/verify-email/+page.server.ts create mode 100644 src/routes/verify-email/+page.svelte create mode 100644 static/favicon.png create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 GIT binary patch literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH