sveltekit-auth-example 5.6.1 → 5.8.3
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 +33 -0
- package/README.md +24 -18
- package/db_create.sql +0 -1
- package/db_schema.sql +3 -3
- package/package.json +8 -5
- package/src/app.d.ts +125 -61
- package/src/app.html +6 -0
- package/src/hooks.server.unit.test.ts +163 -0
- package/src/lib/Turnstile.svelte +53 -0
- package/src/lib/app-state.svelte.unit.test.ts +73 -0
- package/src/lib/auth-redirect.unit.test.ts +92 -0
- package/src/lib/fetch-interceptor.unit.test.ts +99 -0
- package/src/lib/focus.unit.test.ts +91 -0
- package/src/lib/google.ts +41 -17
- package/src/lib/google.unit.test.ts +189 -0
- package/src/lib/server/brevo.ts +179 -0
- package/src/lib/server/brevo.unit.test.ts +186 -0
- package/src/lib/server/db.unit.test.ts +91 -0
- package/src/lib/server/email/mfa-code.ts +6 -6
- package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
- package/src/lib/server/email/password-reset.ts +7 -7
- package/src/lib/server/email/password-reset.unit.test.ts +70 -0
- package/src/lib/server/email/verify-email.ts +7 -7
- package/src/lib/server/email/verify-email.unit.test.ts +79 -0
- package/src/lib/server/turnstile.ts +29 -0
- package/src/lib/server/turnstile.unit.test.ts +111 -0
- package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
- package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
- package/src/routes/auth/forgot/+server.ts +9 -2
- package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
- package/src/routes/auth/google/+server.unit.test.ts +132 -0
- package/src/routes/auth/login/+server.ts +8 -3
- package/src/routes/auth/login/+server.unit.test.ts +221 -0
- package/src/routes/auth/logout/+server.unit.test.ts +85 -0
- package/src/routes/auth/mfa/+server.ts +8 -3
- package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
- package/src/routes/auth/register/+server.ts +14 -3
- package/src/routes/auth/register/+server.unit.test.ts +182 -0
- package/src/routes/auth/reset/+server.ts +8 -2
- package/src/routes/auth/reset/+server.unit.test.ts +139 -0
- package/src/routes/auth/reset/[token]/+page.svelte +11 -1
- package/src/routes/auth/verify/[token]/+server.ts +2 -2
- package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
- package/src/routes/forgot/+page.svelte +9 -1
- package/src/routes/login/+page.svelte +28 -5
- package/src/routes/register/+page.svelte +11 -1
- package/src/service-worker.unit.test.ts +228 -0
- package/svelte.config.js +4 -2
- package/vite.config.ts +5 -10
- package/src/lib/server/sendgrid.ts +0 -22
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('$env/dynamic/private', () => ({ env: { JWT_SECRET: 'test-secret' } }))
|
|
4
|
+
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
5
|
+
vi.mock('$lib/server/email', () => ({ sendMfaCodeEmail: vi.fn() }))
|
|
6
|
+
vi.mock('$lib/server/turnstile', () => ({ verifyTurnstileToken: vi.fn() }))
|
|
7
|
+
|
|
8
|
+
import { POST } from './+server'
|
|
9
|
+
import { query } from '$lib/server/db'
|
|
10
|
+
import { sendMfaCodeEmail } from '$lib/server/email'
|
|
11
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
12
|
+
import jwt from 'jsonwebtoken'
|
|
13
|
+
|
|
14
|
+
const mockQuery = vi.mocked(query)
|
|
15
|
+
const mockSendMfaCodeEmail = vi.mocked(sendMfaCodeEmail)
|
|
16
|
+
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
17
|
+
|
|
18
|
+
const mockUser: User = { id: 7, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
|
|
19
|
+
|
|
20
|
+
const successResult: AuthenticationResult = {
|
|
21
|
+
user: mockUser,
|
|
22
|
+
sessionId: 'sess-123',
|
|
23
|
+
status: 'Login successful.',
|
|
24
|
+
statusCode: 200
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const failResult: AuthenticationResult = {
|
|
28
|
+
user: null,
|
|
29
|
+
sessionId: '',
|
|
30
|
+
status: 'Invalid credentials.',
|
|
31
|
+
statusCode: 401
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeEvent({
|
|
35
|
+
body = { email: 'user@example.com', password: 'Password1!', turnstileToken: 'tok' } as Record<string, unknown>,
|
|
36
|
+
mfaTrustedCookie = undefined as string | undefined
|
|
37
|
+
} = {}) {
|
|
38
|
+
return {
|
|
39
|
+
request: {
|
|
40
|
+
json: vi.fn().mockResolvedValue(body),
|
|
41
|
+
headers: { get: vi.fn().mockReturnValue(null) }
|
|
42
|
+
},
|
|
43
|
+
cookies: {
|
|
44
|
+
get: vi.fn().mockReturnValue(mfaTrustedCookie),
|
|
45
|
+
set: vi.fn(),
|
|
46
|
+
delete: vi.fn()
|
|
47
|
+
},
|
|
48
|
+
locals: {} as App.Locals,
|
|
49
|
+
getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
|
|
50
|
+
} as unknown as Parameters<typeof POST>[0]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks()
|
|
55
|
+
mockVerifyTurnstileToken.mockResolvedValue(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ── MFA flow ─────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('POST /auth/login — MFA flow', () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
mockQuery
|
|
63
|
+
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any) // authenticate
|
|
64
|
+
.mockResolvedValueOnce({ rows: [] } as any) // delete_session
|
|
65
|
+
.mockResolvedValueOnce({ rows: [{ code: '123456' }] } as any) // create_mfa_code
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns { mfaRequired: true } when no trusted cookie is present', async () => {
|
|
69
|
+
const res = await POST(makeEvent())
|
|
70
|
+
|
|
71
|
+
expect(res.status).toBe(200)
|
|
72
|
+
const body = await res.json()
|
|
73
|
+
expect(body).toEqual({ mfaRequired: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('emails the MFA code to the user', async () => {
|
|
77
|
+
await POST(makeEvent())
|
|
78
|
+
|
|
79
|
+
expect(mockSendMfaCodeEmail).toHaveBeenCalledWith('user@example.com', '123456')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('deletes the pre-created session before sending the MFA code', async () => {
|
|
83
|
+
await POST(makeEvent())
|
|
84
|
+
|
|
85
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_session'), [mockUser.id])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not set a session cookie', async () => {
|
|
89
|
+
const event = makeEvent()
|
|
90
|
+
await POST(event)
|
|
91
|
+
expect(event.cookies.set).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ── MFA trusted device ───────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('POST /auth/login — MFA trusted device', () => {
|
|
98
|
+
it('skips MFA and returns { message, user } when trusted cookie is valid', async () => {
|
|
99
|
+
const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
|
|
100
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
101
|
+
|
|
102
|
+
const res = await POST(makeEvent({ mfaTrustedCookie: trustedToken }))
|
|
103
|
+
|
|
104
|
+
expect(res.status).toBe(200)
|
|
105
|
+
const body = await res.json()
|
|
106
|
+
expect(body.user).toEqual(mockUser)
|
|
107
|
+
expect(body.mfaRequired).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('sets a session cookie when the trusted cookie is valid', async () => {
|
|
111
|
+
const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
|
|
112
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
113
|
+
const event = makeEvent({ mfaTrustedCookie: trustedToken })
|
|
114
|
+
|
|
115
|
+
await POST(event)
|
|
116
|
+
|
|
117
|
+
expect(event.cookies.set).toHaveBeenCalledWith('session', 'sess-123', expect.objectContaining({
|
|
118
|
+
httpOnly: true,
|
|
119
|
+
sameSite: 'lax',
|
|
120
|
+
secure: true,
|
|
121
|
+
path: '/'
|
|
122
|
+
}))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('sets event.locals.user when the trusted cookie is valid', async () => {
|
|
126
|
+
const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
|
|
127
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
128
|
+
const event = makeEvent({ mfaTrustedCookie: trustedToken })
|
|
129
|
+
|
|
130
|
+
await POST(event)
|
|
131
|
+
|
|
132
|
+
expect(event.locals.user).toEqual(mockUser)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('falls through to MFA when the trusted cookie has the wrong purpose', async () => {
|
|
136
|
+
const badToken = jwt.sign({ userId: mockUser.id, purpose: 'other' }, 'test-secret')
|
|
137
|
+
mockQuery
|
|
138
|
+
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
139
|
+
.mockResolvedValueOnce({ rows: [] } as any)
|
|
140
|
+
.mockResolvedValueOnce({ rows: [{ code: '654321' }] } as any)
|
|
141
|
+
|
|
142
|
+
const res = await POST(makeEvent({ mfaTrustedCookie: badToken }))
|
|
143
|
+
|
|
144
|
+
expect((await res.json()).mfaRequired).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('falls through to MFA and deletes cookie when trusted token is expired', async () => {
|
|
148
|
+
const expiredToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret', { expiresIn: -1 })
|
|
149
|
+
mockQuery
|
|
150
|
+
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
151
|
+
.mockResolvedValueOnce({ rows: [] } as any)
|
|
152
|
+
.mockResolvedValueOnce({ rows: [{ code: '654321' }] } as any)
|
|
153
|
+
const event = makeEvent({ mfaTrustedCookie: expiredToken })
|
|
154
|
+
|
|
155
|
+
await POST(event)
|
|
156
|
+
|
|
157
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('mfa_trusted', { path: '/' })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ── Credential failures ───────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('POST /auth/login — credential failures', () => {
|
|
164
|
+
it('throws the DB status code on invalid credentials', async () => {
|
|
165
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: failResult }] } as any)
|
|
166
|
+
|
|
167
|
+
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('throws 503 when the database is unreachable', async () => {
|
|
171
|
+
mockQuery.mockRejectedValueOnce(new Error('db down'))
|
|
172
|
+
|
|
173
|
+
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 503 })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('throws 400 when the request body is invalid JSON', async () => {
|
|
177
|
+
const event = makeEvent()
|
|
178
|
+
vi.mocked(event.request.json).mockRejectedValue(new SyntaxError('Unexpected token'))
|
|
179
|
+
|
|
180
|
+
await expect(POST(event)).rejects.toMatchObject({ status: 400 })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('throws 400 when Turnstile verification fails', async () => {
|
|
184
|
+
mockVerifyTurnstileToken.mockResolvedValue(false)
|
|
185
|
+
|
|
186
|
+
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ── Brute-force lockout ───────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe('POST /auth/login — brute-force lockout', () => {
|
|
193
|
+
it('throws 429 after 5 failed attempts', async () => {
|
|
194
|
+
mockQuery.mockResolvedValue({ rows: [{ authenticationResult: failResult }] } as any)
|
|
195
|
+
|
|
196
|
+
// 5 failures to trigger lockout
|
|
197
|
+
for (let i = 0; i < 5; i++) {
|
|
198
|
+
await POST(makeEvent({ body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' } })).catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
POST(makeEvent({ body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' } }))
|
|
203
|
+
).rejects.toMatchObject({ status: 429 })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('clears the lockout tracker on successful login', async () => {
|
|
207
|
+
// Register a failed attempt first
|
|
208
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: failResult }] } as any)
|
|
209
|
+
await POST(makeEvent({ body: { email: 'clear@example.com', password: 'wrong', turnstileToken: 'tok' } })).catch(() => {})
|
|
210
|
+
|
|
211
|
+
// Then succeed — mfa flow needs 3 query responses
|
|
212
|
+
mockQuery
|
|
213
|
+
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
214
|
+
.mockResolvedValueOnce({ rows: [] } as any)
|
|
215
|
+
.mockResolvedValueOnce({ rows: [{ code: '000000' }] } as any)
|
|
216
|
+
|
|
217
|
+
// Should not throw 429
|
|
218
|
+
const res = await POST(makeEvent({ body: { email: 'clear@example.com', password: 'Password1!', turnstileToken: 'tok' } }))
|
|
219
|
+
expect((await res.json()).mfaRequired).toBe(true)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
4
|
+
|
|
5
|
+
import { POST } from './+server'
|
|
6
|
+
import { query } from '$lib/server/db'
|
|
7
|
+
|
|
8
|
+
const mockQuery = vi.mocked(query)
|
|
9
|
+
|
|
10
|
+
const mockUser: User = { id: 3, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
|
|
11
|
+
|
|
12
|
+
function makeEvent({ noUser = false } = {}) {
|
|
13
|
+
return {
|
|
14
|
+
locals: { user: noUser ? undefined : mockUser },
|
|
15
|
+
cookies: { delete: vi.fn() }
|
|
16
|
+
} as unknown as Parameters<typeof POST>[0]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('POST /auth/logout', () => {
|
|
24
|
+
it('returns 200 with a logout message', async () => {
|
|
25
|
+
mockQuery.mockResolvedValue({ rows: [] } as any)
|
|
26
|
+
const res = await POST(makeEvent())
|
|
27
|
+
|
|
28
|
+
expect(res.status).toBe(200)
|
|
29
|
+
const body = await res.json()
|
|
30
|
+
expect(body.message).toBe('Logout successful.')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('deletes the session cookie', async () => {
|
|
34
|
+
mockQuery.mockResolvedValue({ rows: [] } as any)
|
|
35
|
+
const event = makeEvent()
|
|
36
|
+
|
|
37
|
+
await POST(event)
|
|
38
|
+
|
|
39
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('calls delete_session with the user id when authenticated', async () => {
|
|
43
|
+
mockQuery.mockResolvedValue({ rows: [] } as any)
|
|
44
|
+
|
|
45
|
+
await POST(makeEvent())
|
|
46
|
+
|
|
47
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
48
|
+
expect.stringContaining('delete_session'),
|
|
49
|
+
[mockUser.id]
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('skips the DB call when no user is authenticated', async () => {
|
|
54
|
+
const event = makeEvent({ noUser: true })
|
|
55
|
+
|
|
56
|
+
await POST(event)
|
|
57
|
+
|
|
58
|
+
expect(mockQuery).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('still deletes the cookie when no user is authenticated', async () => {
|
|
62
|
+
const event = makeEvent({ noUser: true })
|
|
63
|
+
|
|
64
|
+
await POST(event)
|
|
65
|
+
|
|
66
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('still deletes the cookie when the DB call fails', async () => {
|
|
70
|
+
mockQuery.mockRejectedValue(new Error('db down'))
|
|
71
|
+
const event = makeEvent()
|
|
72
|
+
|
|
73
|
+
await POST(event)
|
|
74
|
+
|
|
75
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('still returns 200 when the DB call fails', async () => {
|
|
79
|
+
mockQuery.mockRejectedValue(new Error('db down'))
|
|
80
|
+
|
|
81
|
+
const res = await POST(makeEvent())
|
|
82
|
+
|
|
83
|
+
expect(res.status).toBe(200)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -2,8 +2,9 @@ import { error, json } from '@sveltejs/kit'
|
|
|
2
2
|
import type { RequestHandler } from './$types'
|
|
3
3
|
import type { Secret } from 'jsonwebtoken'
|
|
4
4
|
import jwt from 'jsonwebtoken'
|
|
5
|
-
import {
|
|
5
|
+
import { env } from '$env/dynamic/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(),
|
|
@@ -60,7 +65,7 @@ export const POST: RequestHandler = async event => {
|
|
|
60
65
|
})
|
|
61
66
|
|
|
62
67
|
// Issue a 30-day trusted-device cookie so MFA is not required again on this device
|
|
63
|
-
const trustedToken = jwt.sign({ userId, purpose: 'mfa-trusted' }, JWT_SECRET as Secret, {
|
|
68
|
+
const trustedToken = jwt.sign({ userId, purpose: 'mfa-trusted' }, env.JWT_SECRET as Secret, {
|
|
64
69
|
expiresIn: '30d'
|
|
65
70
|
})
|
|
66
71
|
cookies.set(MFA_TRUSTED_COOKIE, trustedToken, {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('$env/dynamic/private', () => ({ env: { JWT_SECRET: 'test-secret' } }))
|
|
4
|
+
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
5
|
+
vi.mock('$lib/server/turnstile', () => ({ verifyTurnstileToken: vi.fn() }))
|
|
6
|
+
|
|
7
|
+
import { POST } from './+server'
|
|
8
|
+
import { query } from '$lib/server/db'
|
|
9
|
+
import { verifyTurnstileToken } from '$lib/server/turnstile'
|
|
10
|
+
import jwt from 'jsonwebtoken'
|
|
11
|
+
|
|
12
|
+
const mockQuery = vi.mocked(query)
|
|
13
|
+
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
14
|
+
|
|
15
|
+
const mockUser: User = { id: 5, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
|
|
16
|
+
|
|
17
|
+
function makeEvent(body: Record<string, unknown> = { email: 'user@example.com', code: '123456', turnstileToken: 'tok' }) {
|
|
18
|
+
return {
|
|
19
|
+
request: {
|
|
20
|
+
json: vi.fn().mockResolvedValue(body),
|
|
21
|
+
headers: { get: vi.fn().mockReturnValue(null) }
|
|
22
|
+
},
|
|
23
|
+
cookies: { set: vi.fn() },
|
|
24
|
+
getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
|
|
25
|
+
} as unknown as Parameters<typeof POST>[0]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Set up the three DB calls needed for a successful MFA verification. */
|
|
29
|
+
function setupSuccessQueries() {
|
|
30
|
+
mockQuery
|
|
31
|
+
.mockResolvedValueOnce({ rows: [{ userId: mockUser.id }] } as any) // verify_mfa_code
|
|
32
|
+
.mockResolvedValueOnce({ rows: [{ sessionId: 'sess-xyz' }] } as any) // create_session
|
|
33
|
+
.mockResolvedValueOnce({ rows: [{ get_session: mockUser }] } as any) // get_session
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks()
|
|
38
|
+
mockVerifyTurnstileToken.mockResolvedValue(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('POST /auth/mfa', () => {
|
|
42
|
+
it('returns 200 with message and user on success', async () => {
|
|
43
|
+
setupSuccessQueries()
|
|
44
|
+
|
|
45
|
+
const res = await POST(makeEvent())
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(200)
|
|
48
|
+
const body = await res.json()
|
|
49
|
+
expect(body.message).toBe('Login successful.')
|
|
50
|
+
expect(body.user).toEqual(mockUser)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('sets an httpOnly session cookie on success', async () => {
|
|
54
|
+
setupSuccessQueries()
|
|
55
|
+
const event = makeEvent()
|
|
56
|
+
|
|
57
|
+
await POST(event)
|
|
58
|
+
|
|
59
|
+
expect(event.cookies.set).toHaveBeenCalledWith('session', 'sess-xyz', expect.objectContaining({
|
|
60
|
+
httpOnly: true,
|
|
61
|
+
sameSite: 'lax',
|
|
62
|
+
secure: true,
|
|
63
|
+
path: '/'
|
|
64
|
+
}))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('sets an mfa_trusted cookie on success', async () => {
|
|
68
|
+
setupSuccessQueries()
|
|
69
|
+
const event = makeEvent()
|
|
70
|
+
|
|
71
|
+
await POST(event)
|
|
72
|
+
|
|
73
|
+
expect(event.cookies.set).toHaveBeenCalledWith('mfa_trusted', expect.any(String), expect.objectContaining({
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
sameSite: 'lax',
|
|
76
|
+
secure: true,
|
|
77
|
+
path: '/',
|
|
78
|
+
maxAge: 30 * 24 * 60 * 60
|
|
79
|
+
}))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('mfa_trusted cookie is a valid JWT with correct payload', async () => {
|
|
83
|
+
setupSuccessQueries()
|
|
84
|
+
const event = makeEvent()
|
|
85
|
+
|
|
86
|
+
await POST(event)
|
|
87
|
+
|
|
88
|
+
const [, trustedToken] = vi.mocked(event.cookies.set).mock.calls.find(([name]) => name === 'mfa_trusted')!
|
|
89
|
+
const payload = jwt.verify(trustedToken as string, 'test-secret') as Record<string, unknown>
|
|
90
|
+
expect(payload.userId).toBe(mockUser.id)
|
|
91
|
+
expect(payload.purpose).toBe('mfa-trusted')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('verifies the MFA code with the lowercased email', async () => {
|
|
95
|
+
setupSuccessQueries()
|
|
96
|
+
|
|
97
|
+
await POST(makeEvent({ email: 'User@Example.COM', code: '123456', turnstileToken: 'tok' }))
|
|
98
|
+
|
|
99
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
100
|
+
expect.stringContaining('verify_mfa_code'),
|
|
101
|
+
['user@example.com', '123456']
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('throws 401 when the MFA code is invalid or expired', async () => {
|
|
106
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ userId: null }] } as any)
|
|
107
|
+
|
|
108
|
+
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('throws 400 when email is missing', async () => {
|
|
112
|
+
await expect(
|
|
113
|
+
POST(makeEvent({ code: '123456', turnstileToken: 'tok' }))
|
|
114
|
+
).rejects.toMatchObject({ status: 400 })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('throws 400 when code is missing', async () => {
|
|
118
|
+
await expect(
|
|
119
|
+
POST(makeEvent({ email: 'user@example.com', turnstileToken: 'tok' }))
|
|
120
|
+
).rejects.toMatchObject({ status: 400 })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('throws 400 when the request body is invalid JSON', async () => {
|
|
124
|
+
const event = makeEvent()
|
|
125
|
+
vi.mocked(event.request.json).mockRejectedValue(new SyntaxError('Unexpected token'))
|
|
126
|
+
|
|
127
|
+
await expect(POST(event)).rejects.toMatchObject({ status: 400 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('throws 400 when Turnstile verification fails', async () => {
|
|
131
|
+
mockVerifyTurnstileToken.mockResolvedValue(false)
|
|
132
|
+
|
|
133
|
+
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('uses CF-Connecting-IP header when present', async () => {
|
|
137
|
+
setupSuccessQueries()
|
|
138
|
+
const event = makeEvent()
|
|
139
|
+
vi.mocked(event.request.headers.get).mockReturnValue('9.9.9.9')
|
|
140
|
+
|
|
141
|
+
await POST(event)
|
|
142
|
+
|
|
143
|
+
expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '9.9.9.9')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('falls back to getClientAddress when CF-Connecting-IP is absent', async () => {
|
|
147
|
+
setupSuccessQueries()
|
|
148
|
+
|
|
149
|
+
await POST(makeEvent())
|
|
150
|
+
|
|
151
|
+
expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '127.0.0.1')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { error, json } from '@sveltejs/kit'
|
|
2
2
|
import type { RequestHandler } from './$types'
|
|
3
3
|
import jwt from 'jsonwebtoken'
|
|
4
|
-
import {
|
|
4
|
+
import { env } from '$env/dynamic/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,23 @@ 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: {
|
|
27
|
+
let body: {
|
|
28
|
+
email?: string
|
|
29
|
+
password?: string
|
|
30
|
+
firstName?: string
|
|
31
|
+
lastName?: string
|
|
32
|
+
turnstileToken?: string
|
|
33
|
+
}
|
|
27
34
|
try {
|
|
28
35
|
body = await event.request.json()
|
|
29
36
|
} catch {
|
|
30
37
|
error(400, 'Invalid request body.')
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
const ip = event.request.headers.get('CF-Connecting-IP') ?? event.getClientAddress()
|
|
41
|
+
const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '', ip)
|
|
42
|
+
if (!turnstileOk) error(400, 'Security challenge failed. Please try again.')
|
|
43
|
+
|
|
33
44
|
if (!body.email || !body.password || !body.firstName || !body.lastName)
|
|
34
45
|
error(400, 'Please supply all required fields: email, password, first and last name.')
|
|
35
46
|
|
|
@@ -62,7 +73,7 @@ export const POST: RequestHandler = async event => {
|
|
|
62
73
|
|
|
63
74
|
const token = jwt.sign(
|
|
64
75
|
{ subject: authenticationResult.user.id, purpose: 'verify-email' },
|
|
65
|
-
JWT_SECRET as jwt.Secret,
|
|
76
|
+
env.JWT_SECRET as jwt.Secret,
|
|
66
77
|
{ expiresIn: '24h' }
|
|
67
78
|
)
|
|
68
79
|
|