sveltekit-auth-example 5.6.0 → 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.
Files changed (47) hide show
  1. package/.env.example +5 -3
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +15 -9
  4. package/db_create.sql +1 -364
  5. package/db_schema.sql +3 -3
  6. package/package.json +7 -5
  7. package/src/app.d.ts +125 -40
  8. package/src/app.html +6 -0
  9. package/src/hooks.server.ts +37 -5
  10. package/src/lib/Turnstile.svelte +53 -0
  11. package/src/lib/app-state.svelte.ts +8 -0
  12. package/src/lib/auth-redirect.ts +4 -0
  13. package/src/lib/focus.ts +8 -0
  14. package/src/lib/google.ts +58 -17
  15. package/src/lib/server/brevo.ts +179 -0
  16. package/src/lib/server/db.ts +9 -0
  17. package/src/lib/server/email/mfa-code.ts +12 -6
  18. package/src/lib/server/email/password-reset.ts +12 -6
  19. package/src/lib/server/email/verify-email.ts +12 -6
  20. package/src/lib/server/turnstile.ts +29 -0
  21. package/src/routes/+layout.server.ts +10 -1
  22. package/src/routes/+layout.svelte +14 -0
  23. package/src/routes/admin/+page.server.ts +8 -0
  24. package/src/routes/api/v1/user/+server.ts +20 -0
  25. package/src/routes/auth/[slug]/+server.ts +9 -2
  26. package/src/routes/auth/forgot/+server.ts +17 -0
  27. package/src/routes/auth/google/+server.ts +29 -3
  28. package/src/routes/auth/login/+server.ts +32 -3
  29. package/src/routes/auth/logout/+server.ts +10 -0
  30. package/src/routes/auth/mfa/+server.ts +21 -1
  31. package/src/routes/auth/register/+server.ts +23 -1
  32. package/src/routes/auth/reset/+server.ts +21 -1
  33. package/src/routes/auth/reset/[token]/+page.svelte +21 -1
  34. package/src/routes/auth/reset/[token]/+page.ts +8 -0
  35. package/src/routes/auth/verify/[token]/+server.ts +12 -1
  36. package/src/routes/forgot/+page.svelte +17 -1
  37. package/src/routes/login/+page.server.ts +8 -0
  38. package/src/routes/login/+page.svelte +43 -5
  39. package/src/routes/profile/+page.server.ts +9 -0
  40. package/src/routes/profile/+page.svelte +16 -0
  41. package/src/routes/register/+page.server.ts +8 -1
  42. package/src/routes/register/+page.svelte +26 -1
  43. package/src/routes/teachers/+page.server.ts +9 -0
  44. package/src/service-worker.ts +17 -1
  45. package/svelte.config.js +4 -2
  46. package/vite.config.ts +8 -13
  47. package/src/lib/server/sendgrid.ts +0 -13
@@ -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,23 +15,36 @@
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: ''
20
25
  })
21
26
 
27
+ /**
28
+ * Validates the login form and, if valid, delegates to {@link loginLocal}.
29
+ * Displays any error message returned by the server.
30
+ */
22
31
  async function login() {
23
32
  message = ''
24
33
  submitted = false
25
34
  const form = formEl!
26
35
 
27
36
  if (form.checkValidity()) {
37
+ if (!turnstileToken) {
38
+ message = 'Please complete the security challenge.'
39
+ return
40
+ }
28
41
  try {
29
42
  await loginLocal(credentials)
30
43
  } catch (err) {
31
44
  if (err instanceof Error) {
32
45
  console.error('Login error', err.message)
33
46
  message = err.message
47
+ turnstile?.reset()
34
48
  }
35
49
  }
36
50
  } else {
@@ -45,12 +59,21 @@
45
59
  focusedField?.focus()
46
60
  })
47
61
 
