├── .env.example ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── setup.sql ├── src ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── client │ │ └── webauthn.ts │ └── server │ │ ├── 2fa.ts │ │ ├── db.ts │ │ ├── email-verification.ts │ │ ├── email.ts │ │ ├── encryption.ts │ │ ├── password-reset.ts │ │ ├── password.ts │ │ ├── rate-limit.ts │ │ ├── session.ts │ │ ├── totp.ts │ │ ├── user.ts │ │ ├── utils.ts │ │ └── webauthn.ts └── routes │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── 2fa │ ├── +server.ts │ ├── passkey │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── +server.ts │ │ └── register │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── reset │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── security-key │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── +server.ts │ │ └── register │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── setup │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── totp │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── setup │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── api │ └── webauthn │ │ └── challenge │ │ └── +server.ts │ ├── forgot-password │ ├── +page.server.ts │ └── +page.svelte │ ├── login │ ├── +page.server.ts │ ├── +page.svelte │ └── passkey │ │ └── +server.ts │ ├── recovery-code │ ├── +page.server.ts │ └── +page.svelte │ ├── reset-password │ ├── +page.server.ts │ ├── +page.svelte │ ├── 2fa │ │ ├── +server.ts │ │ ├── passkey │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── +server.ts │ │ ├── recovery-code │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── security-key │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── +server.ts │ │ └── totp │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── verify-email │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── settings │ ├── +page.server.ts │ └── +page.svelte │ ├── signup │ ├── +page.server.ts │ └── +page.svelte │ └── verify-email │ ├── +page.server.ts │ └── +page.svelte ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | ENCRYPTION_KEY="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | 23 | sqlite.db -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "none", 4 | "printWidth": 120, 5 | "plugins": ["prettier-plugin-svelte"], 6 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 pilcrowOnPaper and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for 4 | any purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 7 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 8 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 9 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 10 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 11 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 12 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Email and password example with 2FA and WebAuthn in SvelteKit 2 | 3 | Built with SQLite. 4 | 5 | - Password checks with HaveIBeenPwned 6 | - Sign in with passkeys 7 | - Email verification 8 | - 2FA with TOTP 9 | - 2FA recovery codes 10 | - 2FA with passkeys and security keys 11 | - Password reset with 2FA 12 | - Login throttling and rate limiting 13 | 14 | Emails are just logged to the console. Rate limiting is implemented using JavaScript `Map`. 15 | 16 | ## Initialize project 17 | 18 | Create `sqlite.db` and run `setup.sql`. 19 | 20 | ``` 21 | sqlite3 sqlite.db 22 | ``` 23 | 24 | Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`. 25 | 26 | ```bash 27 | ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA==" 28 | ``` 29 | 30 | > You can use OpenSSL to quickly generate a secure key. 31 | > 32 | > ```bash 33 | > openssl rand --base64 16 34 | > ``` 35 | 36 | Install dependencies and run the application: 37 | 38 | ``` 39 | pnpm i 40 | pnpm dev 41 | ``` 42 | 43 | ## Notes 44 | 45 | - 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. 46 | - This example does not handle unexpected errors gracefully. 47 | - There are some major code duplications (specifically for 2FA) to keep the codebase simple. 48 | - TODO: Passkeys will only work when hosted on `localhost:5173`. Update the host and origin values before deploying. 49 | - TODO: You may need to rewrite some queries and use transactions to avoid race conditions when using MySQL, Postgres, etc. 50 | - TODO: This project relies on the `X-Forwarded-For` header for getting the client's IP address. 51 | - TODO: Logging should be implemented. 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-sveltekit-email-password-webauthn", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check .", 12 | "format": "prettier --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.0.0", 16 | "@sveltejs/kit": "^2.0.0", 17 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 18 | "@types/better-sqlite3": "^7.6.11", 19 | "prettier": "^3.1.1", 20 | "prettier-plugin-svelte": "^3.1.2", 21 | "svelte": "^4.2.7", 22 | "svelte-check": "^4.0.0", 23 | "typescript": "^5.0.0", 24 | "vite": "^5.0.3" 25 | }, 26 | "type": "module", 27 | "dependencies": { 28 | "@node-rs/argon2": "^1.8.3", 29 | "@oslojs/binary": "^1.0.0", 30 | "@oslojs/crypto": "^1.0.1", 31 | "@oslojs/encoding": "^1.1.0", 32 | "@oslojs/otp": "^1.0.0", 33 | "@oslojs/webauthn": "^1.0.0", 34 | "@pilcrowjs/db-query": "^0.0.2", 35 | "@pilcrowjs/object-parser": "^0.0.4", 36 | "better-sqlite3": "^11.3.0", 37 | "uqr": "^0.1.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | '@node-rs/argon2': 12 | specifier: ^1.8.3 13 | version: 1.8.3 14 | '@oslojs/binary': 15 | specifier: ^1.0.0 16 | version: 1.0.0 17 | '@oslojs/crypto': 18 | specifier: ^1.0.1 19 | version: 1.0.1 20 | '@oslojs/encoding': 21 | specifier: ^1.1.0 22 | version: 1.1.0 23 | '@oslojs/otp': 24 | specifier: ^1.0.0 25 | version: 1.0.0 26 | '@oslojs/webauthn': 27 | specifier: ^1.0.0 28 | version: 1.0.0 29 | '@pilcrowjs/db-query': 30 | specifier: ^0.0.2 31 | version: 0.0.2 32 | '@pilcrowjs/object-parser': 33 | specifier: ^0.0.4 34 | version: 0.0.4 35 | better-sqlite3: 36 | specifier: ^11.3.0 37 | version: 11.3.0 38 | uqr: 39 | specifier: ^0.1.2 40 | version: 0.1.2 41 | devDependencies: 42 | '@sveltejs/adapter-auto': 43 | specifier: ^3.0.0 44 | 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))) 45 | '@sveltejs/kit': 46 | specifier: ^2.0.0 47 | 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)) 48 | '@sveltejs/vite-plugin-svelte': 49 | specifier: ^3.0.0 50 | version: 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) 51 | '@types/better-sqlite3': 52 | specifier: ^7.6.11 53 | version: 7.6.11 54 | prettier: 55 | specifier: ^3.1.1 56 | version: 3.3.3 57 | prettier-plugin-svelte: 58 | specifier: ^3.1.2 59 | version: 3.2.7(prettier@3.3.3)(svelte@4.2.19) 60 | svelte: 61 | specifier: ^4.2.7 62 | version: 4.2.19 63 | svelte-check: 64 | specifier: ^4.0.0 65 | version: 4.0.4(svelte@4.2.19)(typescript@5.6.2) 66 | typescript: 67 | specifier: ^5.0.0 68 | version: 5.6.2 69 | vite: 70 | specifier: ^5.0.3 71 | version: 5.4.8(@types/node@22.7.4) 72 | 73 | packages: 74 | 75 | '@ampproject/remapping@2.3.0': 76 | resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} 77 | engines: {node: '>=6.0.0'} 78 | 79 | '@emnapi/core@1.2.0': 80 | resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==} 81 | 82 | '@emnapi/runtime@1.2.0': 83 | resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} 84 | 85 | '@emnapi/wasi-threads@1.0.1': 86 | resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} 87 | 88 | '@esbuild/aix-ppc64@0.21.5': 89 | resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} 90 | engines: {node: '>=12'} 91 | cpu: [ppc64] 92 | os: [aix] 93 | 94 | '@esbuild/android-arm64@0.21.5': 95 | resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} 96 | engines: {node: '>=12'} 97 | cpu: [arm64] 98 | os: [android] 99 | 100 | '@esbuild/android-arm@0.21.5': 101 | resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} 102 | engines: {node: '>=12'} 103 | cpu: [arm] 104 | os: [android] 105 | 106 | '@esbuild/android-x64@0.21.5': 107 | resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} 108 | engines: {node: '>=12'} 109 | cpu: [x64] 110 | os: [android] 111 | 112 | '@esbuild/darwin-arm64@0.21.5': 113 | resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} 114 | engines: {node: '>=12'} 115 | cpu: [arm64] 116 | os: [darwin] 117 | 118 | '@esbuild/darwin-x64@0.21.5': 119 | resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} 120 | engines: {node: '>=12'} 121 | cpu: [x64] 122 | os: [darwin] 123 | 124 | '@esbuild/freebsd-arm64@0.21.5': 125 | resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} 126 | engines: {node: '>=12'} 127 | cpu: [arm64] 128 | os: [freebsd] 129 | 130 | '@esbuild/freebsd-x64@0.21.5': 131 | resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} 132 | engines: {node: '>=12'} 133 | cpu: [x64] 134 | os: [freebsd] 135 | 136 | '@esbuild/linux-arm64@0.21.5': 137 | resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} 138 | engines: {node: '>=12'} 139 | cpu: [arm64] 140 | os: [linux] 141 | 142 | '@esbuild/linux-arm@0.21.5': 143 | resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} 144 | engines: {node: '>=12'} 145 | cpu: [arm] 146 | os: [linux] 147 | 148 | '@esbuild/linux-ia32@0.21.5': 149 | resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} 150 | engines: {node: '>=12'} 151 | cpu: [ia32] 152 | os: [linux] 153 | 154 | '@esbuild/linux-loong64@0.21.5': 155 | resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} 156 | engines: {node: '>=12'} 157 | cpu: [loong64] 158 | os: [linux] 159 | 160 | '@esbuild/linux-mips64el@0.21.5': 161 | resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} 162 | engines: {node: '>=12'} 163 | cpu: [mips64el] 164 | os: [linux] 165 | 166 | '@esbuild/linux-ppc64@0.21.5': 167 | resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} 168 | engines: {node: '>=12'} 169 | cpu: [ppc64] 170 | os: [linux] 171 | 172 | '@esbuild/linux-riscv64@0.21.5': 173 | resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} 174 | engines: {node: '>=12'} 175 | cpu: [riscv64] 176 | os: [linux] 177 | 178 | '@esbuild/linux-s390x@0.21.5': 179 | resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} 180 | engines: {node: '>=12'} 181 | cpu: [s390x] 182 | os: [linux] 183 | 184 | '@esbuild/linux-x64@0.21.5': 185 | resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} 186 | engines: {node: '>=12'} 187 | cpu: [x64] 188 | os: [linux] 189 | 190 | '@esbuild/netbsd-x64@0.21.5': 191 | resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} 192 | engines: {node: '>=12'} 193 | cpu: [x64] 194 | os: [netbsd] 195 | 196 | '@esbuild/openbsd-x64@0.21.5': 197 | resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} 198 | engines: {node: '>=12'} 199 | cpu: [x64] 200 | os: [openbsd] 201 | 202 | '@esbuild/sunos-x64@0.21.5': 203 | resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} 204 | engines: {node: '>=12'} 205 | cpu: [x64] 206 | os: [sunos] 207 | 208 | '@esbuild/win32-arm64@0.21.5': 209 | resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} 210 | engines: {node: '>=12'} 211 | cpu: [arm64] 212 | os: [win32] 213 | 214 | '@esbuild/win32-ia32@0.21.5': 215 | resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} 216 | engines: {node: '>=12'} 217 | cpu: [ia32] 218 | os: [win32] 219 | 220 | '@esbuild/win32-x64@0.21.5': 221 | resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} 222 | engines: {node: '>=12'} 223 | cpu: [x64] 224 | os: [win32] 225 | 226 | '@jridgewell/gen-mapping@0.3.5': 227 | resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} 228 | engines: {node: '>=6.0.0'} 229 | 230 | '@jridgewell/resolve-uri@3.1.2': 231 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 232 | engines: {node: '>=6.0.0'} 233 | 234 | '@jridgewell/set-array@1.2.1': 235 | resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} 236 | engines: {node: '>=6.0.0'} 237 | 238 | '@jridgewell/sourcemap-codec@1.5.0': 239 | resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} 240 | 241 | '@jridgewell/trace-mapping@0.3.25': 242 | resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} 243 | 244 | '@napi-rs/wasm-runtime@0.2.5': 245 | resolution: {integrity: sha512-kwUxR7J9WLutBbulqg1dfOrMTwhMdXLdcGUhcbCcGwnPLt3gz19uHVdwH1syKVDbE022ZS2vZxOWflFLS0YTjw==} 246 | 247 | '@node-rs/argon2-android-arm-eabi@1.8.3': 248 | resolution: {integrity: sha512-JFZPlNM0A8Og+Tncb8UZsQrhEMlbHBXPsT3hRoKImzVmTmq28Os0ucFWow0AACp2coLHBSydXH3Dh0lZup3rWw==} 249 | engines: {node: '>= 10'} 250 | cpu: [arm] 251 | os: [android] 252 | 253 | '@node-rs/argon2-android-arm64@1.8.3': 254 | resolution: {integrity: sha512-zaf8P3T92caeW2xnMA7P1QvRA4pIt/04oilYP44XlTCtMye//vwXDMeK53sl7dvYiJKnzAWDRx41k8vZvpZazg==} 255 | engines: {node: '>= 10'} 256 | cpu: [arm64] 257 | os: [android] 258 | 259 | '@node-rs/argon2-darwin-arm64@1.8.3': 260 | resolution: {integrity: sha512-DV/IbmLGdNXBtXb5o2UI5ba6kvqXqPAJgmMOTUCuHeBSp992GlLHdfU4rzGu0dNrxudBnunNZv+crd0YdEQSUA==} 261 | engines: {node: '>= 10'} 262 | cpu: [arm64] 263 | os: [darwin] 264 | 265 | '@node-rs/argon2-darwin-x64@1.8.3': 266 | resolution: {integrity: sha512-YMjmBGFZhLfYjfQ2gll9A+BZu/zAMV7lWZIbKxb7ZgEofILQwuGmExjDtY3Jplido/6leCEdpmlk2oIsME00LA==} 267 | engines: {node: '>= 10'} 268 | cpu: [x64] 269 | os: [darwin] 270 | 271 | '@node-rs/argon2-freebsd-x64@1.8.3': 272 | resolution: {integrity: sha512-Hq3Rj5Yb2RolTG/luRPnv+XiGCbi5nAK25Pc8ou/tVapwX+iktEm/NXbxc5zsMxraYVkCvfdwBjweC5O+KqCGw==} 273 | engines: {node: '>= 10'} 274 | cpu: [x64] 275 | os: [freebsd] 276 | 277 | '@node-rs/argon2-linux-arm-gnueabihf@1.8.3': 278 | resolution: {integrity: sha512-x49l8RgzKoG0/V0IXa5rrEl1TcJEc936ctlYFvqcunSOyowZ6kiWtrp1qrbOR8gbaNILl11KTF52vF6+h8UlEQ==} 279 | engines: {node: '>= 10'} 280 | cpu: [arm] 281 | os: [linux] 282 | 283 | '@node-rs/argon2-linux-arm64-gnu@1.8.3': 284 | resolution: {integrity: sha512-gJesam/qA63reGkb9qJ2TjFSLBtY41zQh2oei7nfnYsmVQPuHHWItJxEa1Bm21SPW53gZex4jFJbDIgj0+PxIw==} 285 | engines: {node: '>= 10'} 286 | cpu: [arm64] 287 | os: [linux] 288 | 289 | '@node-rs/argon2-linux-arm64-musl@1.8.3': 290 | resolution: {integrity: sha512-7O6kQdSKzB4Tjx/EBa8zKIxnmLkQE8VdJgPm6Ksrpn+ueo0mx2xf76fIDnbbTCtm3UbB+y+FkTo2wLA7tOqIKg==} 291 | engines: {node: '>= 10'} 292 | cpu: [arm64] 293 | os: [linux] 294 | 295 | '@node-rs/argon2-linux-x64-gnu@1.8.3': 296 | resolution: {integrity: sha512-OBH+EFG7BGjFyldaao2H2gSCLmjtrrwf420B1L+lFn7JLW9UAjsIPFKAcWsYwPa/PwYzIge9Y7SGcpqlsSEX0w==} 297 | engines: {node: '>= 10'} 298 | cpu: [x64] 299 | os: [linux] 300 | 301 | '@node-rs/argon2-linux-x64-musl@1.8.3': 302 | resolution: {integrity: sha512-bDbMuyekIxZaN7NaX+gHVkOyABB8bcMEJYeRPW1vCXKHj3brJns1wiUFSxqeUXreupifNVJlQfPt1Y5B/vFXgQ==} 303 | engines: {node: '>= 10'} 304 | cpu: [x64] 305 | os: [linux] 306 | 307 | '@node-rs/argon2-wasm32-wasi@1.8.3': 308 | resolution: {integrity: sha512-NBf2cMCDbNKMzp13Pog8ZPmI0M9U4Ak5b95EUjkp17kdKZFds12dwW67EMnj7Zy+pRqby2QLECaWebDYfNENTg==} 309 | engines: {node: '>=14.0.0'} 310 | cpu: [wasm32] 311 | 312 | '@node-rs/argon2-win32-arm64-msvc@1.8.3': 313 | resolution: {integrity: sha512-AHpPo7UbdW5WWjwreVpgFSY0o1RY4A7cUFaqDXZB2OqEuyrhMxBdZct9PX7PQKI18D85pLsODnR+gvVuTwJ6rQ==} 314 | engines: {node: '>= 10'} 315 | cpu: [arm64] 316 | os: [win32] 317 | 318 | '@node-rs/argon2-win32-ia32-msvc@1.8.3': 319 | resolution: {integrity: sha512-bqzn2rcQkEwCINefhm69ttBVVkgHJb/V03DdBKsPFtiX6H47axXKz62d1imi26zFXhOEYxhKbu3js03GobJOLw==} 320 | engines: {node: '>= 10'} 321 | cpu: [ia32] 322 | os: [win32] 323 | 324 | '@node-rs/argon2-win32-x64-msvc@1.8.3': 325 | resolution: {integrity: sha512-ILlrRThdbp5xNR5gwYM2ic1n/vG5rJ8dQZ+YMRqksl+lnTJ/6FDe5BOyIhiPtiDwlCiCtUA+1NxpDB9KlUCAIA==} 326 | engines: {node: '>= 10'} 327 | cpu: [x64] 328 | os: [win32] 329 | 330 | '@node-rs/argon2@1.8.3': 331 | resolution: {integrity: sha512-sf/QAEI59hsMEEE2J8vO4hKrXrv4Oplte3KI2N4MhMDYpytH0drkVfErmHBfWFZxxIEK03fX1WsBNswS2nIZKg==} 332 | engines: {node: '>= 10'} 333 | 334 | '@oslojs/asn1@1.0.0': 335 | resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} 336 | 337 | '@oslojs/binary@1.0.0': 338 | resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} 339 | 340 | '@oslojs/cbor@1.0.0': 341 | resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} 342 | 343 | '@oslojs/crypto@1.0.0': 344 | resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} 345 | 346 | '@oslojs/crypto@1.0.1': 347 | resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} 348 | 349 | '@oslojs/encoding@1.0.0': 350 | resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} 351 | 352 | '@oslojs/encoding@1.1.0': 353 | resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} 354 | 355 | '@oslojs/otp@1.0.0': 356 | resolution: {integrity: sha512-w/vZfoVsFCCcmsmsXVsIMoWbvr1IZmQ9BsDZwdePSpe8rFKMD1Knd+05iJr415adXkFVyu0tYxgrLPYMynNtXQ==} 357 | 358 | '@oslojs/webauthn@1.0.0': 359 | resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} 360 | 361 | '@pilcrowjs/db-query@0.0.2': 362 | resolution: {integrity: sha512-d1iARoIxeUL2cTGhJe4JPhp/n1sXtgnM1mL7elrfsKjdwwjWTDyPDtVcGQy6W7RvrtZ40Wh0pdeYdBnboQjewg==} 363 | 364 | '@pilcrowjs/object-parser@0.0.4': 365 | resolution: {integrity: sha512-mBy3FMv2lvl/sZX/q03wvl3Km8FWg7kbrqQ/qMxK49uZcBssD76Js5k+o7VuCDJI8SNvsrbIX8y6vclx7bWeSg==} 366 | 367 | '@polka/url@1.0.0-next.28': 368 | resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} 369 | 370 | '@rollup/rollup-android-arm-eabi@4.24.0': 371 | resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==} 372 | cpu: [arm] 373 | os: [android] 374 | 375 | '@rollup/rollup-android-arm64@4.24.0': 376 | resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} 377 | cpu: [arm64] 378 | os: [android] 379 | 380 | '@rollup/rollup-darwin-arm64@4.24.0': 381 | resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} 382 | cpu: [arm64] 383 | os: [darwin] 384 | 385 | '@rollup/rollup-darwin-x64@4.24.0': 386 | resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} 387 | cpu: [x64] 388 | os: [darwin] 389 | 390 | '@rollup/rollup-linux-arm-gnueabihf@4.24.0': 391 | resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} 392 | cpu: [arm] 393 | os: [linux] 394 | 395 | '@rollup/rollup-linux-arm-musleabihf@4.24.0': 396 | resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} 397 | cpu: [arm] 398 | os: [linux] 399 | 400 | '@rollup/rollup-linux-arm64-gnu@4.24.0': 401 | resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} 402 | cpu: [arm64] 403 | os: [linux] 404 | 405 | '@rollup/rollup-linux-arm64-musl@4.24.0': 406 | resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} 407 | cpu: [arm64] 408 | os: [linux] 409 | 410 | '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': 411 | resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} 412 | cpu: [ppc64] 413 | os: [linux] 414 | 415 | '@rollup/rollup-linux-riscv64-gnu@4.24.0': 416 | resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} 417 | cpu: [riscv64] 418 | os: [linux] 419 | 420 | '@rollup/rollup-linux-s390x-gnu@4.24.0': 421 | resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} 422 | cpu: [s390x] 423 | os: [linux] 424 | 425 | '@rollup/rollup-linux-x64-gnu@4.24.0': 426 | resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} 427 | cpu: [x64] 428 | os: [linux] 429 | 430 | '@rollup/rollup-linux-x64-musl@4.24.0': 431 | resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} 432 | cpu: [x64] 433 | os: [linux] 434 | 435 | '@rollup/rollup-win32-arm64-msvc@4.24.0': 436 | resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} 437 | cpu: [arm64] 438 | os: [win32] 439 | 440 | '@rollup/rollup-win32-ia32-msvc@4.24.0': 441 | resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} 442 | cpu: [ia32] 443 | os: [win32] 444 | 445 | '@rollup/rollup-win32-x64-msvc@4.24.0': 446 | resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} 447 | cpu: [x64] 448 | os: [win32] 449 | 450 | '@sveltejs/adapter-auto@3.2.5': 451 | resolution: {integrity: sha512-27LR+uKccZ62lgq4N/hvyU2G+hTP9fxWEAfnZcl70HnyfAjMSsGk1z/SjAPXNCD1mVJIE7IFu3TQ8cQ/UH3c0A==} 452 | peerDependencies: 453 | '@sveltejs/kit': ^2.0.0 454 | 455 | '@sveltejs/kit@2.6.1': 456 | resolution: {integrity: sha512-QFlch3GPGZYidYhdRAub0fONw8UTguPICFHUSPxNkA/jdlU1p6C6yqq19J1QWdxIHS2El/ycDCGrHb3EAiMNqg==} 457 | engines: {node: '>=18.13'} 458 | hasBin: true 459 | peerDependencies: 460 | '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 461 | svelte: ^4.0.0 || ^5.0.0-next.0 462 | vite: ^5.0.3 463 | 464 | '@sveltejs/vite-plugin-svelte-inspector@2.1.0': 465 | resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} 466 | engines: {node: ^18.0.0 || >=20} 467 | peerDependencies: 468 | '@sveltejs/vite-plugin-svelte': ^3.0.0 469 | svelte: ^4.0.0 || ^5.0.0-next.0 470 | vite: ^5.0.0 471 | 472 | '@sveltejs/vite-plugin-svelte@3.1.2': 473 | resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==} 474 | engines: {node: ^18.0.0 || >=20} 475 | peerDependencies: 476 | svelte: ^4.0.0 || ^5.0.0-next.0 477 | vite: ^5.0.0 478 | 479 | '@tybys/wasm-util@0.9.0': 480 | resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} 481 | 482 | '@types/better-sqlite3@7.6.11': 483 | resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==} 484 | 485 | '@types/cookie@0.6.0': 486 | resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 487 | 488 | '@types/estree@1.0.6': 489 | resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} 490 | 491 | '@types/node@22.7.4': 492 | resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} 493 | 494 | acorn@8.12.1: 495 | resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} 496 | engines: {node: '>=0.4.0'} 497 | hasBin: true 498 | 499 | aria-query@5.3.2: 500 | resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} 501 | engines: {node: '>= 0.4'} 502 | 503 | axobject-query@4.1.0: 504 | resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 505 | engines: {node: '>= 0.4'} 506 | 507 | base64-js@1.5.1: 508 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 509 | 510 | better-sqlite3@11.3.0: 511 | resolution: {integrity: sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==} 512 | 513 | bindings@1.5.0: 514 | resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} 515 | 516 | bl@4.1.0: 517 | resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} 518 | 519 | buffer@5.7.1: 520 | resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} 521 | 522 | chokidar@4.0.1: 523 | resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} 524 | engines: {node: '>= 14.16.0'} 525 | 526 | chownr@1.1.4: 527 | resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} 528 | 529 | code-red@1.0.4: 530 | resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} 531 | 532 | cookie@0.6.0: 533 | resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 534 | engines: {node: '>= 0.6'} 535 | 536 | css-tree@2.3.1: 537 | resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} 538 | engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} 539 | 540 | debug@4.3.7: 541 | resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} 542 | engines: {node: '>=6.0'} 543 | peerDependencies: 544 | supports-color: '*' 545 | peerDependenciesMeta: 546 | supports-color: 547 | optional: true 548 | 549 | decompress-response@6.0.0: 550 | resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} 551 | engines: {node: '>=10'} 552 | 553 | deep-extend@0.6.0: 554 | resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} 555 | engines: {node: '>=4.0.0'} 556 | 557 | deepmerge@4.3.1: 558 | resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 559 | engines: {node: '>=0.10.0'} 560 | 561 | detect-libc@2.0.3: 562 | resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} 563 | engines: {node: '>=8'} 564 | 565 | devalue@5.1.1: 566 | resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} 567 | 568 | end-of-stream@1.4.4: 569 | resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} 570 | 571 | esbuild@0.21.5: 572 | resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} 573 | engines: {node: '>=12'} 574 | hasBin: true 575 | 576 | esm-env@1.0.0: 577 | resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} 578 | 579 | estree-walker@3.0.3: 580 | resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 581 | 582 | expand-template@2.0.3: 583 | resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} 584 | engines: {node: '>=6'} 585 | 586 | fdir@6.4.0: 587 | resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==} 588 | peerDependencies: 589 | picomatch: ^3 || ^4 590 | peerDependenciesMeta: 591 | picomatch: 592 | optional: true 593 | 594 | file-uri-to-path@1.0.0: 595 | resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} 596 | 597 | fs-constants@1.0.0: 598 | resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} 599 | 600 | fsevents@2.3.3: 601 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 602 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 603 | os: [darwin] 604 | 605 | github-from-package@0.0.0: 606 | resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} 607 | 608 | globalyzer@0.1.0: 609 | resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} 610 | 611 | globrex@0.1.2: 612 | resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} 613 | 614 | ieee754@1.2.1: 615 | resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 616 | 617 | import-meta-resolve@4.1.0: 618 | resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} 619 | 620 | inherits@2.0.4: 621 | resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 622 | 623 | ini@1.3.8: 624 | resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} 625 | 626 | is-reference@3.0.2: 627 | resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} 628 | 629 | kleur@4.1.5: 630 | resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 631 | engines: {node: '>=6'} 632 | 633 | locate-character@3.0.0: 634 | resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 635 | 636 | magic-string@0.30.11: 637 | resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} 638 | 639 | mdn-data@2.0.30: 640 | resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} 641 | 642 | mimic-response@3.1.0: 643 | resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} 644 | engines: {node: '>=10'} 645 | 646 | minimist@1.2.8: 647 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 648 | 649 | mkdirp-classic@0.5.3: 650 | resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} 651 | 652 | mri@1.2.0: 653 | resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 654 | engines: {node: '>=4'} 655 | 656 | mrmime@2.0.0: 657 | resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} 658 | engines: {node: '>=10'} 659 | 660 | ms@2.1.3: 661 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 662 | 663 | nanoid@3.3.7: 664 | resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 665 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 666 | hasBin: true 667 | 668 | napi-build-utils@1.0.2: 669 | resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} 670 | 671 | node-abi@3.68.0: 672 | resolution: {integrity: sha512-7vbj10trelExNjFSBm5kTvZXXa7pZyKWx9RCKIyqe6I9Ev3IzGpQoqBP3a+cOdxY+pWj6VkP28n/2wWysBHD/A==} 673 | engines: {node: '>=10'} 674 | 675 | once@1.4.0: 676 | resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} 677 | 678 | periscopic@3.1.0: 679 | resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} 680 | 681 | picocolors@1.1.0: 682 | resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} 683 | 684 | postcss@8.4.47: 685 | resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} 686 | engines: {node: ^10 || ^12 || >=14} 687 | 688 | prebuild-install@7.1.2: 689 | resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} 690 | engines: {node: '>=10'} 691 | hasBin: true 692 | 693 | prettier-plugin-svelte@3.2.7: 694 | resolution: {integrity: sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==} 695 | peerDependencies: 696 | prettier: ^3.0.0 697 | svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 698 | 699 | prettier@3.3.3: 700 | resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} 701 | engines: {node: '>=14'} 702 | hasBin: true 703 | 704 | pump@3.0.2: 705 | resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} 706 | 707 | rc@1.2.8: 708 | resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} 709 | hasBin: true 710 | 711 | readable-stream@3.6.2: 712 | resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} 713 | engines: {node: '>= 6'} 714 | 715 | readdirp@4.0.1: 716 | resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} 717 | engines: {node: '>= 14.16.0'} 718 | 719 | rollup@4.24.0: 720 | resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} 721 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} 722 | hasBin: true 723 | 724 | sade@1.8.1: 725 | resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 726 | engines: {node: '>=6'} 727 | 728 | safe-buffer@5.2.1: 729 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 730 | 731 | semver@7.6.3: 732 | resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} 733 | engines: {node: '>=10'} 734 | hasBin: true 735 | 736 | set-cookie-parser@2.7.0: 737 | resolution: {integrity: sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==} 738 | 739 | simple-concat@1.0.1: 740 | resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} 741 | 742 | simple-get@4.0.1: 743 | resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} 744 | 745 | sirv@2.0.4: 746 | resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} 747 | engines: {node: '>= 10'} 748 | 749 | source-map-js@1.2.1: 750 | resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 751 | engines: {node: '>=0.10.0'} 752 | 753 | string_decoder@1.3.0: 754 | resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 755 | 756 | strip-json-comments@2.0.1: 757 | resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} 758 | engines: {node: '>=0.10.0'} 759 | 760 | svelte-check@4.0.4: 761 | resolution: {integrity: sha512-AcHWIPuZb1mh/jKoIrww0ebBPpAvwWd1bfXCnwC2dx4OkydNMaiG//+Xnry91RJMHFH7CiE+6Y2p332DRIaOXQ==} 762 | engines: {node: '>= 18.0.0'} 763 | hasBin: true 764 | peerDependencies: 765 | svelte: ^4.0.0 || ^5.0.0-next.0 766 | typescript: '>=5.0.0' 767 | 768 | svelte-hmr@0.16.0: 769 | resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} 770 | engines: {node: ^12.20 || ^14.13.1 || >= 16} 771 | peerDependencies: 772 | svelte: ^3.19.0 || ^4.0.0 773 | 774 | svelte@4.2.19: 775 | resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} 776 | engines: {node: '>=16'} 777 | 778 | tar-fs@2.1.1: 779 | resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} 780 | 781 | tar-stream@2.2.0: 782 | resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} 783 | engines: {node: '>=6'} 784 | 785 | tiny-glob@0.2.9: 786 | resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} 787 | 788 | totalist@3.0.1: 789 | resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 790 | engines: {node: '>=6'} 791 | 792 | tslib@2.7.0: 793 | resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} 794 | 795 | tunnel-agent@0.6.0: 796 | resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} 797 | 798 | typescript@5.6.2: 799 | resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} 800 | engines: {node: '>=14.17'} 801 | hasBin: true 802 | 803 | undici-types@6.19.8: 804 | resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 805 | 806 | uqr@0.1.2: 807 | resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} 808 | 809 | util-deprecate@1.0.2: 810 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 811 | 812 | vite@5.4.8: 813 | resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} 814 | engines: {node: ^18.0.0 || >=20.0.0} 815 | hasBin: true 816 | peerDependencies: 817 | '@types/node': ^18.0.0 || >=20.0.0 818 | less: '*' 819 | lightningcss: ^1.21.0 820 | sass: '*' 821 | sass-embedded: '*' 822 | stylus: '*' 823 | sugarss: '*' 824 | terser: ^5.4.0 825 | peerDependenciesMeta: 826 | '@types/node': 827 | optional: true 828 | less: 829 | optional: true 830 | lightningcss: 831 | optional: true 832 | sass: 833 | optional: true 834 | sass-embedded: 835 | optional: true 836 | stylus: 837 | optional: true 838 | sugarss: 839 | optional: true 840 | terser: 841 | optional: true 842 | 843 | vitefu@0.2.5: 844 | resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} 845 | peerDependencies: 846 | vite: ^3.0.0 || ^4.0.0 || ^5.0.0 847 | peerDependenciesMeta: 848 | vite: 849 | optional: true 850 | 851 | wrappy@1.0.2: 852 | resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 853 | 854 | snapshots: 855 | 856 | '@ampproject/remapping@2.3.0': 857 | dependencies: 858 | '@jridgewell/gen-mapping': 0.3.5 859 | '@jridgewell/trace-mapping': 0.3.25 860 | 861 | '@emnapi/core@1.2.0': 862 | dependencies: 863 | '@emnapi/wasi-threads': 1.0.1 864 | tslib: 2.7.0 865 | optional: true 866 | 867 | '@emnapi/runtime@1.2.0': 868 | dependencies: 869 | tslib: 2.7.0 870 | optional: true 871 | 872 | '@emnapi/wasi-threads@1.0.1': 873 | dependencies: 874 | tslib: 2.7.0 875 | optional: true 876 | 877 | '@esbuild/aix-ppc64@0.21.5': 878 | optional: true 879 | 880 | '@esbuild/android-arm64@0.21.5': 881 | optional: true 882 | 883 | '@esbuild/android-arm@0.21.5': 884 | optional: true 885 | 886 | '@esbuild/android-x64@0.21.5': 887 | optional: true 888 | 889 | '@esbuild/darwin-arm64@0.21.5': 890 | optional: true 891 | 892 | '@esbuild/darwin-x64@0.21.5': 893 | optional: true 894 | 895 | '@esbuild/freebsd-arm64@0.21.5': 896 | optional: true 897 | 898 | '@esbuild/freebsd-x64@0.21.5': 899 | optional: true 900 | 901 | '@esbuild/linux-arm64@0.21.5': 902 | optional: true 903 | 904 | '@esbuild/linux-arm@0.21.5': 905 | optional: true 906 | 907 | '@esbuild/linux-ia32@0.21.5': 908 | optional: true 909 | 910 | '@esbuild/linux-loong64@0.21.5': 911 | optional: true 912 | 913 | '@esbuild/linux-mips64el@0.21.5': 914 | optional: true 915 | 916 | '@esbuild/linux-ppc64@0.21.5': 917 | optional: true 918 | 919 | '@esbuild/linux-riscv64@0.21.5': 920 | optional: true 921 | 922 | '@esbuild/linux-s390x@0.21.5': 923 | optional: true 924 | 925 | '@esbuild/linux-x64@0.21.5': 926 | optional: true 927 | 928 | '@esbuild/netbsd-x64@0.21.5': 929 | optional: true 930 | 931 | '@esbuild/openbsd-x64@0.21.5': 932 | optional: true 933 | 934 | '@esbuild/sunos-x64@0.21.5': 935 | optional: true 936 | 937 | '@esbuild/win32-arm64@0.21.5': 938 | optional: true 939 | 940 | '@esbuild/win32-ia32@0.21.5': 941 | optional: true 942 | 943 | '@esbuild/win32-x64@0.21.5': 944 | optional: true 945 | 946 | '@jridgewell/gen-mapping@0.3.5': 947 | dependencies: 948 | '@jridgewell/set-array': 1.2.1 949 | '@jridgewell/sourcemap-codec': 1.5.0 950 | '@jridgewell/trace-mapping': 0.3.25 951 | 952 | '@jridgewell/resolve-uri@3.1.2': {} 953 | 954 | '@jridgewell/set-array@1.2.1': {} 955 | 956 | '@jridgewell/sourcemap-codec@1.5.0': {} 957 | 958 | '@jridgewell/trace-mapping@0.3.25': 959 | dependencies: 960 | '@jridgewell/resolve-uri': 3.1.2 961 | '@jridgewell/sourcemap-codec': 1.5.0 962 | 963 | '@napi-rs/wasm-runtime@0.2.5': 964 | dependencies: 965 | '@emnapi/core': 1.2.0 966 | '@emnapi/runtime': 1.2.0 967 | '@tybys/wasm-util': 0.9.0 968 | optional: true 969 | 970 | '@node-rs/argon2-android-arm-eabi@1.8.3': 971 | optional: true 972 | 973 | '@node-rs/argon2-android-arm64@1.8.3': 974 | optional: true 975 | 976 | '@node-rs/argon2-darwin-arm64@1.8.3': 977 | optional: true 978 | 979 | '@node-rs/argon2-darwin-x64@1.8.3': 980 | optional: true 981 | 982 | '@node-rs/argon2-freebsd-x64@1.8.3': 983 | optional: true 984 | 985 | '@node-rs/argon2-linux-arm-gnueabihf@1.8.3': 986 | optional: true 987 | 988 | '@node-rs/argon2-linux-arm64-gnu@1.8.3': 989 | optional: true 990 | 991 | '@node-rs/argon2-linux-arm64-musl@1.8.3': 992 | optional: true 993 | 994 | '@node-rs/argon2-linux-x64-gnu@1.8.3': 995 | optional: true 996 | 997 | '@node-rs/argon2-linux-x64-musl@1.8.3': 998 | optional: true 999 | 1000 | '@node-rs/argon2-wasm32-wasi@1.8.3': 1001 | dependencies: 1002 | '@napi-rs/wasm-runtime': 0.2.5 1003 | optional: true 1004 | 1005 | '@node-rs/argon2-win32-arm64-msvc@1.8.3': 1006 | optional: true 1007 | 1008 | '@node-rs/argon2-win32-ia32-msvc@1.8.3': 1009 | optional: true 1010 | 1011 | '@node-rs/argon2-win32-x64-msvc@1.8.3': 1012 | optional: true 1013 | 1014 | '@node-rs/argon2@1.8.3': 1015 | optionalDependencies: 1016 | '@node-rs/argon2-android-arm-eabi': 1.8.3 1017 | '@node-rs/argon2-android-arm64': 1.8.3 1018 | '@node-rs/argon2-darwin-arm64': 1.8.3 1019 | '@node-rs/argon2-darwin-x64': 1.8.3 1020 | '@node-rs/argon2-freebsd-x64': 1.8.3 1021 | '@node-rs/argon2-linux-arm-gnueabihf': 1.8.3 1022 | '@node-rs/argon2-linux-arm64-gnu': 1.8.3 1023 | '@node-rs/argon2-linux-arm64-musl': 1.8.3 1024 | '@node-rs/argon2-linux-x64-gnu': 1.8.3 1025 | '@node-rs/argon2-linux-x64-musl': 1.8.3 1026 | '@node-rs/argon2-wasm32-wasi': 1.8.3 1027 | '@node-rs/argon2-win32-arm64-msvc': 1.8.3 1028 | '@node-rs/argon2-win32-ia32-msvc': 1.8.3 1029 | '@node-rs/argon2-win32-x64-msvc': 1.8.3 1030 | 1031 | '@oslojs/asn1@1.0.0': 1032 | dependencies: 1033 | '@oslojs/binary': 1.0.0 1034 | 1035 | '@oslojs/binary@1.0.0': {} 1036 | 1037 | '@oslojs/cbor@1.0.0': 1038 | dependencies: 1039 | '@oslojs/binary': 1.0.0 1040 | 1041 | '@oslojs/crypto@1.0.0': 1042 | dependencies: 1043 | '@oslojs/asn1': 1.0.0 1044 | '@oslojs/binary': 1.0.0 1045 | 1046 | '@oslojs/crypto@1.0.1': 1047 | dependencies: 1048 | '@oslojs/asn1': 1.0.0 1049 | '@oslojs/binary': 1.0.0 1050 | 1051 | '@oslojs/encoding@1.0.0': {} 1052 | 1053 | '@oslojs/encoding@1.1.0': {} 1054 | 1055 | '@oslojs/otp@1.0.0': 1056 | dependencies: 1057 | '@oslojs/binary': 1.0.0 1058 | '@oslojs/crypto': 1.0.0 1059 | '@oslojs/encoding': 1.0.0 1060 | 1061 | '@oslojs/webauthn@1.0.0': 1062 | dependencies: 1063 | '@oslojs/asn1': 1.0.0 1064 | '@oslojs/binary': 1.0.0 1065 | '@oslojs/cbor': 1.0.0 1066 | '@oslojs/crypto': 1.0.0 1067 | '@oslojs/encoding': 1.0.0 1068 | 1069 | '@pilcrowjs/db-query@0.0.2': {} 1070 | 1071 | '@pilcrowjs/object-parser@0.0.4': {} 1072 | 1073 | '@polka/url@1.0.0-next.28': {} 1074 | 1075 | '@rollup/rollup-android-arm-eabi@4.24.0': 1076 | optional: true 1077 | 1078 | '@rollup/rollup-android-arm64@4.24.0': 1079 | optional: true 1080 | 1081 | '@rollup/rollup-darwin-arm64@4.24.0': 1082 | optional: true 1083 | 1084 | '@rollup/rollup-darwin-x64@4.24.0': 1085 | optional: true 1086 | 1087 | '@rollup/rollup-linux-arm-gnueabihf@4.24.0': 1088 | optional: true 1089 | 1090 | '@rollup/rollup-linux-arm-musleabihf@4.24.0': 1091 | optional: true 1092 | 1093 | '@rollup/rollup-linux-arm64-gnu@4.24.0': 1094 | optional: true 1095 | 1096 | '@rollup/rollup-linux-arm64-musl@4.24.0': 1097 | optional: true 1098 | 1099 | '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': 1100 | optional: true 1101 | 1102 | '@rollup/rollup-linux-riscv64-gnu@4.24.0': 1103 | optional: true 1104 | 1105 | '@rollup/rollup-linux-s390x-gnu@4.24.0': 1106 | optional: true 1107 | 1108 | '@rollup/rollup-linux-x64-gnu@4.24.0': 1109 | optional: true 1110 | 1111 | '@rollup/rollup-linux-x64-musl@4.24.0': 1112 | optional: true 1113 | 1114 | '@rollup/rollup-win32-arm64-msvc@4.24.0': 1115 | optional: true 1116 | 1117 | '@rollup/rollup-win32-ia32-msvc@4.24.0': 1118 | optional: true 1119 | 1120 | '@rollup/rollup-win32-x64-msvc@4.24.0': 1121 | optional: true 1122 | 1123 | '@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)))': 1124 | dependencies: 1125 | '@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)) 1126 | import-meta-resolve: 4.1.0 1127 | 1128 | '@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))': 1129 | dependencies: 1130 | '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) 1131 | '@types/cookie': 0.6.0 1132 | cookie: 0.6.0 1133 | devalue: 5.1.1 1134 | esm-env: 1.0.0 1135 | import-meta-resolve: 4.1.0 1136 | kleur: 4.1.5 1137 | magic-string: 0.30.11 1138 | mrmime: 2.0.0 1139 | sade: 1.8.1 1140 | set-cookie-parser: 2.7.0 1141 | sirv: 2.0.4 1142 | svelte: 4.2.19 1143 | tiny-glob: 0.2.9 1144 | vite: 5.4.8(@types/node@22.7.4) 1145 | 1146 | '@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))': 1147 | dependencies: 1148 | '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4)) 1149 | debug: 4.3.7 1150 | svelte: 4.2.19 1151 | vite: 5.4.8(@types/node@22.7.4) 1152 | transitivePeerDependencies: 1153 | - supports-color 1154 | 1155 | '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.8(@types/node@22.7.4))': 1156 | dependencies: 1157 | '@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)) 1158 | debug: 4.3.7 1159 | deepmerge: 4.3.1 1160 | kleur: 4.1.5 1161 | magic-string: 0.30.11 1162 | svelte: 4.2.19 1163 | svelte-hmr: 0.16.0(svelte@4.2.19) 1164 | vite: 5.4.8(@types/node@22.7.4) 1165 | vitefu: 0.2.5(vite@5.4.8(@types/node@22.7.4)) 1166 | transitivePeerDependencies: 1167 | - supports-color 1168 | 1169 | '@tybys/wasm-util@0.9.0': 1170 | dependencies: 1171 | tslib: 2.7.0 1172 | optional: true 1173 | 1174 | '@types/better-sqlite3@7.6.11': 1175 | dependencies: 1176 | '@types/node': 22.7.4 1177 | 1178 | '@types/cookie@0.6.0': {} 1179 | 1180 | '@types/estree@1.0.6': {} 1181 | 1182 | '@types/node@22.7.4': 1183 | dependencies: 1184 | undici-types: 6.19.8 1185 | 1186 | acorn@8.12.1: {} 1187 | 1188 | aria-query@5.3.2: {} 1189 | 1190 | axobject-query@4.1.0: {} 1191 | 1192 | base64-js@1.5.1: {} 1193 | 1194 | better-sqlite3@11.3.0: 1195 | dependencies: 1196 | bindings: 1.5.0 1197 | prebuild-install: 7.1.2 1198 | 1199 | bindings@1.5.0: 1200 | dependencies: 1201 | file-uri-to-path: 1.0.0 1202 | 1203 | bl@4.1.0: 1204 | dependencies: 1205 | buffer: 5.7.1 1206 | inherits: 2.0.4 1207 | readable-stream: 3.6.2 1208 | 1209 | buffer@5.7.1: 1210 | dependencies: 1211 | base64-js: 1.5.1 1212 | ieee754: 1.2.1 1213 | 1214 | chokidar@4.0.1: 1215 | dependencies: 1216 | readdirp: 4.0.1 1217 | 1218 | chownr@1.1.4: {} 1219 | 1220 | code-red@1.0.4: 1221 | dependencies: 1222 | '@jridgewell/sourcemap-codec': 1.5.0 1223 | '@types/estree': 1.0.6 1224 | acorn: 8.12.1 1225 | estree-walker: 3.0.3 1226 | periscopic: 3.1.0 1227 | 1228 | cookie@0.6.0: {} 1229 | 1230 | css-tree@2.3.1: 1231 | dependencies: 1232 | mdn-data: 2.0.30 1233 | source-map-js: 1.2.1 1234 | 1235 | debug@4.3.7: 1236 | dependencies: 1237 | ms: 2.1.3 1238 | 1239 | decompress-response@6.0.0: 1240 | dependencies: 1241 | mimic-response: 3.1.0 1242 | 1243 | deep-extend@0.6.0: {} 1244 | 1245 | deepmerge@4.3.1: {} 1246 | 1247 | detect-libc@2.0.3: {} 1248 | 1249 | devalue@5.1.1: {} 1250 | 1251 | end-of-stream@1.4.4: 1252 | dependencies: 1253 | once: 1.4.0 1254 | 1255 | esbuild@0.21.5: 1256 | optionalDependencies: 1257 | '@esbuild/aix-ppc64': 0.21.5 1258 | '@esbuild/android-arm': 0.21.5 1259 | '@esbuild/android-arm64': 0.21.5 1260 | '@esbuild/android-x64': 0.21.5 1261 | '@esbuild/darwin-arm64': 0.21.5 1262 | '@esbuild/darwin-x64': 0.21.5 1263 | '@esbuild/freebsd-arm64': 0.21.5 1264 | '@esbuild/freebsd-x64': 0.21.5 1265 | '@esbuild/linux-arm': 0.21.5 1266 | '@esbuild/linux-arm64': 0.21.5 1267 | '@esbuild/linux-ia32': 0.21.5 1268 | '@esbuild/linux-loong64': 0.21.5 1269 | '@esbuild/linux-mips64el': 0.21.5 1270 | '@esbuild/linux-ppc64': 0.21.5 1271 | '@esbuild/linux-riscv64': 0.21.5 1272 | '@esbuild/linux-s390x': 0.21.5 1273 | '@esbuild/linux-x64': 0.21.5 1274 | '@esbuild/netbsd-x64': 0.21.5 1275 | '@esbuild/openbsd-x64': 0.21.5 1276 | '@esbuild/sunos-x64': 0.21.5 1277 | '@esbuild/win32-arm64': 0.21.5 1278 | '@esbuild/win32-ia32': 0.21.5 1279 | '@esbuild/win32-x64': 0.21.5 1280 | 1281 | esm-env@1.0.0: {} 1282 | 1283 | estree-walker@3.0.3: 1284 | dependencies: 1285 | '@types/estree': 1.0.6 1286 | 1287 | expand-template@2.0.3: {} 1288 | 1289 | fdir@6.4.0: {} 1290 | 1291 | file-uri-to-path@1.0.0: {} 1292 | 1293 | fs-constants@1.0.0: {} 1294 | 1295 | fsevents@2.3.3: 1296 | optional: true 1297 | 1298 | github-from-package@0.0.0: {} 1299 | 1300 | globalyzer@0.1.0: {} 1301 | 1302 | globrex@0.1.2: {} 1303 | 1304 | ieee754@1.2.1: {} 1305 | 1306 | import-meta-resolve@4.1.0: {} 1307 | 1308 | inherits@2.0.4: {} 1309 | 1310 | ini@1.3.8: {} 1311 | 1312 | is-reference@3.0.2: 1313 | dependencies: 1314 | '@types/estree': 1.0.6 1315 | 1316 | kleur@4.1.5: {} 1317 | 1318 | locate-character@3.0.0: {} 1319 | 1320 | magic-string@0.30.11: 1321 | dependencies: 1322 | '@jridgewell/sourcemap-codec': 1.5.0 1323 | 1324 | mdn-data@2.0.30: {} 1325 | 1326 | mimic-response@3.1.0: {} 1327 | 1328 | minimist@1.2.8: {} 1329 | 1330 | mkdirp-classic@0.5.3: {} 1331 | 1332 | mri@1.2.0: {} 1333 | 1334 | mrmime@2.0.0: {} 1335 | 1336 | ms@2.1.3: {} 1337 | 1338 | nanoid@3.3.7: {} 1339 | 1340 | napi-build-utils@1.0.2: {} 1341 | 1342 | node-abi@3.68.0: 1343 | dependencies: 1344 | semver: 7.6.3 1345 | 1346 | once@1.4.0: 1347 | dependencies: 1348 | wrappy: 1.0.2 1349 | 1350 | periscopic@3.1.0: 1351 | dependencies: 1352 | '@types/estree': 1.0.6 1353 | estree-walker: 3.0.3 1354 | is-reference: 3.0.2 1355 | 1356 | picocolors@1.1.0: {} 1357 | 1358 | postcss@8.4.47: 1359 | dependencies: 1360 | nanoid: 3.3.7 1361 | picocolors: 1.1.0 1362 | source-map-js: 1.2.1 1363 | 1364 | prebuild-install@7.1.2: 1365 | dependencies: 1366 | detect-libc: 2.0.3 1367 | expand-template: 2.0.3 1368 | github-from-package: 0.0.0 1369 | minimist: 1.2.8 1370 | mkdirp-classic: 0.5.3 1371 | napi-build-utils: 1.0.2 1372 | node-abi: 3.68.0 1373 | pump: 3.0.2 1374 | rc: 1.2.8 1375 | simple-get: 4.0.1 1376 | tar-fs: 2.1.1 1377 | tunnel-agent: 0.6.0 1378 | 1379 | prettier-plugin-svelte@3.2.7(prettier@3.3.3)(svelte@4.2.19): 1380 | dependencies: 1381 | prettier: 3.3.3 1382 | svelte: 4.2.19 1383 | 1384 | prettier@3.3.3: {} 1385 | 1386 | pump@3.0.2: 1387 | dependencies: 1388 | end-of-stream: 1.4.4 1389 | once: 1.4.0 1390 | 1391 | rc@1.2.8: 1392 | dependencies: 1393 | deep-extend: 0.6.0 1394 | ini: 1.3.8 1395 | minimist: 1.2.8 1396 | strip-json-comments: 2.0.1 1397 | 1398 | readable-stream@3.6.2: 1399 | dependencies: 1400 | inherits: 2.0.4 1401 | string_decoder: 1.3.0 1402 | util-deprecate: 1.0.2 1403 | 1404 | readdirp@4.0.1: {} 1405 | 1406 | rollup@4.24.0: 1407 | dependencies: 1408 | '@types/estree': 1.0.6 1409 | optionalDependencies: 1410 | '@rollup/rollup-android-arm-eabi': 4.24.0 1411 | '@rollup/rollup-android-arm64': 4.24.0 1412 | '@rollup/rollup-darwin-arm64': 4.24.0 1413 | '@rollup/rollup-darwin-x64': 4.24.0 1414 | '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 1415 | '@rollup/rollup-linux-arm-musleabihf': 4.24.0 1416 | '@rollup/rollup-linux-arm64-gnu': 4.24.0 1417 | '@rollup/rollup-linux-arm64-musl': 4.24.0 1418 | '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 1419 | '@rollup/rollup-linux-riscv64-gnu': 4.24.0 1420 | '@rollup/rollup-linux-s390x-gnu': 4.24.0 1421 | '@rollup/rollup-linux-x64-gnu': 4.24.0 1422 | '@rollup/rollup-linux-x64-musl': 4.24.0 1423 | '@rollup/rollup-win32-arm64-msvc': 4.24.0 1424 | '@rollup/rollup-win32-ia32-msvc': 4.24.0 1425 | '@rollup/rollup-win32-x64-msvc': 4.24.0 1426 | fsevents: 2.3.3 1427 | 1428 | sade@1.8.1: 1429 | dependencies: 1430 | mri: 1.2.0 1431 | 1432 | safe-buffer@5.2.1: {} 1433 | 1434 | semver@7.6.3: {} 1435 | 1436 | set-cookie-parser@2.7.0: {} 1437 | 1438 | simple-concat@1.0.1: {} 1439 | 1440 | simple-get@4.0.1: 1441 | dependencies: 1442 | decompress-response: 6.0.0 1443 | once: 1.4.0 1444 | simple-concat: 1.0.1 1445 | 1446 | sirv@2.0.4: 1447 | dependencies: 1448 | '@polka/url': 1.0.0-next.28 1449 | mrmime: 2.0.0 1450 | totalist: 3.0.1 1451 | 1452 | source-map-js@1.2.1: {} 1453 | 1454 | string_decoder@1.3.0: 1455 | dependencies: 1456 | safe-buffer: 5.2.1 1457 | 1458 | strip-json-comments@2.0.1: {} 1459 | 1460 | svelte-check@4.0.4(svelte@4.2.19)(typescript@5.6.2): 1461 | dependencies: 1462 | '@jridgewell/trace-mapping': 0.3.25 1463 | chokidar: 4.0.1 1464 | fdir: 6.4.0 1465 | picocolors: 1.1.0 1466 | sade: 1.8.1 1467 | svelte: 4.2.19 1468 | typescript: 5.6.2 1469 | transitivePeerDependencies: 1470 | - picomatch 1471 | 1472 | svelte-hmr@0.16.0(svelte@4.2.19): 1473 | dependencies: 1474 | svelte: 4.2.19 1475 | 1476 | svelte@4.2.19: 1477 | dependencies: 1478 | '@ampproject/remapping': 2.3.0 1479 | '@jridgewell/sourcemap-codec': 1.5.0 1480 | '@jridgewell/trace-mapping': 0.3.25 1481 | '@types/estree': 1.0.6 1482 | acorn: 8.12.1 1483 | aria-query: 5.3.2 1484 | axobject-query: 4.1.0 1485 | code-red: 1.0.4 1486 | css-tree: 2.3.1 1487 | estree-walker: 3.0.3 1488 | is-reference: 3.0.2 1489 | locate-character: 3.0.0 1490 | magic-string: 0.30.11 1491 | periscopic: 3.1.0 1492 | 1493 | tar-fs@2.1.1: 1494 | dependencies: 1495 | chownr: 1.1.4 1496 | mkdirp-classic: 0.5.3 1497 | pump: 3.0.2 1498 | tar-stream: 2.2.0 1499 | 1500 | tar-stream@2.2.0: 1501 | dependencies: 1502 | bl: 4.1.0 1503 | end-of-stream: 1.4.4 1504 | fs-constants: 1.0.0 1505 | inherits: 2.0.4 1506 | readable-stream: 3.6.2 1507 | 1508 | tiny-glob@0.2.9: 1509 | dependencies: 1510 | globalyzer: 0.1.0 1511 | globrex: 0.1.2 1512 | 1513 | totalist@3.0.1: {} 1514 | 1515 | tslib@2.7.0: 1516 | optional: true 1517 | 1518 | tunnel-agent@0.6.0: 1519 | dependencies: 1520 | safe-buffer: 5.2.1 1521 | 1522 | typescript@5.6.2: {} 1523 | 1524 | undici-types@6.19.8: {} 1525 | 1526 | uqr@0.1.2: {} 1527 | 1528 | util-deprecate@1.0.2: {} 1529 | 1530 | vite@5.4.8(@types/node@22.7.4): 1531 | dependencies: 1532 | esbuild: 0.21.5 1533 | postcss: 8.4.47 1534 | rollup: 4.24.0 1535 | optionalDependencies: 1536 | '@types/node': 22.7.4 1537 | fsevents: 2.3.3 1538 | 1539 | vitefu@0.2.5(vite@5.4.8(@types/node@22.7.4)): 1540 | optionalDependencies: 1541 | vite: 5.4.8(@types/node@22.7.4) 1542 | 1543 | wrappy@1.0.2: {} 1544 | -------------------------------------------------------------------------------- /setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | email TEXT NOT NULL UNIQUE, 4 | username TEXT NOT NULL, 5 | password_hash TEXT NOT NULL, 6 | email_verified INTEGER NOT NULL DEFAULT 0, 7 | recovery_code BLOB NOT NULL 8 | ); 9 | 10 | CREATE INDEX email_index ON user(email); 11 | 12 | CREATE TABLE session ( 13 | id TEXT NOT NULL PRIMARY KEY, 14 | user_id INTEGER NOT NULL REFERENCES user(id), 15 | expires_at INTEGER NOT NULL, 16 | two_factor_verified INTEGER NOT NULL DEFAULT 0 17 | ); 18 | 19 | CREATE TABLE email_verification_request ( 20 | id TEXT NOT NULL PRIMARY KEY, 21 | user_id INTEGER NOT NULL REFERENCES user(id), 22 | email TEXT NOT NULL, 23 | code TEXT NOT NULL, 24 | expires_at INTEGER NOT NULL 25 | ); 26 | 27 | CREATE TABLE password_reset_session ( 28 | id TEXT NOT NULL PRIMARY KEY, 29 | user_id INTEGER NOT NULL REFERENCES user(id), 30 | email TEXT NOT NULL, 31 | code TEXT NOT NULL, 32 | expires_at INTEGER NOT NULL, 33 | email_verified INTEGER NOT NULL NOT NULL DEFAULT 0, 34 | two_factor_verified INTEGER NOT NULL DEFAULT 0 35 | ); 36 | 37 | CREATE TABLE totp_credential ( 38 | id INTEGER NOT NULL PRIMARY KEY, 39 | user_id INTEGER NOT NULL UNIQUE REFERENCES user(id), 40 | key BLOB NOT NULL 41 | ); 42 | 43 | CREATE TABLE passkey_credential ( 44 | id BLOB NOT NULL PRIMARY KEY, 45 | user_id INTEGER NOT NULL REFERENCES user(id), 46 | name TEXT NOT NULL, 47 | algorithm INTEGER NOT NULL, 48 | public_key BLOB NOT NULL 49 | ); 50 | 51 | CREATE TABLE security_key_credential ( 52 | id BLOB NOT NULL PRIMARY KEY, 53 | user_id INTEGER NOT NULL REFERENCES user(id), 54 | name TEXT NOT NULL, 55 | algorithm INTEGER NOT NULL, 56 | public_key BLOB NOT NULL 57 | ); 58 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | import type { User } from "$lib/server/user"; 4 | import type { Session } from "$lib/server/session"; 5 | 6 | declare global { 7 | namespace App { 8 | // interface Error {} 9 | interface Locals { 10 | user: User | null; 11 | session: Session | null; 12 | } 13 | // interface PageData {} 14 | // interface PageState {} 15 | // interface Platform {} 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { RefillingTokenBucket } from "$lib/server/rate-limit"; 2 | import { validateSessionToken, setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/server/session"; 3 | import { sequence } from "@sveltejs/kit/hooks"; 4 | 5 | import type { Handle } from "@sveltejs/kit"; 6 | 7 | const bucket = new RefillingTokenBucket(100, 1); 8 | 9 | const rateLimitHandle: Handle = async ({ event, resolve }) => { 10 | // Note: Assumes X-Forwarded-For will always be defined. 11 | const clientIP = event.request.headers.get("X-Forwarded-For"); 12 | if (clientIP === null) { 13 | return resolve(event); 14 | } 15 | let cost: number; 16 | if (event.request.method === "GET" || event.request.method === "OPTIONS") { 17 | cost = 1; 18 | } else { 19 | cost = 3; 20 | } 21 | if (!bucket.consume(clientIP, cost)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | return resolve(event); 27 | }; 28 | 29 | const authHandle: Handle = async ({ event, resolve }) => { 30 | const token = event.cookies.get("session") ?? null; 31 | if (token === null) { 32 | event.locals.user = null; 33 | event.locals.session = null; 34 | return resolve(event); 35 | } 36 | 37 | const { session, user } = validateSessionToken(token); 38 | if (session !== null) { 39 | setSessionTokenCookie(event, token, session.expiresAt); 40 | } else { 41 | deleteSessionTokenCookie(event); 42 | } 43 | 44 | event.locals.session = session; 45 | event.locals.user = user; 46 | return resolve(event); 47 | }; 48 | 49 | export const handle = sequence(rateLimitHandle, authHandle); 50 | -------------------------------------------------------------------------------- /src/lib/client/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { ObjectParser } from "@pilcrowjs/object-parser"; 3 | 4 | export async function createChallenge(): Promise { 5 | const response = await fetch("/api/webauthn/challenge", { 6 | method: "POST" 7 | }); 8 | if (!response.ok) { 9 | throw new Error("Failed to create challenge"); 10 | } 11 | const result = await response.json(); 12 | const parser = new ObjectParser(result); 13 | return decodeBase64(parser.getString("challenge")); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/server/2fa.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { generateRandomRecoveryCode } from "./utils"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { decryptToString, encryptString } from "./encryption"; 5 | 6 | import type { User } from "./user"; 7 | 8 | export const recoveryCodeBucket = new ExpiringTokenBucket(3, 60 * 60); 9 | 10 | export function resetUser2FAWithRecoveryCode(userId: number, recoveryCode: string): boolean { 11 | // Note: In Postgres and MySQL, these queries should be done in a transaction using SELECT FOR UPDATE 12 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 13 | if (row === null) { 14 | return false; 15 | } 16 | const encryptedRecoveryCode = row.bytes(0); 17 | const userRecoveryCode = decryptToString(encryptedRecoveryCode); 18 | if (recoveryCode !== userRecoveryCode) { 19 | return false; 20 | } 21 | 22 | const newRecoveryCode = generateRandomRecoveryCode(); 23 | const encryptedNewRecoveryCode = encryptString(newRecoveryCode); 24 | 25 | try { 26 | db.execute("BEGIN TRANSACTION", []); 27 | // Compare old recovery code to ensure recovery code wasn't updated. 28 | const result = db.execute("UPDATE user SET recovery_code = ? WHERE id = ? AND recovery_code = ?", [ 29 | encryptedNewRecoveryCode, 30 | userId, 31 | encryptedRecoveryCode 32 | ]); 33 | if (result.changes < 1) { 34 | db.execute("ROLLBACK", []); 35 | return false; 36 | } 37 | db.execute("UPDATE session SET two_factor_verified = 0 WHERE user_id = ?", [userId]); 38 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 39 | db.execute("DELETE FROM passkey_credential WHERE user_id = ?", [userId]); 40 | db.execute("DELETE FROM security_key_credential WHERE user_id = ?", [userId]); 41 | db.execute("COMMIT", []); 42 | } catch (e) { 43 | if (db.inTransaction()) { 44 | db.execute("ROLLBACK", []); 45 | } 46 | throw e; 47 | } 48 | return true; 49 | } 50 | 51 | export function get2FARedirect(user: User): string { 52 | if (user.registeredPasskey) { 53 | return "/2fa/passkey"; 54 | } 55 | if (user.registeredSecurityKey) { 56 | return "/2fa/security-key"; 57 | } 58 | if (user.registeredTOTP) { 59 | return "/2fa/totp"; 60 | } 61 | return "/2fa/setup"; 62 | } 63 | 64 | export function getPasswordReset2FARedirect(user: User): string { 65 | if (user.registeredPasskey) { 66 | return "/reset-password/2fa/passkey"; 67 | } 68 | if (user.registeredSecurityKey) { 69 | return "/reset-password/2fa/security-key"; 70 | } 71 | if (user.registeredTOTP) { 72 | return "/reset-password/2fa/totp"; 73 | } 74 | return "/2fa/setup"; 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/server/db.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from "better-sqlite3"; 2 | import { SyncDatabase } from "@pilcrowjs/db-query"; 3 | 4 | import type { SyncAdapter } from "@pilcrowjs/db-query"; 5 | 6 | const sqlite = sqlite3("sqlite.db"); 7 | 8 | const adapter: SyncAdapter = { 9 | query: (statement: string, params: unknown[]): unknown[][] => { 10 | const result = sqlite 11 | .prepare(statement) 12 | .raw() 13 | .all(...params) as unknown[][]; 14 | for (let i = 0; i < result.length; i++) { 15 | for (let j = 0; j < result[i].length; j++) { 16 | if (result[i][j] instanceof Buffer) { 17 | // Explicitly convert to Uint8Array since SvelteKit's serialization 18 | // doesn't support Node Buffer (even though it's just a sub-class 19 | // of Uint8Array) 20 | result[i][j] = new Uint8Array(result[i][j] as Buffer); 21 | } 22 | } 23 | } 24 | return result as unknown[][]; 25 | }, 26 | execute: (statement: string, params: unknown[]): sqlite3.RunResult => { 27 | const result = sqlite.prepare(statement).run(...params); 28 | return result; 29 | } 30 | }; 31 | 32 | class Database extends SyncDatabase { 33 | public inTransaction(): boolean { 34 | return sqlite.inTransaction; 35 | } 36 | } 37 | 38 | export const db = new Database(adapter); 39 | -------------------------------------------------------------------------------- /src/lib/server/email-verification.ts: -------------------------------------------------------------------------------- 1 | import { generateRandomOTP } from "./utils"; 2 | import { db } from "./db"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 5 | 6 | import type { RequestEvent } from "@sveltejs/kit"; 7 | 8 | export function getUserEmailVerificationRequest(userId: number, id: string): EmailVerificationRequest | null { 9 | const row = db.queryOne( 10 | "SELECT id, user_id, code, email, expires_at FROM email_verification_request WHERE id = ? AND user_id = ?", 11 | [id, userId] 12 | ); 13 | if (row === null) { 14 | return row; 15 | } 16 | const request: EmailVerificationRequest = { 17 | id: row.string(0), 18 | userId: row.number(1), 19 | code: row.string(2), 20 | email: row.string(3), 21 | expiresAt: new Date(row.number(4) * 1000) 22 | }; 23 | return request; 24 | } 25 | 26 | export function createEmailVerificationRequest(userId: number, email: string): EmailVerificationRequest { 27 | deleteUserEmailVerificationRequest(userId); 28 | const idBytes = new Uint8Array(20); 29 | crypto.getRandomValues(idBytes); 30 | const id = encodeBase32LowerCaseNoPadding(idBytes); 31 | 32 | const code = generateRandomOTP(); 33 | const expiresAt = new Date(Date.now() + 1000 * 60 * 10); 34 | db.queryOne( 35 | "INSERT INTO email_verification_request (id, user_id, code, email, expires_at) VALUES (?, ?, ?, ?, ?) RETURNING id", 36 | [id, userId, code, email, Math.floor(expiresAt.getTime() / 1000)] 37 | ); 38 | 39 | const request: EmailVerificationRequest = { 40 | id, 41 | userId, 42 | code, 43 | email, 44 | expiresAt 45 | }; 46 | return request; 47 | } 48 | 49 | export function deleteUserEmailVerificationRequest(userId: number): void { 50 | db.execute("DELETE FROM email_verification_request WHERE user_id = ?", [userId]); 51 | } 52 | 53 | export function sendVerificationEmail(email: string, code: string): void { 54 | console.log(`To ${email}: Your verification code is ${code}`); 55 | } 56 | 57 | export function setEmailVerificationRequestCookie(event: RequestEvent, request: EmailVerificationRequest): void { 58 | event.cookies.set("email_verification", request.id, { 59 | httpOnly: true, 60 | path: "/", 61 | secure: import.meta.env.PROD, 62 | sameSite: "lax", 63 | expires: request.expiresAt 64 | }); 65 | } 66 | 67 | export function deleteEmailVerificationRequestCookie(event: RequestEvent): void { 68 | event.cookies.set("email_verification", "", { 69 | httpOnly: true, 70 | path: "/", 71 | secure: import.meta.env.PROD, 72 | sameSite: "lax", 73 | maxAge: 0 74 | }); 75 | } 76 | 77 | export function getUserEmailVerificationRequestFromRequest(event: RequestEvent): EmailVerificationRequest | null { 78 | if (event.locals.user === null) { 79 | return null; 80 | } 81 | const id = event.cookies.get("email_verification") ?? null; 82 | if (id === null) { 83 | return null; 84 | } 85 | const request = getUserEmailVerificationRequest(event.locals.user.id, id); 86 | if (request === null) { 87 | deleteEmailVerificationRequestCookie(event); 88 | } 89 | return request; 90 | } 91 | 92 | export const sendVerificationEmailBucket = new ExpiringTokenBucket(3, 60 * 10); 93 | 94 | export interface EmailVerificationRequest { 95 | id: string; 96 | userId: number; 97 | code: string; 98 | email: string; 99 | expiresAt: Date; 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/server/email.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | 3 | export function verifyEmailInput(email: string): boolean { 4 | return /^.+@.+\..+$/.test(email) && email.length < 256; 5 | } 6 | 7 | export function checkEmailAvailability(email: string): boolean { 8 | const row = db.queryOne("SELECT COUNT(*) FROM user WHERE email = ?", [email]); 9 | if (row === null) { 10 | throw new Error(); 11 | } 12 | return row.number(0) === 0; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/server/encryption.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { createCipheriv, createDecipheriv } from "crypto"; 3 | import { DynamicBuffer } from "@oslojs/binary"; 4 | 5 | import { ENCRYPTION_KEY } from "$env/static/private"; 6 | 7 | const key = decodeBase64(ENCRYPTION_KEY); 8 | 9 | export function encrypt(data: Uint8Array): Uint8Array { 10 | const iv = new Uint8Array(16); 11 | crypto.getRandomValues(iv); 12 | const cipher = createCipheriv("aes-128-gcm", key, iv); 13 | const encrypted = new DynamicBuffer(0); 14 | encrypted.write(iv); 15 | encrypted.write(cipher.update(data)); 16 | encrypted.write(cipher.final()); 17 | encrypted.write(cipher.getAuthTag()); 18 | return encrypted.bytes(); 19 | } 20 | 21 | export function encryptString(data: string): Uint8Array { 22 | return encrypt(new TextEncoder().encode(data)); 23 | } 24 | 25 | export function decrypt(encrypted: Uint8Array): Uint8Array { 26 | if (encrypted.byteLength < 33) { 27 | throw new Error("Invalid data"); 28 | } 29 | const decipher = createDecipheriv("aes-128-gcm", key, encrypted.slice(0, 16)); 30 | decipher.setAuthTag(encrypted.slice(encrypted.byteLength - 16)); 31 | const decrypted = new DynamicBuffer(0); 32 | decrypted.write(decipher.update(encrypted.slice(16, encrypted.byteLength - 16))); 33 | decrypted.write(decipher.final()); 34 | return decrypted.bytes(); 35 | } 36 | 37 | export function decryptToString(data: Uint8Array): string { 38 | return new TextDecoder().decode(decrypt(data)); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/server/password-reset.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { generateRandomOTP } from "./utils"; 4 | import { sha256 } from "@oslojs/crypto/sha2"; 5 | 6 | import type { RequestEvent } from "@sveltejs/kit"; 7 | import type { User } from "./user"; 8 | 9 | export function createPasswordResetSession(token: string, userId: number, email: string): PasswordResetSession { 10 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 11 | const session: PasswordResetSession = { 12 | id: sessionId, 13 | userId, 14 | email, 15 | expiresAt: new Date(Date.now() + 1000 * 60 * 10), 16 | code: generateRandomOTP(), 17 | emailVerified: false, 18 | twoFactorVerified: false 19 | }; 20 | db.execute("INSERT INTO password_reset_session (id, user_id, email, code, expires_at) VALUES (?, ?, ?, ?, ?)", [ 21 | session.id, 22 | session.userId, 23 | session.email, 24 | session.code, 25 | Math.floor(session.expiresAt.getTime() / 1000) 26 | ]); 27 | return session; 28 | } 29 | 30 | export function validatePasswordResetSessionToken(token: string): PasswordResetSessionValidationResult { 31 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 32 | const row = db.queryOne( 33 | `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, 34 | 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 35 | INNER JOIN user ON password_reset_session.user_id = user.id 36 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 37 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 38 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 39 | WHERE password_reset_session.id = ?`, 40 | [sessionId] 41 | ); 42 | if (row === null) { 43 | return { session: null, user: null }; 44 | } 45 | const session: PasswordResetSession = { 46 | id: row.string(0), 47 | userId: row.number(1), 48 | email: row.string(2), 49 | code: row.string(3), 50 | expiresAt: new Date(row.number(4) * 1000), 51 | emailVerified: Boolean(row.number(5)), 52 | twoFactorVerified: Boolean(row.number(6)) 53 | }; 54 | const user: User = { 55 | id: row.number(7), 56 | email: row.string(8), 57 | username: row.string(9), 58 | emailVerified: Boolean(row.number(10)), 59 | registeredTOTP: Boolean(row.number(11)), 60 | registeredPasskey: Boolean(row.number(12)), 61 | registeredSecurityKey: Boolean(row.number(13)), 62 | registered2FA: false 63 | }; 64 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 65 | user.registered2FA = true; 66 | } 67 | if (Date.now() >= session.expiresAt.getTime()) { 68 | db.execute("DELETE FROM password_reset_session WHERE id = ?", [session.id]); 69 | return { session: null, user: null }; 70 | } 71 | return { session, user }; 72 | } 73 | 74 | export function setPasswordResetSessionAsEmailVerified(sessionId: string): void { 75 | db.execute("UPDATE password_reset_session SET email_verified = 1 WHERE id = ?", [sessionId]); 76 | } 77 | 78 | export function setPasswordResetSessionAs2FAVerified(sessionId: string): void { 79 | db.execute("UPDATE password_reset_session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 80 | } 81 | 82 | export function invalidateUserPasswordResetSessions(userId: number): void { 83 | db.execute("DELETE FROM password_reset_session WHERE user_id = ?", [userId]); 84 | } 85 | 86 | export function validatePasswordResetSessionRequest(event: RequestEvent): PasswordResetSessionValidationResult { 87 | const token = event.cookies.get("password_reset_session") ?? null; 88 | if (token === null) { 89 | return { session: null, user: null }; 90 | } 91 | const result = validatePasswordResetSessionToken(token); 92 | if (result.session === null) { 93 | deletePasswordResetSessionTokenCookie(event); 94 | } 95 | return result; 96 | } 97 | 98 | export function setPasswordResetSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { 99 | event.cookies.set("password_reset_session", token, { 100 | expires: expiresAt, 101 | sameSite: "lax", 102 | httpOnly: true, 103 | path: "/", 104 | secure: !import.meta.env.DEV 105 | }); 106 | } 107 | 108 | export function deletePasswordResetSessionTokenCookie(event: RequestEvent): void { 109 | event.cookies.set("password_reset_session", "", { 110 | maxAge: 0, 111 | sameSite: "lax", 112 | httpOnly: true, 113 | path: "/", 114 | secure: !import.meta.env.DEV 115 | }); 116 | } 117 | 118 | export function sendPasswordResetEmail(email: string, code: string): void { 119 | console.log(`To ${email}: Your reset code is ${code}`); 120 | } 121 | 122 | export interface PasswordResetSession { 123 | id: string; 124 | userId: number; 125 | email: string; 126 | expiresAt: Date; 127 | code: string; 128 | emailVerified: boolean; 129 | twoFactorVerified: boolean; 130 | } 131 | 132 | export type PasswordResetSessionValidationResult = 133 | | { session: PasswordResetSession; user: User } 134 | | { session: null; user: null }; 135 | -------------------------------------------------------------------------------- /src/lib/server/password.ts: -------------------------------------------------------------------------------- 1 | import { hash, verify } from "@node-rs/argon2"; 2 | import { sha1 } from "@oslojs/crypto/sha1"; 3 | import { encodeHexLowerCase } from "@oslojs/encoding"; 4 | 5 | export async function hashPassword(password: string): Promise { 6 | return await hash(password, { 7 | memoryCost: 19456, 8 | timeCost: 2, 9 | outputLen: 32, 10 | parallelism: 1 11 | }); 12 | } 13 | 14 | export async function verifyPasswordHash(hash: string, password: string): Promise { 15 | return await verify(hash, password); 16 | } 17 | 18 | export async function verifyPasswordStrength(password: string): Promise { 19 | if (password.length < 8 || password.length > 255) { 20 | return false; 21 | } 22 | const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password))); 23 | const hashPrefix = hash.slice(0, 5); 24 | const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`); 25 | const data = await response.text(); 26 | const items = data.split("\n"); 27 | for (const item of items) { 28 | const hashSuffix = item.slice(0, 35).toLowerCase(); 29 | if (hash === hashPrefix + hashSuffix) { 30 | return false; 31 | } 32 | } 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/server/rate-limit.ts: -------------------------------------------------------------------------------- 1 | export class RefillingTokenBucket<_Key> { 2 | public max: number; 3 | public refillIntervalSeconds: number; 4 | 5 | constructor(max: number, refillIntervalSeconds: number) { 6 | this.max = max; 7 | this.refillIntervalSeconds = refillIntervalSeconds; 8 | } 9 | 10 | private storage = new Map<_Key, RefillBucket>(); 11 | 12 | public check(key: _Key, cost: number): boolean { 13 | const bucket = this.storage.get(key) ?? null; 14 | if (bucket === null) { 15 | return true; 16 | } 17 | const now = Date.now(); 18 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 19 | if (refill > 0) { 20 | return Math.min(bucket.count + refill, this.max) >= cost; 21 | } 22 | return bucket.count >= cost; 23 | } 24 | 25 | public consume(key: _Key, cost: number): boolean { 26 | let bucket = this.storage.get(key) ?? null; 27 | const now = Date.now(); 28 | if (bucket === null) { 29 | bucket = { 30 | count: this.max - cost, 31 | refilledAt: now 32 | }; 33 | this.storage.set(key, bucket); 34 | return true; 35 | } 36 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 37 | bucket.count = Math.min(bucket.count + refill, this.max); 38 | bucket.refilledAt = now; 39 | if (bucket.count < cost) { 40 | return false; 41 | } 42 | bucket.count -= cost; 43 | this.storage.set(key, bucket); 44 | return true; 45 | } 46 | } 47 | 48 | export class Throttler<_Key> { 49 | public timeoutSeconds: number[]; 50 | 51 | private storage = new Map<_Key, ThrottlingCounter>(); 52 | 53 | constructor(timeoutSeconds: number[]) { 54 | this.timeoutSeconds = timeoutSeconds; 55 | } 56 | 57 | public consume(key: _Key): boolean { 58 | let counter = this.storage.get(key) ?? null; 59 | const now = Date.now(); 60 | if (counter === null) { 61 | counter = { 62 | timeout: 0, 63 | updatedAt: now 64 | }; 65 | this.storage.set(key, counter); 66 | return true; 67 | } 68 | const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout] * 1000; 69 | if (!allowed) { 70 | return false; 71 | } 72 | counter.updatedAt = now; 73 | counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1); 74 | this.storage.set(key, counter); 75 | return true; 76 | } 77 | 78 | public reset(key: _Key): void { 79 | this.storage.delete(key); 80 | } 81 | } 82 | 83 | export class ExpiringTokenBucket<_Key> { 84 | public max: number; 85 | public expiresInSeconds: number; 86 | 87 | private storage = new Map<_Key, ExpiringBucket>(); 88 | 89 | constructor(max: number, expiresInSeconds: number) { 90 | this.max = max; 91 | this.expiresInSeconds = expiresInSeconds; 92 | } 93 | 94 | public check(key: _Key, cost: number): boolean { 95 | const bucket = this.storage.get(key) ?? null; 96 | const now = Date.now(); 97 | if (bucket === null) { 98 | return true; 99 | } 100 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 101 | return true; 102 | } 103 | return bucket.count >= cost; 104 | } 105 | 106 | public consume(key: _Key, cost: number): boolean { 107 | let bucket = this.storage.get(key) ?? null; 108 | const now = Date.now(); 109 | if (bucket === null) { 110 | bucket = { 111 | count: this.max - cost, 112 | createdAt: now 113 | }; 114 | this.storage.set(key, bucket); 115 | return true; 116 | } 117 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 118 | bucket.count = this.max; 119 | } 120 | if (bucket.count < cost) { 121 | return false; 122 | } 123 | bucket.count -= cost; 124 | this.storage.set(key, bucket); 125 | return true; 126 | } 127 | 128 | public reset(key: _Key): void { 129 | this.storage.delete(key); 130 | } 131 | } 132 | 133 | interface RefillBucket { 134 | count: number; 135 | refilledAt: number; 136 | } 137 | 138 | interface ExpiringBucket { 139 | count: number; 140 | createdAt: number; 141 | } 142 | 143 | interface ThrottlingCounter { 144 | timeout: number; 145 | updatedAt: number; 146 | } 147 | -------------------------------------------------------------------------------- /src/lib/server/session.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { sha256 } from "@oslojs/crypto/sha2"; 4 | 5 | import type { User } from "./user"; 6 | import type { RequestEvent } from "@sveltejs/kit"; 7 | 8 | export function validateSessionToken(token: string): SessionValidationResult { 9 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 10 | const row = db.queryOne( 11 | ` 12 | 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 13 | INNER JOIN user ON session.user_id = user.id 14 | LEFT JOIN totp_credential ON session.user_id = totp_credential.user_id 15 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 16 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 17 | WHERE session.id = ? 18 | `, 19 | [sessionId] 20 | ); 21 | 22 | if (row === null) { 23 | return { session: null, user: null }; 24 | } 25 | const session: Session = { 26 | id: row.string(0), 27 | userId: row.number(1), 28 | expiresAt: new Date(row.number(2) * 1000), 29 | twoFactorVerified: Boolean(row.number(3)) 30 | }; 31 | const user: User = { 32 | id: row.number(4), 33 | email: row.string(5), 34 | username: row.string(6), 35 | emailVerified: Boolean(row.number(7)), 36 | registeredTOTP: Boolean(row.number(8)), 37 | registeredPasskey: Boolean(row.number(9)), 38 | registeredSecurityKey: Boolean(row.number(10)), 39 | registered2FA: false 40 | }; 41 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 42 | user.registered2FA = true; 43 | } 44 | if (Date.now() >= session.expiresAt.getTime()) { 45 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 46 | return { session: null, user: null }; 47 | } 48 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 49 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 50 | db.execute("UPDATE session SET expires_at = ? WHERE session.id = ?", [ 51 | Math.floor(session.expiresAt.getTime() / 1000), 52 | sessionId 53 | ]); 54 | } 55 | return { session, user }; 56 | } 57 | 58 | export function invalidateSession(sessionId: string): void { 59 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 60 | } 61 | 62 | export function invalidateUserSessions(userId: number): void { 63 | db.execute("DELETE FROM session WHERE user_id = ?", [userId]); 64 | } 65 | 66 | export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { 67 | event.cookies.set("session", token, { 68 | httpOnly: true, 69 | path: "/", 70 | secure: import.meta.env.PROD, 71 | sameSite: "lax", 72 | expires: expiresAt 73 | }); 74 | } 75 | 76 | export function deleteSessionTokenCookie(event: RequestEvent): void { 77 | event.cookies.set("session", "", { 78 | httpOnly: true, 79 | path: "/", 80 | secure: import.meta.env.PROD, 81 | sameSite: "lax", 82 | maxAge: 0 83 | }); 84 | } 85 | 86 | export function generateSessionToken(): string { 87 | const tokenBytes = new Uint8Array(20); 88 | crypto.getRandomValues(tokenBytes); 89 | const token = encodeBase32LowerCaseNoPadding(tokenBytes); 90 | return token; 91 | } 92 | 93 | export function createSession(token: string, userId: number, flags: SessionFlags): Session { 94 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 95 | const session: Session = { 96 | id: sessionId, 97 | userId, 98 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 99 | twoFactorVerified: flags.twoFactorVerified 100 | }; 101 | db.execute("INSERT INTO session (id, user_id, expires_at, two_factor_verified) VALUES (?, ?, ?, ?)", [ 102 | session.id, 103 | session.userId, 104 | Math.floor(session.expiresAt.getTime() / 1000), 105 | Number(session.twoFactorVerified) 106 | ]); 107 | return session; 108 | } 109 | 110 | export function setSessionAs2FAVerified(sessionId: string): void { 111 | db.execute("UPDATE session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 112 | } 113 | 114 | export interface SessionFlags { 115 | twoFactorVerified: boolean; 116 | } 117 | 118 | export interface Session extends SessionFlags { 119 | id: string; 120 | expiresAt: Date; 121 | userId: number; 122 | } 123 | 124 | type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; 125 | -------------------------------------------------------------------------------- /src/lib/server/totp.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decrypt, encrypt } from "./encryption"; 3 | import { ExpiringTokenBucket, RefillingTokenBucket } from "./rate-limit"; 4 | 5 | export const totpBucket = new ExpiringTokenBucket(5, 60 * 30); 6 | export const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); 7 | 8 | export function getUserTOTPKey(userId: number): Uint8Array | null { 9 | const row = db.queryOne("SELECT totp_credential.key FROM totp_credential WHERE user_id = ?", [userId]); 10 | if (row === null) { 11 | throw new Error("Invalid user ID"); 12 | } 13 | const encrypted = row.bytesNullable(0); 14 | if (encrypted === null) { 15 | return null; 16 | } 17 | return decrypt(encrypted); 18 | } 19 | 20 | export function updateUserTOTPKey(userId: number, key: Uint8Array): void { 21 | const encrypted = encrypt(key); 22 | try { 23 | db.execute("BEGIN TRANSACTION", []); 24 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 25 | db.execute("INSERT INTO totp_credential (user_id, key) VALUES (?, ?)", [userId, encrypted]); 26 | db.execute("COMMIT", []); 27 | } catch (e) { 28 | if (db.inTransaction()) { 29 | db.execute("ROLLBACK", []); 30 | } 31 | throw e; 32 | } 33 | } 34 | 35 | export function deleteUserTOTPKey(userId: number): void { 36 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/server/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decryptToString, encryptString } from "./encryption"; 3 | import { hashPassword } from "./password"; 4 | import { generateRandomRecoveryCode } from "./utils"; 5 | 6 | export function verifyUsernameInput(username: string): boolean { 7 | return username.length > 3 && username.length < 32 && username.trim() === username; 8 | } 9 | 10 | export async function createUser(email: string, username: string, password: string): Promise { 11 | const passwordHash = await hashPassword(password); 12 | const recoveryCode = generateRandomRecoveryCode(); 13 | const encryptedRecoveryCode = encryptString(recoveryCode); 14 | const row = db.queryOne( 15 | "INSERT INTO user (email, username, password_hash, recovery_code) VALUES (?, ?, ?, ?) RETURNING user.id", 16 | [email, username, passwordHash, encryptedRecoveryCode] 17 | ); 18 | if (row === null) { 19 | throw new Error("Unexpected error"); 20 | } 21 | const user: User = { 22 | id: row.number(0), 23 | username, 24 | email, 25 | emailVerified: false, 26 | registeredTOTP: false, 27 | registeredPasskey: false, 28 | registeredSecurityKey: false, 29 | registered2FA: false 30 | }; 31 | return user; 32 | } 33 | 34 | export async function updateUserPassword(userId: number, password: string): Promise { 35 | const passwordHash = await hashPassword(password); 36 | db.execute("UPDATE user SET password_hash = ? WHERE id = ?", [passwordHash, userId]); 37 | } 38 | 39 | export function updateUserEmailAndSetEmailAsVerified(userId: number, email: string): void { 40 | db.execute("UPDATE user SET email = ?, email_verified = 1 WHERE id = ?", [email, userId]); 41 | } 42 | 43 | export function setUserAsEmailVerifiedIfEmailMatches(userId: number, email: string): boolean { 44 | const result = db.execute("UPDATE user SET email_verified = 1 WHERE id = ? AND email = ?", [userId, email]); 45 | return result.changes > 0; 46 | } 47 | 48 | export function getUserPasswordHash(userId: number): string { 49 | const row = db.queryOne("SELECT password_hash FROM user WHERE id = ?", [userId]); 50 | if (row === null) { 51 | throw new Error("Invalid user ID"); 52 | } 53 | return row.string(0); 54 | } 55 | 56 | export function getUserRecoverCode(userId: number): string { 57 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 58 | if (row === null) { 59 | throw new Error("Invalid user ID"); 60 | } 61 | return decryptToString(row.bytes(0)); 62 | } 63 | 64 | export function resetUserRecoveryCode(userId: number): string { 65 | const recoveryCode = generateRandomRecoveryCode(); 66 | const encrypted = encryptString(recoveryCode); 67 | db.execute("UPDATE user SET recovery_code = ? WHERE id = ?", [encrypted, userId]); 68 | return recoveryCode; 69 | } 70 | 71 | export function getUserFromEmail(email: string): User | null { 72 | const row = db.queryOne( 73 | `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 74 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 75 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 76 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 77 | WHERE user.email = ?`, 78 | [email] 79 | ); 80 | if (row === null) { 81 | return null; 82 | } 83 | const user: User = { 84 | id: row.number(0), 85 | email: row.string(1), 86 | username: row.string(2), 87 | emailVerified: Boolean(row.number(3)), 88 | registeredTOTP: Boolean(row.number(4)), 89 | registeredPasskey: Boolean(row.number(5)), 90 | registeredSecurityKey: Boolean(row.number(6)), 91 | registered2FA: false 92 | }; 93 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 94 | user.registered2FA = true; 95 | } 96 | return user; 97 | } 98 | 99 | export interface User { 100 | id: number; 101 | email: string; 102 | username: string; 103 | emailVerified: boolean; 104 | registeredTOTP: boolean; 105 | registeredSecurityKey: boolean; 106 | registeredPasskey: boolean; 107 | registered2FA: boolean; 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/server/utils.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase32UpperCaseNoPadding } from "@oslojs/encoding"; 2 | 3 | export function generateRandomOTP(): string { 4 | const bytes = new Uint8Array(5); 5 | crypto.getRandomValues(bytes); 6 | const code = encodeBase32UpperCaseNoPadding(bytes); 7 | return code; 8 | } 9 | 10 | export function generateRandomRecoveryCode(): string { 11 | const recoveryCodeBytes = new Uint8Array(10); 12 | crypto.getRandomValues(recoveryCodeBytes); 13 | const recoveryCode = encodeBase32UpperCaseNoPadding(recoveryCodeBytes); 14 | return recoveryCode; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/server/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { encodeHexLowerCase } from "@oslojs/encoding"; 2 | import { db } from "./db"; 3 | 4 | const challengeBucket = new Set(); 5 | 6 | export function createWebAuthnChallenge(): Uint8Array { 7 | const challenge = new Uint8Array(20); 8 | crypto.getRandomValues(challenge); 9 | const encoded = encodeHexLowerCase(challenge); 10 | challengeBucket.add(encoded); 11 | return challenge; 12 | } 13 | 14 | export function verifyWebAuthnChallenge(challenge: Uint8Array): boolean { 15 | const encoded = encodeHexLowerCase(challenge); 16 | return challengeBucket.delete(encoded); 17 | } 18 | 19 | export function getUserPasskeyCredentials(userId: number): WebAuthnUserCredential[] { 20 | const rows = db.query("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE user_id = ?", [ 21 | userId 22 | ]); 23 | const credentials: WebAuthnUserCredential[] = []; 24 | for (const row of rows) { 25 | const credential: WebAuthnUserCredential = { 26 | id: row.bytes(0), 27 | userId: row.number(1), 28 | name: row.string(2), 29 | algorithmId: row.number(3), 30 | publicKey: row.bytes(4) 31 | }; 32 | credentials.push(credential); 33 | } 34 | return credentials; 35 | } 36 | 37 | export function getPasskeyCredential(credentialId: Uint8Array): WebAuthnUserCredential | null { 38 | const row = db.queryOne("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ?", [ 39 | credentialId 40 | ]); 41 | if (row === null) { 42 | return null; 43 | } 44 | const credential: WebAuthnUserCredential = { 45 | id: row.bytes(0), 46 | userId: row.number(1), 47 | name: row.string(2), 48 | algorithmId: row.number(3), 49 | publicKey: row.bytes(4) 50 | }; 51 | return credential; 52 | } 53 | 54 | export function getUserPasskeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 55 | const row = db.queryOne( 56 | "SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ? AND user_id = ?", 57 | [credentialId, userId] 58 | ); 59 | if (row === null) { 60 | return null; 61 | } 62 | const credential: WebAuthnUserCredential = { 63 | id: row.bytes(0), 64 | userId: row.number(1), 65 | name: row.string(2), 66 | algorithmId: row.number(3), 67 | publicKey: row.bytes(4) 68 | }; 69 | return credential; 70 | } 71 | 72 | export function createPasskeyCredential(credential: WebAuthnUserCredential): void { 73 | db.execute("INSERT INTO passkey_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 74 | credential.id, 75 | credential.userId, 76 | credential.name, 77 | credential.algorithmId, 78 | credential.publicKey 79 | ]); 80 | } 81 | 82 | export function deleteUserPasskeyCredential(userId: number, credentialId: Uint8Array): boolean { 83 | const result = db.execute("DELETE FROM passkey_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 84 | return result.changes > 0; 85 | } 86 | 87 | export function getUserSecurityKeyCredentials(userId: number): WebAuthnUserCredential[] { 88 | const rows = db.query( 89 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE user_id = ?", 90 | [userId] 91 | ); 92 | const credentials: WebAuthnUserCredential[] = []; 93 | for (const row of rows) { 94 | const credential: WebAuthnUserCredential = { 95 | id: row.bytes(0), 96 | userId: row.number(1), 97 | name: row.string(2), 98 | algorithmId: row.number(3), 99 | publicKey: row.bytes(4) 100 | }; 101 | credentials.push(credential); 102 | } 103 | return credentials; 104 | } 105 | 106 | export function getUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 107 | const row = db.queryOne( 108 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE id = ? AND user_id = ?", 109 | [credentialId, userId] 110 | ); 111 | if (row === null) { 112 | return null; 113 | } 114 | const credential: WebAuthnUserCredential = { 115 | id: row.bytes(0), 116 | userId: row.number(1), 117 | name: row.string(2), 118 | algorithmId: row.number(3), 119 | publicKey: row.bytes(4) 120 | }; 121 | return credential; 122 | } 123 | 124 | export function createSecurityKeyCredential(credential: WebAuthnUserCredential): void { 125 | db.execute("INSERT INTO security_key_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 126 | credential.id, 127 | credential.userId, 128 | credential.name, 129 | credential.algorithmId, 130 | credential.publicKey 131 | ]); 132 | } 133 | 134 | export function deleteUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): boolean { 135 | const result = db.execute("DELETE FROM security_key_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 136 | return result.changes > 0; 137 | } 138 | 139 | export interface WebAuthnUserCredential { 140 | id: Uint8Array; 141 | userId: number; 142 | name: string; 143 | algorithmId: number; 144 | publicKey: Uint8Array; 145 | } 146 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 2 | Email and password example with 2FA and WebAuthn in SvelteKit 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { deleteSessionTokenCookie, invalidateSession } from "$lib/server/session"; 3 | import { get2FARedirect } from "$lib/server/2fa"; 4 | 5 | import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; 6 | 7 | export function load(event: PageServerLoadEvent) { 8 | if (event.locals.session === null || event.locals.user === null) { 9 | return redirect(302, "/login"); 10 | } 11 | if (!event.locals.user.emailVerified) { 12 | return redirect(302, "/verify-email"); 13 | } 14 | if (!event.locals.user.registered2FA) { 15 | return redirect(302, "/2fa/setup"); 16 | } 17 | if (!event.locals.session.twoFactorVerified) { 18 | return redirect(302, get2FARedirect(event.locals.user)); 19 | } 20 | return { 21 | user: event.locals.user 22 | }; 23 | } 24 | 25 | export const actions: Actions = { 26 | default: action 27 | }; 28 | 29 | async function action(event: RequestEvent) { 30 | if (event.locals.session === null) { 31 | return fail(401, { 32 | message: "Not authenticated" 33 | }); 34 | } 35 | invalidateSession(event.locals.session.id); 36 | deleteSessionTokenCookie(event); 37 | return redirect(302, "/login"); 38 | } 39 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | Home 11 | Settings 12 |
13 |
14 |

Hi {data.user.username}!

15 |
16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/routes/2fa/+server.ts: -------------------------------------------------------------------------------- 1 | import { get2FARedirect } from "$lib/server/2fa"; 2 | import { redirect } from "@sveltejs/kit"; 3 | 4 | import type { RequestEvent } from "./$types"; 5 | 6 | export function GET(event: RequestEvent): Response { 7 | if (event.locals.session === null || event.locals.user === null) { 8 | return new Response(null, { 9 | status: 302, 10 | headers: { 11 | Location: "/login" 12 | } 13 | }); 14 | } 15 | if (event.locals.session.twoFactorVerified) { 16 | return new Response(null, { 17 | status: 302, 18 | headers: { 19 | Location: "/" 20 | } 21 | }); 22 | } 23 | if (!event.locals.user.registered2FA) { 24 | return new Response(null, { 25 | status: 302, 26 | headers: { 27 | Location: "/2fa/setup" 28 | } 29 | }); 30 | } 31 | return new Response(null, { 32 | status: 302, 33 | headers: { 34 | Location: get2FARedirect(event.locals.user) 35 | } 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/routes/2fa/passkey/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { get2FARedirect } from "$lib/server/2fa"; 3 | import { getUserPasskeyCredentials } from "$lib/server/webauthn"; 4 | 5 | import type { RequestEvent } from "./$types"; 6 | 7 | export async function load(event: RequestEvent) { 8 | if (event.locals.session === null || event.locals.user === null) { 9 | return redirect(302, "/login"); 10 | } 11 | if (!event.locals.user.emailVerified) { 12 | return redirect(302, "/verify-email"); 13 | } 14 | if (!event.locals.user.registered2FA) { 15 | return redirect(302, "/"); 16 | } 17 | if (event.locals.session.twoFactorVerified) { 18 | return redirect(302, "/"); 19 | } 20 | if (!event.locals.user.registeredPasskey) { 21 | return redirect(302, get2FARedirect(event.locals.user)); 22 | } 23 | const credentials = getUserPasskeyCredentials(event.locals.user.id); 24 | return { 25 | credentials, 26 | user: event.locals.user 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/2fa/passkey/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |

Authenticate with passkeys

14 |
15 | 56 |

{message}

57 |
58 | Use recovery code 59 | 60 | {#if data.user.registeredTOTP} 61 | Use authenticator apps 62 | {/if} 63 | {#if data.user.registeredSecurityKey} 64 | Use security keys 65 | {/if} 66 | -------------------------------------------------------------------------------- /src/routes/2fa/passkey/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | coseAlgorithmRS256, 6 | createAssertionSignatureMessage, 7 | parseAuthenticatorData 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn"; 13 | import { setSessionAs2FAVerified } from "$lib/server/session"; 14 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 15 | import { sha256 } from "@oslojs/crypto/sha2"; 16 | 17 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 18 | import type { RequestEvent } from "./$types"; 19 | 20 | export async function POST(event: RequestEvent) { 21 | if (event.locals.session === null || event.locals.user === null) { 22 | return new Response("Not authenticated", { 23 | status: 401 24 | }); 25 | } 26 | if ( 27 | !event.locals.user.emailVerified || 28 | !event.locals.user.registeredPasskey || 29 | event.locals.session.twoFactorVerified 30 | ) { 31 | return new Response("Forbidden", { 32 | status: 403 33 | }); 34 | } 35 | 36 | const data: unknown = await event.request.json(); 37 | const parser = new ObjectParser(data); 38 | let encodedAuthenticatorData: string; 39 | let encodedClientDataJSON: string; 40 | let encodedCredentialId: string; 41 | let encodedSignature: string; 42 | try { 43 | encodedAuthenticatorData = parser.getString("authenticator_data"); 44 | encodedClientDataJSON = parser.getString("client_data_json"); 45 | encodedCredentialId = parser.getString("credential_id"); 46 | encodedSignature = parser.getString("signature"); 47 | } catch { 48 | return new Response("Invalid or missing fields", { 49 | status: 400 50 | }); 51 | } 52 | let authenticatorDataBytes: Uint8Array; 53 | let clientDataJSON: Uint8Array; 54 | let credentialId: Uint8Array; 55 | let signatureBytes: Uint8Array; 56 | try { 57 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 58 | clientDataJSON = decodeBase64(encodedClientDataJSON); 59 | credentialId = decodeBase64(encodedCredentialId); 60 | signatureBytes = decodeBase64(encodedSignature); 61 | } catch { 62 | return new Response("Invalid or missing fields", { 63 | status: 400 64 | }); 65 | } 66 | 67 | let authenticatorData: AuthenticatorData; 68 | try { 69 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 70 | } catch { 71 | return new Response("Invalid data", { 72 | status: 400 73 | }); 74 | } 75 | // TODO: Update host 76 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 77 | return new Response("Invalid data", { 78 | status: 400 79 | }); 80 | } 81 | if (!authenticatorData.userPresent) { 82 | return new Response("Invalid data", { 83 | status: 400 84 | }); 85 | } 86 | 87 | let clientData: ClientData; 88 | try { 89 | clientData = parseClientDataJSON(clientDataJSON); 90 | } catch { 91 | return new Response("Invalid data", { 92 | status: 400 93 | }); 94 | } 95 | if (clientData.type !== ClientDataType.Get) { 96 | return new Response("Invalid data", { 97 | status: 400 98 | }); 99 | } 100 | 101 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 102 | return new Response("Invalid data", { 103 | status: 400 104 | }); 105 | } 106 | // TODO: Update origin 107 | if (clientData.origin !== "http://localhost:5173") { 108 | return new Response("Invalid data", { 109 | status: 400 110 | }); 111 | } 112 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 113 | return new Response("Invalid data", { 114 | status: 400 115 | }); 116 | } 117 | 118 | const credential = getUserPasskeyCredential(event.locals.user.id, credentialId); 119 | if (credential === null) { 120 | return new Response("Invalid credential", { 121 | status: 400 122 | }); 123 | } 124 | 125 | let validSignature: boolean; 126 | if (credential.algorithmId === coseAlgorithmES256) { 127 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 128 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 129 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 130 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 131 | } else if (credential.algorithmId === coseAlgorithmRS256) { 132 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 135 | } else { 136 | return new Response("Internal error", { 137 | status: 500 138 | }); 139 | } 140 | 141 | if (!validSignature) { 142 | return new Response("Invalid signature", { 143 | status: 400 144 | }); 145 | } 146 | 147 | setSessionAs2FAVerified(event.locals.session.id); 148 | return new Response(null, { 149 | status: 204 150 | }); 151 | } 152 | -------------------------------------------------------------------------------- /src/routes/2fa/passkey/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { get2FARedirect } from "$lib/server/2fa"; 3 | import { bigEndian } from "@oslojs/binary"; 4 | import { 5 | parseAttestationObject, 6 | AttestationStatementFormat, 7 | parseClientDataJSON, 8 | coseAlgorithmES256, 9 | coseEllipticCurveP256, 10 | ClientDataType, 11 | coseAlgorithmRS256 12 | } from "@oslojs/webauthn"; 13 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 14 | import { decodeBase64 } from "@oslojs/encoding"; 15 | import { verifyWebAuthnChallenge, createPasskeyCredential, getUserPasskeyCredentials } from "$lib/server/webauthn"; 16 | import { setSessionAs2FAVerified } from "$lib/server/session"; 17 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 18 | import { SqliteError } from "better-sqlite3"; 19 | 20 | import type { WebAuthnUserCredential } from "$lib/server/webauthn"; 21 | import type { 22 | AttestationStatement, 23 | AuthenticatorData, 24 | ClientData, 25 | COSEEC2PublicKey, 26 | COSERSAPublicKey 27 | } from "@oslojs/webauthn"; 28 | import type { Actions, RequestEvent } from "./$types"; 29 | 30 | export async function load(event: RequestEvent) { 31 | if (event.locals.session === null || event.locals.user === null) { 32 | return redirect(302, "/login"); 33 | } 34 | if (!event.locals.user.emailVerified) { 35 | return redirect(302, "/verify-email"); 36 | } 37 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 38 | return redirect(302, get2FARedirect(event.locals.user)); 39 | } 40 | 41 | const credentials = getUserPasskeyCredentials(event.locals.user.id); 42 | 43 | const credentialUserId = new Uint8Array(8); 44 | bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0); 45 | 46 | return { 47 | credentials, 48 | credentialUserId, 49 | user: event.locals.user 50 | }; 51 | } 52 | 53 | export const actions: Actions = { 54 | default: action 55 | }; 56 | 57 | async function action(event: RequestEvent) { 58 | if (event.locals.session === null || event.locals.user === null) { 59 | return fail(401, { 60 | message: "Not authenticated" 61 | }); 62 | } 63 | if (!event.locals.user.emailVerified) { 64 | return fail(403, { 65 | message: "Forbidden" 66 | }); 67 | } 68 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 69 | return fail(403, { 70 | message: "Forbidden" 71 | }); 72 | } 73 | 74 | const formData = await event.request.formData(); 75 | const name = formData.get("name"); 76 | const encodedAttestationObject = formData.get("attestation_object"); 77 | const encodedClientDataJSON = formData.get("client_data_json"); 78 | if ( 79 | typeof name !== "string" || 80 | typeof encodedAttestationObject !== "string" || 81 | typeof encodedClientDataJSON !== "string" 82 | ) { 83 | return fail(400, { 84 | message: "Invalid or missing fields" 85 | }); 86 | } 87 | 88 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 89 | try { 90 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 91 | clientDataJSON = decodeBase64(encodedClientDataJSON); 92 | } catch { 93 | return fail(400, { 94 | message: "Invalid or missing fields" 95 | }); 96 | } 97 | 98 | let attestationStatement: AttestationStatement; 99 | let authenticatorData: AuthenticatorData; 100 | try { 101 | const attestationObject = parseAttestationObject(attestationObjectBytes); 102 | attestationStatement = attestationObject.attestationStatement; 103 | authenticatorData = attestationObject.authenticatorData; 104 | } catch { 105 | return fail(400, { 106 | message: "Invalid data" 107 | }); 108 | } 109 | if (attestationStatement.format !== AttestationStatementFormat.None) { 110 | return fail(400, { 111 | message: "Invalid data" 112 | }); 113 | } 114 | // TODO: Update host 115 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 116 | return fail(400, { 117 | message: "Invalid data" 118 | }); 119 | } 120 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 121 | return fail(400, { 122 | message: "Invalid data" 123 | }); 124 | } 125 | if (authenticatorData.credential === null) { 126 | return fail(400, { 127 | message: "Invalid data" 128 | }); 129 | } 130 | 131 | let clientData: ClientData; 132 | try { 133 | clientData = parseClientDataJSON(clientDataJSON); 134 | } catch { 135 | return fail(400, { 136 | message: "Invalid data" 137 | }); 138 | } 139 | if (clientData.type !== ClientDataType.Create) { 140 | return fail(400, { 141 | message: "Invalid data" 142 | }); 143 | } 144 | 145 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 146 | return fail(400, { 147 | message: "Invalid data" 148 | }); 149 | } 150 | // TODO: Update origin 151 | if (clientData.origin !== "http://localhost:5173") { 152 | return fail(400, { 153 | message: "Invalid data" 154 | }); 155 | } 156 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 157 | return fail(400, { 158 | message: "Invalid data" 159 | }); 160 | } 161 | 162 | let credential: WebAuthnUserCredential; 163 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 164 | let cosePublicKey: COSEEC2PublicKey; 165 | try { 166 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 167 | } catch { 168 | return fail(400, { 169 | message: "Invalid data" 170 | }); 171 | } 172 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 173 | return fail(400, { 174 | message: "Unsupported algorithm" 175 | }); 176 | } 177 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 178 | credential = { 179 | id: authenticatorData.credential.id, 180 | userId: event.locals.user.id, 181 | algorithmId: coseAlgorithmES256, 182 | name, 183 | publicKey: encodedPublicKey 184 | }; 185 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 186 | let cosePublicKey: COSERSAPublicKey; 187 | try { 188 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 189 | } catch { 190 | return fail(400, { 191 | message: "Invalid data" 192 | }); 193 | } 194 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 195 | credential = { 196 | id: authenticatorData.credential.id, 197 | userId: event.locals.user.id, 198 | algorithmId: coseAlgorithmRS256, 199 | name, 200 | publicKey: encodedPublicKey 201 | }; 202 | } else { 203 | return fail(400, { 204 | message: "Unsupported algorithm" 205 | }); 206 | } 207 | 208 | // We don't have to worry about race conditions since queries are synchronous 209 | const credentials = getUserPasskeyCredentials(event.locals.user.id); 210 | if (credentials.length >= 5) { 211 | return fail(400, { 212 | message: "Too many credentials" 213 | }); 214 | } 215 | 216 | try { 217 | createPasskeyCredential(credential); 218 | } catch (e) { 219 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 220 | return fail(400, { 221 | message: "Invalid data" 222 | }); 223 | } 224 | return fail(500, { 225 | message: "Internal error" 226 | }); 227 | } 228 | 229 | if (!event.locals.session.twoFactorVerified) { 230 | setSessionAs2FAVerified(event.locals.session.id); 231 | } 232 | 233 | if (!event.locals.user.registered2FA) { 234 | return redirect(302, "/recovery-code"); 235 | } 236 | return redirect(302, "/"); 237 | } 238 | -------------------------------------------------------------------------------- /src/routes/2fa/passkey/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |

Register passkey

16 | 68 |
69 | 70 | 71 | 72 | 73 | 74 |

{form?.message ?? ""}

75 |
76 | -------------------------------------------------------------------------------- /src/routes/2fa/reset/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa"; 2 | import { fail, redirect } from "@sveltejs/kit"; 3 | 4 | import type { Actions, RequestEvent } from "./$types"; 5 | 6 | export const actions: Actions = { 7 | default: action 8 | }; 9 | 10 | export async function load(event: RequestEvent) { 11 | if (event.locals.session === null || event.locals.user === null) { 12 | return redirect(302, "/login"); 13 | } 14 | if (!event.locals.user.emailVerified) { 15 | return redirect(302, "/verify-email"); 16 | } 17 | if (!event.locals.user.registered2FA) { 18 | return redirect(302, "/2fa/setup"); 19 | } 20 | if (event.locals.session.twoFactorVerified) { 21 | return redirect(302, "/"); 22 | } 23 | return {}; 24 | } 25 | 26 | async function action(event: RequestEvent) { 27 | if (event.locals.session === null || event.locals.user === null) { 28 | return fail(401, { 29 | message: "Not authenticated" 30 | }); 31 | } 32 | if (!event.locals.user.emailVerified || !event.locals.user.registered2FA || event.locals.session.twoFactorVerified) { 33 | return fail(403, { 34 | message: "Forbidden" 35 | }); 36 | } 37 | if (!recoveryCodeBucket.check(event.locals.user.id, 1)) { 38 | return fail(429, { 39 | message: "Too many requests" 40 | }); 41 | } 42 | 43 | const formData = await event.request.formData(); 44 | const code = formData.get("code"); 45 | if (typeof code !== "string") { 46 | return fail(400, { 47 | message: "Invalid or missing fields" 48 | }); 49 | } 50 | if (code === "") { 51 | return fail(400, { 52 | message: "Please enter your code" 53 | }); 54 | } 55 | if (!recoveryCodeBucket.consume(event.locals.user.id, 1)) { 56 | return fail(429, { 57 | message: "Too many requests" 58 | }); 59 | } 60 | const valid = resetUser2FAWithRecoveryCode(event.locals.user.id, code); 61 | if (!valid) { 62 | return fail(400, { 63 | message: "Invalid recovery code" 64 | }); 65 | } 66 | recoveryCodeBucket.reset(event.locals.user.id); 67 | return redirect(302, "/2fa/setup"); 68 | } 69 | -------------------------------------------------------------------------------- /src/routes/2fa/reset/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Recover your account

10 |
11 | 12 |
13 | 14 |

{form?.message ?? ""}

15 |
16 | -------------------------------------------------------------------------------- /src/routes/2fa/security-key/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { get2FARedirect } from "$lib/server/2fa"; 3 | import { getUserSecurityKeyCredentials } from "$lib/server/webauthn"; 4 | 5 | import type { RequestEvent } from "./$types"; 6 | 7 | export async function load(event: RequestEvent) { 8 | if (event.locals.session === null || event.locals.user === null) { 9 | return redirect(302, "/login"); 10 | } 11 | if (!event.locals.user.emailVerified) { 12 | return redirect(302, "/verify-email"); 13 | } 14 | if (!event.locals.user.registered2FA) { 15 | return redirect(302, "/"); 16 | } 17 | if (event.locals.session.twoFactorVerified) { 18 | return redirect(302, "/"); 19 | } 20 | if (!event.locals.user.registeredSecurityKey) { 21 | return redirect(302, get2FARedirect(event.locals.user)); 22 | } 23 | const credentials = getUserSecurityKeyCredentials(event.locals.user.id); 24 | return { 25 | credentials, 26 | user: event.locals.user 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/2fa/security-key/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |

Authenticate with security keys

14 |
15 | 56 |

{message}

57 |
58 | Use recovery code 59 | 60 | {#if data.user.registeredTOTP} 61 | Use authenticator apps 62 | {/if} 63 | {#if data.user.registeredPasskey} 64 | Use passkeys 65 | {/if} 66 | -------------------------------------------------------------------------------- /src/routes/2fa/security-key/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | coseAlgorithmRS256, 6 | createAssertionSignatureMessage, 7 | parseAuthenticatorData 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn"; 13 | import { setSessionAs2FAVerified } from "$lib/server/session"; 14 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 15 | import { sha256 } from "@oslojs/crypto/sha2"; 16 | 17 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 18 | import type { RequestEvent } from "./$types"; 19 | 20 | export async function POST(event: RequestEvent) { 21 | if (event.locals.session === null || event.locals.user === null) { 22 | return new Response("Not authenticated", { 23 | status: 401 24 | }); 25 | } 26 | if ( 27 | !event.locals.user.emailVerified || 28 | !event.locals.user.registeredSecurityKey || 29 | event.locals.session.twoFactorVerified 30 | ) { 31 | return new Response("Forbidden", { 32 | status: 403 33 | }); 34 | } 35 | 36 | const data: unknown = await event.request.json(); 37 | const parser = new ObjectParser(data); 38 | let encodedAuthenticatorData: string; 39 | let encodedClientDataJSON: string; 40 | let encodedCredentialId: string; 41 | let encodedSignature: string; 42 | try { 43 | encodedAuthenticatorData = parser.getString("authenticator_data"); 44 | encodedClientDataJSON = parser.getString("client_data_json"); 45 | encodedCredentialId = parser.getString("credential_id"); 46 | encodedSignature = parser.getString("signature"); 47 | } catch { 48 | return new Response("Invalid or missing fields", { 49 | status: 400 50 | }); 51 | } 52 | let authenticatorDataBytes: Uint8Array; 53 | let clientDataJSON: Uint8Array; 54 | let credentialId: Uint8Array; 55 | let signatureBytes: Uint8Array; 56 | try { 57 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 58 | clientDataJSON = decodeBase64(encodedClientDataJSON); 59 | credentialId = decodeBase64(encodedCredentialId); 60 | signatureBytes = decodeBase64(encodedSignature); 61 | } catch { 62 | return new Response("Invalid or missing fields", { 63 | status: 400 64 | }); 65 | } 66 | 67 | let authenticatorData: AuthenticatorData; 68 | try { 69 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 70 | } catch { 71 | return new Response("Invalid data", { 72 | status: 400 73 | }); 74 | } 75 | // TODO: Update host 76 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 77 | return new Response("Invalid data", { 78 | status: 400 79 | }); 80 | } 81 | if (!authenticatorData.userPresent) { 82 | return new Response("Invalid data", { 83 | status: 400 84 | }); 85 | } 86 | 87 | let clientData: ClientData; 88 | try { 89 | clientData = parseClientDataJSON(clientDataJSON); 90 | } catch { 91 | return new Response("Invalid data", { 92 | status: 400 93 | }); 94 | } 95 | if (clientData.type !== ClientDataType.Get) { 96 | return new Response("Invalid data", { 97 | status: 400 98 | }); 99 | } 100 | 101 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 102 | return new Response("Invalid data", { 103 | status: 400 104 | }); 105 | } 106 | // TODO: Update origin 107 | if (clientData.origin !== "http://localhost:5173") { 108 | return new Response("Invalid data", { 109 | status: 400 110 | }); 111 | } 112 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 113 | return new Response("Invalid data", { 114 | status: 400 115 | }); 116 | } 117 | 118 | const credential = getUserSecurityKeyCredential(event.locals.user.id, credentialId); 119 | if (credential === null) { 120 | return new Response("Invalid credential", { 121 | status: 400 122 | }); 123 | } 124 | 125 | let validSignature: boolean; 126 | if (credential.algorithmId === coseAlgorithmES256) { 127 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 128 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 129 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 130 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 131 | } else if (credential.algorithmId === coseAlgorithmRS256) { 132 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 135 | } else { 136 | return new Response("Internal error", { 137 | status: 500 138 | }); 139 | } 140 | 141 | if (!validSignature) { 142 | return new Response("Invalid signature", { 143 | status: 400 144 | }); 145 | } 146 | 147 | setSessionAs2FAVerified(event.locals.session.id); 148 | return new Response(null, { 149 | status: 204 150 | }); 151 | } 152 | -------------------------------------------------------------------------------- /src/routes/2fa/security-key/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { get2FARedirect } from "$lib/server/2fa"; 3 | import { bigEndian } from "@oslojs/binary"; 4 | import { 5 | parseAttestationObject, 6 | AttestationStatementFormat, 7 | parseClientDataJSON, 8 | coseAlgorithmES256, 9 | coseEllipticCurveP256, 10 | ClientDataType, 11 | coseAlgorithmRS256 12 | } from "@oslojs/webauthn"; 13 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 14 | import { decodeBase64 } from "@oslojs/encoding"; 15 | import { 16 | verifyWebAuthnChallenge, 17 | createSecurityKeyCredential, 18 | getUserSecurityKeyCredentials 19 | } from "$lib/server/webauthn"; 20 | import { setSessionAs2FAVerified } from "$lib/server/session"; 21 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 22 | import { SqliteError } from "better-sqlite3"; 23 | 24 | import type { WebAuthnUserCredential } from "$lib/server/webauthn"; 25 | import type { 26 | AttestationStatement, 27 | AuthenticatorData, 28 | ClientData, 29 | COSEEC2PublicKey, 30 | COSERSAPublicKey 31 | } from "@oslojs/webauthn"; 32 | import type { Actions, RequestEvent } from "./$types"; 33 | 34 | export async function load(event: RequestEvent) { 35 | if (event.locals.session === null || event.locals.user === null) { 36 | return redirect(302, "/login"); 37 | } 38 | if (!event.locals.user.emailVerified) { 39 | return redirect(302, "/verify-email"); 40 | } 41 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 42 | return redirect(302, get2FARedirect(event.locals.user)); 43 | } 44 | 45 | const credentials = getUserSecurityKeyCredentials(event.locals.user.id); 46 | 47 | const credentialUserId = new Uint8Array(8); 48 | bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0); 49 | 50 | return { 51 | credentials, 52 | credentialUserId, 53 | user: event.locals.user 54 | }; 55 | } 56 | 57 | export const actions: Actions = { 58 | default: action 59 | }; 60 | 61 | async function action(event: RequestEvent) { 62 | if (event.locals.session === null || event.locals.user === null) { 63 | return fail(401, { 64 | message: "Not authenticated" 65 | }); 66 | } 67 | if (!event.locals.user.emailVerified) { 68 | return fail(403, { 69 | message: "Forbidden" 70 | }); 71 | } 72 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 73 | return fail(403, { 74 | message: "Forbidden" 75 | }); 76 | } 77 | 78 | const formData = await event.request.formData(); 79 | const name = formData.get("name"); 80 | const encodedAttestationObject = formData.get("attestation_object"); 81 | const encodedClientDataJSON = formData.get("client_data_json"); 82 | if ( 83 | typeof name !== "string" || 84 | typeof encodedAttestationObject !== "string" || 85 | typeof encodedClientDataJSON !== "string" 86 | ) { 87 | return fail(400, { 88 | message: "Invalid or missing fields" 89 | }); 90 | } 91 | 92 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 93 | try { 94 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 95 | clientDataJSON = decodeBase64(encodedClientDataJSON); 96 | } catch { 97 | return fail(400, { 98 | message: "Invalid or missing fields" 99 | }); 100 | } 101 | 102 | let attestationStatement: AttestationStatement; 103 | let authenticatorData: AuthenticatorData; 104 | try { 105 | const attestationObject = parseAttestationObject(attestationObjectBytes); 106 | attestationStatement = attestationObject.attestationStatement; 107 | authenticatorData = attestationObject.authenticatorData; 108 | } catch { 109 | return fail(400, { 110 | message: "Invalid data" 111 | }); 112 | } 113 | if (attestationStatement.format !== AttestationStatementFormat.None) { 114 | return fail(400, { 115 | message: "Invalid data" 116 | }); 117 | } 118 | // TODO: Update host 119 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 120 | return fail(400, { 121 | message: "Invalid data" 122 | }); 123 | } 124 | if (!authenticatorData.userPresent) { 125 | return fail(400, { 126 | message: "Invalid data" 127 | }); 128 | } 129 | if (authenticatorData.credential === null) { 130 | return fail(400, { 131 | message: "Invalid data" 132 | }); 133 | } 134 | 135 | let clientData: ClientData; 136 | try { 137 | clientData = parseClientDataJSON(clientDataJSON); 138 | } catch { 139 | return fail(400, { 140 | message: "Invalid data" 141 | }); 142 | } 143 | if (clientData.type !== ClientDataType.Create) { 144 | return fail(400, { 145 | message: "Invalid data" 146 | }); 147 | } 148 | 149 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 150 | return fail(400, { 151 | message: "Invalid data" 152 | }); 153 | } 154 | // TODO: Update origin 155 | if (clientData.origin !== "http://localhost:5173") { 156 | return fail(400, { 157 | message: "Invalid data" 158 | }); 159 | } 160 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 161 | return fail(400, { 162 | message: "Invalid data" 163 | }); 164 | } 165 | 166 | let credential: WebAuthnUserCredential; 167 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 168 | let cosePublicKey: COSEEC2PublicKey; 169 | try { 170 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 171 | } catch { 172 | return fail(400, { 173 | message: "Invalid data" 174 | }); 175 | } 176 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 177 | return fail(400, { 178 | message: "Unsupported algorithm" 179 | }); 180 | } 181 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 182 | credential = { 183 | id: authenticatorData.credential.id, 184 | userId: event.locals.user.id, 185 | algorithmId: coseAlgorithmES256, 186 | name, 187 | publicKey: encodedPublicKey 188 | }; 189 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 190 | let cosePublicKey: COSERSAPublicKey; 191 | try { 192 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 193 | } catch { 194 | return fail(400, { 195 | message: "Invalid data" 196 | }); 197 | } 198 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 199 | credential = { 200 | id: authenticatorData.credential.id, 201 | userId: event.locals.user.id, 202 | algorithmId: coseAlgorithmRS256, 203 | name, 204 | publicKey: encodedPublicKey 205 | }; 206 | } else { 207 | return fail(400, { 208 | message: "Unsupported algorithm" 209 | }); 210 | } 211 | 212 | // We don't have to worry about race conditions since queries are synchronous 213 | const credentials = getUserSecurityKeyCredentials(event.locals.user.id); 214 | if (credentials.length >= 5) { 215 | return fail(400, { 216 | message: "Too many credentials" 217 | }); 218 | } 219 | 220 | try { 221 | createSecurityKeyCredential(credential); 222 | } catch (e) { 223 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 224 | return fail(400, { 225 | message: "Invalid data" 226 | }); 227 | } 228 | return fail(500, { 229 | message: "Internal error" 230 | }); 231 | } 232 | 233 | if (!event.locals.session.twoFactorVerified) { 234 | setSessionAs2FAVerified(event.locals.session.id); 235 | } 236 | 237 | if (!event.locals.user.registered2FA) { 238 | return redirect(302, "/recovery-code"); 239 | } 240 | return redirect(302, "/"); 241 | } 242 | -------------------------------------------------------------------------------- /src/routes/2fa/security-key/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |

Register security key

16 | 69 |
70 | 71 | 72 | 73 | 74 | 75 |

{form?.message ?? ""}

76 |
77 | -------------------------------------------------------------------------------- /src/routes/2fa/setup/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | import type { RequestEvent } from "./$types"; 4 | 5 | export async function load(event: RequestEvent) { 6 | if (event.locals.session === null || event.locals.user === null) { 7 | return redirect(302, "/login"); 8 | } 9 | if (!event.locals.user.emailVerified) { 10 | return redirect(302, "/verify-email"); 11 | } 12 | if (event.locals.user.registered2FA) { 13 | return redirect(302, "/"); 14 | } 15 | return {}; 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/2fa/setup/+page.svelte: -------------------------------------------------------------------------------- 1 |

Set up two-factor authentication

2 | 7 | -------------------------------------------------------------------------------- /src/routes/2fa/totp/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { totpBucket, getUserTOTPKey } from "$lib/server/totp"; 2 | import { fail, redirect } from "@sveltejs/kit"; 3 | import { verifyTOTP } from "@oslojs/otp"; 4 | import { setSessionAs2FAVerified } from "$lib/server/session"; 5 | 6 | import type { Actions, RequestEvent } from "./$types"; 7 | 8 | export async function load(event: RequestEvent) { 9 | if (event.locals.session === null || event.locals.user === null) { 10 | return redirect(302, "/login"); 11 | } 12 | if (!event.locals.user.emailVerified) { 13 | return redirect(302, "/verify-email"); 14 | } 15 | if (!event.locals.user.registered2FA) { 16 | return redirect(302, "/2fa/setup"); 17 | } 18 | if (event.locals.session.twoFactorVerified) { 19 | return redirect(302, "/"); 20 | } 21 | return { 22 | user: event.locals.user 23 | }; 24 | } 25 | 26 | export const actions: Actions = { 27 | default: action 28 | }; 29 | 30 | async function action(event: RequestEvent) { 31 | if (event.locals.session === null || event.locals.user === null) { 32 | return fail(401, { 33 | message: "Not authenticated" 34 | }); 35 | } 36 | if (!event.locals.user.emailVerified || !event.locals.user.registeredTOTP || event.locals.session.twoFactorVerified) { 37 | return fail(403, { 38 | message: "Forbidden" 39 | }); 40 | } 41 | if (!totpBucket.check(event.locals.user.id, 1)) { 42 | return fail(429, { 43 | message: "Too many requests" 44 | }); 45 | } 46 | 47 | const formData = await event.request.formData(); 48 | const code = formData.get("code"); 49 | if (typeof code !== "string") { 50 | return fail(400, { 51 | message: "Invalid or missing fields" 52 | }); 53 | } 54 | if (code === "") { 55 | return fail(400, { 56 | message: "Enter your code" 57 | }); 58 | } 59 | if (!totpBucket.consume(event.locals.user.id, 1)) { 60 | return fail(429, { 61 | message: "Too many requests" 62 | }); 63 | } 64 | const totpKey = getUserTOTPKey(event.locals.user.id); 65 | if (totpKey === null) { 66 | return fail(403, { 67 | message: "Forbidden" 68 | }); 69 | } 70 | if (!verifyTOTP(totpKey, 30, 6, code)) { 71 | return fail(400, { 72 | message: "Invalid code" 73 | }); 74 | } 75 | totpBucket.reset(event.locals.user.id); 76 | setSessionAs2FAVerified(event.locals.session.id); 77 | return redirect(302, "/"); 78 | } 79 | -------------------------------------------------------------------------------- /src/routes/2fa/totp/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Authenticate with authenticator app

11 |

Enter the code from your app.

12 |
13 | 14 |
15 | 16 |

{form?.message ?? ""}

17 |
18 | Use recovery code 19 | 20 | {#if data.user.registeredPasskey} 21 | Use passkeys 22 | {/if} 23 | {#if data.user.registeredSecurityKey} 24 | Use security keys 25 | {/if} 26 | -------------------------------------------------------------------------------- /src/routes/2fa/totp/setup/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { createTOTPKeyURI, verifyTOTP } from "@oslojs/otp"; 2 | import { fail, redirect } from "@sveltejs/kit"; 3 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 4 | import { totpUpdateBucket, updateUserTOTPKey } from "$lib/server/totp"; 5 | import { setSessionAs2FAVerified } from "$lib/server/session"; 6 | import { renderSVG } from "uqr"; 7 | import { get2FARedirect } from "$lib/server/2fa"; 8 | 9 | import type { Actions, RequestEvent } from "./$types"; 10 | 11 | export async function load(event: RequestEvent) { 12 | if (event.locals.session === null || event.locals.user === null) { 13 | return redirect(302, "/login"); 14 | } 15 | if (!event.locals.user.emailVerified) { 16 | return redirect(302, "/verify-email"); 17 | } 18 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 19 | return redirect(302, get2FARedirect(event.locals.user)); 20 | } 21 | 22 | const totpKey = new Uint8Array(20); 23 | crypto.getRandomValues(totpKey); 24 | const encodedTOTPKey = encodeBase64(totpKey); 25 | const keyURI = createTOTPKeyURI("Demo", event.locals.user.username, totpKey, 30, 6); 26 | const qrcode = renderSVG(keyURI); 27 | return { 28 | encodedTOTPKey, 29 | qrcode 30 | }; 31 | } 32 | 33 | export const actions: Actions = { 34 | default: action 35 | }; 36 | 37 | async function action(event: RequestEvent) { 38 | if (event.locals.session === null || event.locals.user === null) { 39 | return fail(401, { 40 | message: "Not authenticated" 41 | }); 42 | } 43 | if (!event.locals.user.emailVerified) { 44 | return fail(403, { 45 | message: "Forbidden" 46 | }); 47 | } 48 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 49 | return fail(403, { 50 | message: "Forbidden" 51 | }); 52 | } 53 | if (!totpUpdateBucket.check(event.locals.user.id, 1)) { 54 | return fail(429, { 55 | message: "Too many requests" 56 | }); 57 | } 58 | 59 | const formData = await event.request.formData(); 60 | const encodedKey = formData.get("key"); 61 | const code = formData.get("code"); 62 | if (typeof encodedKey !== "string" || typeof code !== "string") { 63 | return fail(400, { 64 | message: "Invalid or missing fields" 65 | }); 66 | } 67 | if (code === "") { 68 | return fail(400, { 69 | message: "Please enter your code" 70 | }); 71 | } 72 | if (encodedKey.length !== 28) { 73 | return fail(400, { 74 | message: "Please enter your code" 75 | }); 76 | } 77 | let key: Uint8Array; 78 | try { 79 | key = decodeBase64(encodedKey); 80 | } catch { 81 | return fail(400, { 82 | message: "Invalid key" 83 | }); 84 | } 85 | if (key.byteLength !== 20) { 86 | return fail(400, { 87 | message: "Invalid key" 88 | }); 89 | } 90 | if (!totpUpdateBucket.consume(event.locals.user.id, 1)) { 91 | return fail(429, { 92 | message: "Too many requests" 93 | }); 94 | } 95 | if (!verifyTOTP(key, 30, 6, code)) { 96 | return fail(400, { 97 | message: "Invalid code" 98 | }); 99 | } 100 | updateUserTOTPKey(event.locals.session.userId, key); 101 | setSessionAs2FAVerified(event.locals.session.id); 102 | if (!event.locals.user.registered2FA) { 103 | return redirect(302, "/recovery-code"); 104 | } 105 | return redirect(302, "/"); 106 | } 107 | -------------------------------------------------------------------------------- /src/routes/2fa/totp/setup/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Set up authenticator app

11 |
12 | {@html data.qrcode} 13 |
14 |
15 | 16 | 17 |
18 | 19 |

{form?.message ?? ""}

20 |
21 | -------------------------------------------------------------------------------- /src/routes/api/webauthn/challenge/+server.ts: -------------------------------------------------------------------------------- 1 | import { createWebAuthnChallenge } from "$lib/server/webauthn"; 2 | import { encodeBase64 } from "@oslojs/encoding"; 3 | import { RefillingTokenBucket } from "$lib/server/rate-limit"; 4 | 5 | import type { RequestEvent } from "./$types"; 6 | 7 | const webauthnChallengeRateLimitBucket = new RefillingTokenBucket(30, 10); 8 | 9 | export async function POST(event: RequestEvent) { 10 | // TODO: Assumes X-Forwarded-For is always included. 11 | const clientIP = event.request.headers.get("X-Forwarded-For"); 12 | if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) { 13 | return new Response("Too many requests", { 14 | status: 429 15 | }); 16 | } 17 | const challenge = createWebAuthnChallenge(); 18 | return new Response(JSON.stringify({ challenge: encodeBase64(challenge) })); 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/forgot-password/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { verifyEmailInput } from "$lib/server/email"; 2 | import { getUserFromEmail } from "$lib/server/user"; 3 | import { 4 | createPasswordResetSession, 5 | invalidateUserPasswordResetSessions, 6 | sendPasswordResetEmail, 7 | setPasswordResetSessionTokenCookie 8 | } from "$lib/server/password-reset"; 9 | import { RefillingTokenBucket } from "$lib/server/rate-limit"; 10 | import { generateSessionToken } from "$lib/server/session"; 11 | import { fail, redirect } from "@sveltejs/kit"; 12 | 13 | import type { Actions, RequestEvent } from "./$types"; 14 | 15 | const ipBucket = new RefillingTokenBucket(3, 60); 16 | const userBucket = new RefillingTokenBucket(3, 60); 17 | 18 | export const actions: Actions = { 19 | default: action 20 | }; 21 | 22 | async function action(event: RequestEvent) { 23 | // TODO: Assumes X-Forwarded-For is always included. 24 | const clientIP = event.request.headers.get("X-Forwarded-For"); 25 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 26 | return fail(429, { 27 | message: "Too many requests", 28 | email: "" 29 | }); 30 | } 31 | 32 | const formData = await event.request.formData(); 33 | const email = formData.get("email"); 34 | if (typeof email !== "string") { 35 | return fail(400, { 36 | message: "Invalid or missing fields", 37 | email: "" 38 | }); 39 | } 40 | if (!verifyEmailInput(email)) { 41 | return fail(400, { 42 | message: "Invalid email", 43 | email 44 | }); 45 | } 46 | const user = getUserFromEmail(email); 47 | if (user === null) { 48 | return fail(400, { 49 | message: "Account does not exist", 50 | email 51 | }); 52 | } 53 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 54 | return fail(400, { 55 | message: "Too many requests", 56 | email 57 | }); 58 | } 59 | if (!userBucket.consume(user.id, 1)) { 60 | return fail(400, { 61 | message: "Too many requests", 62 | email 63 | }); 64 | } 65 | invalidateUserPasswordResetSessions(user.id); 66 | const sessionToken = generateSessionToken(); 67 | const session = createPasswordResetSession(sessionToken, user.id, user.email); 68 | sendPasswordResetEmail(session.email, session.code); 69 | setPasswordResetSessionTokenCookie(event, sessionToken, session.expiresAt); 70 | return redirect(302, "/reset-password/verify-email"); 71 | } 72 | -------------------------------------------------------------------------------- /src/routes/forgot-password/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Forgot your password?

10 |
11 | 12 |
13 | 14 |

{form?.message ?? ""}

15 |
16 | Sign in 17 | -------------------------------------------------------------------------------- /src/routes/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { verifyEmailInput } from "$lib/server/email"; 3 | import { getUserFromEmail, getUserPasswordHash } from "$lib/server/user"; 4 | import { RefillingTokenBucket, Throttler } from "$lib/server/rate-limit"; 5 | import { verifyPasswordHash } from "$lib/server/password"; 6 | import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; 7 | import { get2FARedirect } from "$lib/server/2fa"; 8 | 9 | import type { SessionFlags } from "$lib/server/session"; 10 | import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; 11 | 12 | export function load(event: PageServerLoadEvent) { 13 | if (event.locals.session !== null && event.locals.user !== null) { 14 | if (!event.locals.user.emailVerified) { 15 | return redirect(302, "/verify-email"); 16 | } 17 | if (!event.locals.user.registered2FA) { 18 | return redirect(302, "/2fa/setup"); 19 | } 20 | if (!event.locals.session.twoFactorVerified) { 21 | return redirect(302, get2FARedirect(event.locals.user)); 22 | } 23 | return redirect(302, "/"); 24 | } 25 | return {}; 26 | } 27 | 28 | const throttler = new Throttler([0, 1, 2, 4, 8, 16, 30, 60, 180, 300]); 29 | const ipBucket = new RefillingTokenBucket(20, 1); 30 | 31 | export const actions: Actions = { 32 | default: action 33 | }; 34 | 35 | async function action(event: RequestEvent) { 36 | // TODO: Assumes X-Forwarded-For is always included. 37 | const clientIP = event.request.headers.get("X-Forwarded-For"); 38 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 39 | return fail(429, { 40 | message: "Too many requests", 41 | email: "" 42 | }); 43 | } 44 | 45 | const formData = await event.request.formData(); 46 | const email = formData.get("email"); 47 | const password = formData.get("password"); 48 | if (typeof email !== "string" || typeof password !== "string") { 49 | return fail(400, { 50 | message: "Invalid or missing fields", 51 | email: "" 52 | }); 53 | } 54 | if (email === "" || password === "") { 55 | return fail(400, { 56 | message: "Please enter your email and password.", 57 | email 58 | }); 59 | } 60 | if (!verifyEmailInput(email)) { 61 | return fail(400, { 62 | message: "Invalid email", 63 | email 64 | }); 65 | } 66 | const user = getUserFromEmail(email); 67 | if (user === null) { 68 | return fail(400, { 69 | message: "Account does not exist", 70 | email 71 | }); 72 | } 73 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 74 | return fail(429, { 75 | message: "Too many requests", 76 | email: "" 77 | }); 78 | } 79 | if (!throttler.consume(user.id)) { 80 | return fail(429, { 81 | message: "Too many requests", 82 | email: "" 83 | }); 84 | } 85 | const passwordHash = getUserPasswordHash(user.id); 86 | const validPassword = await verifyPasswordHash(passwordHash, password); 87 | if (!validPassword) { 88 | return fail(400, { 89 | message: "Invalid password", 90 | email 91 | }); 92 | } 93 | throttler.reset(user.id); 94 | const sessionFlags: SessionFlags = { 95 | twoFactorVerified: false 96 | }; 97 | const sessionToken = generateSessionToken(); 98 | const session = createSession(sessionToken, user.id, sessionFlags); 99 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 100 | 101 | if (!user.emailVerified) { 102 | return redirect(302, "/verify-email"); 103 | } 104 | if (!user.registered2FA) { 105 | return redirect(302, "/2fa/setup"); 106 | } 107 | return redirect(302, get2FARedirect(user)); 108 | } 109 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |

Sign in

15 |
16 | 17 |
25 | 26 |
27 | 28 |

{form?.message ?? ""}

29 |
30 |
31 | 67 |

{passkeyErrorMessage}

68 |
69 | Create an account 70 | Forgot password? 71 | -------------------------------------------------------------------------------- /src/routes/login/passkey/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getPasskeyCredential } from "$lib/server/webauthn"; 13 | import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 16 | 17 | import type { RequestEvent } from "./$types"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | import type { SessionFlags } from "$lib/server/session"; 20 | 21 | // Stricter rate limiting can be omitted here since creating challenges are rate-limited 22 | export async function POST(context: RequestEvent): Promise { 23 | const data: unknown = await context.request.json(); 24 | const parser = new ObjectParser(data); 25 | let encodedAuthenticatorData: string; 26 | let encodedClientDataJSON: string; 27 | let encodedCredentialId: string; 28 | let encodedSignature: string; 29 | try { 30 | encodedAuthenticatorData = parser.getString("authenticator_data"); 31 | encodedClientDataJSON = parser.getString("client_data_json"); 32 | encodedCredentialId = parser.getString("credential_id"); 33 | encodedSignature = parser.getString("signature"); 34 | } catch { 35 | return new Response("Invalid or missing fields", { 36 | status: 400 37 | }); 38 | } 39 | let authenticatorDataBytes: Uint8Array; 40 | let clientDataJSON: Uint8Array; 41 | let credentialId: Uint8Array; 42 | let signatureBytes: Uint8Array; 43 | try { 44 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 45 | clientDataJSON = decodeBase64(encodedClientDataJSON); 46 | credentialId = decodeBase64(encodedCredentialId); 47 | signatureBytes = decodeBase64(encodedSignature); 48 | } catch { 49 | return new Response("Invalid or missing fields", { 50 | status: 400 51 | }); 52 | } 53 | 54 | let authenticatorData: AuthenticatorData; 55 | try { 56 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 57 | } catch { 58 | return new Response("Invalid data", { 59 | status: 400 60 | }); 61 | } 62 | // TODO: Update host 63 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 64 | return new Response("Invalid data", { 65 | status: 400 66 | }); 67 | } 68 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 69 | return new Response("Invalid data", { 70 | status: 400 71 | }); 72 | } 73 | 74 | let clientData: ClientData; 75 | try { 76 | clientData = parseClientDataJSON(clientDataJSON); 77 | } catch { 78 | return new Response("Invalid data", { 79 | status: 400 80 | }); 81 | } 82 | if (clientData.type !== ClientDataType.Get) { 83 | return new Response("Invalid data", { 84 | status: 400 85 | }); 86 | } 87 | 88 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 89 | return new Response("Invalid data", { 90 | status: 400 91 | }); 92 | } 93 | // TODO: Update origin 94 | if (clientData.origin !== "http://localhost:5173") { 95 | return new Response("Invalid data", { 96 | status: 400 97 | }); 98 | } 99 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 100 | return new Response("Invalid data", { 101 | status: 400 102 | }); 103 | } 104 | 105 | const credential = getPasskeyCredential(credentialId); 106 | if (credential === null) { 107 | return new Response("Invalid credential", { 108 | status: 400 109 | }); 110 | } 111 | 112 | let validSignature: boolean; 113 | if (credential.algorithmId === coseAlgorithmES256) { 114 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 115 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 116 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 117 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 118 | } else if (credential.algorithmId === coseAlgorithmRS256) { 119 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 120 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 121 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 122 | } else { 123 | return new Response("Internal error", { 124 | status: 500 125 | }); 126 | } 127 | 128 | if (!validSignature) { 129 | return new Response("Invalid signature", { 130 | status: 400 131 | }); 132 | } 133 | const sessionFlags: SessionFlags = { 134 | twoFactorVerified: true 135 | }; 136 | const sessionToken = generateSessionToken(); 137 | const session = createSession(sessionToken, credential.userId, sessionFlags); 138 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 139 | return new Response(null, { 140 | status: 204 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /src/routes/recovery-code/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getUserRecoverCode } from "$lib/server/user"; 2 | import { redirect } from "@sveltejs/kit"; 3 | import { get2FARedirect } from "$lib/server/2fa"; 4 | 5 | import type { RequestEvent } from "./$types"; 6 | 7 | export async function load(event: RequestEvent) { 8 | if (event.locals.session === null || event.locals.user === null) { 9 | return redirect(302, "/login"); 10 | } 11 | if (!event.locals.user.emailVerified) { 12 | return redirect(302, "/verify-email"); 13 | } 14 | if (!event.locals.user.registered2FA) { 15 | return redirect(302, "/2fa/setup"); 16 | } 17 | if (!event.locals.session.twoFactorVerified) { 18 | return redirect(302, get2FARedirect(event.locals.user)); 19 | } 20 | const recoveryCode = getUserRecoverCode(event.locals.user.id); 21 | return { 22 | recoveryCode 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/recovery-code/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |

Recovery code

8 |

Your recovery code is: {data.recoveryCode}

9 |

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

10 | Next 11 | -------------------------------------------------------------------------------- /src/routes/reset-password/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deletePasswordResetSessionTokenCookie, 3 | invalidateUserPasswordResetSessions, 4 | validatePasswordResetSessionRequest 5 | } from "$lib/server/password-reset"; 6 | import { fail, redirect } from "@sveltejs/kit"; 7 | import { verifyPasswordStrength } from "$lib/server/password"; 8 | import { 9 | createSession, 10 | generateSessionToken, 11 | invalidateUserSessions, 12 | setSessionTokenCookie 13 | } from "$lib/server/session"; 14 | import { updateUserPassword } from "$lib/server/user"; 15 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 16 | 17 | import type { Actions, RequestEvent } from "./$types"; 18 | import type { SessionFlags } from "$lib/server/session"; 19 | 20 | export async function load(event: RequestEvent) { 21 | const { session, user } = validatePasswordResetSessionRequest(event); 22 | if (session === null) { 23 | return redirect(302, "/forgot-password"); 24 | } 25 | if (!session.emailVerified) { 26 | return redirect(302, "/reset-password/verify-email"); 27 | } 28 | if (user.registered2FA && !session.twoFactorVerified) { 29 | return redirect(302, getPasswordReset2FARedirect(user)); 30 | } 31 | return {}; 32 | } 33 | 34 | export const actions: Actions = { 35 | default: action 36 | }; 37 | 38 | async function action(event: RequestEvent) { 39 | const { session: passwordResetSession, user } = validatePasswordResetSessionRequest(event); 40 | if (passwordResetSession === null) { 41 | return fail(401, { 42 | message: "Not authenticated" 43 | }); 44 | } 45 | if (!passwordResetSession.emailVerified) { 46 | return fail(403, { 47 | message: "Forbidden" 48 | }); 49 | } 50 | if (user.registered2FA && !passwordResetSession.twoFactorVerified) { 51 | return fail(403, { 52 | message: "Forbidden" 53 | }); 54 | } 55 | const formData = await event.request.formData(); 56 | const password = formData.get("password"); 57 | if (typeof password !== "string") { 58 | return fail(400, { 59 | message: "Invalid or missing fields" 60 | }); 61 | } 62 | 63 | const strongPassword = await verifyPasswordStrength(password); 64 | if (!strongPassword) { 65 | return fail(400, { 66 | message: "Weak password" 67 | }); 68 | } 69 | invalidateUserPasswordResetSessions(passwordResetSession.userId); 70 | invalidateUserSessions(passwordResetSession.userId); 71 | await updateUserPassword(passwordResetSession.userId, password); 72 | 73 | const sessionFlags: SessionFlags = { 74 | twoFactorVerified: passwordResetSession.twoFactorVerified 75 | }; 76 | const sessionToken = generateSessionToken(); 77 | const session = createSession(sessionToken, user.id, sessionFlags); 78 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 79 | deletePasswordResetSessionTokenCookie(event); 80 | return redirect(302, "/"); 81 | } 82 | -------------------------------------------------------------------------------- /src/routes/reset-password/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Enter your new password

10 |
11 | 12 |
13 | 14 |

{form?.message ?? ""}

15 |
16 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/+server.ts: -------------------------------------------------------------------------------- 1 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 2 | import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 3 | 4 | import type { RequestEvent } from "./$types"; 5 | 6 | export async function GET(event: RequestEvent) { 7 | const { session, user } = validatePasswordResetSessionRequest(event); 8 | if (session === null) { 9 | return new Response(null, { 10 | status: 302, 11 | headers: { 12 | Location: "/login" 13 | } 14 | }); 15 | } 16 | if (!user.registered2FA || session.twoFactorVerified) { 17 | return new Response(null, { 18 | status: 302, 19 | headers: { 20 | Location: "/reset-password" 21 | } 22 | }); 23 | } 24 | return new Response(null, { 25 | status: 302, 26 | headers: { 27 | Location: getPasswordReset2FARedirect(user) 28 | } 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/passkey/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 3 | import { getUserPasskeyCredentials } from "$lib/server/webauthn"; 4 | import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 5 | 6 | import type { RequestEvent } from "./$types"; 7 | 8 | export async function load(event: RequestEvent) { 9 | const { session, user } = validatePasswordResetSessionRequest(event); 10 | 11 | if (session === null) { 12 | return redirect(302, "/forgot-password"); 13 | } 14 | if (!session.emailVerified) { 15 | return redirect(302, "/reset-password/verify-email"); 16 | } 17 | if (!user.registered2FA) { 18 | return redirect(302, "/reset-password"); 19 | } 20 | if (session.twoFactorVerified) { 21 | return redirect(302, "/reset-password"); 22 | } 23 | if (!user.registeredPasskey) { 24 | return redirect(302, getPasswordReset2FARedirect(user)); 25 | } 26 | const credentials = getUserPasskeyCredentials(user.id); 27 | return { 28 | user, 29 | credentials 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/passkey/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |

Authenticate with passkeys

14 |
15 | 56 |

{message}

57 |
58 | Use recovery code 59 | {#if data.user.registeredSecurityKey} 60 | Use security keys 61 | {/if} 62 | {#if data.user.registeredTOTP} 63 | Use authenticator apps 64 | {/if} 65 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/passkey/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | coseAlgorithmRS256, 6 | createAssertionSignatureMessage, 7 | parseAuthenticatorData 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn"; 13 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 16 | 17 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 18 | import type { RequestEvent } from "./$types"; 19 | 20 | export async function POST(event: RequestEvent) { 21 | const { session, user } = validatePasswordResetSessionRequest(event); 22 | if (session === null || user === null) { 23 | return new Response("Not authenticated", { 24 | status: 401 25 | }); 26 | } 27 | if (!session.emailVerified || !user.registeredPasskey || session.twoFactorVerified) { 28 | return new Response("Forbidden", { 29 | status: 403 30 | }); 31 | } 32 | 33 | const data: unknown = await event.request.json(); 34 | const parser = new ObjectParser(data); 35 | let encodedAuthenticatorData: string; 36 | let encodedClientDataJSON: string; 37 | let encodedCredentialId: string; 38 | let encodedSignature: string; 39 | try { 40 | encodedAuthenticatorData = parser.getString("authenticator_data"); 41 | encodedClientDataJSON = parser.getString("client_data_json"); 42 | encodedCredentialId = parser.getString("credential_id"); 43 | encodedSignature = parser.getString("signature"); 44 | } catch { 45 | return new Response("Invalid or missing fields", { 46 | status: 400 47 | }); 48 | } 49 | let authenticatorDataBytes: Uint8Array; 50 | let clientDataJSON: Uint8Array; 51 | let credentialId: Uint8Array; 52 | let signatureBytes: Uint8Array; 53 | try { 54 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 55 | clientDataJSON = decodeBase64(encodedClientDataJSON); 56 | credentialId = decodeBase64(encodedCredentialId); 57 | signatureBytes = decodeBase64(encodedSignature); 58 | } catch { 59 | return new Response("Invalid or missing fields", { 60 | status: 400 61 | }); 62 | } 63 | 64 | let authenticatorData: AuthenticatorData; 65 | try { 66 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 67 | } catch { 68 | return new Response("Invalid data", { 69 | status: 400 70 | }); 71 | } 72 | // TODO: Update host 73 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 74 | return new Response("Invalid data", { 75 | status: 400 76 | }); 77 | } 78 | if (!authenticatorData.userPresent) { 79 | return new Response("Invalid data", { 80 | status: 400 81 | }); 82 | } 83 | 84 | let clientData: ClientData; 85 | try { 86 | clientData = parseClientDataJSON(clientDataJSON); 87 | } catch { 88 | return new Response("Invalid data", { 89 | status: 400 90 | }); 91 | } 92 | if (clientData.type !== ClientDataType.Get) { 93 | return new Response("Invalid data", { 94 | status: 400 95 | }); 96 | } 97 | 98 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 99 | return new Response("Invalid data", { 100 | status: 400 101 | }); 102 | } 103 | // TODO: Update origin 104 | if (clientData.origin !== "http://localhost:5173") { 105 | return new Response("Invalid data", { 106 | status: 400 107 | }); 108 | } 109 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 110 | return new Response("Invalid data", { 111 | status: 400 112 | }); 113 | } 114 | 115 | const credential = getUserPasskeyCredential(user.id, credentialId); 116 | if (credential === null) { 117 | return new Response("Invalid credential", { 118 | status: 400 119 | }); 120 | } 121 | 122 | let validSignature: boolean; 123 | if (credential.algorithmId === coseAlgorithmES256) { 124 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 125 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 126 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 127 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 128 | } else if (credential.algorithmId === coseAlgorithmRS256) { 129 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 130 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 131 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 132 | } else { 133 | return new Response("Internal error", { 134 | status: 500 135 | }); 136 | } 137 | 138 | if (!validSignature) { 139 | return new Response("Invalid signature", { 140 | status: 400 141 | }); 142 | } 143 | 144 | setPasswordResetSessionAs2FAVerified(session.id); 145 | return new Response(null, { 146 | status: 204 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/recovery-code/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 2 | import { fail, redirect } from "@sveltejs/kit"; 3 | import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa"; 4 | 5 | import type { Actions, RequestEvent } from "./$types"; 6 | 7 | export async function load(event: RequestEvent) { 8 | const { session, user } = validatePasswordResetSessionRequest(event); 9 | 10 | if (session === null) { 11 | return redirect(302, "/forgot-password"); 12 | } 13 | if (!session.emailVerified) { 14 | return redirect(302, "/reset-password/verify-email"); 15 | } 16 | if (!user.registered2FA) { 17 | return redirect(302, "/reset-password"); 18 | } 19 | if (session.twoFactorVerified) { 20 | return redirect(302, "/reset-password"); 21 | } 22 | return {}; 23 | } 24 | 25 | export const actions: Actions = { 26 | default: action 27 | }; 28 | 29 | async function action(event: RequestEvent) { 30 | const { session, user } = validatePasswordResetSessionRequest(event); 31 | if (session === null) { 32 | return fail(401, { 33 | message: "Not authenticated" 34 | }); 35 | } 36 | if (!session.emailVerified || !user.registered2FA || session.twoFactorVerified) { 37 | return fail(403, { 38 | message: "Forbidden" 39 | }); 40 | } 41 | 42 | if (!recoveryCodeBucket.check(session.userId, 1)) { 43 | return fail(429, { 44 | message: "Too many requests" 45 | }); 46 | } 47 | const formData = await event.request.formData(); 48 | const code = formData.get("code"); 49 | if (typeof code !== "string") { 50 | return fail(400, { 51 | message: "Invalid or missing fields" 52 | }); 53 | } 54 | if (code === "") { 55 | return fail(400, { 56 | message: "Please enter your code" 57 | }); 58 | } 59 | if (!recoveryCodeBucket.consume(session.userId, 1)) { 60 | return fail(429, { 61 | message: "Too many requests" 62 | }); 63 | } 64 | const valid = resetUser2FAWithRecoveryCode(session.userId, code); 65 | if (!valid) { 66 | return fail(400, { 67 | message: "Invalid code" 68 | }); 69 | } 70 | recoveryCodeBucket.reset(session.userId); 71 | return redirect(302, "/reset-password"); 72 | } 73 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/recovery-code/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Use your recovery code

10 |
11 | 12 |
13 | 14 |

{form?.message ?? ""}

15 |
-------------------------------------------------------------------------------- /src/routes/reset-password/2fa/security-key/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 3 | import { getUserSecurityKeyCredentials } from "$lib/server/webauthn"; 4 | import { validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 5 | 6 | import type { RequestEvent } from "./$types"; 7 | 8 | export async function load(event: RequestEvent) { 9 | const { session, user } = validatePasswordResetSessionRequest(event); 10 | 11 | if (session === null) { 12 | return redirect(302, "/forgot-password"); 13 | } 14 | if (!session.emailVerified) { 15 | return redirect(302, "/reset-password/verify-email"); 16 | } 17 | if (!user.registered2FA) { 18 | return redirect(302, "/reset-password"); 19 | } 20 | if (session.twoFactorVerified) { 21 | return redirect(302, "/reset-password"); 22 | } 23 | if (!user.registeredSecurityKey) { 24 | return redirect(302, getPasswordReset2FARedirect(user)); 25 | } 26 | const credentials = getUserSecurityKeyCredentials(user.id); 27 | return { 28 | credentials, 29 | user 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/security-key/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |

Authenticate with security keys

14 |
15 | 56 |

{message}

57 |
58 | Use recovery code 59 | {#if data.user.registeredPasskey} 60 | Use passkeys 61 | {/if} 62 | {#if data.user.registeredTOTP} 63 | Use authenticator apps 64 | {/if} 65 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/security-key/+server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | coseAlgorithmRS256, 6 | createAssertionSignatureMessage, 7 | parseAuthenticatorData 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn"; 13 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset"; 16 | 17 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 18 | import type { RequestEvent } from "./$types"; 19 | 20 | export async function POST(event: RequestEvent) { 21 | const { session, user } = validatePasswordResetSessionRequest(event); 22 | if (session === null || user === null) { 23 | return new Response("Not authenticated", { 24 | status: 401 25 | }); 26 | } 27 | if (!session.emailVerified || !user.registeredSecurityKey || session.twoFactorVerified) { 28 | return new Response("Forbidden", { 29 | status: 403 30 | }); 31 | } 32 | 33 | const data: unknown = await event.request.json(); 34 | const parser = new ObjectParser(data); 35 | let encodedAuthenticatorData: string; 36 | let encodedClientDataJSON: string; 37 | let encodedCredentialId: string; 38 | let encodedSignature: string; 39 | try { 40 | encodedAuthenticatorData = parser.getString("authenticator_data"); 41 | encodedClientDataJSON = parser.getString("client_data_json"); 42 | encodedCredentialId = parser.getString("credential_id"); 43 | encodedSignature = parser.getString("signature"); 44 | } catch { 45 | return new Response("Invalid or missing fields", { 46 | status: 400 47 | }); 48 | } 49 | let authenticatorDataBytes: Uint8Array; 50 | let clientDataJSON: Uint8Array; 51 | let credentialId: Uint8Array; 52 | let signatureBytes: Uint8Array; 53 | try { 54 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 55 | clientDataJSON = decodeBase64(encodedClientDataJSON); 56 | credentialId = decodeBase64(encodedCredentialId); 57 | signatureBytes = decodeBase64(encodedSignature); 58 | } catch { 59 | return new Response("Invalid or missing fields", { 60 | status: 400 61 | }); 62 | } 63 | 64 | let authenticatorData: AuthenticatorData; 65 | try { 66 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 67 | } catch { 68 | return new Response("Invalid data", { 69 | status: 400 70 | }); 71 | } 72 | // TODO: Update host 73 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 74 | return new Response("Invalid data", { 75 | status: 400 76 | }); 77 | } 78 | if (!authenticatorData.userPresent) { 79 | return new Response("Invalid data", { 80 | status: 400 81 | }); 82 | } 83 | 84 | let clientData: ClientData; 85 | try { 86 | clientData = parseClientDataJSON(clientDataJSON); 87 | } catch { 88 | return new Response("Invalid data", { 89 | status: 400 90 | }); 91 | } 92 | if (clientData.type !== ClientDataType.Get) { 93 | return new Response("Invalid data", { 94 | status: 400 95 | }); 96 | } 97 | 98 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 99 | return new Response("Invalid data", { 100 | status: 400 101 | }); 102 | } 103 | // TODO: Update origin 104 | if (clientData.origin !== "http://localhost:5173") { 105 | return new Response("Invalid data", { 106 | status: 400 107 | }); 108 | } 109 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 110 | return new Response("Invalid data", { 111 | status: 400 112 | }); 113 | } 114 | 115 | const credential = getUserSecurityKeyCredential(user.id, credentialId); 116 | if (credential === null) { 117 | return new Response("Invalid credential", { 118 | status: 400 119 | }); 120 | } 121 | 122 | let validSignature: boolean; 123 | if (credential.algorithmId === coseAlgorithmES256) { 124 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 125 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 126 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 127 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 128 | } else if (credential.algorithmId === coseAlgorithmRS256) { 129 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 130 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 131 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 132 | } else { 133 | return new Response("Internal error", { 134 | status: 500 135 | }); 136 | } 137 | 138 | if (!validSignature) { 139 | return new Response("Invalid signature", { 140 | status: 400 141 | }); 142 | } 143 | 144 | setPasswordResetSessionAs2FAVerified(session.id); 145 | return new Response(null, { 146 | status: 204 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/totp/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { verifyTOTP } from "@oslojs/otp"; 2 | import { validatePasswordResetSessionRequest, setPasswordResetSessionAs2FAVerified } from "$lib/server/password-reset"; 3 | import { totpBucket, getUserTOTPKey } from "$lib/server/totp"; 4 | import { fail, redirect } from "@sveltejs/kit"; 5 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 6 | 7 | import type { Actions, RequestEvent } from "./$types"; 8 | 9 | export async function load(event: RequestEvent) { 10 | const { session, user } = validatePasswordResetSessionRequest(event); 11 | 12 | if (session === null) { 13 | return redirect(302, "/forgot-password"); 14 | } 15 | if (!session.emailVerified) { 16 | return redirect(302, "/reset-password/verify-email"); 17 | } 18 | if (!user.registered2FA) { 19 | return redirect(302, "/reset-password"); 20 | } 21 | if (session.twoFactorVerified) { 22 | return redirect(302, "/reset-password"); 23 | } 24 | if (!user.registeredTOTP) { 25 | return redirect(302, getPasswordReset2FARedirect(user)); 26 | } 27 | return { 28 | user 29 | }; 30 | } 31 | 32 | export const actions: Actions = { 33 | default: action 34 | }; 35 | 36 | async function action(event: RequestEvent) { 37 | const { session, user } = validatePasswordResetSessionRequest(event); 38 | if (session === null) { 39 | return fail(401, { 40 | message: "Not authenticated" 41 | }); 42 | } 43 | if (!session.emailVerified || !user.registeredTOTP || session.twoFactorVerified) { 44 | return fail(403, { 45 | message: "Forbidden" 46 | }); 47 | } 48 | if (!totpBucket.check(session.userId, 1)) { 49 | return fail(429, { 50 | message: "Too many requests" 51 | }); 52 | } 53 | 54 | const formData = await event.request.formData(); 55 | const code = formData.get("code"); 56 | if (typeof code !== "string") { 57 | return fail(400, { 58 | message: "Invalid or missing fields" 59 | }); 60 | } 61 | if (code === "") { 62 | return fail(400, { 63 | message: "Please enter your code" 64 | }); 65 | } 66 | const totpKey = getUserTOTPKey(session.userId); 67 | if (totpKey === null) { 68 | return fail(403, { 69 | message: "Forbidden" 70 | }); 71 | } 72 | if (!totpBucket.consume(session.userId, 1)) { 73 | return fail(429, { 74 | message: "Too many requests" 75 | }); 76 | } 77 | if (!verifyTOTP(totpKey, 30, 6, code)) { 78 | return fail(400, { 79 | message: "Invalid code" 80 | }); 81 | } 82 | totpBucket.reset(session.userId); 83 | setPasswordResetSessionAs2FAVerified(session.id); 84 | return redirect(302, "/reset-password"); 85 | } 86 | -------------------------------------------------------------------------------- /src/routes/reset-password/2fa/totp/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Authenticate with authenticator app

11 |

Enter the code from your app.

12 |
13 | 14 |
15 | 16 |

{form?.message ?? ""}

17 |
18 | Use recovery code 19 | {#if data.user.registeredSecurityKey} 20 | Use security keys 21 | {/if} 22 | {#if data.user.registeredPasskey} 23 | Use passkeys 24 | {/if} 25 | -------------------------------------------------------------------------------- /src/routes/reset-password/verify-email/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validatePasswordResetSessionRequest, 3 | setPasswordResetSessionAsEmailVerified 4 | } from "$lib/server/password-reset"; 5 | import { ExpiringTokenBucket } from "$lib/server/rate-limit"; 6 | import { setUserAsEmailVerifiedIfEmailMatches } from "$lib/server/user"; 7 | import { fail, redirect } from "@sveltejs/kit"; 8 | 9 | import type { Actions, RequestEvent } from "./$types"; 10 | import { getPasswordReset2FARedirect } from "$lib/server/2fa"; 11 | 12 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 13 | 14 | export async function load(event: RequestEvent) { 15 | const { session, user } = validatePasswordResetSessionRequest(event); 16 | if (session === null) { 17 | return redirect(302, "/forgot-password"); 18 | } 19 | if (session.emailVerified) { 20 | if (!session.twoFactorVerified) { 21 | return redirect(302, getPasswordReset2FARedirect(user)); 22 | } 23 | return redirect(302, "/reset-password"); 24 | } 25 | return { 26 | email: session.email 27 | }; 28 | } 29 | 30 | export const actions: Actions = { 31 | default: action 32 | }; 33 | 34 | async function action(event: RequestEvent) { 35 | const { session, user } = validatePasswordResetSessionRequest(event); 36 | if (session === null) { 37 | return fail(401, { 38 | message: "Not authenticated" 39 | }); 40 | } 41 | if (session.emailVerified) { 42 | return fail(403, { 43 | message: "Forbidden" 44 | }); 45 | } 46 | if (!bucket.check(session.userId, 1)) { 47 | return fail(429, { 48 | message: "Too many requests" 49 | }); 50 | } 51 | 52 | const formData = await event.request.formData(); 53 | const code = formData.get("code"); 54 | if (typeof code !== "string") { 55 | return fail(400, { 56 | message: "Invalid or missing fields" 57 | }); 58 | } 59 | if (code === "") { 60 | return fail(400, { 61 | message: "Please enter your code" 62 | }); 63 | } 64 | if (!bucket.consume(session.userId, 1)) { 65 | return fail(429, { message: "Too many requests" }); 66 | } 67 | if (code !== session.code) { 68 | return fail(400, { 69 | message: "Incorrect code" 70 | }); 71 | } 72 | bucket.reset(session.userId); 73 | setPasswordResetSessionAsEmailVerified(session.id); 74 | const emailMatches = setUserAsEmailVerifiedIfEmailMatches(session.userId, session.email); 75 | if (!emailMatches) { 76 | return fail(400, { 77 | message: "Please restart the process" 78 | }); 79 | } 80 | if (!user.registered2FA) { 81 | return redirect(302, "/reset-password"); 82 | } 83 | return redirect(302, getPasswordReset2FARedirect(user)); 84 | } 85 | -------------------------------------------------------------------------------- /src/routes/reset-password/verify-email/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Verify your email address

11 |

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

12 |
13 | 14 | 15 | 16 |

{form?.message ?? ""}

17 |
18 | -------------------------------------------------------------------------------- /src/routes/settings/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmailVerificationRequest, 3 | sendVerificationEmail, 4 | sendVerificationEmailBucket, 5 | setEmailVerificationRequestCookie 6 | } from "$lib/server/email-verification"; 7 | import { fail, redirect } from "@sveltejs/kit"; 8 | import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email"; 9 | import { verifyPasswordHash, verifyPasswordStrength } from "$lib/server/password"; 10 | import { getUserPasswordHash, getUserRecoverCode, resetUserRecoveryCode, updateUserPassword } from "$lib/server/user"; 11 | import { 12 | createSession, 13 | generateSessionToken, 14 | invalidateUserSessions, 15 | setSessionTokenCookie 16 | } from "$lib/server/session"; 17 | import { 18 | deleteUserPasskeyCredential, 19 | deleteUserSecurityKeyCredential, 20 | getUserPasskeyCredentials, 21 | getUserSecurityKeyCredentials 22 | } from "$lib/server/webauthn"; 23 | import { decodeBase64 } from "@oslojs/encoding"; 24 | import { get2FARedirect } from "$lib/server/2fa"; 25 | import { deleteUserTOTPKey, totpUpdateBucket } from "$lib/server/totp"; 26 | import { ExpiringTokenBucket } from "$lib/server/rate-limit"; 27 | 28 | import type { Actions, RequestEvent } from "./$types"; 29 | import type { SessionFlags } from "$lib/server/session"; 30 | 31 | const passwordUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); 32 | 33 | export async function load(event: RequestEvent) { 34 | if (event.locals.session === null || event.locals.user === null) { 35 | return redirect(302, "/login"); 36 | } 37 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 38 | return redirect(302, get2FARedirect(event.locals.user)); 39 | } 40 | let recoveryCode: string | null = null; 41 | if (event.locals.user.registered2FA) { 42 | recoveryCode = getUserRecoverCode(event.locals.user.id); 43 | } 44 | const passkeyCredentials = getUserPasskeyCredentials(event.locals.user.id); 45 | const securityKeyCredentials = getUserSecurityKeyCredentials(event.locals.user.id); 46 | return { 47 | recoveryCode, 48 | user: event.locals.user, 49 | passkeyCredentials, 50 | securityKeyCredentials 51 | }; 52 | } 53 | 54 | export const actions: Actions = { 55 | update_password: updatePasswordAction, 56 | update_email: updateEmailAction, 57 | disconnect_totp: disconnectTOTPAction, 58 | delete_passkey: deletePasskeyAction, 59 | delete_security_key: deleteSecurityKeyAction, 60 | regenerate_recovery_code: regenerateRecoveryCodeAction 61 | }; 62 | 63 | async function updatePasswordAction(event: RequestEvent) { 64 | if (event.locals.session === null || event.locals.user === null) { 65 | return fail(401, { 66 | password: { 67 | message: "Not authenticated" 68 | } 69 | }); 70 | } 71 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 72 | return fail(403, { 73 | password: { 74 | message: "Forbidden" 75 | } 76 | }); 77 | } 78 | if (!passwordUpdateBucket.check(event.locals.session.id, 1)) { 79 | return fail(429, { 80 | password: { 81 | message: "Too many requests" 82 | } 83 | }); 84 | } 85 | 86 | const formData = await event.request.formData(); 87 | const password = formData.get("password"); 88 | const newPassword = formData.get("new_password"); 89 | if (typeof password !== "string" || typeof newPassword !== "string") { 90 | return fail(400, { 91 | password: { 92 | message: "Invalid or missing fields" 93 | } 94 | }); 95 | } 96 | const strongPassword = await verifyPasswordStrength(newPassword); 97 | if (!strongPassword) { 98 | return fail(400, { 99 | password: { 100 | message: "Weak password" 101 | } 102 | }); 103 | } 104 | 105 | if (!passwordUpdateBucket.consume(event.locals.session.id, 1)) { 106 | return fail(429, { 107 | password: { 108 | message: "Too many requests" 109 | } 110 | }); 111 | } 112 | const passwordHash = getUserPasswordHash(event.locals.user.id); 113 | const validPassword = await verifyPasswordHash(passwordHash, password); 114 | if (!validPassword) { 115 | return fail(400, { 116 | password: { 117 | message: "Incorrect password" 118 | } 119 | }); 120 | } 121 | passwordUpdateBucket.reset(event.locals.session.id); 122 | invalidateUserSessions(event.locals.user.id); 123 | await updateUserPassword(event.locals.user.id, newPassword); 124 | 125 | const sessionToken = generateSessionToken(); 126 | const sessionFlags: SessionFlags = { 127 | twoFactorVerified: event.locals.session.twoFactorVerified 128 | }; 129 | const session = createSession(sessionToken, event.locals.user.id, sessionFlags); 130 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 131 | return { 132 | password: { 133 | message: "Updated password" 134 | } 135 | }; 136 | } 137 | 138 | async function updateEmailAction(event: RequestEvent) { 139 | if (event.locals.session === null || event.locals.user === null) { 140 | return fail(401, { 141 | email: { 142 | message: "Not authenticated" 143 | } 144 | }); 145 | } 146 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 147 | return fail(403, { 148 | email: { 149 | message: "Forbidden" 150 | } 151 | }); 152 | } 153 | if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) { 154 | return fail(429, { 155 | email: { 156 | message: "Too many requests" 157 | } 158 | }); 159 | } 160 | 161 | const formData = await event.request.formData(); 162 | const email = formData.get("email"); 163 | if (typeof email !== "string") { 164 | return fail(400, { 165 | email: { 166 | message: "Invalid or missing fields" 167 | } 168 | }); 169 | } 170 | if (email === "") { 171 | return fail(400, { 172 | email: { 173 | message: "Please enter your email" 174 | } 175 | }); 176 | } 177 | if (!verifyEmailInput(email)) { 178 | return fail(400, { 179 | email: { 180 | message: "Please enter a valid email" 181 | } 182 | }); 183 | } 184 | const emailAvailable = checkEmailAvailability(email); 185 | if (!emailAvailable) { 186 | return fail(400, { 187 | email: { 188 | message: "This email is already used" 189 | } 190 | }); 191 | } 192 | if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) { 193 | return fail(429, { 194 | email: { 195 | message: "Too many requests" 196 | } 197 | }); 198 | } 199 | const verificationRequest = createEmailVerificationRequest(event.locals.user.id, email); 200 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 201 | setEmailVerificationRequestCookie(event, verificationRequest); 202 | return redirect(302, "/verify-email"); 203 | } 204 | 205 | async function disconnectTOTPAction(event: RequestEvent) { 206 | if (event.locals.session === null || event.locals.user === null) { 207 | return fail(401); 208 | } 209 | if (!event.locals.user.emailVerified) { 210 | return fail(403); 211 | } 212 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 213 | return fail(403); 214 | } 215 | if (!totpUpdateBucket.consume(event.locals.user.id, 1)) { 216 | return fail(429); 217 | } 218 | deleteUserTOTPKey(event.locals.user.id); 219 | return {}; 220 | } 221 | 222 | async function deletePasskeyAction(event: RequestEvent) { 223 | if (event.locals.session === null || event.locals.user === null) { 224 | return fail(401); 225 | } 226 | if (!event.locals.user.emailVerified) { 227 | return fail(403); 228 | } 229 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 230 | return fail(403); 231 | } 232 | const formData = await event.request.formData(); 233 | const encodedCredentialId = formData.get("credential_id"); 234 | if (typeof encodedCredentialId !== "string") { 235 | return fail(400); 236 | } 237 | let credentialId: Uint8Array; 238 | try { 239 | credentialId = decodeBase64(encodedCredentialId); 240 | } catch { 241 | return fail(400); 242 | } 243 | const deleted = deleteUserPasskeyCredential(event.locals.user.id, credentialId); 244 | if (!deleted) { 245 | return fail(400); 246 | } 247 | return {}; 248 | } 249 | 250 | async function deleteSecurityKeyAction(event: RequestEvent) { 251 | if (event.locals.session === null || event.locals.user === null) { 252 | return fail(401); 253 | } 254 | if (!event.locals.user.emailVerified) { 255 | return fail(403); 256 | } 257 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 258 | return fail(403); 259 | } 260 | const formData = await event.request.formData(); 261 | const encodedCredentialId = formData.get("credential_id"); 262 | if (typeof encodedCredentialId !== "string") { 263 | return fail(400); 264 | } 265 | let credentialId: Uint8Array; 266 | try { 267 | credentialId = decodeBase64(encodedCredentialId); 268 | } catch { 269 | return fail(400); 270 | } 271 | const deleted = deleteUserSecurityKeyCredential(event.locals.user.id, credentialId); 272 | if (!deleted) { 273 | return fail(400); 274 | } 275 | return {}; 276 | } 277 | 278 | async function regenerateRecoveryCodeAction(event: RequestEvent) { 279 | if (event.locals.session === null || event.locals.user === null) { 280 | return fail(401); 281 | } 282 | if (!event.locals.user.emailVerified) { 283 | return fail(403); 284 | } 285 | if (!event.locals.session.twoFactorVerified) { 286 | return fail(403); 287 | } 288 | resetUserRecoveryCode(event.locals.session.userId); 289 | return {}; 290 | } 291 | -------------------------------------------------------------------------------- /src/routes/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | Home 13 | Settings 14 |
15 |
16 |

Settings

17 |
18 |

Update email

19 |

Your email: {data.user.email}

20 |
21 | 22 |
23 | 24 |

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

25 |
26 |
27 |
28 |

Update password

29 |
30 | 31 |
32 | 33 |
40 | 41 |

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

42 |
43 |
44 |
45 |

Authenticator app

46 | {#if data.user.registeredTOTP} 47 | Update TOTP 48 |
49 | 50 |
51 | {:else} 52 | Set up TOTP 53 | {/if} 54 |
55 |
56 |

Passkeys

57 |

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

58 |
    59 | {#each data.passkeyCredentials as credential} 60 |
  • 61 |

    {credential.name}

    62 |
    63 | 64 | 65 |
    66 |
  • 67 | {/each} 68 |
69 | Add 70 |
71 |
72 |

Security keys

73 |

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

74 |
    75 | {#each data.securityKeyCredentials as credential} 76 |
  • 77 |

    {credential.name}

    78 |
    79 | 80 | 81 |
    82 |
  • 83 | {/each} 84 |
85 | Add 86 |
87 | {#if data.recoveryCode !== null} 88 |
89 |

Recovery code

90 |

Your recovery code is: {data.recoveryCode}}

91 |
92 | 93 |
94 |
95 | {/if} 96 |
97 | -------------------------------------------------------------------------------- /src/routes/signup/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email"; 3 | import { createUser, verifyUsernameInput } from "$lib/server/user"; 4 | import { RefillingTokenBucket } from "$lib/server/rate-limit"; 5 | import { verifyPasswordStrength } from "$lib/server/password"; 6 | import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session"; 7 | import { 8 | createEmailVerificationRequest, 9 | sendVerificationEmail, 10 | setEmailVerificationRequestCookie 11 | } from "$lib/server/email-verification"; 12 | import { get2FARedirect } from "$lib/server/2fa"; 13 | 14 | import type { SessionFlags } from "$lib/server/session"; 15 | import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types"; 16 | 17 | const ipBucket = new RefillingTokenBucket(3, 10); 18 | 19 | export function load(event: PageServerLoadEvent) { 20 | if (event.locals.session !== null && event.locals.user !== null) { 21 | if (!event.locals.user.emailVerified) { 22 | return redirect(302, "/verify-email"); 23 | } 24 | if (!event.locals.user.registered2FA) { 25 | return redirect(302, "/2fa/setup"); 26 | } 27 | if (!event.locals.session.twoFactorVerified) { 28 | return redirect(302, get2FARedirect(event.locals.user)); 29 | } 30 | return redirect(302, "/"); 31 | } 32 | return {}; 33 | } 34 | 35 | export const actions: Actions = { 36 | default: action 37 | }; 38 | 39 | async function action(event: RequestEvent) { 40 | // TODO: Assumes X-Forwarded-For is always included. 41 | const clientIP = event.request.headers.get("X-Forwarded-For"); 42 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 43 | return fail(429, { 44 | message: "Too many requests", 45 | email: "", 46 | username: "" 47 | }); 48 | } 49 | 50 | const formData = await event.request.formData(); 51 | const email = formData.get("email"); 52 | const username = formData.get("username"); 53 | const password = formData.get("password"); 54 | if (typeof email !== "string" || typeof username !== "string" || typeof password !== "string") { 55 | return fail(400, { 56 | message: "Invalid or missing fields", 57 | email: "", 58 | username: "" 59 | }); 60 | } 61 | if (email === "" || password === "" || username === "") { 62 | return fail(400, { 63 | message: "Please enter your username, email, and password", 64 | email: "", 65 | username: "" 66 | }); 67 | } 68 | if (!verifyEmailInput(email)) { 69 | return fail(400, { 70 | message: "Invalid email", 71 | email, 72 | username 73 | }); 74 | } 75 | const emailAvailable = checkEmailAvailability(email); 76 | if (!emailAvailable) { 77 | return fail(400, { 78 | message: "Email is already used", 79 | email, 80 | username 81 | }); 82 | } 83 | if (!verifyUsernameInput(username)) { 84 | return fail(400, { 85 | message: "Invalid username", 86 | email, 87 | username 88 | }); 89 | } 90 | const strongPassword = await verifyPasswordStrength(password); 91 | if (!strongPassword) { 92 | return fail(400, { 93 | message: "Weak password", 94 | email, 95 | username 96 | }); 97 | } 98 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 99 | return fail(429, { 100 | message: "Too many requests", 101 | email, 102 | username 103 | }); 104 | } 105 | const user = await createUser(email, username, password); 106 | const emailVerificationRequest = createEmailVerificationRequest(user.id, user.email); 107 | sendVerificationEmail(emailVerificationRequest.email, emailVerificationRequest.code); 108 | setEmailVerificationRequestCookie(event, emailVerificationRequest); 109 | 110 | const sessionFlags: SessionFlags = { 111 | twoFactorVerified: false 112 | }; 113 | const sessionToken = generateSessionToken(); 114 | const session = createSession(sessionToken, user.id, sessionFlags); 115 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 116 | throw redirect(302, "/2fa/setup"); 117 | } 118 | -------------------------------------------------------------------------------- /src/routes/signup/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Create an account

10 |

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

11 |
12 | 13 |
21 | 22 |
30 | 31 |
32 | 33 |

{form?.message ?? ""}

34 |
35 | Sign in 36 | -------------------------------------------------------------------------------- /src/routes/verify-email/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect } from "@sveltejs/kit"; 2 | import { 3 | createEmailVerificationRequest, 4 | deleteEmailVerificationRequestCookie, 5 | deleteUserEmailVerificationRequest, 6 | getUserEmailVerificationRequestFromRequest, 7 | sendVerificationEmail, 8 | sendVerificationEmailBucket, 9 | setEmailVerificationRequestCookie 10 | } from "$lib/server/email-verification"; 11 | import { invalidateUserPasswordResetSessions } from "$lib/server/password-reset"; 12 | import { updateUserEmailAndSetEmailAsVerified } from "$lib/server/user"; 13 | import { ExpiringTokenBucket } from "$lib/server/rate-limit"; 14 | 15 | import type { Actions, RequestEvent } from "./$types"; 16 | 17 | export async function load(event: RequestEvent) { 18 | if (event.locals.user === null) { 19 | return redirect(302, "/login"); 20 | } 21 | let verificationRequest = getUserEmailVerificationRequestFromRequest(event); 22 | if (verificationRequest === null || Date.now() >= verificationRequest.expiresAt.getTime()) { 23 | if (event.locals.user.emailVerified) { 24 | return redirect(302, "/"); 25 | } 26 | // Note: We don't need rate limiting since it takes time before requests expire 27 | verificationRequest = createEmailVerificationRequest(event.locals.user.id, event.locals.user.email); 28 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 29 | setEmailVerificationRequestCookie(event, verificationRequest); 30 | } 31 | return { 32 | email: verificationRequest.email 33 | }; 34 | } 35 | 36 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 37 | 38 | export const actions: Actions = { 39 | verify: verifyCode, 40 | resend: resendEmail 41 | }; 42 | 43 | async function verifyCode(event: RequestEvent) { 44 | if (event.locals.session === null || event.locals.user === null) { 45 | return fail(401); 46 | } 47 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 48 | return fail(401); 49 | } 50 | if (!bucket.check(event.locals.user.id, 1)) { 51 | return fail(429, { 52 | verify: { 53 | message: "Too many requests" 54 | } 55 | }); 56 | } 57 | 58 | let verificationRequest = getUserEmailVerificationRequestFromRequest(event); 59 | if (verificationRequest === null) { 60 | return fail(401); 61 | } 62 | const formData = await event.request.formData(); 63 | const code = formData.get("code"); 64 | if (typeof code !== "string") { 65 | return fail(400, { 66 | verify: { 67 | message: "Invalid or missing fields" 68 | } 69 | }); 70 | } 71 | if (code === "") { 72 | return fail(400, { 73 | verify: { 74 | message: "Enter your code" 75 | } 76 | }); 77 | } 78 | if (!bucket.consume(event.locals.user.id, 1)) { 79 | return fail(400, { 80 | verify: { 81 | message: "Too many requests" 82 | } 83 | }); 84 | } 85 | if (Date.now() >= verificationRequest.expiresAt.getTime()) { 86 | verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email); 87 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 88 | return { 89 | verify: { 90 | message: "The verification code was expired. We sent another code to your inbox." 91 | } 92 | }; 93 | } 94 | if (verificationRequest.code !== code) { 95 | return fail(400, { 96 | verify: { 97 | message: "Incorrect code." 98 | } 99 | }); 100 | } 101 | deleteUserEmailVerificationRequest(event.locals.user.id); 102 | invalidateUserPasswordResetSessions(event.locals.user.id); 103 | updateUserEmailAndSetEmailAsVerified(event.locals.user.id, verificationRequest.email); 104 | deleteEmailVerificationRequestCookie(event); 105 | if (!event.locals.user.registered2FA) { 106 | return redirect(302, "/2fa/setup"); 107 | } 108 | return redirect(302, "/"); 109 | } 110 | 111 | async function resendEmail(event: RequestEvent) { 112 | if (event.locals.session === null || event.locals.user === null) { 113 | return fail(401, { 114 | resend: { 115 | message: "Not authenticated" 116 | } 117 | }); 118 | } 119 | if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { 120 | return fail(401, { 121 | resend: { 122 | message: "Forbidden" 123 | } 124 | }); 125 | } 126 | if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) { 127 | return fail(429, { 128 | resend: { 129 | message: "Too many requests" 130 | } 131 | }); 132 | } 133 | let verificationRequest = getUserEmailVerificationRequestFromRequest(event); 134 | if (verificationRequest === null) { 135 | if (event.locals.user.emailVerified) { 136 | return fail(403, { 137 | resend: { 138 | message: "Forbidden" 139 | } 140 | }); 141 | } 142 | if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) { 143 | return fail(429, { 144 | resend: { 145 | message: "Too many requests" 146 | } 147 | }); 148 | } 149 | verificationRequest = createEmailVerificationRequest(event.locals.user.id, event.locals.user.email); 150 | } else { 151 | if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) { 152 | return fail(429, { 153 | resend: { 154 | message: "Too many requests" 155 | } 156 | }); 157 | } 158 | verificationRequest = createEmailVerificationRequest(event.locals.user.id, verificationRequest.email); 159 | } 160 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 161 | setEmailVerificationRequestCookie(event, verificationRequest); 162 | return { 163 | resend: { 164 | message: "A new code was sent to your inbox." 165 | } 166 | }; 167 | } 168 | -------------------------------------------------------------------------------- /src/routes/verify-email/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Verify your email address

11 |

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

12 |
13 | 14 | 15 | 16 |

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

17 |
18 |
19 | 20 |

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

21 |
22 | Change your email 23 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucia-auth/example-sveltekit-email-password-webauthn/c3811a6728b860091c49b6c3d0cfda9f86454225/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | --------------------------------------------------------------------------------