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.
- package/.env.example +5 -3
- package/CHANGELOG.md +32 -0
- package/README.md +15 -9
- package/db_create.sql +1 -364
- package/db_schema.sql +3 -3
- package/package.json +7 -5
- package/src/app.d.ts +125 -40
- package/src/app.html +6 -0
- package/src/hooks.server.ts +37 -5
- package/src/lib/Turnstile.svelte +53 -0
- package/src/lib/app-state.svelte.ts +8 -0
- package/src/lib/auth-redirect.ts +4 -0
- package/src/lib/focus.ts +8 -0
- package/src/lib/google.ts +58 -17
- package/src/lib/server/brevo.ts +179 -0
- package/src/lib/server/db.ts +9 -0
- package/src/lib/server/email/mfa-code.ts +12 -6
- package/src/lib/server/email/password-reset.ts +12 -6
- package/src/lib/server/email/verify-email.ts +12 -6
- package/src/lib/server/turnstile.ts +29 -0
- package/src/routes/+layout.server.ts +10 -1
- package/src/routes/+layout.svelte +14 -0
- package/src/routes/admin/+page.server.ts +8 -0
- package/src/routes/api/v1/user/+server.ts +20 -0
- package/src/routes/auth/[slug]/+server.ts +9 -2
- package/src/routes/auth/forgot/+server.ts +17 -0
- package/src/routes/auth/google/+server.ts +29 -3
- package/src/routes/auth/login/+server.ts +32 -3
- package/src/routes/auth/logout/+server.ts +10 -0
- package/src/routes/auth/mfa/+server.ts +21 -1
- package/src/routes/auth/register/+server.ts +23 -1
- package/src/routes/auth/reset/+server.ts +21 -1
- package/src/routes/auth/reset/[token]/+page.svelte +21 -1
- package/src/routes/auth/reset/[token]/+page.ts +8 -0
- package/src/routes/auth/verify/[token]/+server.ts +12 -1
- package/src/routes/forgot/+page.svelte +17 -1
- package/src/routes/login/+page.server.ts +8 -0
- package/src/routes/login/+page.svelte +43 -5
- package/src/routes/profile/+page.server.ts +9 -0
- package/src/routes/profile/+page.svelte +16 -0
- package/src/routes/register/+page.server.ts +8 -1
- package/src/routes/register/+page.svelte +26 -1
- package/src/routes/teachers/+page.server.ts +9 -0
- package/src/service-worker.ts +17 -1
- package/svelte.config.js +4 -2
- package/vite.config.ts +8 -13
- 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 (
|
|
113
|
+
if (!/^[0-9]{6}$/.test(mfaCode)) {
|
|
86
114
|
mfaSubmitted = true
|
|
87
|
-
|
|
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']
|
package/src/service-worker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,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
|
-
}
|