sveltekit-auth-example 5.1.0 → 5.1.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.
@@ -52,7 +52,7 @@ export const POST: RequestHandler = async event => {
52
52
  // Prevent hooks.server.ts's handler() from deleting cookie thinking no one has authenticated
53
53
  event.locals.user = userSession.user
54
54
 
55
- cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', path: '/' })
55
+ cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' })
56
56
  return json({ message: 'Successful Google Sign-In.', user: userSession.user })
57
57
  } catch (err) {
58
58
  let message = ''
@@ -0,0 +1,68 @@
1
+ import { error, json } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import { query } from '$lib/server/db'
4
+
5
+ // In-memory failed-attempt tracker for account lockout (per email)
6
+ // For production use a shared store like Redis.
7
+ const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
8
+
9
+ const MAX_FAILURES = 5
10
+ const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
11
+
12
+ export const POST: RequestHandler = async event => {
13
+ const { cookies } = event
14
+
15
+ let body: { email?: string; password?: string }
16
+ try {
17
+ body = await event.request.json()
18
+ } catch {
19
+ error(400, 'Invalid request body.')
20
+ }
21
+
22
+ const email = body.email?.toLowerCase() ?? ''
23
+
24
+ // Check per-email account lockout
25
+ const attempts = failedAttempts.get(email)
26
+ if (attempts && Date.now() < attempts.lockedUntil) {
27
+ error(429, 'Account temporarily locked due to too many failed attempts. Try again later.')
28
+ }
29
+
30
+ let result
31
+ try {
32
+ const sql = `SELECT authenticate($1) AS "authenticationResult";`
33
+ result = await query(sql, [JSON.stringify(body)])
34
+ } catch {
35
+ error(503, 'Could not communicate with database.')
36
+ }
37
+
38
+ const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
39
+
40
+ if (!authenticationResult.user) {
41
+ // Track failed attempt for lockout
42
+ if (email) {
43
+ const existing = failedAttempts.get(email)
44
+ if (existing) {
45
+ existing.count++
46
+ if (existing.count >= MAX_FAILURES) {
47
+ existing.lockedUntil = Date.now() + LOCKOUT_MS
48
+ existing.count = 0
49
+ }
50
+ } else {
51
+ failedAttempts.set(email, { count: 1, lockedUntil: 0 })
52
+ }
53
+ }
54
+ error(authenticationResult.statusCode, authenticationResult.status)
55
+ }
56
+
57
+ // Clear lockout tracker on successful login
58
+ failedAttempts.delete(email)
59
+
60
+ event.locals.user = authenticationResult.user
61
+ cookies.set('session', authenticationResult.sessionId, {
62
+ httpOnly: true,
63
+ sameSite: 'lax',
64
+ secure: true,
65
+ path: '/'
66
+ })
67
+ return json({ message: authenticationResult.status, user: authenticationResult.user })
68
+ }
@@ -0,0 +1,19 @@
1
+ import { json } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import { query } from '$lib/server/db'
4
+
5
+ export const POST: RequestHandler = async event => {
6
+ const { cookies } = event
7
+
8
+ if (event.locals.user) {
9
+ try {
10
+ await query(`CALL delete_session($1);`, [event.locals.user.id])
11
+ } catch (err) {
12
+ console.error('Failed to delete session from database:', err)
13
+ // Best effort — still clear the cookie below
14
+ }
15
+ }
16
+
17
+ cookies.delete('session', { path: '/' })
18
+ return json({ message: 'Logout successful.' })
19
+ }
@@ -0,0 +1,64 @@
1
+ import { error, json } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import type { MailDataRequired } from '@sendgrid/mail'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
6
+ import { query } from '$lib/server/db'
7
+ import { sendMessage } from '$lib/server/sendgrid'
8
+
9
+ export const POST: RequestHandler = async event => {
10
+ let body: { email?: string; password?: string; firstName?: string; lastName?: string }
11
+ try {
12
+ body = await event.request.json()
13
+ } catch {
14
+ error(400, 'Invalid request body.')
15
+ }
16
+
17
+ if (!body.email || !body.password || !body.firstName || !body.lastName)
18
+ error(400, 'Please supply all required fields: email, password, first and last name.')
19
+
20
+ let result
21
+ try {
22
+ const sql = `SELECT register($1) AS "authenticationResult";`
23
+ result = await query(sql, [JSON.stringify(body)])
24
+ } catch {
25
+ error(503, 'Could not communicate with database.')
26
+ }
27
+
28
+ const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
29
+
30
+ if (!authenticationResult.user)
31
+ error(authenticationResult.statusCode, authenticationResult.status)
32
+
33
+ // The DB auto-creates a session, but the user must verify email before logging in. Clean it up.
34
+ try {
35
+ await query(`CALL delete_session($1);`, [authenticationResult.sessionId])
36
+ } catch (err) {
37
+ console.error('Failed to delete pre-verification session:', err)
38
+ }
39
+
40
+ const token = jwt.sign(
41
+ { subject: authenticationResult.user.id, purpose: 'verify-email' },
42
+ JWT_SECRET as jwt.Secret,
43
+ { expiresIn: '24h' }
44
+ )
45
+
46
+ const message: MailDataRequired = {
47
+ to: { email: body.email },
48
+ from: SENDGRID_SENDER,
49
+ subject: 'Verify your email address',
50
+ categories: ['account'],
51
+ html: `
52
+ <p>Thanks for registering! Please verify your email address by clicking the link below:</p>
53
+ <p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
54
+ <p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
55
+ `
56
+ }
57
+
58
+ await sendMessage(message)
59
+
60
+ return json({
61
+ message: 'Registration successful. Please check your email to verify your account.',
62
+ emailVerification: true
63
+ })
64
+ }
@@ -18,6 +18,13 @@ export const PUT: RequestHandler = async event => {
18
18
  const sql = `CALL reset_password($1, $2);`
19
19
  await query(sql, [userId, password])
20
20
 
21
+ // Invalidate all existing sessions so the old password can no longer be used
22
+ try {
23
+ await query(`CALL delete_session($1);`, [userId])
24
+ } catch (err) {
25
+ console.error('Failed to invalidate sessions after password reset:', err)
26
+ }
27
+
21
28
  return json({
22
29
  message: 'Password successfully reset.'
23
30
  })
@@ -2,7 +2,7 @@
2
2
  import type { PageData } from './$types'
3
3
  import { onMount } from 'svelte'
4
4
  import { goto } from '$app/navigation'
5
- import { toast } from '../../../../stores'
5
+ import { appState } from '$lib/app-state.svelte'
6
6
  import { focusOnFirstError } from '$lib/focus'
7
7
 
8
8
  interface Props {
@@ -12,13 +12,17 @@
12
12
  let { data }: Props = $props()
13
13
 
14
14
  let focusedField: HTMLInputElement | undefined = $state()
15
+ let formEl: HTMLFormElement | undefined = $state()
15
16
  let password = $state('')
16
17
  let confirmPassword: HTMLInputElement | undefined = $state()
17
18
  let message = $state('')
18
19
  let submitted = $state(false)
19
20
  let passwordMismatch = $state(false)
21
+ let loading = $state(false)
20
22
 
21
23
  onMount(() => {
24
+ // Remove the token from the URL to prevent it appearing in logs and Referer headers
25
+ history.replaceState('', document.title, '/auth/reset')
22
26
  focusedField?.focus()
23
27
  })
24
28
 
@@ -31,7 +35,7 @@
31
35
  message = ''
32
36
  submitted = false
33
37
  passwordMismatch = false
34
- const form = document.getElementById('reset') as HTMLFormElement
38
+ const form = formEl!
35
39
 
36
40
  if (!passwordMatch()) {
37
41
  passwordMismatch = true
@@ -39,30 +43,35 @@
39
43
  }
40
44
 
41
45
  if (form.checkValidity()) {
42
- const url = `/auth/reset`
43
- const res = await fetch(url, {
44
- method: 'PUT',
45
- headers: {
46
- 'Content-Type': 'application/json'
47
- },
48
- body: JSON.stringify({
49
- token: data.token,
50
- password
46
+ loading = true
47
+ try {
48
+ const url = `/auth/reset`
49
+ const res = await fetch(url, {
50
+ method: 'PUT',
51
+ headers: {
52
+ 'Content-Type': 'application/json'
53
+ },
54
+ body: JSON.stringify({
55
+ token: data.token,
56
+ password
57
+ })
51
58
  })
52
- })
53
59
 
54
- if (res.ok) {
55
- $toast = {
56
- title: 'Password Reset Succesful',
57
- body: 'Your password was reset. Please login.',
58
- isOpen: true
60
+ if (res.ok) {
61
+ appState.toast = {
62
+ title: 'Password Reset Successful',
63
+ body: 'Your password was reset. Please login.',
64
+ isOpen: true
65
+ }
66
+
67
+ goto('/login')
68
+ } else {
69
+ const body = await res.json()
70
+ console.log('Failed reset', body)
71
+ message = body.message
59
72
  }
60
-
61
- goto('/login')
62
- } else {
63
- const body = await res.json()
64
- console.log('Failed reset', body)
65
- message = body.message
73
+ } finally {
74
+ loading = false
66
75
  }
67
76
  } else {
68
77
  submitted = true
@@ -76,19 +85,20 @@
76
85
  </svelte:head>
77
86
 
78
87
  <form
79
- id="reset"
88
+ bind:this={formEl}
80
89
  autocomplete="on"
81
90
  novalidate
82
91
  class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
83
92
  class:submitted
93
+ onsubmit={(e) => { e.preventDefault(); resetPassword() }}
84
94
  >
85
- <h4><strong>New Password</strong></h4>
95
+ <h4>New Password</h4>
86
96
  <p>Please provide a new password.</p>
87
97
 
88
98
  <label class="tw:block tw:text-sm tw:font-medium" for="password">
89
99
  Password
90
100
  <input
91
- class="tw:peer tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500 tw:[.submitted_&]:invalid:border-red-500"
101
+ class="form-input-validated"
92
102
  id="password"
93
103
  type="password"
94
104
  bind:value={password}
@@ -99,7 +109,7 @@
99
109
  placeholder="Password"
100
110
  autocomplete="new-password"
101
111
  />
102
- <span class="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Password with 8 chars or more required</span>
112
+ <span class="form-error">Password with 8 chars or more required</span>
103
113
  <span class="tw:text-xs tw:text-gray-500">
104
114
  Minimum 8 characters, one capital letter, one number, one special character.
105
115
  </span>
@@ -108,7 +118,7 @@
108
118
  <label class="tw:block tw:text-sm tw:font-medium" for="passwordConfirm">
109
119
  Password (retype)
110
120
  <input
111
- class="tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500"
121
+ class="form-input"
112
122
  class:tw:border-red-500={passwordMismatch}
113
123
  id="passwordConfirm"
114
124
  type="password"
@@ -129,11 +139,11 @@
129
139
  {/if}
130
140
 
131
141
  <button
132
- onclick={resetPassword}
133
- type="button"
134
- class="tw:w-full tw:rounded tw:bg-blue-600 tw:px-4 tw:py-2 tw:font-semibold tw:text-white hover:tw:bg-blue-700"
142
+ type="submit"
143
+ class="btn-primary"
144
+ disabled={loading}
135
145
  >
136
- Reset Password
146
+ {loading ? 'Resetting...' : 'Reset Password'}
137
147
  </button>
138
148
  </form>
139
149
 
@@ -0,0 +1,48 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import type { JwtPayload } from 'jsonwebtoken'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET } from '$env/static/private'
6
+ import { query } from '$lib/server/db'
7
+
8
+ // Handles email verification links sent after registration
9
+ export const GET: RequestHandler = async event => {
10
+ const { token } = event.params
11
+ const { cookies } = event
12
+
13
+ // Verify the JWT — extract userId only if token is valid and correct purpose
14
+ let userId: string | undefined
15
+ try {
16
+ const decoded = jwt.verify(token, JWT_SECRET as jwt.Secret) as JwtPayload
17
+ if (decoded.purpose === 'verify-email' && decoded.subject) {
18
+ userId = decoded.subject
19
+ }
20
+ } catch {
21
+ // Expired or tampered token — fall through to redirect below
22
+ }
23
+
24
+ if (!userId) redirect(302, '/login?error=invalid-token')
25
+
26
+ // Mark email as verified and create a session atomically
27
+ let sessionId: string | undefined
28
+ try {
29
+ const { rows } = await query<{ verify_email_and_create_session: string }>(
30
+ `SELECT verify_email_and_create_session($1)`,
31
+ [userId]
32
+ )
33
+ sessionId = rows[0]?.verify_email_and_create_session
34
+ } catch (err) {
35
+ console.error('Email verification DB error:', err)
36
+ }
37
+
38
+ if (!sessionId) redirect(302, '/login?error=verification-failed')
39
+
40
+ cookies.set('session', sessionId, {
41
+ httpOnly: true,
42
+ sameSite: 'lax',
43
+ secure: true,
44
+ path: '/'
45
+ })
46
+
47
+ redirect(302, '/')
48
+ }
@@ -1,13 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte'
3
3
  import { goto } from '$app/navigation'
4
- import { toast } from '../../stores'
4
+ import { appState } from '$lib/app-state.svelte'
5
5
  import { focusOnFirstError } from '$lib/focus'
6
6
 
7
7
  let focusedField: HTMLInputElement | undefined = $state()
8
+ let formEl: HTMLFormElement | undefined = $state()
8
9
  let email: string = $state('')
9
10
  let message: string = $state('')
10
11
  let submitted = $state(false)
12
+ let loading = $state(false)
11
13
 
12
14
  onMount(() => {
13
15
  focusedField?.focus()
@@ -15,28 +17,33 @@
15
17
 
16
18
  const sendPasswordReset = async () => {
17
19
  message = ''
18
- const form = document.getElementById('forgot') as HTMLFormElement
20
+ const form = formEl!
19
21
 
20
22
  if (form.checkValidity()) {
21
23
  if (email.toLowerCase().includes('gmail.com')) {
22
24
  return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
23
25
  }
24
- const url = `/auth/forgot`
25
- const res = await fetch(url, {
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': 'application/json'
29
- },
30
- body: JSON.stringify({ email })
31
- })
26
+ loading = true
27
+ try {
28
+ const url = `/auth/forgot`
29
+ const res = await fetch(url, {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/json'
33
+ },
34
+ body: JSON.stringify({ email })
35
+ })
32
36
 
33
- if (res.ok) {
34
- $toast = {
35
- title: 'Password Reset',
36
- body: 'Please check your inbox for a password reset email (junk mail, too).',
37
- isOpen: true
37
+ if (res.ok) {
38
+ appState.toast = {
39
+ title: 'Password Reset',
40
+ body: 'Please check your inbox for a password reset email (junk mail, too).',
41
+ isOpen: true
42
+ }
43
+ return goto('/')
38
44
  }
39
- return goto('/')
45
+ } finally {
46
+ loading = false
40
47
  }
41
48
  } else {
42
49
  submitted = true
@@ -50,13 +57,14 @@
50
57
  </svelte:head>
51
58
 
52
59
  <form
53
- id="forgot"
60
+ bind:this={formEl}
54
61
  autocomplete="on"
55
62
  novalidate
56
63
  class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
57
64
  class:submitted
65
+ onsubmit={(e) => { e.preventDefault(); sendPasswordReset() }}
58
66
  >
59
- <h4><strong>Forgot password</strong></h4>
67
+ <h4>Forgot password</h4>
60
68
  <p>Hey, you're human. We get it.</p>
61
69
 
62
70
  <label class="tw:block tw:text-sm tw:font-medium" for="email">
@@ -66,12 +74,12 @@
66
74
  bind:value={email}
67
75
  type="email"
68
76
  id="email"
69
- class="tw:peer tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500 tw:[.submitted_&]:invalid:border-red-500"
77
+ class="form-input-validated"
70
78
  required
71
79
  placeholder="Email"
72
80
  autocomplete="email"
73
81
  />
74
- <span class="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Email address required</span>
82
+ <span class="form-error">Email address required</span>
75
83
  </label>
76
84
 
77
85
  {#if message}
@@ -79,11 +87,11 @@
79
87
  {/if}
80
88
 
81
89
  <button
82
- onclick={sendPasswordReset}
83
- type="button"
84
- class="tw:w-full tw:rounded tw:bg-blue-600 tw:px-4 tw:py-2 tw:font-semibold tw:text-white hover:tw:bg-blue-700"
90
+ type="submit"
91
+ class="btn-primary"
92
+ disabled={loading}
85
93
  >
86
- Send Email
94
+ {loading ? 'Sending...' : 'Send Email'}
87
95
  </button>
88
96
  </form>
89
97
 
@@ -12,6 +12,56 @@
12
12
 
13
13
  html,
14
14
  :host {
15
- @apply tw:font-sans tw:text-gray-800;
15
+ @apply tw:font-sans tw:text-gray-800 tw:bg-white;
16
+ @variant dark {
17
+ @apply tw:text-gray-100 tw:bg-gray-900;
18
+ }
19
+ }
20
+ }
21
+
22
+ @layer components {
23
+ .form-input,
24
+ .form-input-validated {
25
+ display: block;
26
+ width: 100%;
27
+ margin-top: 0.25rem;
28
+ appearance: none;
29
+ -webkit-appearance: none;
30
+ @apply tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-gray-900 tw:dark:bg-gray-800 tw:dark:border-gray-600 tw:dark:text-gray-200;
31
+ padding: 0.375rem 0.75rem;
32
+ font-size: var(--text-sm, 0.875rem);
33
+ line-height: var(--tw-leading-sm, 1.25rem);
34
+ }
35
+ .form-input:focus,
36
+ .form-input-validated:focus {
37
+ outline: none;
38
+ box-shadow: 0 0 0 2px var(--color-blue-500, #3b82f6);
39
+ }
40
+ /* peer class is applied at the HTML element level and cannot be set via CSS */
41
+ .submitted .form-input-validated:invalid {
42
+ border-color: var(--color-red-500, #ef4444);
43
+ }
44
+ .form-error {
45
+ display: none;
46
+ font-size: var(--text-xs, 0.75rem);
47
+ color: var(--color-red-600, #dc2626);
48
+ margin-top: 0.125rem;
49
+ }
50
+ .submitted .form-input-validated:invalid ~ .form-error {
51
+ display: block;
52
+ }
53
+ .btn-primary {
54
+ display: block;
55
+ width: 100%;
56
+ border-radius: var(--radius, 0.25rem);
57
+ background-color: var(--color-blue-600, #2563eb);
58
+ padding: 0.5rem 1rem;
59
+ font-weight: 600;
60
+ color: white;
61
+ border: none;
62
+ cursor: pointer;
63
+ }
64
+ .btn-primary:hover {
65
+ background-color: var(--color-blue-700, #1d4ed8);
16
66
  }
17
67
  }
@@ -0,0 +1,9 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import type { PageServerLoad } from './$types'
3
+
4
+ export const load: PageServerLoad = ({ locals }) => {
5
+ if (locals.user) {
6
+ redirect(302, '/')
7
+ }
8
+ return {}
9
+ }