62
+ /**
63
+ * POSTs credentials to `/auth/login`.
64
+ *
65
+ * If the server requires MFA, sets `mfaRequired` to show the code form.
66
+ * Otherwise, updates {@link appState.user} and redirects via
67
+ * {@link redirectAfterLogin}.
68
+ *
69
+ * @param credentials - The user's email and password.
70
+ */
48
71
  async function loginLocal(credentials: Credentials) {
49
72
  loading = true
50
73
  try {
51
74
  const res = await fetch('/auth/login', {
52
75
  method: 'POST',
53
- body: JSON.stringify(credentials),
76
+ body: JSON.stringify({ ...credentials, turnstileToken }),
54
77
  headers: {
55
78
  'Content-Type': 'application/json'
56
79
  }
@@ -77,14 +100,24 @@
77
100
  }
78
101
  }
79
102
 
103
+ /**
104
+ * Validates the MFA form and POSTs the code to `/auth/mfa`.
105
+ *
106
+ * On success, updates {@link appState.user} and redirects via
107
+ * {@link redirectAfterLogin}. Displays any error returned by the server.
108
+ */
80
109
  async function verifyMfa() {
81
110
  message = ''
82
111
  mfaSubmitted = false
83
- const form = mfaFormEl!
84
112
 
85
- if (!form.checkValidity()) {
113
+ if (!/^[0-9]{6}$/.test(mfaCode)) {
86
114
  mfaSubmitted = true
87
- focusOnFirstError(form)
115
+ mfaFormEl?.querySelector<HTMLInputElement>('#mfaCode')?.focus()
116
+ return
117
+ }
118
+
119
+ if (!mfaTurnstileToken) {
120
+ message = 'Please complete the security challenge.'
88
121
  return
89
122
  }
90
123
 
@@ -92,7 +125,7 @@
92
125
  try {
93
126
  const res = await fetch('/auth/mfa', {
94
127
  method: 'POST',
95
- body: JSON.stringify({ email: credentials.email, code: mfaCode }),
128
+ body: JSON.stringify({ email: credentials.email, code: mfaCode, turnstileToken: mfaTurnstileToken }),
96
129
  headers: { 'Content-Type': 'application/json' }
97
130
  })
98
131
  const fromEndpoint = await res.json()
@@ -106,6 +139,7 @@
106
139
  if (err instanceof Error) {
107
140
  console.error('MFA error', err)
108
141
  message = err.message
142
+ mfaTurnstile?.reset()
109
143
  }
110
144
  } finally {
111
145
  loading = false
@@ -175,6 +209,8 @@
175
209
  </button>
176
210
  </p>
177
211
  </form>
212
+
213
+ <Turnstile bind:this={mfaTurnstile} bind:token={mfaTurnstileToken} />
178
214
  {:else}
179
215
  <form
180
216
  bind:this={formEl}
@@ -269,6 +305,8 @@
269
305
  <p class="tw:text-red-600">{message}</p>
270
306
  {/if}
271
307
 
308
+ <Turnstile bind:this={turnstile} bind:token={turnstileToken} />
309
+
272
310
  <button type="submit" class="btn-primary" disabled={loading}>
273
311
  {loading ? 'Signing in...' : 'Sign In'}
274
312
  </button>
@@ -1,6 +1,15 @@
1
1
  import { redirect } from '@sveltejs/kit'
2
2
  import type { PageServerLoad } from './$types'
3
3
 
4
+ /**
5
+ * Page server load function for the profile route.
6
+ *
7
+ * Requires the user to be authenticated with any recognized role
8
+ * (`admin`, `teacher`, or `student`). Unauthenticated or unauthorized
9
+ * users are redirected to the login page with a `referrer` parameter.
10
+ *
11
+ * @returns The authenticated `user` object for use in the page component.
12
+ */
4
13
  export const load: PageServerLoad = async ({ locals }) => {
5
14
  const { user } = locals // populated by /src/hooks.ts
6
15
 
@@ -6,7 +6,9 @@
6
6
  import { focusOnFirstError } from '$lib/focus'
7
7
  import { appState } from '$lib/app-state.svelte'
8
8
 
9
+ /** Props for the profile page. */
9
10
  interface Props {
11
+ /** Server data containing the authenticated user's profile. */
10
12
  data: PageData
11
13
  }
12
14
 
@@ -28,6 +30,13 @@
28
30
  focusedField?.focus()
29
31
  })
30
32
 
33
+ /**
34
+ * Submits the updated profile to `/api/v1/user` (PUT).
35
+ *
36
+ * Skips the password-match check for Gmail users since they manage their
37
+ * password through Google. On success, syncs {@link appState.user} so the
38
+ * navbar reflects the changes immediately.
39
+ */
31
40
  async function update() {
32
41
  message = ''
33
42
  submitted = false
@@ -62,6 +71,12 @@
62
71
  }
63
72
  }
64
73
 
74
+ /**
75
+ * Permanently deletes the authenticated user's account after confirmation.
76
+ *
77
+ * Sends a DELETE to `/api/v1/user`. On success, clears {@link appState.user}
78
+ * and redirects to the login page.
79
+ */
65
80
  async function deleteAccount() {
66
81
  if (
67
82
  !confirm('Are you sure you want to permanently delete your account? This cannot be undone.')
@@ -81,6 +96,7 @@
81
96
  }
82
97
  }
83
98
 
99
+ /** Returns `true` if the password and confirm-password fields match. */
84
100
  const passwordMatch = () => {
85
101
  if (!user.password) user.password = ''
86
102
  return user.password == confirmPassword?.value
@@ -1,10 +1,17 @@
1
1
  import { redirect } from '@sveltejs/kit'
2
2
  import type { PageServerLoad } from './$types'
3
3
 
4
+ /**
5
+ * Page server load function for the register route.
6
+ *
7
+ * Redirects already-authenticated users to the home page so they don't see
8
+ * the registration form unnecessarily.
9
+ *
10
+ * @returns An empty object for unauthenticated users.
11
+ */
4
12
  export const load: PageServerLoad = ({ locals }) => {
5
13
  const { user } = locals
6
14
  if (user) {
7
- // Redirect to home if user is logged in already
8
15
  redirect(302, '/')
9
16
  }
10
17
  return {}
@@ -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,7 +25,13 @@
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
 
31
+ /**
32
+ * Validates the registration form and, if valid, delegates to {@link registerLocal}.
33
+ * Checks that passwords match before form validation runs.
34
+ */
28
35
  async function register() {
29
36
  const form = formEl!
30
37
  message = ''
@@ -37,12 +44,17 @@
37
44
  }
38
45
 
39
46
  if (form.checkValidity()) {
47
+ if (!turnstileToken) {
48
+ message = 'Please complete the security challenge.'
49
+ return
50
+ }
40
51
  try {
41
52
  await registerLocal(user)
42
53
  } catch (err) {
43
54
  if (err instanceof Error) {
44
55
  message = err.message
45
56
  console.log('Login error', message)
57
+ turnstile?.reset()
46
58
  }
47
59
  }
48
60
  } else {
@@ -57,12 +69,22 @@
57
69
  focusedField?.focus()
58
70
  })
59
71
 
72
+ /**
73
+ * POSTs the new user data to `/auth/register`.
74
+ *
75
+ * The server ignores the `role` field and always assigns the lowest privilege
76
+ * (`student`). On success with `emailVerification: true`, sets
77
+ * `emailVerificationSent` to show the confirmation message instead of the form.
78
+ *
79
+ * @param user - The user object collected from the registration form.
80
+ * @throws {Error} With a user-friendly message on HTTP errors.
81
+ */
60
82
  async function registerLocal(user: User) {
61
83
  loading = true
62
84
  try {
63
85
  const res = await fetch('/auth/register', {
64
86
  method: 'POST',
65
- 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)
66
88
  headers: {
67
89
  'Content-Type': 'application/json'
68
90
  }
@@ -86,6 +108,7 @@
86
108
  }
87
109
  }
88
110
 
111
+ /** Returns `true` if the password and confirm-password fields match. */
89
112
  const passwordMatch = () => {
90
113
  if (!user) return false // placate TypeScript
91
114
  if (!user.password) user.password = ''
@@ -254,6 +277,8 @@
254
277
  <p class="tw:text-red-600">{message}</p>
255
278
  {/if}
256
279
 
280
+ <Turnstile bind:this={turnstile} bind:token={turnstileToken} />
281
+
257
282
  <button type="submit" class="btn-primary" disabled={loading}>
258
283
  {loading ? 'Creating account...' : 'Register'}
259
284
  </button>
@@ -1,6 +1,15 @@
1
1
  import { redirect } from '@sveltejs/kit'
2
2
  import type { PageServerLoad } from './$types'
3
3
 
4
+ /**
5
+ * Page server load function for the teachers route.
6
+ *
7
+ * Restricts access to users with the `teacher` or `admin` role. Unauthenticated
8
+ * or unauthorized users are redirected to the login page with a `referrer`
9
+ * parameter so they are returned here after logging in.
10
+ *
11
+ * @returns An object with a placeholder `message` for teacher/admin-only server content.
12
+ */
4
13
  export const load: PageServerLoad = async ({ locals }) => {
5
14
  const { user } = locals
6
15
  const authorized = ['admin', 'teacher']
@@ -7,7 +7,7 @@ import { build, files, version } from '$service-worker'
7
7
 
8
8
  const sw = self as unknown as ServiceWorkerGlobalScope
9
9
 
10
- // Create a unique cache name for this deployment
10
+ /** Unique cache name for this deployment, keyed by the SvelteKit build version. */
11
11
  const CACHE = `cache-${version}`
12
12
 
13
13
  const ASSETS = [
@@ -15,6 +15,10 @@ const ASSETS = [
15
15
  ...files // everything in `static`
16
16
  ]
17
17
 
18
+ /**
19
+ * On install, opens the versioned cache and pre-caches all build artifacts
20
+ * and static files so they are available offline.
21
+ */
18
22
  sw.addEventListener('install', event => {
19
23
  // Create a new cache and add all files to it
20
24
  async function addFilesToCache() {
@@ -25,6 +29,10 @@ sw.addEventListener('install', event => {
25
29
  event.waitUntil(addFilesToCache())
26
30
  })
27
31
 
32
+ /**
33
+ * On activate, removes all caches from previous deployments, keeping only
34
+ * the cache for the current build version.
35
+ */
28
36
  sw.addEventListener('activate', event => {
29
37
  // Remove previous cached data from disk
30
38
  async function deleteOldCaches() {
@@ -36,6 +44,14 @@ sw.addEventListener('activate', event => {
36
44
  event.waitUntil(deleteOldCaches())
37
45
  })
38
46
 
47
+ /**
48
+ * Intercepts GET requests and applies a cache-first strategy for pre-cached
49
+ * assets, falling back to network-first (with cache fallback) for all other
50
+ * requests.
51
+ *
52
+ * API (`/api`), auth (`/auth`), and non-HTTP requests are bypassed and go
53
+ * directly to the network.
54
+ */
39
55
  sw.addEventListener('fetch', event => {
40
56
  // ignore POST requests etc
41
57
  if (event.request.method !== 'GET') return
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,13 +0,0 @@
1
- import type { MailDataRequired } from '@sendgrid/mail'
2
- import sgMail from '@sendgrid/mail'
3
- import { env } from '$env/dynamic/private'
4
-
5
- export const sendMessage = async (message: Partial<MailDataRequired>) => {
6
- const { SENDGRID_SENDER, SENDGRID_KEY } = env
7
- sgMail.setApiKey(SENDGRID_KEY)
8
- const completeMessage = <MailDataRequired>{
9
- from: SENDGRID_SENDER, // default sender can be altered
10
- ...message
11
- }
12
- await sgMail.send(completeMessage)
13
- }