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 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
- SENDGRID_KEY=replace_with_your_own
6
- SENDGRID_SENDER=replace_with_your_own
7
- PUBLIC_GOOGLE_CLIENT_ID=REPLACE_WITH_YOUR_OWN
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
  [![Node](https://img.shields.io/node/v/sveltekit-auth-example)](https://nodejs.org)
5
5
  [![Svelte](https://img.shields.io/badge/Svelte-5-orange)](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, and OWASP-compliant password hashing out of the box.
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 (SendGrid) | ✅ User profile management |
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 [SendGrid](https://sendgrid.com) account (for password reset emails)
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 Twilio SendGrid account](https://signup.sendgrid.com) and generate an API Key following [this documentation](https://docs.sendgrid.com/ui/account-and-settings/api-keys) and add a sender as documented [here](https://docs.sendgrid.com/ui/sending-email/senders).
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
- SENDGRID_KEY=replace_with_your_own
62
- SENDGRID_SENDER=replace_with_your_own
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 | admin123 | admin |
90
- | teacher@example.com | teacher123 | teacher |
91
- | student@example.com | student123 | student |
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":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
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":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
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":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
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.6.1",
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": "^6.2.4",
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.10",
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": "^7.3.1",
73
- "vitest": "^4.0.18",
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 namespace App {
7
- /** Per-request server-side locals populated by `hooks.server.ts`. */
8
- interface Locals {
9
- /** The authenticated user, or `undefined` when no valid session exists. */
10
- user: User | undefined
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
- // interface Platform {}
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
- /** Private environment variables (server-side only). */
16
- interface PrivateEnv {
17
- // $env/static/private
18
- /** PostgreSQL connection string. */
19
- DATABASE_URL: string
20
- /** Public-facing domain used to construct email links (e.g. `https://example.com`). */
21
- DOMAIN: string
22
- /** Secret key used to sign and verify JWTs. */
23
- JWT_SECRET: string
24
- /** SendGrid API key. */
25
- SENDGRID_KEY: string
26
- /** Default sender email address for outgoing mail. */
27
- SENDGRID_SENDER: string
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
- /** Public environment variables (safe to expose to the client). */
31
- interface PublicEnv {
32
- // $env/static/public
33
- /** Google OAuth 2.0 client ID for Google Sign-In. */
34
- PUBLIC_GOOGLE_CLIENT_ID: string
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
- /** Result returned by the `authenticate` and `register` SQL functions. */
39
- interface AuthenticationResult {
40
- /** HTTP status code to use when the operation fails. */
41
- statusCode: NumericRange<400, 599>
42
- /** Human-readable status message. */
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
- /** Raw login credentials submitted by the user. */
51
- interface Credentials {
52
- email: string
53
- password: string
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
- /** Persistent properties stored in the database for a user account. */
57
- interface UserProperties {
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 user record, or `undefined`/`null` when unauthenticated. */
70
- type User = UserProperties | undefined | null
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
- /** A database session paired with its associated user. */
73
- interface UserSession {
74
- /** UUID session identifier stored in the `session` cookie. */
75
- id: string
76
- user: User
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
@@ -11,6 +11,12 @@
11
11
  async
12
12
  defer
13
13
  ></script>
14
+ <script
15
+ nonce="%sveltekit.nonce%"
16
+ src="https://challenges.cloudflare.com/turnstile/v0/api.js"
17
+ async
18
+ defer
19
+ ></script>
14
20
  %sveltekit.head%
15
21
  </head>
16
22
  <body data-sveltekit-preload-data="hover">
@@ -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
- const btn = document.getElementById('googleButton')
14
- if (btn) {
15
- const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
16
- google.accounts.id.renderButton(btn, {
17
- type: 'standard',
18
- theme: 'outline',
19
- size: 'large',
20
- width: Math.floor(width)
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
- if (!appState.googleInitialized) {
37
- google.accounts.id.initialize({
38
- client_id: PUBLIC_GOOGLE_CLIENT_ID,
39
- callback: googleCallback
40
- })
41
- appState.googleInitialized = true
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 { SENDGRID_SENDER } from '$env/static/private'
2
- import { sendMessage } from '$lib/server/sendgrid'
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
- from: SENDGRID_SENDER,
12
+ to: [{ email: toEmail }],
13
+ sender: { email: EMAIL },
14
14
  subject: 'Your login verification code',
15
- categories: ['account'],
16
- html: `
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, SENDGRID_SENDER } from '$env/static/private'
2
- import { sendMessage } from '$lib/server/sendgrid'
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
- from: SENDGRID_SENDER,
12
+ to: [{ email: toEmail }],
13
+ sender: { email: EMAIL },
14
14
  subject: 'Password reset',
15
- categories: ['account'],
16
- html: `
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, SENDGRID_SENDER } from '$env/static/private'
2
- import { sendMessage } from '$lib/server/sendgrid'
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
- from: SENDGRID_SENDER,
12
+ to: [{ email: toEmail }],
13
+ sender: { email: EMAIL },
14
14
  subject: 'Verify your email address',
15
- categories: ['account'],
16
- html: `
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 (!form.checkValidity()) {
113
+ if (!/^[0-9]{6}$/.test(mfaCode)) {
105
114
  mfaSubmitted = true
106
- focusOnFirstError(form)
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 { sveltekit } from '@sveltejs/kit/vite'
2
- import { defineConfig } from 'vite'
3
- import tailwindcss from '@tailwindcss/vite'
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
- sourcemap: process.env.NODE_ENV !== 'production'
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
- host: 'localhost',
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
- }