sveltekit-auth-example 5.6.1 → 5.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -3
- package/CHANGELOG.md +28 -0
- package/README.md +15 -9
- package/db_schema.sql +3 -3
- package/package.json +6 -5
- package/src/app.d.ts +125 -61
- package/src/app.html +6 -0
- package/src/lib/Turnstile.svelte +53 -0
- package/src/lib/google.ts +41 -17
- package/src/lib/server/brevo.ts +179 -0
- package/src/lib/server/email/mfa-code.ts +6 -6
- package/src/lib/server/email/password-reset.ts +6 -6
- package/src/lib/server/email/verify-email.ts +6 -6
- package/src/lib/server/turnstile.ts +29 -0
- package/src/routes/auth/forgot/+server.ts +7 -0
- package/src/routes/auth/login/+server.ts +6 -1
- package/src/routes/auth/mfa/+server.ts +6 -1
- package/src/routes/auth/register/+server.ts +6 -1
- package/src/routes/auth/reset/+server.ts +6 -1
- package/src/routes/auth/reset/[token]/+page.svelte +11 -1
- package/src/routes/forgot/+page.svelte +9 -1
- package/src/routes/login/+page.svelte +24 -5
- package/src/routes/register/+page.svelte +11 -1
- package/svelte.config.js +4 -2
- package/vite.config.ts +8 -13
- package/src/lib/server/sendgrid.ts +0 -22
package/.env.example
CHANGED
|
@@ -2,6 +2,8 @@ DATABASE_URL=postgres://REPLACE_WITH_USER:REPLACE_WITH_PASSWORD@localhost:5432/a
|
|
|
2
2
|
DATABASE_SSL=false
|
|
3
3
|
DOMAIN=http://localhost:3000
|
|
4
4
|
JWT_SECRET=replace_with_your_own
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
PUBLIC_GOOGLE_CLIENT_ID=
|
|
5
|
+
BREVO_KEY=replace_with_your_own
|
|
6
|
+
EMAIL=replace_with_your_own
|
|
7
|
+
PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
8
|
+
PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
|
|
9
|
+
TURNSTILE_SECRET_KEY=replace_with_your_own
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
- Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
|
|
4
4
|
|
|
5
|
+
# 5.8.2
|
|
6
|
+
|
|
7
|
+
- Add Chrome DevTools
|
|
8
|
+
|
|
9
|
+
# 5.8.1
|
|
10
|
+
|
|
11
|
+
- Fix MFA verification code always showing "6-digit verification code required" for valid codes: replaced `form.checkValidity()` and `codeInput.checkValidity()` (which picked up Cloudflare Turnstile's injected form elements) with a direct JS regex test against the bound `mfaCode` value
|
|
12
|
+
- Move `<Turnstile>` outside the MFA `<form>` so its injected inputs are never part of the form's element collection
|
|
13
|
+
- Fix `google is not defined` crash on hard reload of `/login`: Google GSI script is loaded `async defer` and may not be ready when `onMount` fires; added `whenGoogleReady()` helper in `src/lib/google.ts` that polls until `google` is available (up to 10 s) before calling `initializeGoogleAccounts()` and `renderGoogleButton()`
|
|
14
|
+
|
|
15
|
+
# 5.8.0
|
|
16
|
+
|
|
17
|
+
- Replace SendGrid with Brevo for all transactional email (password reset, email verification, MFA code)
|
|
18
|
+
- New `src/lib/server/brevo.ts` sends email via the Brevo API with exponential-backoff retry logic (up to 4 attempts), 30-second per-request timeout, and `Retry-After` header support
|
|
19
|
+
- Email templates (`password-reset.ts`, `mfa-code.ts`, `verify-email.ts`) updated to use Brevo message format (`sender`, `to[]`, `htmlContent`, `tags`)
|
|
20
|
+
- Env vars changed: `SENDGRID_KEY` → `BREVO_KEY`, `SENDGRID_SENDER` → `EMAIL`
|
|
21
|
+
- `app.d.ts` `PrivateEnv` updated to reflect new env vars
|
|
22
|
+
- README updated with Brevo setup instructions and new env var names
|
|
23
|
+
|
|
24
|
+
# 5.7.0
|
|
25
|
+
|
|
26
|
+
- Add Cloudflare Turnstile CAPTCHA to login, register, forgot password, MFA, and password reset forms
|
|
27
|
+
- New `src/lib/Turnstile.svelte` reusable Svelte 5 component wraps the Turnstile widget with `reset()` export
|
|
28
|
+
- New `src/lib/server/turnstile.ts` server-side token verification utility (`verifyTurnstileToken`)
|
|
29
|
+
- All auth endpoints verify the Turnstile challenge token before processing requests
|
|
30
|
+
- New env vars: `PUBLIC_TURNSTILE_SITE_KEY` (client) and `TURNSTILE_SECRET_KEY` (server)
|
|
31
|
+
- Extend `app.d.ts` with `PublicEnv`, `PrivateEnv` (add `TURNSTILE_SECRET_KEY`), and `Window.turnstile` types; wrap in `declare global`
|
|
32
|
+
|
|
5
33
|
# 5.6.1
|
|
6
34
|
|
|
7
35
|
- Add JSDoc comments throughout the codebase (`app.d.ts`, server hooks, route handlers, lib utilities, Svelte components)
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://svelte.dev)
|
|
6
6
|
|
|
7
|
-
A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control,
|
|
7
|
+
A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, OWASP-compliant password hashing, and bot protection out of the box.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
@@ -12,10 +12,11 @@ A complete, production-ready authentication and authorization starter for **Svel
|
|
|
12
12
|
| ---------------------------------------------- | --------------------------------------- |
|
|
13
13
|
| ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
|
|
14
14
|
| ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
|
|
15
|
-
| ✅ Forgot password / email reset (
|
|
15
|
+
| ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
|
|
16
16
|
| ✅ Session management + timeout | ✅ Rate limiting |
|
|
17
17
|
| ✅ Role-based access control | ✅ Password complexity enforcement |
|
|
18
18
|
| ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
|
|
19
|
+
| ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
|
|
19
20
|
|
|
20
21
|
## Stack
|
|
21
22
|
|
|
@@ -28,8 +29,9 @@ A complete, production-ready authentication and authorization starter for **Svel
|
|
|
28
29
|
|
|
29
30
|
- Node.js 24.14.0 or later
|
|
30
31
|
- PostgreSQL 16 or later
|
|
31
|
-
- A [
|
|
32
|
+
- A [Brevo](https://brevo.com) account (for transactional emails)
|
|
32
33
|
- A [Google API client ID](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) (for Sign in with Google)
|
|
34
|
+
- A [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) site key and secret key (for bot protection on auth forms)
|
|
33
35
|
|
|
34
36
|
## Setting up the project
|
|
35
37
|
|
|
@@ -49,7 +51,7 @@ bash db_create.sh
|
|
|
49
51
|
|
|
50
52
|
2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). Make sure you include `http://localhost:3000` and `http://localhost` in the Authorized JavaScript origins, and `http://localhost:3000/auth/google/callback` in the Authorized redirect URIs for your Client ID for Web application. **Do not access the site using http://127.0.0.1:3000** — use `http://localhost:3000` or it will not work.
|
|
51
53
|
|
|
52
|
-
3. [Create a free
|
|
54
|
+
3. [Create a free Brevo account](https://app.brevo.com/account/register) and generate an API Key under **SMTP & API** settings. Set `EMAIL` to the sender address verified in your Brevo account.
|
|
53
55
|
|
|
54
56
|
4. Create a **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
|
|
55
57
|
|
|
@@ -58,9 +60,11 @@ DATABASE_URL=postgres://user:password@localhost:5432/auth
|
|
|
58
60
|
DATABASE_SSL=false
|
|
59
61
|
DOMAIN=http://localhost:3000
|
|
60
62
|
JWT_SECRET=replace_with_your_own
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
BREVO_KEY=replace_with_your_own
|
|
64
|
+
EMAIL=replace_with_your_own
|
|
63
65
|
PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
66
|
+
PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
|
|
67
|
+
TURNSTILE_SECRET_KEY=replace_with_your_own
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
## Run locally
|
|
@@ -86,9 +90,11 @@ The db_create.sql script adds three users to the database with obvious roles:
|
|
|
86
90
|
|
|
87
91
|
| Email | Password | Role |
|
|
88
92
|
| ------------------- | ---------- | ------- |
|
|
89
|
-
| admin@example.com |
|
|
90
|
-
| teacher@example.com |
|
|
91
|
-
| student@example.com |
|
|
93
|
+
| admin@example.com | Admin1234! | admin |
|
|
94
|
+
| teacher@example.com | Teacher1234! | teacher |
|
|
95
|
+
| student@example.com | Student1234! | student |
|
|
96
|
+
|
|
97
|
+
> **MFA note:** Local account logins require a 6-digit code sent to the user's email address. To successfully log in with the seed accounts above, either update their email addresses in the database to your own (`UPDATE users SET email = 'you@yourdomain.com' WHERE email = 'admin@example.com';`), or retrieve the code directly from the `mfa_codes` table after submitting the login form (`SELECT code FROM mfa_codes;`).
|
|
92
98
|
|
|
93
99
|
## How it works
|
|
94
100
|
|
package/db_schema.sql
CHANGED
|
@@ -357,13 +357,13 @@ $BODY$;
|
|
|
357
357
|
ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
|
|
358
358
|
|
|
359
359
|
CALL public.upsert_user (
|
|
360
|
-
'{"id":0, "role":"admin", "email":"admin@example.com", "password":"
|
|
360
|
+
'{"id":0, "role":"admin", "email":"admin@example.com", "password":"Admin1234!", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
|
|
361
361
|
);
|
|
362
362
|
|
|
363
363
|
CALL public.upsert_user (
|
|
364
|
-
'{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"
|
|
364
|
+
'{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"Teacher1234!", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
|
|
365
365
|
);
|
|
366
366
|
|
|
367
367
|
CALL public.upsert_user (
|
|
368
|
-
'{"id":0, "role":"student", "email":"student@example.com", "password":"
|
|
368
|
+
'{"id":0, "role":"student", "email":"student@example.com", "password":"Student1234!", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
|
|
369
369
|
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-auth-example",
|
|
3
3
|
"description": "SvelteKit Authentication Example",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.8.2",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@sveltejs/adapter-node": "^5.5.4",
|
|
50
50
|
"@sveltejs/kit": "^2.54.0",
|
|
51
51
|
"@sveltejs/mcp": "^0.1.21",
|
|
52
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
52
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
53
53
|
"@tailwindcss/vite": "^4.2.1",
|
|
54
54
|
"@types/bootstrap": "5.2.10",
|
|
55
55
|
"@types/google.accounts": "^0.0.18",
|
|
@@ -64,13 +64,14 @@
|
|
|
64
64
|
"prettier-plugin-sql": "^0.19.2",
|
|
65
65
|
"prettier-plugin-svelte": "^3.5.1",
|
|
66
66
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
67
|
-
"svelte": "^5.53.
|
|
67
|
+
"svelte": "^5.53.11",
|
|
68
68
|
"svelte-check": "^4.4.5",
|
|
69
69
|
"tslib": "^2.8.1",
|
|
70
70
|
"typescript": "^5.9.3",
|
|
71
71
|
"typescript-eslint": "^8.57.0",
|
|
72
|
-
"vite": "^
|
|
73
|
-
"
|
|
72
|
+
"vite": "^8.0.0",
|
|
73
|
+
"vite-plugin-devtools-json": "^1.0.0",
|
|
74
|
+
"vitest": "^4.1.0",
|
|
74
75
|
"vitest-browser-svelte": "^2.0.2"
|
|
75
76
|
},
|
|
76
77
|
"packageManager": "yarn@4.13.0"
|
package/src/app.d.ts
CHANGED
|
@@ -3,75 +3,139 @@
|
|
|
3
3
|
// See https://kit.svelte.dev/docs/types#app
|
|
4
4
|
// for information about these interfaces
|
|
5
5
|
// and what to do when importing types
|
|
6
|
-
declare
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
declare global {
|
|
7
|
+
declare namespace App {
|
|
8
|
+
/** Per-request server-side locals populated by `hooks.server.ts`. */
|
|
9
|
+
interface Locals {
|
|
10
|
+
/** The authenticated user, or `undefined` when no valid session exists. */
|
|
11
|
+
user: User | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// interface Platform {}
|
|
15
|
+
|
|
16
|
+
/** Error response returned by the Brevo email API. */
|
|
17
|
+
interface BrevoErrorResponse {
|
|
18
|
+
/** Optional machine‑readable error code. */
|
|
19
|
+
code?: string
|
|
20
|
+
/** Human‑readable error message. */
|
|
21
|
+
message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Successful response returned by the Brevo email API. */
|
|
25
|
+
interface BrevoSuccessResponse {
|
|
26
|
+
/** Message identifier assigned by Brevo. */
|
|
27
|
+
messageId: string
|
|
28
|
+
}
|
|
12
29
|
|
|
13
|
-
|
|
30
|
+
/** Basic email address with optional display name. */
|
|
31
|
+
interface EmailAddress {
|
|
32
|
+
/** Email address string. */
|
|
33
|
+
email: string
|
|
34
|
+
/** Optional display name associated with the address. */
|
|
35
|
+
name?: string
|
|
36
|
+
}
|
|
14
37
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
/** Payload used when sending email via Brevo. */
|
|
39
|
+
interface EmailMessageBrevo {
|
|
40
|
+
/** Primary recipients. */
|
|
41
|
+
to: EmailAddress[]
|
|
42
|
+
/** Carbon‑copy recipients. */
|
|
43
|
+
cc?: EmailAddress[]
|
|
44
|
+
/** Blind carbon‑copy recipients. */
|
|
45
|
+
bcc?: EmailAddress[]
|
|
46
|
+
/** Sender address. */
|
|
47
|
+
sender: EmailAddress
|
|
48
|
+
/** Optional reply‑to address. */
|
|
49
|
+
replyTo?: EmailAddress
|
|
50
|
+
/** Subject line for the email. */
|
|
51
|
+
subject: string
|
|
52
|
+
/** Additional SMTP or provider‑specific headers. */
|
|
53
|
+
headers?: Record<string, string>
|
|
54
|
+
/** Tag names applied to the message. */
|
|
55
|
+
tags?: string[]
|
|
56
|
+
/** HTML content of the email. */
|
|
57
|
+
htmlContent?: string
|
|
58
|
+
/** Plain‑text content of the email. */
|
|
59
|
+
textContent?: string
|
|
60
|
+
/** Attachments encoded as base64 strings. */
|
|
61
|
+
attachment?: Array<{ content: string; name: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Private environment variables (server-side only). */
|
|
65
|
+
interface PrivateEnv {
|
|
66
|
+
// $env/static/private
|
|
67
|
+
/** PostgreSQL connection string. */
|
|
68
|
+
DATABASE_URL: string
|
|
69
|
+
/** Public-facing domain used to construct email links (e.g. `https://example.com`). */
|
|
70
|
+
DOMAIN: string
|
|
71
|
+
/** Secret key used to sign and verify JWTs. */
|
|
72
|
+
JWT_SECRET: string
|
|
73
|
+
/** Brevo (Sendinblue) API key. */
|
|
74
|
+
BREVO_KEY: string
|
|
75
|
+
/** Default sender email address for outgoing mail. */
|
|
76
|
+
EMAIL: string
|
|
77
|
+
/** Cloudflare Turnstile secret key used to verify challenge tokens server-side. */
|
|
78
|
+
TURNSTILE_SECRET_KEY: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Public environment variables (safe to expose to the client). */
|
|
82
|
+
interface PublicEnv {
|
|
83
|
+
// $env/static/public
|
|
84
|
+
/** Google OAuth 2.0 client ID for Google Sign-In. */
|
|
85
|
+
PUBLIC_GOOGLE_CLIENT_ID: string
|
|
86
|
+
/** Cloudflare Turnstile site key rendered in the browser widget. */
|
|
87
|
+
PUBLIC_TURNSTILE_SITE_KEY: string
|
|
88
|
+
}
|
|
28
89
|
}
|
|
29
90
|
|
|
30
|
-
/**
|
|
31
|
-
interface
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
/** Result returned by the `authenticate` and `register` SQL functions. */
|
|
92
|
+
interface AuthenticationResult {
|
|
93
|
+
/** HTTP status code to use when the operation fails. */
|
|
94
|
+
statusCode: NumericRange<400, 599>
|
|
95
|
+
/** Human-readable status message. */
|
|
96
|
+
status: string
|
|
97
|
+
/** The authenticated or registered user, if successful. */
|
|
98
|
+
user: User
|
|
99
|
+
/** The newly created session ID. */
|
|
100
|
+
sessionId: string
|
|
35
101
|
}
|
|
36
|
-
}
|
|
37
102
|
|
|
38
|
-
/**
|
|
39
|
-
interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
status: string
|
|
44
|
-
/** The authenticated or registered user, if successful. */
|
|
45
|
-
user: User
|
|
46
|
-
/** The newly created session ID. */
|
|
47
|
-
sessionId: string
|
|
48
|
-
}
|
|
103
|
+
/** Raw login credentials submitted by the user. */
|
|
104
|
+
interface Credentials {
|
|
105
|
+
email: string
|
|
106
|
+
password: string
|
|
107
|
+
}
|
|
49
108
|
|
|
50
|
-
/**
|
|
51
|
-
interface
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
109
|
+
/** Persistent properties stored in the database for a user account. */
|
|
110
|
+
interface UserProperties {
|
|
111
|
+
id: number
|
|
112
|
+
/** ISO-8601 datetime at which the current session expires. */
|
|
113
|
+
expires?: string
|
|
114
|
+
role: 'student' | 'teacher' | 'admin'
|
|
115
|
+
password?: string
|
|
116
|
+
firstName?: string
|
|
117
|
+
lastName?: string
|
|
118
|
+
email?: string
|
|
119
|
+
phone?: string
|
|
120
|
+
}
|
|
55
121
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
id: number
|
|
59
|
-
/** ISO-8601 datetime at which the current session expires. */
|
|
60
|
-
expires?: string
|
|
61
|
-
role: 'student' | 'teacher' | 'admin'
|
|
62
|
-
password?: string
|
|
63
|
-
firstName?: string
|
|
64
|
-
lastName?: string
|
|
65
|
-
email?: string
|
|
66
|
-
phone?: string
|
|
67
|
-
}
|
|
122
|
+
/** A user record, or `undefined`/`null` when unauthenticated. */
|
|
123
|
+
type User = UserProperties | undefined | null
|
|
68
124
|
|
|
69
|
-
/** A
|
|
70
|
-
|
|
125
|
+
/** A database session paired with its associated user. */
|
|
126
|
+
interface UserSession {
|
|
127
|
+
/** UUID session identifier stored in the `session` cookie. */
|
|
128
|
+
id: string
|
|
129
|
+
user: User
|
|
130
|
+
}
|
|
71
131
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
132
|
+
interface Window {
|
|
133
|
+
turnstile?: {
|
|
134
|
+
render: (container: HTMLElement, options: Record<string, unknown>) => string
|
|
135
|
+
reset: (widgetId: string) => void
|
|
136
|
+
remove: (widgetId: string) => void
|
|
137
|
+
}
|
|
138
|
+
}
|
|
77
139
|
}
|
|
140
|
+
|
|
141
|
+
export {}
|
package/src/app.html
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte'
|
|
3
|
+
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
token: string
|
|
7
|
+
theme?: 'light' | 'dark' | 'auto'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { token = $bindable(''), theme = 'auto' }: Props = $props()
|
|
11
|
+
|
|
12
|
+
let container: HTMLDivElement | undefined = $state()
|
|
13
|
+
let widgetId: string | undefined
|
|
14
|
+
|
|
15
|
+
/** Resets the Turnstile widget and clears the token. Call this after a failed submission. */
|
|
16
|
+
export function reset() {
|
|
17
|
+
if (widgetId !== undefined && typeof window !== 'undefined' && window.turnstile) {
|
|
18
|
+
window.turnstile.reset(widgetId)
|
|
19
|
+
token = ''
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onMount(() => {
|
|
24
|
+
const tryRender = () => {
|
|
25
|
+
if (!container || !window.turnstile) {
|
|
26
|
+
setTimeout(tryRender, 50)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
widgetId = window.turnstile.render(container, {
|
|
30
|
+
sitekey: PUBLIC_TURNSTILE_SITE_KEY,
|
|
31
|
+
theme,
|
|
32
|
+
callback: (t: string) => {
|
|
33
|
+
token = t
|
|
34
|
+
},
|
|
35
|
+
'expired-callback': () => {
|
|
36
|
+
token = ''
|
|
37
|
+
},
|
|
38
|
+
'error-callback': () => {
|
|
39
|
+
token = ''
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
tryRender()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
onDestroy(() => {
|
|
47
|
+
if (widgetId !== undefined && window.turnstile) {
|
|
48
|
+
window.turnstile.remove(widgetId)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div bind:this={container}></div>
|
package/src/lib/google.ts
CHANGED
|
@@ -2,6 +2,26 @@ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
|
|
|
2
2
|
import { appState } from '$lib/app-state.svelte'
|
|
3
3
|
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Waits for the Google Identity Services SDK to load, then calls `fn`.
|
|
7
|
+
* Polls every 50 ms; gives up after 10 seconds.
|
|
8
|
+
*/
|
|
9
|
+
function whenGoogleReady(fn: () => void) {
|
|
10
|
+
if (typeof google !== 'undefined') {
|
|
11
|
+
fn()
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const deadline = Date.now() + 10_000
|
|
15
|
+
const id = setInterval(() => {
|
|
16
|
+
if (typeof google !== 'undefined') {
|
|
17
|
+
clearInterval(id)
|
|
18
|
+
fn()
|
|
19
|
+
} else if (Date.now() > deadline) {
|
|
20
|
+
clearInterval(id)
|
|
21
|
+
}
|
|
22
|
+
}, 50)
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
/**
|
|
6
26
|
* Renders the Google Sign-In button inside the element with id `googleButton`.
|
|
7
27
|
*
|
|
@@ -10,16 +30,18 @@ import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
|
10
30
|
* correctly within its container.
|
|
11
31
|
*/
|
|
12
32
|
export function renderGoogleButton() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
whenGoogleReady(() => {
|
|
34
|
+
const btn = document.getElementById('googleButton')
|
|
35
|
+
if (btn) {
|
|
36
|
+
const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
|
|
37
|
+
google.accounts.id.renderButton(btn, {
|
|
38
|
+
type: 'standard',
|
|
39
|
+
theme: 'outline',
|
|
40
|
+
size: 'large',
|
|
41
|
+
width: Math.floor(width)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
})
|
|
23
45
|
}
|
|
24
46
|
|
|
25
47
|
/**
|
|
@@ -33,13 +55,15 @@ export function renderGoogleButton() {
|
|
|
33
55
|
* 3. Redirects the user via {@link redirectAfterLogin}.
|
|
34
56
|
*/
|
|
35
57
|
export function initializeGoogleAccounts() {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
whenGoogleReady(() => {
|
|
59
|
+
if (!appState.googleInitialized) {
|
|
60
|
+
google.accounts.id.initialize({
|
|
61
|
+
client_id: PUBLIC_GOOGLE_CLIENT_ID,
|
|
62
|
+
callback: googleCallback
|
|
63
|
+
})
|
|
64
|
+
appState.googleInitialized = true
|
|
65
|
+
}
|
|
66
|
+
})
|
|
43
67
|
|
|
44
68
|
async function googleCallback(response: google.accounts.id.CredentialResponse) {
|
|
45
69
|
const res = await fetch('/auth/google', {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// See https://developers.brevo.com/reference/sendtransacemail
|
|
2
|
+
import { env } from '$env/dynamic/private'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Delays execution for the specified number of milliseconds.
|
|
6
|
+
* @param ms - The number of milliseconds to delay.
|
|
7
|
+
* @returns A promise that resolves after the specified delay.
|
|
8
|
+
*/
|
|
9
|
+
async function delay(ms: number) {
|
|
10
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calculates an exponential backoff delay with jitter for retry attempts.
|
|
15
|
+
* Formula: 1000ms * 2^(attempt - 1) + random(0-500ms)
|
|
16
|
+
* Example delays: attempt 1 = 1000-1500ms, attempt 2 = 2000-2500ms, attempt 3 = 4000-4500ms
|
|
17
|
+
* @param attempt - The retry attempt number (1-based).
|
|
18
|
+
* @returns The backoff delay in milliseconds with added jitter.
|
|
19
|
+
*/
|
|
20
|
+
function getBackoffDelay(attempt: number) {
|
|
21
|
+
// attempt: 1, 2, 3 -> 1000, 2000, 4000 (plus jitter)
|
|
22
|
+
const base = 1000
|
|
23
|
+
const exponential = base * 2 ** (attempt - 1)
|
|
24
|
+
const jitter = Math.random() * 500 // 0-500ms jitter
|
|
25
|
+
return exponential + jitter
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Type guard to check if the response data is a Brevo error response.
|
|
30
|
+
* @param data - The response data to check.
|
|
31
|
+
* @returns True if the data is a BrevoErrorResponse.
|
|
32
|
+
*/
|
|
33
|
+
function isBrevoError(data: unknown): data is BrevoErrorResponse {
|
|
34
|
+
return typeof data === 'object' && data !== null && 'code' in data
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates that an email message has all required fields.
|
|
39
|
+
* @param message - The email message to validate.
|
|
40
|
+
* @throws {Error} If any required field is missing or invalid.
|
|
41
|
+
*/
|
|
42
|
+
function validateMessage(message: EmailMessageBrevo): void {
|
|
43
|
+
if (!message.sender) {
|
|
44
|
+
throw new Error('Email message is missing sender')
|
|
45
|
+
}
|
|
46
|
+
if (!message.to || message.to.length === 0) {
|
|
47
|
+
throw new Error('Email message is missing recipients')
|
|
48
|
+
}
|
|
49
|
+
if (!message.subject) {
|
|
50
|
+
throw new Error('Email message is missing subject')
|
|
51
|
+
}
|
|
52
|
+
if (!message.htmlContent && !message.textContent) {
|
|
53
|
+
throw new Error('Email message is missing content (htmlContent or textContent required)')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sends an email message via the Brevo API with automatic retry logic.
|
|
59
|
+
* Implements exponential backoff with jitter for transient failures (429, 5xx errors).
|
|
60
|
+
* Validates message fields and throws errors for missing required data or non-retriable failures.
|
|
61
|
+
*
|
|
62
|
+
* @param message - The email message object containing sender, recipients, subject, and content.
|
|
63
|
+
* @throws {Error} If BREVO_KEY is missing, message validation fails, or sending fails after all retry attempts.
|
|
64
|
+
* @returns A promise that resolves when the email is successfully sent (HTTP 201).
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* - Retries up to 4 times (1 initial attempt + 3 retries) for network errors and retriable HTTP errors.
|
|
68
|
+
* - Uses 30-second timeout per request.
|
|
69
|
+
* - Respects Retry-After header from rate limit responses.
|
|
70
|
+
* - Logs warnings for retriable failures and errors for permanent failures.
|
|
71
|
+
*/
|
|
72
|
+
export async function sendMessage(message: EmailMessageBrevo): Promise<void> {
|
|
73
|
+
if (!env.BREVO_KEY) {
|
|
74
|
+
throw new Error('Brevo API key is missing from environment variables')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
validateMessage(message)
|
|
78
|
+
|
|
79
|
+
const maxAttempts = 4 // initial try + 3 retries
|
|
80
|
+
const recipients = message.to.map(r => r.email).join(', ')
|
|
81
|
+
|
|
82
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
83
|
+
let response: Response
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const controller = new AbortController()
|
|
87
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30s timeout
|
|
88
|
+
|
|
89
|
+
response = await fetch('https://api.brevo.com/v3/smtp/email', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
accept: 'application/json',
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
'api-key': env.BREVO_KEY
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify(message),
|
|
97
|
+
signal: controller.signal
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
clearTimeout(timeoutId)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (attempt === maxAttempts) {
|
|
103
|
+
console.error('Brevo send failed after retries (network error)', {
|
|
104
|
+
error,
|
|
105
|
+
subject: message.subject,
|
|
106
|
+
to: recipients
|
|
107
|
+
})
|
|
108
|
+
throw new Error('Failed to send email after retries', { cause: error })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const wait = getBackoffDelay(attempt)
|
|
112
|
+
console.warn(
|
|
113
|
+
`Brevo send network error (attempt ${attempt}) — retrying in ${Math.round(wait)}ms`,
|
|
114
|
+
{
|
|
115
|
+
error,
|
|
116
|
+
subject: message.subject
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
await delay(wait)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (response.status === 201) {
|
|
124
|
+
return // Success - email sent
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Handle 400 bad request errors with code property
|
|
128
|
+
if (response.status === 400) {
|
|
129
|
+
const body = await response.json().catch(() => null)
|
|
130
|
+
const errorCode = isBrevoError(body) ? body.code : undefined
|
|
131
|
+
console.error('Brevo send failed (bad request)', {
|
|
132
|
+
status: response.status,
|
|
133
|
+
code: errorCode,
|
|
134
|
+
body,
|
|
135
|
+
subject: message.subject,
|
|
136
|
+
to: recipients
|
|
137
|
+
})
|
|
138
|
+
throw new Error(`Failed to send email: ${errorCode || 'Bad request'}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const retriable = response.status === 429 || (response.status >= 500 && response.status <= 599)
|
|
142
|
+
|
|
143
|
+
if (!retriable || attempt === maxAttempts) {
|
|
144
|
+
const body = await response.json().catch(() => null)
|
|
145
|
+
console.error('Brevo send failed (no more retries)', {
|
|
146
|
+
status: response.status,
|
|
147
|
+
statusText: response.statusText,
|
|
148
|
+
body,
|
|
149
|
+
subject: message.subject,
|
|
150
|
+
to: recipients
|
|
151
|
+
})
|
|
152
|
+
throw new Error(`Failed to send email: ${response.status} ${response.statusText}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check for Retry-After header and use it if available
|
|
156
|
+
const retryAfterHeader = response.headers.get('Retry-After')
|
|
157
|
+
let retryAfter: number | null = null
|
|
158
|
+
|
|
159
|
+
if (retryAfterHeader) {
|
|
160
|
+
const parsed = parseInt(retryAfterHeader, 10)
|
|
161
|
+
// If it's a valid number, treat as seconds; otherwise it might be an HTTP date
|
|
162
|
+
if (!isNaN(parsed)) {
|
|
163
|
+
retryAfter = parsed * 1000
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const wait = retryAfter || getBackoffDelay(attempt)
|
|
168
|
+
|
|
169
|
+
console.warn(`Brevo send failed (attempt ${attempt}) — retrying in ${Math.round(wait)}ms`, {
|
|
170
|
+
status: response.status,
|
|
171
|
+
statusText: response.statusText,
|
|
172
|
+
subject: message.subject,
|
|
173
|
+
retryAfter: retryAfter ? `${retryAfter}ms (from header)` : undefined
|
|
174
|
+
})
|
|
175
|
+
await delay(wait)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error('Unexpected Brevo retry logic failure')
|
|
179
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { sendMessage } from '$lib/server/
|
|
1
|
+
import { EMAIL } from '$env/static/private'
|
|
2
|
+
import { sendMessage } from '$lib/server/brevo'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Sends a multi-factor authentication (MFA) verification code email to the user.
|
|
@@ -9,11 +9,11 @@ import { sendMessage } from '$lib/server/sendgrid'
|
|
|
9
9
|
*/
|
|
10
10
|
export const sendMfaCodeEmail = async (toEmail: string, code: string) => {
|
|
11
11
|
await sendMessage({
|
|
12
|
-
to: { email: toEmail },
|
|
13
|
-
|
|
12
|
+
to: [{ email: toEmail }],
|
|
13
|
+
sender: { email: EMAIL },
|
|
14
14
|
subject: 'Your login verification code',
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
tags: ['account'],
|
|
16
|
+
htmlContent: `
|
|
17
17
|
<p>Your verification code is:</p>
|
|
18
18
|
<p style="font-size: 2em; font-weight: bold; letter-spacing: 0.25em;">${code}</p>
|
|
19
19
|
<p>This code expires in 10 minutes. If you did not attempt to log in, you can safely ignore this email.</p>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { DOMAIN,
|
|
2
|
-
import { sendMessage } from '$lib/server/
|
|
1
|
+
import { DOMAIN, EMAIL } from '$env/static/private'
|
|
2
|
+
import { sendMessage } from '$lib/server/brevo'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Sends a password reset email containing a link with a one-time reset token.
|
|
@@ -9,11 +9,11 @@ import { sendMessage } from '$lib/server/sendgrid'
|
|
|
9
9
|
*/
|
|
10
10
|
export const sendPasswordResetEmail = async (toEmail: string, token: string) => {
|
|
11
11
|
await sendMessage({
|
|
12
|
-
to: { email: toEmail },
|
|
13
|
-
|
|
12
|
+
to: [{ email: toEmail }],
|
|
13
|
+
sender: { email: EMAIL },
|
|
14
14
|
subject: 'Password reset',
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
tags: ['account'],
|
|
16
|
+
htmlContent: `
|
|
17
17
|
<p><a href="${DOMAIN}/auth/reset/${token}">Reset my password</a>. Your browser will open and ask you to
|
|
18
18
|
provide a new password with a confirmation then redirect you to your login page.</p>
|
|
19
19
|
`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { DOMAIN,
|
|
2
|
-
import { sendMessage } from '$lib/server/
|
|
1
|
+
import { DOMAIN, EMAIL } from '$env/static/private'
|
|
2
|
+
import { sendMessage } from '$lib/server/brevo'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Sends an email verification link to a newly registered user.
|
|
@@ -9,11 +9,11 @@ import { sendMessage } from '$lib/server/sendgrid'
|
|
|
9
9
|
*/
|
|
10
10
|
export const sendVerificationEmail = async (toEmail: string, token: string) => {
|
|
11
11
|
await sendMessage({
|
|
12
|
-
to: { email: toEmail },
|
|
13
|
-
|
|
12
|
+
to: [{ email: toEmail }],
|
|
13
|
+
sender: { email: EMAIL },
|
|
14
14
|
subject: 'Verify your email address',
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
tags: ['account'],
|
|
16
|
+
htmlContent: `
|
|
17
17
|
<p>Thanks for registering! Please verify your email address by clicking the link below:</p>
|
|
18
18
|
<p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
|
|
19
19
|
<p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TURNSTILE_SECRET_KEY } from '$env/static/private'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifies a Cloudflare Turnstile challenge token against the siteverify API.
|
|
5
|
+
*
|
|
6
|
+
* @param token - The `cf-turnstile-response` token submitted by the client.
|
|
7
|
+
* @param ip - Optional client IP address passed to Cloudflare for additional validation.
|
|
8
|
+
* @returns `true` if the token is valid, `false` otherwise.
|
|
9
|
+
*/
|
|
10
|
+
export async function verifyTurnstileToken(token: string, ip?: string): Promise<boolean> {
|
|
11
|
+
if (!token) return false
|
|
12
|
+
|
|
13
|
+
const formData = new FormData()
|
|
14
|
+
formData.append('secret', TURNSTILE_SECRET_KEY)
|
|
15
|
+
formData.append('response', token)
|
|
16
|
+
if (ip) formData.append('remoteip', ip)
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body: formData
|
|
22
|
+
})
|
|
23
|
+
const data: { success: boolean } = await res.json()
|
|
24
|
+
return data.success === true
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error('Turnstile verification error:', err)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -4,6 +4,8 @@ import jwt from 'jsonwebtoken'
|
|
|
4
4
|
import { JWT_SECRET } from '$env/static/private'
|
|
5
5
|
import { query } from '$lib/server/db'
|
|
6
6
|
import { sendPasswordResetEmail } from '$lib/server/email'
|
|
7
|
+
import { error } from '@sveltejs/kit'
|
|
8
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Handles a forgot-password request.
|
|
@@ -17,6 +19,11 @@ import { sendPasswordResetEmail } from '$lib/server/email'
|
|
|
17
19
|
*/
|
|
18
20
|
export const POST: RequestHandler = async event => {
|
|
19
21
|
const body = await event.request.json()
|
|
22
|
+
|
|
23
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
24
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
25
|
+
if (!turnstileOk) error(400, 'Security challenge failed. Please try again.')
|
|
26
|
+
|
|
20
27
|
const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
|
|
21
28
|
const { rows } = await query(sql, [body.email])
|
|
22
29
|
|
|
@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'
|
|
|
5
5
|
import { JWT_SECRET } from '$env/static/private'
|
|
6
6
|
import { query } from '$lib/server/db'
|
|
7
7
|
import { sendMfaCodeEmail } from '$lib/server/email'
|
|
8
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* In-memory failed-attempt tracker used for per-email account lockout.
|
|
@@ -41,13 +42,17 @@ const MFA_TRUSTED_COOKIE = 'mfa_trusted'
|
|
|
41
42
|
export const POST: RequestHandler = async event => {
|
|
42
43
|
const { cookies } = event
|
|
43
44
|
|
|
44
|
-
let body: { email?: string; password?: string }
|
|
45
|
+
let body: { email?: string; password?: string; turnstileToken?: string }
|
|
45
46
|
try {
|
|
46
47
|
body = await event.request.json()
|
|
47
48
|
} catch {
|
|
48
49
|
error(400, 'Invalid request body.')
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
53
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
54
|
+
if (!turnstileOk) error(400, 'Security challenge failed. Please try again.')
|
|
55
|
+
|
|
51
56
|
const email = body.email?.toLowerCase() ?? ''
|
|
52
57
|
|
|
53
58
|
// Check per-email account lockout
|
|
@@ -4,6 +4,7 @@ import type { Secret } from 'jsonwebtoken'
|
|
|
4
4
|
import jwt from 'jsonwebtoken'
|
|
5
5
|
import { JWT_SECRET } from '$env/static/private'
|
|
6
6
|
import { query } from '$lib/server/db'
|
|
7
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
7
8
|
|
|
8
9
|
/** Name of the cookie used to mark a device as MFA-trusted. */
|
|
9
10
|
const MFA_TRUSTED_COOKIE = 'mfa_trusted'
|
|
@@ -26,7 +27,7 @@ const MFA_TRUSTED_MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds
|
|
|
26
27
|
export const POST: RequestHandler = async event => {
|
|
27
28
|
const { cookies } = event
|
|
28
29
|
|
|
29
|
-
let body: { email?: string; code?: string }
|
|
30
|
+
let body: { email?: string; code?: string; turnstileToken?: string }
|
|
30
31
|
try {
|
|
31
32
|
body = await event.request.json()
|
|
32
33
|
} catch {
|
|
@@ -35,6 +36,10 @@ export const POST: RequestHandler = async event => {
|
|
|
35
36
|
|
|
36
37
|
if (!body.email || !body.code) error(400, 'Email and verification code are required.')
|
|
37
38
|
|
|
39
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
40
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
41
|
+
if (!turnstileOk) error(400, 'Security challenge failed. Please try again.')
|
|
42
|
+
|
|
38
43
|
// Verify the code; returns user_id on success, NULL on failure/expiry
|
|
39
44
|
const verifyResult = await query(`SELECT verify_mfa_code($1, $2) AS "userId";`, [
|
|
40
45
|
body.email.toLowerCase(),
|
|
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'
|
|
|
4
4
|
import { JWT_SECRET } from '$env/static/private'
|
|
5
5
|
import { query } from '$lib/server/db'
|
|
6
6
|
import { sendVerificationEmail } from '$lib/server/email'
|
|
7
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Registers a new user account.
|
|
@@ -23,13 +24,17 @@ import { sendVerificationEmail } from '$lib/server/email'
|
|
|
23
24
|
* @throws The status code from the registration result on other failures (e.g. duplicate email).
|
|
24
25
|
*/
|
|
25
26
|
export const POST: RequestHandler = async event => {
|
|
26
|
-
let body: { email?: string; password?: string; firstName?: string; lastName?: string }
|
|
27
|
+
let body: { email?: string; password?: string; firstName?: string; lastName?: string; turnstileToken?: string }
|
|
27
28
|
try {
|
|
28
29
|
body = await event.request.json()
|
|
29
30
|
} catch {
|
|
30
31
|
error(400, 'Invalid request body.')
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
35
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
36
|
+
if (!turnstileOk) error(400, 'Security challenge failed. Please try again.')
|
|
37
|
+
|
|
33
38
|
if (!body.email || !body.password || !body.firstName || !body.lastName)
|
|
34
39
|
error(400, 'Please supply all required fields: email, password, first and last name.')
|
|
35
40
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { json } from '@sveltejs/kit'
|
|
1
|
+
import { json, error } from '@sveltejs/kit'
|
|
2
2
|
import type { RequestHandler } from './$types'
|
|
3
3
|
import type { JwtPayload } from 'jsonwebtoken'
|
|
4
4
|
import jwt from 'jsonwebtoken'
|
|
5
5
|
import { JWT_SECRET } from '$env/static/private'
|
|
6
6
|
import { query } from '$lib/server/db'
|
|
7
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Resets a user's password using a signed JWT reset token.
|
|
@@ -24,6 +25,10 @@ export const PUT: RequestHandler = async event => {
|
|
|
24
25
|
const body = await event.request.json()
|
|
25
26
|
const { token, password } = body
|
|
26
27
|
|
|
28
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
29
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
30
|
+
if (!turnstileOk) return json({ message: 'Security challenge failed. Please try again.' }, { status: 400 })
|
|
31
|
+
|
|
27
32
|
// Check the validity of the token and extract userId
|
|
28
33
|
try {
|
|
29
34
|
const decoded = <JwtPayload>jwt.verify(token, <jwt.Secret>JWT_SECRET)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { goto } from '$app/navigation'
|
|
5
5
|
import { appState } from '$lib/app-state.svelte'
|
|
6
6
|
import { focusOnFirstError } from '$lib/focus'
|
|
7
|
+
import Turnstile from '$lib/Turnstile.svelte'
|
|
7
8
|
|
|
8
9
|
/** Props for the password-reset page. */
|
|
9
10
|
interface Props {
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
let submitted = $state(false)
|
|
22
23
|
let passwordMismatch = $state(false)
|
|
23
24
|
let loading = $state(false)
|
|
25
|
+
let turnstileToken = $state('')
|
|
26
|
+
let turnstile: Turnstile | undefined = $state()
|
|
24
27
|
|
|
25
28
|
onMount(() => {
|
|
26
29
|
// Remove the token from the URL to prevent it appearing in logs and Referer headers
|
|
@@ -53,6 +56,10 @@
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
if (form.checkValidity()) {
|
|
59
|
+
if (!turnstileToken) {
|
|
60
|
+
message = 'Please complete the security challenge.'
|
|
61
|
+
return
|
|
62
|
+
}
|
|
56
63
|
loading = true
|
|
57
64
|
try {
|
|
58
65
|
const url = `/auth/reset`
|
|
@@ -63,7 +70,8 @@
|
|
|
63
70
|
},
|
|
64
71
|
body: JSON.stringify({
|
|
65
72
|
token: data.token,
|
|
66
|
-
password
|
|
73
|
+
password,
|
|
74
|
+
turnstileToken
|
|
67
75
|
})
|
|
68
76
|
})
|
|
69
77
|
|
|
@@ -151,6 +159,8 @@
|
|
|
151
159
|
<p class="tw:text-red-600">{message}</p>
|
|
152
160
|
{/if}
|
|
153
161
|
|
|
162
|
+
<Turnstile bind:this={turnstile} bind:token={turnstileToken} />
|
|
163
|
+
|
|
154
164
|
<button type="submit" class="btn-primary" disabled={loading}>
|
|
155
165
|
{loading ? 'Resetting...' : 'Reset Password'}
|
|
156
166
|
</button>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { goto } from '$app/navigation'
|
|
4
4
|
import { appState } from '$lib/app-state.svelte'
|
|
5
5
|
import { focusOnFirstError } from '$lib/focus'
|
|
6
|
+
import Turnstile from '$lib/Turnstile.svelte'
|
|
6
7
|
|
|
7
8
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
8
9
|
let formEl: HTMLFormElement | undefined = $state()
|
|
@@ -10,6 +11,8 @@
|
|
|
10
11
|
let message: string = $state('')
|
|
11
12
|
let submitted = $state(false)
|
|
12
13
|
let loading = $state(false)
|
|
14
|
+
let turnstileToken = $state('')
|
|
15
|
+
let turnstile: Turnstile | undefined = $state()
|
|
13
16
|
|
|
14
17
|
onMount(() => {
|
|
15
18
|
focusedField?.focus()
|
|
@@ -31,6 +34,9 @@
|
|
|
31
34
|
if (email.toLowerCase().includes('gmail.com')) {
|
|
32
35
|
return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
|
|
33
36
|
}
|
|
37
|
+
if (!turnstileToken) {
|
|
38
|
+
return (message = 'Please complete the security challenge.')
|
|
39
|
+
}
|
|
34
40
|
loading = true
|
|
35
41
|
try {
|
|
36
42
|
const url = `/auth/forgot`
|
|
@@ -39,7 +45,7 @@
|
|
|
39
45
|
headers: {
|
|
40
46
|
'Content-Type': 'application/json'
|
|
41
47
|
},
|
|
42
|
-
body: JSON.stringify({ email })
|
|
48
|
+
body: JSON.stringify({ email, turnstileToken })
|
|
43
49
|
})
|
|
44
50
|
|
|
45
51
|
if (res.ok) {
|
|
@@ -97,6 +103,8 @@
|
|
|
97
103
|
<p class="tw:text-red-600">{message}</p>
|
|
98
104
|
{/if}
|
|
99
105
|
|
|
106
|
+
<Turnstile bind:this={turnstile} bind:token={turnstileToken} />
|
|
107
|
+
|
|
100
108
|
<button type="submit" class="btn-primary" disabled={loading}>
|
|
101
109
|
{loading ? 'Sending...' : 'Send Email'}
|
|
102
110
|
</button>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { focusOnFirstError } from '$lib/focus'
|
|
5
5
|
import { initializeGoogleAccounts, renderGoogleButton } from '$lib/google'
|
|
6
6
|
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
7
|
+
import Turnstile from '$lib/Turnstile.svelte'
|
|
7
8
|
|
|
8
9
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
9
10
|
let formEl: HTMLFormElement | undefined = $state()
|
|
@@ -14,6 +15,10 @@
|
|
|
14
15
|
let loading = $state(false)
|
|
15
16
|
let mfaRequired = $state(false)
|
|
16
17
|
let mfaCode = $state('')
|
|
18
|
+
let turnstileToken = $state('')
|
|
19
|
+
let mfaTurnstileToken = $state('')
|
|
20
|
+
let turnstile: Turnstile | undefined = $state()
|
|
21
|
+
let mfaTurnstile: Turnstile | undefined = $state()
|
|
17
22
|
const credentials: Credentials = $state({
|
|
18
23
|
email: '',
|
|
19
24
|
password: ''
|
|
@@ -29,12 +34,17 @@
|
|
|
29
34
|
const form = formEl!
|
|
30
35
|
|
|
31
36
|
if (form.checkValidity()) {
|
|
37
|
+
if (!turnstileToken) {
|
|
38
|
+
message = 'Please complete the security challenge.'
|
|
39
|
+
return
|
|
40
|
+
}
|
|
32
41
|
try {
|
|
33
42
|
await loginLocal(credentials)
|
|
34
43
|
} catch (err) {
|
|
35
44
|
if (err instanceof Error) {
|
|
36
45
|
console.error('Login error', err.message)
|
|
37
46
|
message = err.message
|
|
47
|
+
turnstile?.reset()
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
} else {
|
|
@@ -63,7 +73,7 @@
|
|
|
63
73
|
try {
|
|
64
74
|
const res = await fetch('/auth/login', {
|
|
65
75
|
method: 'POST',
|
|
66
|
-
body: JSON.stringify(credentials),
|
|
76
|
+
body: JSON.stringify({ ...credentials, turnstileToken }),
|
|
67
77
|
headers: {
|
|
68
78
|
'Content-Type': 'application/json'
|
|
69
79
|
}
|
|
@@ -99,11 +109,15 @@
|
|
|
99
109
|
async function verifyMfa() {
|
|
100
110
|
message = ''
|
|
101
111
|
mfaSubmitted = false
|
|
102
|
-
const form = mfaFormEl!
|
|
103
112
|
|
|
104
|
-
if (
|
|
113
|
+
if (!/^[0-9]{6}$/.test(mfaCode)) {
|
|
105
114
|
mfaSubmitted = true
|
|
106
|
-
|
|
115
|
+
mfaFormEl?.querySelector<HTMLInputElement>('#mfaCode')?.focus()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!mfaTurnstileToken) {
|
|
120
|
+
message = 'Please complete the security challenge.'
|
|
107
121
|
return
|
|
108
122
|
}
|
|
109
123
|
|
|
@@ -111,7 +125,7 @@
|
|
|
111
125
|
try {
|
|
112
126
|
const res = await fetch('/auth/mfa', {
|
|
113
127
|
method: 'POST',
|
|
114
|
-
body: JSON.stringify({ email: credentials.email, code: mfaCode }),
|
|
128
|
+
body: JSON.stringify({ email: credentials.email, code: mfaCode, turnstileToken: mfaTurnstileToken }),
|
|
115
129
|
headers: { 'Content-Type': 'application/json' }
|
|
116
130
|
})
|
|
117
131
|
const fromEndpoint = await res.json()
|
|
@@ -125,6 +139,7 @@
|
|
|
125
139
|
if (err instanceof Error) {
|
|
126
140
|
console.error('MFA error', err)
|
|
127
141
|
message = err.message
|
|
142
|
+
mfaTurnstile?.reset()
|
|
128
143
|
}
|
|
129
144
|
} finally {
|
|
130
145
|
loading = false
|
|
@@ -194,6 +209,8 @@
|
|
|
194
209
|
</button>
|
|
195
210
|
</p>
|
|
196
211
|
</form>
|
|
212
|
+
|
|
213
|
+
<Turnstile bind:this={mfaTurnstile} bind:token={mfaTurnstileToken} />
|
|
197
214
|
{:else}
|
|
198
215
|
<form
|
|
199
216
|
bind:this={formEl}
|
|
@@ -288,6 +305,8 @@
|
|
|
288
305
|
<p class="tw:text-red-600">{message}</p>
|
|
289
306
|
{/if}
|
|
290
307
|
|
|
308
|
+
<Turnstile bind:this={turnstile} bind:token={turnstileToken} />
|
|
309
|
+
|
|
291
310
|
<button type="submit" class="btn-primary" disabled={loading}>
|
|
292
311
|
{loading ? 'Signing in...' : 'Sign In'}
|
|
293
312
|
</button>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
3
|
import { focusOnFirstError } from '$lib/focus'
|
|
4
4
|
import { initializeGoogleAccounts, renderGoogleButton } from '$lib/google'
|
|
5
|
+
import Turnstile from '$lib/Turnstile.svelte'
|
|
5
6
|
|
|
6
7
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
7
8
|
// Pattern stored as a variable to avoid Svelte parsing `{8,}` as a template expression
|
|
@@ -24,6 +25,8 @@
|
|
|
24
25
|
let emailVerificationSent = $state(false)
|
|
25
26
|
|
|
26
27
|
let formEl: HTMLFormElement | undefined = $state()
|
|
28
|
+
let turnstileToken = $state('')
|
|
29
|
+
let turnstile: Turnstile | undefined = $state()
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Validates the registration form and, if valid, delegates to {@link registerLocal}.
|
|
@@ -41,12 +44,17 @@
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
if (form.checkValidity()) {
|
|
47
|
+
if (!turnstileToken) {
|
|
48
|
+
message = 'Please complete the security challenge.'
|
|
49
|
+
return
|
|
50
|
+
}
|
|
44
51
|
try {
|
|
45
52
|
await registerLocal(user)
|
|
46
53
|
} catch (err) {
|
|
47
54
|
if (err instanceof Error) {
|
|
48
55
|
message = err.message
|
|
49
56
|
console.log('Login error', message)
|
|
57
|
+
turnstile?.reset()
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
} else {
|
|
@@ -76,7 +84,7 @@
|
|
|
76
84
|
try {
|
|
77
85
|
const res = await fetch('/auth/register', {
|
|
78
86
|
method: 'POST',
|
|
79
|
-
body: JSON.stringify(user), // server ignores user.role - always set it to 'student' (lowest priv)
|
|
87
|
+
body: JSON.stringify({ ...user, turnstileToken }), // server ignores user.role - always set it to 'student' (lowest priv)
|
|
80
88
|
headers: {
|
|
81
89
|
'Content-Type': 'application/json'
|
|
82
90
|
}
|
|
@@ -269,6 +277,8 @@
|
|
|
269
277
|
<p class="tw:text-red-600">{message}</p>
|
|
270
278
|
{/if}
|
|
271
279
|
|
|
280
|
+
<Turnstile bind:this={turnstile} bind:token={turnstileToken} />
|
|
281
|
+
|
|
272
282
|
<button type="submit" class="btn-primary" disabled={loading}>
|
|
273
283
|
{loading ? 'Creating account...' : 'Register'}
|
|
274
284
|
</button>
|
package/svelte.config.js
CHANGED
|
@@ -8,7 +8,8 @@ const baseCsp = [
|
|
|
8
8
|
'https://www.gstatic.com/recaptcha/', // recaptcha
|
|
9
9
|
'https://accounts.google.com/gsi/', // sign-in w/google
|
|
10
10
|
'https://www.google.com/recaptcha/', // recapatcha
|
|
11
|
-
'https://fonts.gstatic.com/' // recaptcha fonts
|
|
11
|
+
'https://fonts.gstatic.com/', // recaptcha fonts
|
|
12
|
+
'https://challenges.cloudflare.com/' // turnstile
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
if (!production) baseCsp.push('ws://localhost:3000')
|
|
@@ -41,7 +42,8 @@ const config = {
|
|
|
41
42
|
'img-src': ['data:', 'blob:', ...baseCsp],
|
|
42
43
|
'style-src': ['unsafe-inline', ...baseCsp],
|
|
43
44
|
'object-src': ['none'],
|
|
44
|
-
'base-uri': ['self']
|
|
45
|
+
'base-uri': ['self'],
|
|
46
|
+
'frame-src': ['https://challenges.cloudflare.com/', 'https://accounts.google.com/']
|
|
45
47
|
}
|
|
46
48
|
},
|
|
47
49
|
files: {
|
package/vite.config.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import devtoolsJson from 'vite-plugin-devtools-json';
|
|
2
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
3
|
+
import { defineConfig } from 'vite';
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
5
|
|
|
5
6
|
export default defineConfig({
|
|
6
|
-
build: {
|
|
7
|
-
|
|
8
|
-
},
|
|
9
|
-
plugins: [sveltekit(), tailwindcss()],
|
|
7
|
+
build: { sourcemap: process.env.NODE_ENV !== 'production' },
|
|
8
|
+
plugins: [sveltekit(), tailwindcss(), devtoolsJson()],
|
|
10
9
|
test: {
|
|
11
10
|
include: ['src/**/*.unit.test.ts', 'tests/**/*.unit.test.ts']
|
|
12
11
|
},
|
|
13
|
-
server: {
|
|
14
|
-
|
|
15
|
-
port: 3000,
|
|
16
|
-
open: 'http://localhost:3000'
|
|
17
|
-
}
|
|
18
|
-
})
|
|
12
|
+
server: { host: 'localhost', port: 3000, open: 'http://localhost:3000' }
|
|
13
|
+
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { MailDataRequired } from '@sendgrid/mail'
|
|
2
|
-
import sgMail from '@sendgrid/mail'
|
|
3
|
-
import { env } from '$env/dynamic/private'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Sends a transactional email via the SendGrid API.
|
|
7
|
-
*
|
|
8
|
-
* Merges the provided message with the default sender configured in the environment.
|
|
9
|
-
* Any field in `message` (including `from`) will override the default.
|
|
10
|
-
*
|
|
11
|
-
* @param message - Partial SendGrid mail data. Must include at minimum `to`, `subject`, and `html` or `text`.
|
|
12
|
-
* @throws {Error} If the SendGrid API key is missing or the API request fails.
|
|
13
|
-
*/
|
|
14
|
-
export const sendMessage = async (message: Partial<MailDataRequired>) => {
|
|
15
|
-
const { SENDGRID_SENDER, SENDGRID_KEY } = env
|
|
16
|
-
sgMail.setApiKey(SENDGRID_KEY)
|
|
17
|
-
const completeMessage = <MailDataRequired>{
|
|
18
|
-
from: SENDGRID_SENDER, // default sender can be altered
|
|
19
|
-
...message
|
|
20
|
-
}
|
|
21
|
-
await sgMail.send(completeMessage)
|
|
22
|
-
}
|