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/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
parser: '@typescript-eslint/parser',
|
|
4
|
+
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
|
5
|
+
plugins: ['svelte3', '@typescript-eslint'],
|
|
6
|
+
ignorePatterns: ['*.cjs'],
|
|
7
|
+
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
|
8
|
+
settings: {
|
|
9
|
+
'svelte3/typescript': () => require('typescript')
|
|
10
|
+
},
|
|
11
|
+
parserOptions: {
|
|
12
|
+
sourceType: 'module',
|
|
13
|
+
ecmaVersion: 2019
|
|
14
|
+
},
|
|
15
|
+
env: {
|
|
16
|
+
browser: true,
|
|
17
|
+
es2017: true,
|
|
18
|
+
node: true
|
|
19
|
+
}
|
|
20
|
+
};
|
package/.prettierrc
ADDED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# 1.0.3
|
|
2
|
+
* [Fix] user created or updated when password mismatches (@lxy-yz)
|
|
3
|
+
* Updated project dependencies
|
|
4
|
+
* Replaced Sveltestrap's Toast with native Bootstrap 5 JavaScript to avoid error with @popperjs import (lacks type=module)
|
|
5
|
+
* Added declarations for Session and Locals for type safety
|
|
6
|
+
|
|
7
|
+
# 1.0.2
|
|
8
|
+
|
|
9
|
+
* [Fix] Updated endpoints and hooks to conform to SvelteKit's API changes.
|
|
10
|
+
* Updated project dependencies
|
|
11
|
+
|
|
12
|
+
# 1.0.1
|
|
13
|
+
|
|
14
|
+
* Switched to dotenv vs. VITE_ env values for better security
|
|
15
|
+
* Load Sign in with Google via code instead of static template
|
|
16
|
+
* Fix logout (didn't work if session expired)
|
|
17
|
+
* Fix login button rendering if that's the starting page
|
|
18
|
+
|
|
19
|
+
# Backlog
|
|
20
|
+
|
|
21
|
+
* [Low] Add password complexity check
|
|
22
|
+
* [Low] Add Google reCaptcha 3
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Nate Stuyvesant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SvelteKit Authentication and Authorization Example
|
|
2
|
+
|
|
3
|
+
This is an example of how to register, authenticate, and update users and limit their access to
|
|
4
|
+
areas of the website by role (admin, teacher, student).
|
|
5
|
+
|
|
6
|
+
It's an SPA built with SvelteKit and a PostgreSQL database back-end. Code is TypeScript and the website is styled using Bootstrap. PostgreSQL functions handle password hashing and UUID generation for the session ID.
|
|
7
|
+
|
|
8
|
+
Unlike most authentication examples, this SPA does not use callbacks that redirect back to the site (causing the website to be reloaded with a visual flash). Because of that, **getSession()** in hooks.ts is not useful. Instead, the client makes REST calls, gets the user in the body of the response (if successful) and adds the user to the client-side session store.
|
|
9
|
+
|
|
10
|
+
The website supports two types of authentication:
|
|
11
|
+
1. **Local accounts** via username (email) and password
|
|
12
|
+
- The login form (/src/routes/login.svelte) sends the login info as JSON to endpoint /auth/login
|
|
13
|
+
- The endpoint passes the JSON to PostgreSQL function authenticate(json) which hashes the password and compares it to the stored hashed password in the users table. The function returns JSON containing a session ID (v4 UUID) and user object (sans password).
|
|
14
|
+
- The endpoint sends session ID as an httpOnly SameSite cookie and the user object in the body of the response.
|
|
15
|
+
- The client stores the user object in SvelteKit's session store.
|
|
16
|
+
2. **Sign in with Google**
|
|
17
|
+
- **Sign in with Google** is initialized in /src/routes/__layout.svelte.
|
|
18
|
+
- **One Tap** "dialog" is displayed on the Home page (/src/routes/index.svelte) while the **Sign in with Google** button is on the login page (/src/routes/login.svelte).
|
|
19
|
+
- Clicking either button opens a new window asking the user to authorize this website. If they OK it, a JWT is sent to a callback function.
|
|
20
|
+
- The callback function (in /src/lib/auth.ts) sends the JWT to an endpoint on this server /auth/google.
|
|
21
|
+
- The endpoint decodes and validates the user information then calls the PostgreSQL function start_gmail_user_session to upsert the user to the database returing a session id in an httpOnly SameSite cookie and user in the body of the response.
|
|
22
|
+
- The client stores the user object in SvelteKit's session store.
|
|
23
|
+
|
|
24
|
+
As the client calls endpoints, each request to the server includes the session ID in the httpOnly cookie. The handle() function in hooks.ts checks the database for this session ID. If it exists and is not expired, it attaches the user (including role) to request.locals. Each endpoint can then examine request.locals.user.role to determine whether to respond or return a 401.
|
|
25
|
+
|
|
26
|
+
> There is some overhead to checking the user session in a database each time versus using a JSON web token; however, validating each request avoids problems discussed in [this article](https://redis.com/blog/json-web-tokens-jwt-are-dangerous-for-user-sessions/) and [this one](https://scotch.io/bar-talk/why-jwts-suck-as-session-tokens). For a high-volume website, I would use Redis or the equivalent.
|
|
27
|
+
|
|
28
|
+
Pages use the session.user.role to determine whether they are authorized. While a malicious user could alter the client-side session store to see pages they should not, the dynamic data in those restricted pages is served via endpoints which check request.locals.user to determine whether to grant access.
|
|
29
|
+
|
|
30
|
+
The forgot password functionality uses SendInBlue to send the email. You would need to have a SendInBlue account and set three environmental variables. Email sending is in /src/routes/auth/forgot.ts. This code could easily be replaced by nodemailer or something similar.
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
- PostgreSQL 13 or higher
|
|
34
|
+
- Node.js 16.13.0 or higher
|
|
35
|
+
- npm 8.1.0 or higher
|
|
36
|
+
- Google API client
|
|
37
|
+
- SendInBlue account (only used for emailing password reset link - the sample can run without it but forgot password will not work)
|
|
38
|
+
|
|
39
|
+
## Setting up the project
|
|
40
|
+
|
|
41
|
+
Here are the steps using a macOS, Linux or UNIX command-line:
|
|
42
|
+
|
|
43
|
+
1. Get the project and setup the database
|
|
44
|
+
```bash
|
|
45
|
+
# Clone the repo to your current directory
|
|
46
|
+
git clone https://github.com/nstuyvesant/sveltekit-auth-example.git
|
|
47
|
+
|
|
48
|
+
# Install the dependencies
|
|
49
|
+
cd /sveltekit-auth-example
|
|
50
|
+
npm install
|
|
51
|
+
|
|
52
|
+
# Create PostgreSQL database
|
|
53
|
+
psql -d postgres -f db_create.sql
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid).
|
|
57
|
+
|
|
58
|
+
3. Create an **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
|
|
59
|
+
```bash
|
|
60
|
+
DATABASE_URL=postgres://user:password@localhost:5432/auth
|
|
61
|
+
DOMAIN=http://localhost:3000
|
|
62
|
+
JWT_SECRET=replace_with_your_own
|
|
63
|
+
SEND_IN_BLUE_URL=https://api.sendinblue.com
|
|
64
|
+
SEND_IN_BLUE_KEY=replace_with_your_own
|
|
65
|
+
SEND_IN_BLUE_FROM='{ "email":"jdoe@example.com", "name":"John Doe" }'
|
|
66
|
+
SEND_IN_BLUE_ADMINS='{ "email":"jdoe@example.com", "name":"John Doe" }'
|
|
67
|
+
VITE_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Run locally
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Start the server and open the app in a new browser tab
|
|
74
|
+
npm run dev -- --open
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Valid logins
|
|
78
|
+
|
|
79
|
+
The db_create.sql script adds three users to the database with obvious roles:
|
|
80
|
+
- admin@example.com password admin123
|
|
81
|
+
- teacher@example.com password teacher123
|
|
82
|
+
- student@example.com password student123
|
|
83
|
+
|
|
84
|
+
## My ask of you
|
|
85
|
+
|
|
86
|
+
Please report any issues or areas where the code can be optimized. I am still learning Svelte and SvelteKit. All feedback is appreciated.
|
package/db_create.sql
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
-- Invoke this script via
|
|
2
|
+
-- $ psql -d postgres -f db_create.sql
|
|
3
|
+
|
|
4
|
+
-- Create role if not already there
|
|
5
|
+
DO
|
|
6
|
+
$do$
|
|
7
|
+
BEGIN
|
|
8
|
+
IF NOT EXISTS (
|
|
9
|
+
SELECT -- SELECT list can stay empty for this
|
|
10
|
+
FROM pg_catalog.pg_roles
|
|
11
|
+
WHERE rolname = 'auth') THEN
|
|
12
|
+
|
|
13
|
+
CREATE ROLE auth;
|
|
14
|
+
END IF;
|
|
15
|
+
END
|
|
16
|
+
$do$;
|
|
17
|
+
|
|
18
|
+
-- Forcefully disconnect anyone
|
|
19
|
+
SELECT pid, pg_terminate_backend(pid)
|
|
20
|
+
FROM pg_stat_activity
|
|
21
|
+
WHERE datname = 'auth' AND pid <> pg_backend_pid();
|
|
22
|
+
|
|
23
|
+
DROP DATABASE IF EXISTS auth;
|
|
24
|
+
|
|
25
|
+
CREATE DATABASE auth
|
|
26
|
+
WITH
|
|
27
|
+
OWNER = auth
|
|
28
|
+
ENCODING = 'UTF8'
|
|
29
|
+
TABLESPACE = pg_default
|
|
30
|
+
CONNECTION LIMIT = -1;
|
|
31
|
+
|
|
32
|
+
COMMENT ON DATABASE auth IS 'SvelteKit Auth Example';
|
|
33
|
+
|
|
34
|
+
-- Connect to auth database
|
|
35
|
+
\connect auth
|
|
36
|
+
|
|
37
|
+
-- Required for password hashing
|
|
38
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
39
|
+
|
|
40
|
+
-- Required to generate UUIDs for sessions
|
|
41
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
42
|
+
|
|
43
|
+
-- Using hard-coded roles (often this would be a table)
|
|
44
|
+
CREATE TYPE public.roles AS ENUM
|
|
45
|
+
('student', 'teacher', 'admin');
|
|
46
|
+
|
|
47
|
+
ALTER TYPE public.roles OWNER TO auth;
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS public.users (
|
|
50
|
+
id integer NOT NULL GENERATED ALWAYS AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
|
|
51
|
+
role roles NOT NULL DEFAULT 'student'::roles,
|
|
52
|
+
email character varying(80) COLLATE pg_catalog."default" NOT NULL,
|
|
53
|
+
password character varying(80) COLLATE pg_catalog."default",
|
|
54
|
+
first_name character varying(20) COLLATE pg_catalog."default" NOT NULL,
|
|
55
|
+
last_name character varying(20) COLLATE pg_catalog."default" NOT NULL,
|
|
56
|
+
phone character varying(23) COLLATE pg_catalog."default",
|
|
57
|
+
CONSTRAINT users_pkey PRIMARY KEY (id),
|
|
58
|
+
CONSTRAINT users_email_unique UNIQUE (email)
|
|
59
|
+
) TABLESPACE pg_default;
|
|
60
|
+
|
|
61
|
+
ALTER TABLE public.users OWNER to auth;
|
|
62
|
+
|
|
63
|
+
CREATE INDEX users_first_name_index
|
|
64
|
+
ON public.users USING btree
|
|
65
|
+
(first_name COLLATE pg_catalog."default" ASC NULLS LAST)
|
|
66
|
+
TABLESPACE pg_default;
|
|
67
|
+
|
|
68
|
+
CREATE INDEX users_last_name_index
|
|
69
|
+
ON public.users USING btree
|
|
70
|
+
(last_name COLLATE pg_catalog."default" ASC NULLS LAST)
|
|
71
|
+
TABLESPACE pg_default;
|
|
72
|
+
|
|
73
|
+
CREATE INDEX users_password
|
|
74
|
+
ON public.users USING btree
|
|
75
|
+
(password COLLATE pg_catalog."default" ASC NULLS LAST)
|
|
76
|
+
TABLESPACE pg_default;
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS public.sessions (
|
|
79
|
+
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
|
80
|
+
user_id integer NOT NULL,
|
|
81
|
+
expires timestamp with time zone DEFAULT (CURRENT_TIMESTAMP + '02:00:00'::interval),
|
|
82
|
+
CONSTRAINT sessions_pkey PRIMARY KEY (id),
|
|
83
|
+
CONSTRAINT sessions_user_fkey FOREIGN KEY (user_id)
|
|
84
|
+
REFERENCES public.users (id) MATCH SIMPLE
|
|
85
|
+
ON UPDATE NO ACTION
|
|
86
|
+
ON DELETE CASCADE
|
|
87
|
+
NOT VALID
|
|
88
|
+
) TABLESPACE pg_default;
|
|
89
|
+
|
|
90
|
+
ALTER TABLE public.sessions OWNER to auth;
|
|
91
|
+
|
|
92
|
+
CREATE OR REPLACE FUNCTION public.authenticate(
|
|
93
|
+
input json,
|
|
94
|
+
OUT response json)
|
|
95
|
+
RETURNS json
|
|
96
|
+
LANGUAGE 'plpgsql'
|
|
97
|
+
COST 100
|
|
98
|
+
VOLATILE PARALLEL UNSAFE
|
|
99
|
+
AS $BODY$
|
|
100
|
+
DECLARE
|
|
101
|
+
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
102
|
+
input_password varchar(80) := (input->>'password')::varchar;
|
|
103
|
+
BEGIN
|
|
104
|
+
IF input_email IS NULL OR input_password IS NULL THEN
|
|
105
|
+
response := json_build_object('statusCode', 400, 'status', 'Please provide an email address and password to authenticate.', 'user', NULL);
|
|
106
|
+
RETURN;
|
|
107
|
+
END IF;
|
|
108
|
+
|
|
109
|
+
WITH user_authenticated AS (
|
|
110
|
+
SELECT users.id, role, first_name, last_name, phone
|
|
111
|
+
FROM users
|
|
112
|
+
WHERE email = input_email AND password = crypt(input_password, password) LIMIT 1
|
|
113
|
+
)
|
|
114
|
+
SELECT json_build_object(
|
|
115
|
+
'statusCode', CASE WHEN (SELECT COUNT(*) FROM user_authenticated) > 0 THEN 200 ELSE 401 END,
|
|
116
|
+
'status', CASE WHEN (SELECT COUNT(*) FROM user_authenticated) > 0
|
|
117
|
+
THEN 'Login successful.'
|
|
118
|
+
ELSE 'Invalid username/password combination.'
|
|
119
|
+
END,
|
|
120
|
+
'user', CASE WHEN (SELECT COUNT(*) FROM user_authenticated) > 0
|
|
121
|
+
THEN (SELECT json_build_object(
|
|
122
|
+
'id', user_authenticated.id,
|
|
123
|
+
'role', user_authenticated.role,
|
|
124
|
+
'email', input_email,
|
|
125
|
+
'firstName', user_authenticated.first_name,
|
|
126
|
+
'lastName', user_authenticated.last_name,
|
|
127
|
+
'phone', user_authenticated.phone)
|
|
128
|
+
FROM user_authenticated)
|
|
129
|
+
ELSE NULL
|
|
130
|
+
END,
|
|
131
|
+
'sessionId', (SELECT create_session(user_authenticated.id) FROM user_authenticated)
|
|
132
|
+
) INTO response;
|
|
133
|
+
END;
|
|
134
|
+
$BODY$;
|
|
135
|
+
|
|
136
|
+
ALTER FUNCTION public.authenticate(json) OWNER TO auth;
|
|
137
|
+
|
|
138
|
+
CREATE OR REPLACE FUNCTION public.create_session(
|
|
139
|
+
input_user_id integer)
|
|
140
|
+
RETURNS uuid
|
|
141
|
+
LANGUAGE 'sql'
|
|
142
|
+
COST 100
|
|
143
|
+
VOLATILE PARALLEL UNSAFE
|
|
144
|
+
AS $BODY$
|
|
145
|
+
DELETE FROM sessions WHERE user_id = input_user_id;
|
|
146
|
+
INSERT INTO sessions(user_id) VALUES (input_user_id) RETURNING sessions.id;
|
|
147
|
+
$BODY$;
|
|
148
|
+
|
|
149
|
+
ALTER FUNCTION public.create_session(integer) OWNER TO auth;
|
|
150
|
+
|
|
151
|
+
CREATE OR REPLACE FUNCTION public.get_session(input_session_id uuid)
|
|
152
|
+
RETURNS json
|
|
153
|
+
LANGUAGE 'sql'
|
|
154
|
+
AS $BODY$
|
|
155
|
+
SELECT json_build_object(
|
|
156
|
+
'id', sessions.user_id,
|
|
157
|
+
'role', users.role,
|
|
158
|
+
'email', users.email,
|
|
159
|
+
'firstName', users.first_name,
|
|
160
|
+
'lastName', users.last_name,
|
|
161
|
+
'phone', users.phone
|
|
162
|
+
) AS user
|
|
163
|
+
FROM sessions
|
|
164
|
+
INNER JOIN users ON sessions.user_id = users.id
|
|
165
|
+
WHERE sessions.id = input_session_id AND expires > CURRENT_TIMESTAMP LIMIT 1;
|
|
166
|
+
$BODY$;
|
|
167
|
+
|
|
168
|
+
ALTER FUNCTION public.get_session(uuid) OWNER TO auth;
|
|
169
|
+
|
|
170
|
+
CREATE OR REPLACE FUNCTION public.register(
|
|
171
|
+
input json,
|
|
172
|
+
OUT user_session json)
|
|
173
|
+
RETURNS json
|
|
174
|
+
LANGUAGE 'plpgsql'
|
|
175
|
+
COST 100
|
|
176
|
+
VOLATILE PARALLEL UNSAFE
|
|
177
|
+
AS $BODY$
|
|
178
|
+
DECLARE
|
|
179
|
+
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
180
|
+
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
181
|
+
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
182
|
+
input_phone varchar(23) := TRIM((input->>'phone')::varchar);
|
|
183
|
+
input_password varchar(80) := (input->>'password')::varchar;
|
|
184
|
+
BEGIN
|
|
185
|
+
SELECT json_build_object('id', create_session(users.id), 'user', json_build_object('id', users.id, 'role', users.role, 'email', input_email, 'firstName', users.first_name, 'lastName', users.last_name, 'phone', users.phone)) INTO user_session FROM users WHERE email = input_email;
|
|
186
|
+
IF NOT FOUND THEN
|
|
187
|
+
INSERT INTO users(role, password, email, first_name, last_name, phone)
|
|
188
|
+
VALUES('student', crypt(input_password, input_password), input_email, input_first_name, input_last_name, input_phone)
|
|
189
|
+
RETURNING
|
|
190
|
+
json_build_object(
|
|
191
|
+
'sessionId', create_session(users.id),
|
|
192
|
+
'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', input_phone)
|
|
193
|
+
) INTO user_session;
|
|
194
|
+
END IF;
|
|
195
|
+
END;
|
|
196
|
+
$BODY$;
|
|
197
|
+
|
|
198
|
+
ALTER FUNCTION public.register(json) OWNER TO auth;
|
|
199
|
+
|
|
200
|
+
CREATE OR REPLACE FUNCTION public.start_gmail_user_session(
|
|
201
|
+
input json,
|
|
202
|
+
OUT user_session json)
|
|
203
|
+
RETURNS json
|
|
204
|
+
LANGUAGE 'plpgsql'
|
|
205
|
+
COST 100
|
|
206
|
+
VOLATILE PARALLEL UNSAFE
|
|
207
|
+
AS $BODY$
|
|
208
|
+
DECLARE
|
|
209
|
+
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
210
|
+
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
211
|
+
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
212
|
+
BEGIN
|
|
213
|
+
SELECT json_build_object('id', create_session(users.id), 'user', json_build_object('id', users.id, 'role', users.role, 'email', input_email, 'firstName', users.first_name, 'lastName', users.last_name, 'phone', users.phone)) INTO user_session FROM users WHERE email = input_email;
|
|
214
|
+
IF NOT FOUND THEN
|
|
215
|
+
INSERT INTO users(role, email, first_name, last_name)
|
|
216
|
+
VALUES('student', input_email, input_first_name, input_last_name)
|
|
217
|
+
RETURNING
|
|
218
|
+
json_build_object(
|
|
219
|
+
'id', create_session(users.id),
|
|
220
|
+
'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', null)
|
|
221
|
+
) INTO user_session;
|
|
222
|
+
END IF;
|
|
223
|
+
END;
|
|
224
|
+
$BODY$;
|
|
225
|
+
|
|
226
|
+
ALTER FUNCTION public.start_gmail_user_session(json) OWNER TO auth;
|
|
227
|
+
|
|
228
|
+
CREATE PROCEDURE public.delete_session(input_id integer)
|
|
229
|
+
LANGUAGE sql
|
|
230
|
+
AS $$
|
|
231
|
+
DELETE FROM sessions WHERE user_id = input_id;
|
|
232
|
+
$$;
|
|
233
|
+
|
|
234
|
+
CREATE OR REPLACE PROCEDURE public.reset_password(
|
|
235
|
+
input_id integer,
|
|
236
|
+
input_password text)
|
|
237
|
+
LANGUAGE 'sql'
|
|
238
|
+
AS $BODY$
|
|
239
|
+
UPDATE users SET password = crypt(input_password, gen_salt('bf', 8)) WHERE id = input_id;
|
|
240
|
+
END;
|
|
241
|
+
$BODY$;
|
|
242
|
+
|
|
243
|
+
ALTER PROCEDURE public.reset_password(integer, text) OWNER TO auth;
|
|
244
|
+
|
|
245
|
+
CREATE OR REPLACE PROCEDURE public.upsert_user(input json)
|
|
246
|
+
LANGUAGE plpgsql
|
|
247
|
+
AS $BODY$
|
|
248
|
+
DECLARE
|
|
249
|
+
input_id integer := COALESCE((input->>'id')::integer,0);
|
|
250
|
+
input_role roles := COALESCE((input->>'role')::roles, 'student');
|
|
251
|
+
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
252
|
+
input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
|
|
253
|
+
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
254
|
+
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
255
|
+
input_phone varchar(23) := TRIM((input->>'phone')::varchar);
|
|
256
|
+
BEGIN
|
|
257
|
+
IF input_id = 0 THEN
|
|
258
|
+
INSERT INTO users (role, email, password, first_name, last_name, phone)
|
|
259
|
+
VALUES (
|
|
260
|
+
input_role, input_email, crypt(input_password, gen_salt('bf', 8)),
|
|
261
|
+
input_first_name, input_last_name, input_phone);
|
|
262
|
+
ELSE
|
|
263
|
+
UPDATE users SET
|
|
264
|
+
role = input_role,
|
|
265
|
+
email = input_email,
|
|
266
|
+
password = CASE WHEN input_password = ''
|
|
267
|
+
THEN password -- leave as is (we are updating fields other than the password)
|
|
268
|
+
ELSE crypt(input_password, gen_salt('bf', 8))
|
|
269
|
+
END,
|
|
270
|
+
first_name = input_first_name,
|
|
271
|
+
last_name = input_last_name,
|
|
272
|
+
phone = input_phone
|
|
273
|
+
WHERE id = input_id;
|
|
274
|
+
END IF;
|
|
275
|
+
END;
|
|
276
|
+
$BODY$;
|
|
277
|
+
|
|
278
|
+
ALTER PROCEDURE public.upsert_user(json) OWNER TO auth;
|
|
279
|
+
|
|
280
|
+
CREATE OR REPLACE PROCEDURE public.update_user(input_id integer, input json)
|
|
281
|
+
LANGUAGE plpgsql
|
|
282
|
+
AS $BODY$
|
|
283
|
+
DECLARE
|
|
284
|
+
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
285
|
+
input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
|
|
286
|
+
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
287
|
+
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
288
|
+
input_phone varchar(23) := TRIM((input->>'phone')::varchar);
|
|
289
|
+
BEGIN
|
|
290
|
+
UPDATE users SET
|
|
291
|
+
email = input_email,
|
|
292
|
+
password = CASE WHEN input_password = ''
|
|
293
|
+
THEN password -- leave as is (we are updating fields other than the password)
|
|
294
|
+
ELSE crypt(input_password, gen_salt('bf', 8))
|
|
295
|
+
END,
|
|
296
|
+
first_name = input_first_name,
|
|
297
|
+
last_name = input_last_name,
|
|
298
|
+
phone = input_phone
|
|
299
|
+
WHERE id = input_id;
|
|
300
|
+
END;
|
|
301
|
+
$BODY$;
|
|
302
|
+
|
|
303
|
+
ALTER PROCEDURE public.update_user(integer, json) OWNER TO auth;
|
|
304
|
+
|
|
305
|
+
CALL public.upsert_user('{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json);
|
|
306
|
+
CALL public.upsert_user('{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json);
|
|
307
|
+
CALL public.upsert_user('{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json);
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sveltekit-auth-example",
|
|
3
|
+
"description": "SvelteKit Authentication Example",
|
|
4
|
+
"version": "1.0.3",
|
|
5
|
+
"private": false,
|
|
6
|
+
"author": "Nate Stuyvesant",
|
|
7
|
+
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/nstuyvesant/sveltekit-auth-example.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/nstuyvesant/sveltekit-auth-example/issues"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "svelte-kit dev",
|
|
17
|
+
"serve": "npm run dev -- --open",
|
|
18
|
+
"build": "svelte-kit build",
|
|
19
|
+
"preview": "svelte-kit preview",
|
|
20
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
21
|
+
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
|
22
|
+
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
|
|
23
|
+
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": "~16.14.2",
|
|
27
|
+
"npm": "^8.6.0"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"cookie": "^0.4.2",
|
|
32
|
+
"dotenv": "^16.0.0",
|
|
33
|
+
"google-auth-library": "^7.11.1",
|
|
34
|
+
"jsonwebtoken": "^8.5.1",
|
|
35
|
+
"pg": "^8.7.3",
|
|
36
|
+
"pg-native": "^3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@sveltejs/adapter-node": "next",
|
|
40
|
+
"@sveltejs/kit": "next",
|
|
41
|
+
"@types/jsonwebtoken": "^8.5.8",
|
|
42
|
+
"@types/pg": "^8.6.5",
|
|
43
|
+
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
|
44
|
+
"@typescript-eslint/parser": "^5.18.0",
|
|
45
|
+
"bootstrap": "^5.1.3",
|
|
46
|
+
"bootstrap-icons": "^1.8.1",
|
|
47
|
+
"eslint": "^8.12.0",
|
|
48
|
+
"eslint-config-prettier": "^8.5.0",
|
|
49
|
+
"eslint-plugin-svelte3": "^3.4.1",
|
|
50
|
+
"prettier": "^2.6.2",
|
|
51
|
+
"prettier-plugin-svelte": "^2.6.0",
|
|
52
|
+
"sass": "^1.49.11",
|
|
53
|
+
"svelte": "^3.46.6",
|
|
54
|
+
"svelte-check": "^2.4.6",
|
|
55
|
+
"svelte-preprocess": "^4.10.5",
|
|
56
|
+
"tslib": "^2.3.1",
|
|
57
|
+
"typescript": "^4.6.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/app.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<link rel="icon" href="/favicon.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
%svelte.head%
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="svelte">%svelte.body%</div>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/// <reference types="@sveltejs/kit" />
|
|
2
|
+
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
4
|
+
|
|
5
|
+
// See https://kit.svelte.dev/docs/typescript
|
|
6
|
+
// for information about these interfaces
|
|
7
|
+
declare namespace App {
|
|
8
|
+
interface Locals {
|
|
9
|
+
user: User
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// interface Platform {}
|
|
13
|
+
|
|
14
|
+
interface Session {
|
|
15
|
+
reservationDate: Date
|
|
16
|
+
scheduledClass?: ScheduledClass
|
|
17
|
+
user?: User
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// interface Stuff {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ImportMetaEnv {
|
|
24
|
+
VITE_GOOGLE_CLIENT_ID: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type AuthenticationResult = {
|
|
28
|
+
statusCode: number
|
|
29
|
+
status: string
|
|
30
|
+
user: User
|
|
31
|
+
sessionId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Credentials = {
|
|
35
|
+
email: string
|
|
36
|
+
password: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type MessageAddressee = {
|
|
40
|
+
email: string
|
|
41
|
+
name?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Message = {
|
|
45
|
+
sender?: MessageAddressee[]
|
|
46
|
+
to: MessageAddressee[]
|
|
47
|
+
subject: string
|
|
48
|
+
htmlContent?: string
|
|
49
|
+
textContent?: string
|
|
50
|
+
tags?: string[]
|
|
51
|
+
contact?: Person
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type User = {
|
|
55
|
+
id?: number
|
|
56
|
+
role?: 'student' | 'teacher' | 'admin'
|
|
57
|
+
password?: string
|
|
58
|
+
firstName?: string
|
|
59
|
+
lastName?: string
|
|
60
|
+
email?: string
|
|
61
|
+
phone?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type UserSession = {
|
|
65
|
+
id: string,
|
|
66
|
+
user: User
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Window {
|
|
70
|
+
google?: any
|
|
71
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as cookie from 'cookie'
|
|
2
|
+
import type { Handle, GetSession, RequestEvent } from '@sveltejs/kit'
|
|
3
|
+
import { query } from './routes/_db'
|
|
4
|
+
|
|
5
|
+
// Attach authorization to each server request (role may have changed)
|
|
6
|
+
async function attachUserToRequest(sessionId: string, event: RequestEvent) {
|
|
7
|
+
const sql = `
|
|
8
|
+
SELECT * FROM get_session($1);`
|
|
9
|
+
const { rows } = await query(sql, [sessionId])
|
|
10
|
+
if (rows?.length > 0) {
|
|
11
|
+
event.locals.user = rows[0].get_session
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deleteCookieIfNoUser(event: RequestEvent, response: Response) {
|
|
16
|
+
if (!event.locals.user) {
|
|
17
|
+
response.headers['Set-Cookie'] = `session=; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date().toUTCString()}`
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Invoked for each endpoint called and initially for SSR router
|
|
22
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
23
|
+
|
|
24
|
+
// before endpoint or page is called
|
|
25
|
+
const cookies = cookie.parse(event.request.headers.get('Cookie') || '')
|
|
26
|
+
if (cookies.session) {
|
|
27
|
+
await attachUserToRequest(cookies.session, event)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const response = await resolve(event)
|
|
31
|
+
|
|
32
|
+
// after endpoint or page is called
|
|
33
|
+
deleteCookieIfNoUser(event, response)
|
|
34
|
+
return response
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only useful for authentication schemes that redirect back to the website - not
|
|
38
|
+
// an SPA with client-side routing that handles authentication seamlessly
|
|
39
|
+
export const getSession: GetSession = event => {
|
|
40
|
+
return event.locals.user ?
|
|
41
|
+
{ user: event.locals.user }
|
|
42
|
+
: {}
|
|
43
|
+
}
|