sveltekit-auth-example 1.0.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/.eslintrc.cjs +20 -0
- package/.prettierrc +6 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/db_create.sql +307 -0
- package/package.json +59 -0
- package/src/app.html +12 -0
- package/src/global.d.ts +71 -0
- package/src/hooks.ts +43 -0
- package/src/lib/auth.ts +152 -0
- package/src/lib/config.ts +4 -0
- package/src/lib/focus.ts +10 -0
- package/src/routes/__error.svelte +16 -0
- package/src/routes/__layout.svelte +84 -0
- package/src/routes/_db.ts +17 -0
- package/src/routes/_send-in-blue.ts +29 -0
- package/src/routes/admin.svelte +40 -0
- package/src/routes/api/v1/admin.ts +21 -0
- package/src/routes/api/v1/teacher.ts +21 -0
- package/src/routes/api/v1/user.ts +36 -0
- package/src/routes/auth/[slug].ts +82 -0
- package/src/routes/auth/forgot.ts +42 -0
- package/src/routes/auth/google.ts +65 -0
- package/src/routes/auth/reset/[token].svelte +116 -0
- package/src/routes/auth/reset/index.ts +39 -0
- package/src/routes/forgot.svelte +77 -0
- package/src/routes/index.svelte +2 -0
- package/src/routes/info.svelte +6 -0
- package/src/routes/login.svelte +127 -0
- package/src/routes/profile.svelte +122 -0
- package/src/routes/register.svelte +133 -0
- package/src/routes/teachers.svelte +39 -0
- package/src/stores.ts +7 -0
- package/static/favicon.png +0 -0
- package/svelte.config.js +15 -0
- package/tsconfig.json +32 -0
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import type { Readable, Writable } from 'svelte/store'
|
|
3
|
+
import { config } from '$lib/config'
|
|
4
|
+
|
|
5
|
+
type Page = Readable<{
|
|
6
|
+
url: URL;
|
|
7
|
+
params: Record<string, string>;
|
|
8
|
+
stuff: App.Stuff;
|
|
9
|
+
status: number;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
export default function useAuth(page: Page, session: Writable<any>, goto) {
|
|
14
|
+
|
|
15
|
+
// Required to use session.set()
|
|
16
|
+
let sessionValue
|
|
17
|
+
session.subscribe(value => {
|
|
18
|
+
sessionValue = value
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
let referrer
|
|
22
|
+
page.subscribe(value => {
|
|
23
|
+
referrer = value.url.searchParams.get('referrer')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const loadScript = () => new Promise( (resolve, reject) => {
|
|
27
|
+
const script = document.createElement('script')
|
|
28
|
+
script.id = 'gsiScript'
|
|
29
|
+
script.async = true
|
|
30
|
+
script.src = 'https://accounts.google.com/gsi/client'
|
|
31
|
+
script.onerror = (error) => reject(error)
|
|
32
|
+
script.onload = () => resolve(script)
|
|
33
|
+
document.body.appendChild(script)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function googleAccountsIdInitialize() {
|
|
37
|
+
return window.google.accounts.id.initialize({
|
|
38
|
+
client_id: config.googleClientId,
|
|
39
|
+
callback: googleCallback
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function googleAccountsIdRenderButton(htmlId: string) {
|
|
44
|
+
return window.google.accounts.id.renderButton(
|
|
45
|
+
document.getElementById(htmlId), {
|
|
46
|
+
theme: 'filled_blue',
|
|
47
|
+
size: 'large',
|
|
48
|
+
width: '367'
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function initializeSignInWithGoogle(htmlId?: string) {
|
|
54
|
+
googleAccountsIdInitialize()
|
|
55
|
+
|
|
56
|
+
if (htmlId) {
|
|
57
|
+
return googleAccountsIdRenderButton(htmlId)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!sessionValue.user) window.google.accounts.id.prompt()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setSessionUser(user: User | null) {
|
|
64
|
+
session.update(s => ({
|
|
65
|
+
...s,
|
|
66
|
+
user
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function googleCallback(response) {
|
|
71
|
+
const res = await fetch('/auth/google', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json'
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({ token: response.credential })
|
|
77
|
+
})
|
|
78
|
+
const fromEndpoint = await res.json()
|
|
79
|
+
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
session.set({ user: fromEndpoint.user })
|
|
82
|
+
const { role } = fromEndpoint.user
|
|
83
|
+
if (referrer) return goto(referrer)
|
|
84
|
+
if (role === 'teacher') return goto('/teachers')
|
|
85
|
+
if (role === 'admin') return goto('/admin')
|
|
86
|
+
// Don't stay on login if successfully authenticated
|
|
87
|
+
if (window.location.pathname === '/login') goto('/')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function registerLocal(user: User) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch('/auth/register', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: JSON.stringify(user),
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json'
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
const fromEndpoint = await res.json()
|
|
101
|
+
if (res.ok) {
|
|
102
|
+
session.set({ user: fromEndpoint.user })
|
|
103
|
+
goto('/')
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error(fromEndpoint.message)
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('Login error', err)
|
|
109
|
+
throw new Error(err.message)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loginLocal(credentials) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch('/auth/login', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body: JSON.stringify(credentials),
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json'
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
const fromEndpoint = await res.json()
|
|
123
|
+
if (res.ok) {
|
|
124
|
+
setSessionUser(fromEndpoint.user)
|
|
125
|
+
const { role } = fromEndpoint.user
|
|
126
|
+
if (referrer) return goto(referrer)
|
|
127
|
+
if (role === 'teacher') return goto('/teachers')
|
|
128
|
+
if (role === 'admin') return goto('/admin')
|
|
129
|
+
return goto('/')
|
|
130
|
+
} else {
|
|
131
|
+
throw new Error(fromEndpoint.message)
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('Login error', err)
|
|
135
|
+
throw new Error(err.message)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function logout() {
|
|
140
|
+
// Request server delete httpOnly cookie called session
|
|
141
|
+
const url = '/auth/logout'
|
|
142
|
+
const res = await fetch(url, {
|
|
143
|
+
method: 'POST'
|
|
144
|
+
})
|
|
145
|
+
if (res.ok) {
|
|
146
|
+
session.set({}) // delete session.user from
|
|
147
|
+
goto('/login')
|
|
148
|
+
} else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { loadScript, initializeSignInWithGoogle, registerLocal, loginLocal, logout }
|
|
152
|
+
}
|
package/src/lib/focus.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts" context="module">
|
|
2
|
+
export const load = ({ error, status }) => {
|
|
3
|
+
return {
|
|
4
|
+
props: {
|
|
5
|
+
errorMessage: `${status}: ${error.message}`
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
export let errorMessage
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<h1>Error</h1>
|
|
16
|
+
<h4>{errorMessage}</h4>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
import { goto } from '$app/navigation'
|
|
4
|
+
import { page, session } from '$app/stores'
|
|
5
|
+
import { toast } from '../stores'
|
|
6
|
+
import useAuth from '$lib/auth'
|
|
7
|
+
|
|
8
|
+
// Vue.js Composition API style
|
|
9
|
+
const { loadScript, initializeSignInWithGoogle, logout } = useAuth(page, session, goto)
|
|
10
|
+
|
|
11
|
+
let sessionValue
|
|
12
|
+
session.subscribe(value => {
|
|
13
|
+
sessionValue = value
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
let Toast
|
|
17
|
+
|
|
18
|
+
onMount(async() => {
|
|
19
|
+
await import('bootstrap/js/dist/collapse')
|
|
20
|
+
Toast = (await import('bootstrap/js/dist/toast')).default
|
|
21
|
+
await loadScript()
|
|
22
|
+
initializeSignInWithGoogle()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const openToast = (open: boolean) => {
|
|
26
|
+
if (open) {
|
|
27
|
+
const toastDiv = <HTMLDivElement> document.getElementById('authToast')
|
|
28
|
+
const t = new Toast(toastDiv)
|
|
29
|
+
t.show()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$: openToast($toast.isOpen)
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
|
37
|
+
<div class="container">
|
|
38
|
+
<a class="navbar-brand" href="/">Auth</a>
|
|
39
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain" aria-controls="navbarMain" aria-expanded="false" aria-label="Toggle navigation">
|
|
40
|
+
<span class="navbar-toggler-icon"></span>
|
|
41
|
+
</button>
|
|
42
|
+
<div class="collapse navbar-collapse" id="navbarMain">
|
|
43
|
+
<div class="navbar-nav">
|
|
44
|
+
<a class="nav-link active" aria-current="page" href="/">Home</a>
|
|
45
|
+
<a class="nav-link" href="/info">Info</a>
|
|
46
|
+
<a class="nav-link" class:d-none={!$session.user} href="/profile">Profile</a>
|
|
47
|
+
<a class="nav-link" class:d-none={!$session.user || $session.user?.role !== 'admin'} href="/admin">Admin</a>
|
|
48
|
+
<a class="nav-link" class:d-none={!$session.user || $session.user?.role === 'student'} href="/teachers">Teachers</a>
|
|
49
|
+
<a class="nav-link" class:d-none={!!$session.user} href="/login">Login</a>
|
|
50
|
+
<a on:click|preventDefault={logout} class="nav-link" class:d-none={!$session.user} href={'#'}>Logout</a>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</nav>
|
|
55
|
+
|
|
56
|
+
<main class="container">
|
|
57
|
+
<slot/>
|
|
58
|
+
|
|
59
|
+
<div id="authToast" class="toast position-fixed top-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
|
|
60
|
+
<div class="toast-header bg-primary text-white">
|
|
61
|
+
<strong class="me-auto">{$toast.title}</strong>
|
|
62
|
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="toast-body">
|
|
65
|
+
{$toast.body}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
</main>
|
|
70
|
+
|
|
71
|
+
<style lang="scss" global>
|
|
72
|
+
// Load Bootstrap's SCSS
|
|
73
|
+
@import 'bootstrap/scss/bootstrap';
|
|
74
|
+
|
|
75
|
+
// Make Retina displays crisper
|
|
76
|
+
* {
|
|
77
|
+
-webkit-font-smoothing: antialiased;
|
|
78
|
+
-moz-osx-font-smoothing: grayscale;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.toast {
|
|
82
|
+
z-index: 9999;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import dotenv from 'dotenv'
|
|
3
|
+
import type { QueryResult} from 'pg'
|
|
4
|
+
import pg from 'pg'
|
|
5
|
+
|
|
6
|
+
dotenv.config()
|
|
7
|
+
|
|
8
|
+
const pgNativePool = new pg.native.Pool({
|
|
9
|
+
max: 10, // default
|
|
10
|
+
connectionString: process.env['DATABASE_URL'],
|
|
11
|
+
ssl: {
|
|
12
|
+
rejectUnauthorized: false
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
type PostgresQueryResult = (sql: string, params?: any[]) => Promise<QueryResult<any>>
|
|
17
|
+
export const query: PostgresQueryResult = (sql, params?) => pgNativePool.query(sql, params)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import dotenv from 'dotenv'
|
|
2
|
+
|
|
3
|
+
dotenv.config()
|
|
4
|
+
|
|
5
|
+
const SEND_IN_BLUE_KEY = process.env['SEND_IN_BLUE_KEY']
|
|
6
|
+
const SEND_IN_BLUE_URL = process.env['SEND_IN_BLUE_URL']
|
|
7
|
+
const SEND_IN_BLUE_FROM = <MessageAddressee> JSON.parse(process.env['SEND_IN_BLUE_FROM'])
|
|
8
|
+
const SEND_IN_BLUE_ADMINS = <MessageAddressee> JSON.parse(process.env['SEND_IN_BLUE_ADMINS'])
|
|
9
|
+
|
|
10
|
+
// POST or PUT submission to SendInBlue
|
|
11
|
+
const submit = async (method, url, data) => {
|
|
12
|
+
const response: Response = await fetch(`${SEND_IN_BLUE_URL}${url}`, {
|
|
13
|
+
method,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'api-key': SEND_IN_BLUE_KEY
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify(data)
|
|
19
|
+
})
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
console.error('Error from SendInBlue:', response)
|
|
22
|
+
throw new Error(`Error communicating with SendInBlue.`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sender = SEND_IN_BLUE_FROM
|
|
27
|
+
const to = SEND_IN_BLUE_ADMINS
|
|
28
|
+
|
|
29
|
+
export const sendMessage = async (message: Message) => submit('POST', '/v3/smtp/email', { sender, to: [to], ...message })
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script context="module" lang="ts">
|
|
2
|
+
import type { Load } from '@sveltejs/kit'
|
|
3
|
+
|
|
4
|
+
export const load: Load = async ({ session }) => {
|
|
5
|
+
const authorized = ['admin']
|
|
6
|
+
if (!authorized.includes(session.user?.role)) {
|
|
7
|
+
return {
|
|
8
|
+
status: 302,
|
|
9
|
+
redirect: '/login?referrer=/admin'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const url = '/api/v1/admin'
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
method: 'GET',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// if !res.ok, error is returned as message
|
|
20
|
+
const { message } = await res.json()
|
|
21
|
+
return {
|
|
22
|
+
props: {
|
|
23
|
+
message
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script lang="ts">
|
|
31
|
+
export let message
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<svelte:head>
|
|
35
|
+
<title>Administration</title>
|
|
36
|
+
</svelte:head>
|
|
37
|
+
|
|
38
|
+
<h1>Admin</h1>
|
|
39
|
+
<h4>Admin Role</h4>
|
|
40
|
+
<p>{message}</p>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
|
|
3
|
+
export const get: RequestHandler = async event => {
|
|
4
|
+
const authorized = ['admin']
|
|
5
|
+
|
|
6
|
+
if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
|
|
7
|
+
return {
|
|
8
|
+
status: 401,
|
|
9
|
+
body: {
|
|
10
|
+
message: 'Unauthorized - admin role required.'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
status: 200,
|
|
17
|
+
body: {
|
|
18
|
+
message: 'Admin-only content from endpoint.'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
|
|
3
|
+
export const get: RequestHandler = async event=> {
|
|
4
|
+
const authorized = ['admin', 'teacher']
|
|
5
|
+
|
|
6
|
+
if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
|
|
7
|
+
return {
|
|
8
|
+
status: 401,
|
|
9
|
+
body: {
|
|
10
|
+
message: 'Unauthorized - admin or teacher role required.'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
status: 200,
|
|
17
|
+
body: {
|
|
18
|
+
message: 'Teachers or Admin-only content from endpoint.'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
import { query } from '../../_db'
|
|
3
|
+
|
|
4
|
+
export const put: RequestHandler = async event => {
|
|
5
|
+
const authorized = ['admin', 'teacher', 'student']
|
|
6
|
+
|
|
7
|
+
if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
|
|
8
|
+
return {
|
|
9
|
+
status: 401,
|
|
10
|
+
body: {
|
|
11
|
+
message: 'Unauthorized - must be logged-in.'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sql = `CALL update_user($1, $2);`
|
|
17
|
+
try {
|
|
18
|
+
// Only permit update of the authenticated user
|
|
19
|
+
const body = await event.request.json()
|
|
20
|
+
await query(sql, [event.locals.user.id, JSON.stringify(body)])
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return {
|
|
23
|
+
status: 503,
|
|
24
|
+
body: {
|
|
25
|
+
message: 'Could not communicate with database.'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
status: 200,
|
|
32
|
+
body: {
|
|
33
|
+
message: 'Successfully updated user profile.'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
import { query } from '../_db'
|
|
3
|
+
|
|
4
|
+
export const post: RequestHandler = async event => {
|
|
5
|
+
const { slug } = event.params
|
|
6
|
+
|
|
7
|
+
let result
|
|
8
|
+
let sql
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
switch (slug) {
|
|
12
|
+
case 'login':
|
|
13
|
+
sql = `SELECT authenticate($1) AS "authenticationResult";`
|
|
14
|
+
break
|
|
15
|
+
case 'register':
|
|
16
|
+
sql = `SELECT register($1) AS "authenticationResult";`
|
|
17
|
+
break
|
|
18
|
+
case 'logout':
|
|
19
|
+
if (event.locals.user) { // if user is null, they are logged out anyway (session might have ended)
|
|
20
|
+
sql = `CALL delete_session($1);`
|
|
21
|
+
result = await query(sql, [event.locals.user.id])
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: {
|
|
26
|
+
'Set-Cookie': `session=; Path=/; SameSite=Lax; HttpOnly; Expires=${new Date().toUTCString()}`
|
|
27
|
+
},
|
|
28
|
+
body: {
|
|
29
|
+
message: 'Logout successful.'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
default:
|
|
33
|
+
return {
|
|
34
|
+
status: 404,
|
|
35
|
+
body: {
|
|
36
|
+
message: 'Invalid endpoint.',
|
|
37
|
+
user: null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only /auth/login and /auth/register at this point
|
|
43
|
+
const body = await event.request.json()
|
|
44
|
+
result = await query(sql, [JSON.stringify(body)])
|
|
45
|
+
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
status: 503,
|
|
49
|
+
body: {
|
|
50
|
+
message: 'Could not communicate with database.',
|
|
51
|
+
user: null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
|
|
57
|
+
|
|
58
|
+
if (!authenticationResult.user) {
|
|
59
|
+
return {
|
|
60
|
+
status: authenticationResult.statusCode,
|
|
61
|
+
body: {
|
|
62
|
+
message: authenticationResult.status,
|
|
63
|
+
user: null,
|
|
64
|
+
sessionId: null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Prevent hooks.ts:handle() from deleting cookie we just set
|
|
70
|
+
event.locals.user = authenticationResult.user
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
status: authenticationResult.statusCode,
|
|
74
|
+
headers: { // database expires sessions in 2 hours (could do it here too)
|
|
75
|
+
'Set-Cookie': `session=${authenticationResult.sessionId}; Path=/; SameSite=Lax; HttpOnly;`
|
|
76
|
+
},
|
|
77
|
+
body: {
|
|
78
|
+
message: authenticationResult.status,
|
|
79
|
+
user: authenticationResult.user,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
import type { Secret } from 'jsonwebtoken'
|
|
3
|
+
import jwt from 'jsonwebtoken'
|
|
4
|
+
import dotenv from 'dotenv'
|
|
5
|
+
import { query } from '../_db'
|
|
6
|
+
import { sendMessage } from '../_send-in-blue'
|
|
7
|
+
|
|
8
|
+
dotenv.config()
|
|
9
|
+
const DOMAIN = process.env['DOMAIN']
|
|
10
|
+
const JWT_SECRET: Secret = process.env['JWT_SECRET']
|
|
11
|
+
|
|
12
|
+
export const post: RequestHandler = async event => {
|
|
13
|
+
const body = await event.request.json()
|
|
14
|
+
const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
|
|
15
|
+
const { rows } = await query(sql, [body.email])
|
|
16
|
+
|
|
17
|
+
if (rows.length > 0) {
|
|
18
|
+
const { userId } = rows[0]
|
|
19
|
+
// Create JWT with userId expiring in 30 mins
|
|
20
|
+
const secret = JWT_SECRET
|
|
21
|
+
const token = jwt.sign({ subject: userId }, secret, {
|
|
22
|
+
expiresIn: '30m'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Email URL with token to user
|
|
26
|
+
const message: Message = {
|
|
27
|
+
// sender: JSON.parse(<string> VITE_EMAIL_FROM),
|
|
28
|
+
to: [{ email: body.email }],
|
|
29
|
+
subject: 'Password reset',
|
|
30
|
+
tags: ['account'],
|
|
31
|
+
htmlContent: `
|
|
32
|
+
<a href="${DOMAIN}/auth/reset/${token}">Reset my password</a>. Your browser will open and ask you to provide a
|
|
33
|
+
new password with a confirmation then redirect you to your login page.
|
|
34
|
+
`
|
|
35
|
+
}
|
|
36
|
+
sendMessage(message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
status: 204
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit'
|
|
2
|
+
import { OAuth2Client } from 'google-auth-library'
|
|
3
|
+
import { query } from '../_db';
|
|
4
|
+
import { config } from '$lib/config'
|
|
5
|
+
|
|
6
|
+
// Verify JWT per https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
|
7
|
+
async function getGoogleUserFromJWT(token: string): Promise<User> {
|
|
8
|
+
try {
|
|
9
|
+
const clientId = config.googleClientId
|
|
10
|
+
const client = new OAuth2Client(clientId)
|
|
11
|
+
const ticket = await client.verifyIdToken({
|
|
12
|
+
idToken: token,
|
|
13
|
+
audience: clientId
|
|
14
|
+
});
|
|
15
|
+
const payload = ticket.getPayload()
|
|
16
|
+
return {
|
|
17
|
+
firstName: payload['given_name'],
|
|
18
|
+
lastName: payload['family_name'],
|
|
19
|
+
email: payload['email']
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`Google user could not be authenticated: ${error.message}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Upsert user and get session ID
|
|
27
|
+
async function upsertGoogleUser(user: User): Promise<UserSession> {
|
|
28
|
+
try {
|
|
29
|
+
const sql = `SELECT start_gmail_user_session($1) AS user_session;`
|
|
30
|
+
const { rows } = await query(sql, [JSON.stringify(user)])
|
|
31
|
+
return <UserSession> rows[0].user_session
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`GMail user could not be upserted: ${error.message}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Returns local user if Google user authenticated (and authorized our app)
|
|
38
|
+
export const post: RequestHandler = async event => {
|
|
39
|
+
try {
|
|
40
|
+
const { token } = await event.request.json()
|
|
41
|
+
const user = await getGoogleUserFromJWT(token)
|
|
42
|
+
const userSession = await upsertGoogleUser(user)
|
|
43
|
+
|
|
44
|
+
// Prevent hooks.ts's handler() from deleting cookie thinking no one has authenticated
|
|
45
|
+
event.locals.user = userSession.user
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { // database expires sessions in 2 hours
|
|
50
|
+
'Set-Cookie': `session=${userSession.id}; Path=/; SameSite=Lax; HttpOnly;`
|
|
51
|
+
},
|
|
52
|
+
body: {
|
|
53
|
+
message: 'Successful Google Sign-In.',
|
|
54
|
+
user: userSession.user
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return { // session cookie deleted by hooks.js handle()
|
|
59
|
+
status: 401,
|
|
60
|
+
body: {
|
|
61
|
+
message: error.message
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|