├── .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 |
13 |
14 | Hi {data.user.username}!
15 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
15 |
16 | Settings
17 |
18 | Update email
19 | Your email: {data.user.email}
20 |
26 |
27 |
28 | Update password
29 |
43 |
44 |
45 | Authenticator app
46 | {#if data.user.registeredTOTP}
47 | Update TOTP
48 |
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 |
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 |
82 |
83 | {/each}
84 |
85 | Add
86 |
87 | {#if data.recoveryCode !== null}
88 |
89 | Recovery code
90 | Your recovery code is: {data.recoveryCode}}
91 |
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 |
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 |
18 |
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 |
--------------------------------------------------------------------------------