sveltekit-auth-example 5.6.0 → 5.6.1
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/CHANGELOG.md +4 -0
- package/db_create.sql +1 -364
- package/package.json +2 -1
- package/src/app.d.ts +22 -1
- package/src/hooks.server.ts +37 -5
- package/src/lib/app-state.svelte.ts +8 -0
- package/src/lib/auth-redirect.ts +4 -0
- package/src/lib/focus.ts +8 -0
- package/src/lib/google.ts +17 -0
- package/src/lib/server/db.ts +9 -0
- package/src/lib/server/email/mfa-code.ts +6 -0
- package/src/lib/server/email/password-reset.ts +6 -0
- package/src/lib/server/email/verify-email.ts +6 -0
- package/src/lib/server/sendgrid.ts +9 -0
- package/src/routes/+layout.server.ts +10 -1
- package/src/routes/+layout.svelte +14 -0
- package/src/routes/admin/+page.server.ts +8 -0
- package/src/routes/api/v1/user/+server.ts +20 -0
- package/src/routes/auth/[slug]/+server.ts +9 -2
- package/src/routes/auth/forgot/+server.ts +10 -0
- package/src/routes/auth/google/+server.ts +29 -3
- package/src/routes/auth/login/+server.ts +26 -2
- package/src/routes/auth/logout/+server.ts +10 -0
- package/src/routes/auth/mfa/+server.ts +15 -0
- package/src/routes/auth/register/+server.ts +17 -0
- package/src/routes/auth/reset/+server.ts +15 -0
- package/src/routes/auth/reset/[token]/+page.svelte +10 -0
- package/src/routes/auth/reset/[token]/+page.ts +8 -0
- package/src/routes/auth/verify/[token]/+server.ts +12 -1
- package/src/routes/forgot/+page.svelte +8 -0
- package/src/routes/login/+page.server.ts +8 -0
- package/src/routes/login/+page.svelte +19 -0
- package/src/routes/profile/+page.server.ts +9 -0
- package/src/routes/profile/+page.svelte +16 -0
- package/src/routes/register/+page.server.ts +8 -1
- package/src/routes/register/+page.svelte +15 -0
- package/src/routes/teachers/+page.server.ts +9 -0
- package/src/service-worker.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
- Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
|
|
4
4
|
|
|
5
|
+
# 5.6.1
|
|
6
|
+
|
|
7
|
+
- Add JSDoc comments throughout the codebase (`app.d.ts`, server hooks, route handlers, lib utilities, Svelte components)
|
|
8
|
+
|
|
5
9
|
# 5.6.0
|
|
6
10
|
|
|
7
11
|
- Split `db_create.sql` into `db_create.sql` (role + database creation) and `db_schema.sql` (schema, functions, seed data)
|
package/db_create.sql
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
-- Run via db_create.sh or:
|
|
2
2
|
-- $ psql -d postgres -f db_create.sql && psql -d auth -f db_schema.sql
|
|
3
|
+
|
|
3
4
|
-- Create role if not already there
|
|
4
5
|
DO $do$
|
|
5
6
|
BEGIN
|
|
@@ -32,367 +33,3 @@ LIMIT
|
|
|
32
33
|
= -1;
|
|
33
34
|
|
|
34
35
|
COMMENT ON DATABASE auth IS 'SvelteKit Auth Example';
|
|
35
|
-
|
|
36
|
-
-- Required to generate UUIDs for sessions
|
|
37
|
-
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
38
|
-
|
|
39
|
-
-- Required for case-insensitive text (email_address domain)
|
|
40
|
-
CREATE EXTENSION IF NOT EXISTS citext;
|
|
41
|
-
|
|
42
|
-
-- Using hard-coded roles (often this would be a table)
|
|
43
|
-
CREATE TYPE public.roles AS ENUM('student', 'teacher', 'admin');
|
|
44
|
-
|
|
45
|
-
ALTER TYPE public.roles OWNER TO auth;
|
|
46
|
-
|
|
47
|
-
-- Domains
|
|
48
|
-
CREATE DOMAIN public.email_address AS citext CHECK (
|
|
49
|
-
length(VALUE) <= 254
|
|
50
|
-
AND VALUE = btrim(VALUE)
|
|
51
|
-
AND VALUE ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
COMMENT ON DOMAIN public.email_address IS 'RFC-compliant email address (case-insensitive, max 254 chars)';
|
|
55
|
-
|
|
56
|
-
CREATE DOMAIN public.persons_name AS text CHECK (length(VALUE) <= 20) NOT NULL;
|
|
57
|
-
|
|
58
|
-
COMMENT ON DOMAIN public.persons_name IS 'Person first or last name (max 20 characters)';
|
|
59
|
-
|
|
60
|
-
CREATE DOMAIN public.phone_number AS text CHECK (
|
|
61
|
-
VALUE IS NULL
|
|
62
|
-
OR length(VALUE) <= 50
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
COMMENT ON DOMAIN public.phone_number IS 'Phone number (max 50 characters)';
|
|
66
|
-
|
|
67
|
-
CREATE TABLE IF NOT EXISTS public.users (
|
|
68
|
-
id integer NOT NULL GENERATED ALWAYS AS IDENTITY (
|
|
69
|
-
INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1
|
|
70
|
-
),
|
|
71
|
-
role roles NOT NULL DEFAULT 'student'::roles,
|
|
72
|
-
email email_address NOT NULL,
|
|
73
|
-
password character varying(80) COLLATE pg_catalog."default",
|
|
74
|
-
first_name persons_name,
|
|
75
|
-
last_name persons_name,
|
|
76
|
-
opt_out boolean NOT NULL DEFAULT false,
|
|
77
|
-
email_verified boolean NOT NULL DEFAULT false,
|
|
78
|
-
phone phone_number,
|
|
79
|
-
CONSTRAINT users_pkey PRIMARY KEY (id),
|
|
80
|
-
CONSTRAINT users_email_unique UNIQUE (email)
|
|
81
|
-
) TABLESPACE pg_default;
|
|
82
|
-
|
|
83
|
-
ALTER TABLE public.users OWNER to auth;
|
|
84
|
-
|
|
85
|
-
CREATE INDEX users_first_name_index ON public.users USING btree (
|
|
86
|
-
first_name COLLATE pg_catalog."default" ASC NULLS LAST
|
|
87
|
-
) TABLESPACE pg_default;
|
|
88
|
-
|
|
89
|
-
CREATE INDEX users_last_name_index ON public.users USING btree (
|
|
90
|
-
last_name COLLATE pg_catalog."default" ASC NULLS LAST
|
|
91
|
-
) TABLESPACE pg_default;
|
|
92
|
-
|
|
93
|
-
CREATE INDEX users_password ON public.users USING btree (
|
|
94
|
-
password COLLATE pg_catalog."default" ASC NULLS LAST
|
|
95
|
-
) TABLESPACE pg_default;
|
|
96
|
-
|
|
97
|
-
CREATE TABLE IF NOT EXISTS public.sessions (
|
|
98
|
-
id uuid NOT NULL DEFAULT uuid_generate_v4 (),
|
|
99
|
-
user_id integer NOT NULL,
|
|
100
|
-
expires timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '2 hours'),
|
|
101
|
-
CONSTRAINT sessions_pkey PRIMARY KEY (id),
|
|
102
|
-
CONSTRAINT sessions_user_fkey FOREIGN KEY (user_id) REFERENCES public.users (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE,
|
|
103
|
-
CONSTRAINT sessions_one_per_user UNIQUE (user_id)
|
|
104
|
-
) TABLESPACE pg_default;
|
|
105
|
-
|
|
106
|
-
ALTER TABLE public.sessions OWNER to auth;
|
|
107
|
-
|
|
108
|
-
CREATE OR REPLACE FUNCTION public.authenticate (input json, OUT response json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
|
|
109
|
-
DECLARE
|
|
110
|
-
input_email text := trim(input->>'email');
|
|
111
|
-
input_password text := input->>'password';
|
|
112
|
-
v_user users%ROWTYPE;
|
|
113
|
-
BEGIN
|
|
114
|
-
IF input_email IS NULL OR input_password IS NULL THEN
|
|
115
|
-
response := json_build_object('statusCode', 400, 'status', 'Please provide an email address and password to authenticate.', 'user', NULL, 'sessionId', NULL);
|
|
116
|
-
RETURN;
|
|
117
|
-
END IF;
|
|
118
|
-
|
|
119
|
-
SELECT * INTO v_user FROM users
|
|
120
|
-
WHERE email = input_email AND password = crypt(input_password, password) LIMIT 1;
|
|
121
|
-
|
|
122
|
-
IF NOT FOUND THEN
|
|
123
|
-
response := json_build_object('statusCode', 401, 'status', 'Invalid username/password combination.', 'user', NULL, 'sessionId', NULL);
|
|
124
|
-
ELSIF NOT v_user.email_verified THEN
|
|
125
|
-
response := json_build_object('statusCode', 403, 'status', 'Please verify your email address before logging in.', 'user', NULL, 'sessionId', NULL);
|
|
126
|
-
ELSE
|
|
127
|
-
response := json_build_object(
|
|
128
|
-
'statusCode', 200,
|
|
129
|
-
'status', 'Login successful.',
|
|
130
|
-
'user', json_build_object('id', v_user.id, 'role', v_user.role, 'email', input_email, 'firstName', v_user.first_name, 'lastName', v_user.last_name, 'phone', v_user.phone, 'optOut', v_user.opt_out),
|
|
131
|
-
'sessionId', create_session(v_user.id)
|
|
132
|
-
);
|
|
133
|
-
END IF;
|
|
134
|
-
END;
|
|
135
|
-
$BODY$;
|
|
136
|
-
|
|
137
|
-
ALTER FUNCTION public.authenticate (json) OWNER TO auth;
|
|
138
|
-
|
|
139
|
-
CREATE OR REPLACE FUNCTION public.create_session (input_user_id integer) RETURNS uuid LANGUAGE 'sql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
|
|
140
|
-
-- Remove expired sessions (index-friendly cleanup)
|
|
141
|
-
DELETE FROM sessions WHERE expires < CURRENT_TIMESTAMP;
|
|
142
|
-
-- Remove any existing session(s) for this user
|
|
143
|
-
DELETE FROM sessions WHERE user_id = input_user_id;
|
|
144
|
-
-- Create the new session
|
|
145
|
-
INSERT INTO sessions(user_id) VALUES (input_user_id) RETURNING sessions.id;
|
|
146
|
-
$BODY$;
|
|
147
|
-
|
|
148
|
-
ALTER FUNCTION public.create_session (integer) OWNER TO auth;
|
|
149
|
-
|
|
150
|
-
CREATE OR REPLACE FUNCTION public.get_session (input_session_id uuid) RETURNS json LANGUAGE 'sql' AS $BODY$
|
|
151
|
-
SELECT json_build_object(
|
|
152
|
-
'id', sessions.user_id,
|
|
153
|
-
'role', users.role,
|
|
154
|
-
'email', users.email,
|
|
155
|
-
'firstName', users.first_name,
|
|
156
|
-
'lastName', users.last_name,
|
|
157
|
-
'phone', users.phone,
|
|
158
|
-
'optOut', users.opt_out,
|
|
159
|
-
'expires', sessions.expires
|
|
160
|
-
) AS user
|
|
161
|
-
FROM sessions
|
|
162
|
-
INNER JOIN users ON sessions.user_id = users.id
|
|
163
|
-
WHERE sessions.id = input_session_id AND expires > CURRENT_TIMESTAMP LIMIT 1;
|
|
164
|
-
$BODY$;
|
|
165
|
-
|
|
166
|
-
ALTER FUNCTION public.get_session (uuid) OWNER TO auth;
|
|
167
|
-
|
|
168
|
-
-- Like get_session but also bumps the expiry (sliding sessions).
|
|
169
|
-
-- Returns NULL if the session is expired or does not exist.
|
|
170
|
-
CREATE OR REPLACE FUNCTION public.get_and_update_session (input_session_id uuid) RETURNS json LANGUAGE 'plpgsql' AS $BODY$
|
|
171
|
-
DECLARE
|
|
172
|
-
result json;
|
|
173
|
-
BEGIN
|
|
174
|
-
UPDATE sessions
|
|
175
|
-
SET expires = CURRENT_TIMESTAMP + INTERVAL '2 hours'
|
|
176
|
-
WHERE id = input_session_id AND expires > CURRENT_TIMESTAMP;
|
|
177
|
-
|
|
178
|
-
IF NOT FOUND THEN
|
|
179
|
-
RETURN NULL;
|
|
180
|
-
END IF;
|
|
181
|
-
|
|
182
|
-
SELECT json_build_object(
|
|
183
|
-
'id', sessions.user_id,
|
|
184
|
-
'role', users.role,
|
|
185
|
-
'email', users.email,
|
|
186
|
-
'firstName', users.first_name,
|
|
187
|
-
'lastName', users.last_name,
|
|
188
|
-
'phone', users.phone,
|
|
189
|
-
'optOut', users.opt_out,
|
|
190
|
-
'expires', sessions.expires
|
|
191
|
-
) INTO result
|
|
192
|
-
FROM sessions
|
|
193
|
-
INNER JOIN users ON sessions.user_id = users.id
|
|
194
|
-
WHERE sessions.id = input_session_id;
|
|
195
|
-
|
|
196
|
-
RETURN result;
|
|
197
|
-
END;
|
|
198
|
-
$BODY$;
|
|
199
|
-
|
|
200
|
-
ALTER FUNCTION public.get_and_update_session (uuid) OWNER TO auth;
|
|
201
|
-
|
|
202
|
-
CREATE OR REPLACE FUNCTION public.verify_email_and_create_session (input_id integer) RETURNS uuid LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
|
|
203
|
-
DECLARE
|
|
204
|
-
session_id uuid;
|
|
205
|
-
BEGIN
|
|
206
|
-
UPDATE users SET email_verified = true WHERE id = input_id;
|
|
207
|
-
SELECT create_session(input_id) INTO session_id;
|
|
208
|
-
RETURN session_id;
|
|
209
|
-
END;
|
|
210
|
-
$BODY$;
|
|
211
|
-
|
|
212
|
-
ALTER FUNCTION public.verify_email_and_create_session (integer) OWNER TO auth;
|
|
213
|
-
|
|
214
|
-
CREATE OR REPLACE FUNCTION public.register (input json, OUT user_session json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
|
|
215
|
-
DECLARE
|
|
216
|
-
input_email text := trim(input->>'email');
|
|
217
|
-
input_first_name text := trim(input->>'firstName');
|
|
218
|
-
input_last_name text := trim(input->>'lastName');
|
|
219
|
-
input_phone text := trim(input->>'phone');
|
|
220
|
-
input_password text := input->>'password';
|
|
221
|
-
BEGIN
|
|
222
|
-
PERFORM id FROM users WHERE email = input_email;
|
|
223
|
-
IF NOT FOUND THEN
|
|
224
|
-
INSERT INTO users(role, password, email, first_name, last_name, phone)
|
|
225
|
-
VALUES('student', crypt(input_password, gen_salt('bf', 12)), input_email, input_first_name, input_last_name, input_phone)
|
|
226
|
-
RETURNING
|
|
227
|
-
json_build_object(
|
|
228
|
-
'sessionId', create_session(users.id),
|
|
229
|
-
'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', input_phone, 'optOut', users.opt_out)
|
|
230
|
-
) INTO user_session;
|
|
231
|
-
ELSE -- user is registering account that already exists so set sessionId and user to null so client can let them know
|
|
232
|
-
SELECT authenticate(input) INTO user_session;
|
|
233
|
-
END IF;
|
|
234
|
-
END;
|
|
235
|
-
$BODY$;
|
|
236
|
-
|
|
237
|
-
ALTER FUNCTION public.register (json) OWNER TO auth;
|
|
238
|
-
|
|
239
|
-
CREATE OR REPLACE FUNCTION public.start_gmail_user_session (input json, OUT user_session json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
|
|
240
|
-
DECLARE
|
|
241
|
-
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
242
|
-
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
243
|
-
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
244
|
-
BEGIN
|
|
245
|
-
-- Google verifies email ownership; mark user as verified on every sign-in
|
|
246
|
-
UPDATE users SET email_verified = true WHERE email = input_email;
|
|
247
|
-
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;
|
|
248
|
-
IF NOT FOUND THEN
|
|
249
|
-
INSERT INTO users(role, email, first_name, last_name, email_verified)
|
|
250
|
-
VALUES('student', input_email, input_first_name, input_last_name, true)
|
|
251
|
-
RETURNING
|
|
252
|
-
json_build_object(
|
|
253
|
-
'id', create_session(users.id),
|
|
254
|
-
'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', null)
|
|
255
|
-
) INTO user_session;
|
|
256
|
-
END IF;
|
|
257
|
-
END;
|
|
258
|
-
$BODY$;
|
|
259
|
-
|
|
260
|
-
ALTER FUNCTION public.start_gmail_user_session (json) OWNER TO auth;
|
|
261
|
-
|
|
262
|
-
CREATE PROCEDURE public.delete_session (input_id integer) LANGUAGE sql AS $$
|
|
263
|
-
DELETE FROM sessions WHERE user_id = input_id;
|
|
264
|
-
$$;
|
|
265
|
-
|
|
266
|
-
CREATE OR REPLACE PROCEDURE public.delete_user (input_id integer) LANGUAGE sql AS $$
|
|
267
|
-
DELETE FROM users WHERE id = input_id;
|
|
268
|
-
$$;
|
|
269
|
-
|
|
270
|
-
ALTER PROCEDURE public.delete_user (integer) OWNER TO auth;
|
|
271
|
-
|
|
272
|
-
CREATE OR REPLACE PROCEDURE public.reset_password (IN input_id integer, IN input_password text) LANGUAGE plpgsql AS $procedure$
|
|
273
|
-
BEGIN
|
|
274
|
-
UPDATE users SET password = crypt(input_password, gen_salt('bf', 12)) WHERE id = input_id;
|
|
275
|
-
END;
|
|
276
|
-
$procedure$;
|
|
277
|
-
|
|
278
|
-
ALTER PROCEDURE public.reset_password (integer, text) OWNER TO auth;
|
|
279
|
-
|
|
280
|
-
CREATE OR REPLACE PROCEDURE public.upsert_user (input json) LANGUAGE plpgsql AS $BODY$
|
|
281
|
-
DECLARE
|
|
282
|
-
input_id integer := COALESCE((input->>'id')::integer,0);
|
|
283
|
-
input_role roles := COALESCE((input->>'role')::roles, 'student');
|
|
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
|
-
IF input_id = 0 THEN
|
|
291
|
-
INSERT INTO users (role, email, password, first_name, last_name, phone, email_verified)
|
|
292
|
-
VALUES (
|
|
293
|
-
input_role, input_email, crypt(input_password, gen_salt('bf', 12)),
|
|
294
|
-
input_first_name, input_last_name, input_phone, true);
|
|
295
|
-
ELSE
|
|
296
|
-
UPDATE users SET
|
|
297
|
-
role = input_role,
|
|
298
|
-
email = input_email,
|
|
299
|
-
email_verified = true,
|
|
300
|
-
password = CASE WHEN input_password = ''
|
|
301
|
-
THEN password -- leave as is (we are updating fields other than the password)
|
|
302
|
-
ELSE crypt(input_password, gen_salt('bf', 12))
|
|
303
|
-
END,
|
|
304
|
-
first_name = input_first_name,
|
|
305
|
-
last_name = input_last_name,
|
|
306
|
-
phone = input_phone
|
|
307
|
-
WHERE id = input_id;
|
|
308
|
-
END IF;
|
|
309
|
-
END;
|
|
310
|
-
$BODY$;
|
|
311
|
-
|
|
312
|
-
ALTER PROCEDURE public.upsert_user (json) OWNER TO auth;
|
|
313
|
-
|
|
314
|
-
CREATE OR REPLACE PROCEDURE public.update_user (input_id integer, input json) LANGUAGE plpgsql AS $BODY$
|
|
315
|
-
DECLARE
|
|
316
|
-
input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
|
|
317
|
-
input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
|
|
318
|
-
input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
|
|
319
|
-
input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
|
|
320
|
-
input_phone varchar(23) := TRIM((input->>'phone')::varchar);
|
|
321
|
-
BEGIN
|
|
322
|
-
UPDATE users SET
|
|
323
|
-
email = input_email,
|
|
324
|
-
password = CASE WHEN input_password = ''
|
|
325
|
-
THEN password -- leave as is (we are updating fields other than the password)
|
|
326
|
-
ELSE crypt(input_password, gen_salt('bf', 12))
|
|
327
|
-
END,
|
|
328
|
-
first_name = input_first_name,
|
|
329
|
-
last_name = input_last_name,
|
|
330
|
-
phone = input_phone
|
|
331
|
-
WHERE id = input_id;
|
|
332
|
-
END;
|
|
333
|
-
$BODY$;
|
|
334
|
-
|
|
335
|
-
ALTER PROCEDURE public.update_user (integer, json) OWNER TO auth;
|
|
336
|
-
|
|
337
|
-
-- MFA codes table (one pending code per user, replaced on each new login attempt)
|
|
338
|
-
CREATE TABLE IF NOT EXISTS public.mfa_codes (
|
|
339
|
-
user_id integer NOT NULL,
|
|
340
|
-
code varchar(6) NOT NULL,
|
|
341
|
-
expires timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '10 minutes'),
|
|
342
|
-
CONSTRAINT mfa_codes_pkey PRIMARY KEY (user_id),
|
|
343
|
-
CONSTRAINT mfa_codes_user_fkey FOREIGN KEY (user_id) REFERENCES public.users (id) ON DELETE CASCADE
|
|
344
|
-
) TABLESPACE pg_default;
|
|
345
|
-
|
|
346
|
-
ALTER TABLE public.mfa_codes OWNER TO auth;
|
|
347
|
-
|
|
348
|
-
-- Generate and store a fresh 6-digit MFA code for a user, returning the code
|
|
349
|
-
CREATE OR REPLACE FUNCTION public.create_mfa_code (input_user_id integer) RETURNS varchar LANGUAGE plpgsql AS $BODY$
|
|
350
|
-
DECLARE
|
|
351
|
-
v_code varchar(6) := lpad(floor(random() * 1000000)::integer::text, 6, '0');
|
|
352
|
-
BEGIN
|
|
353
|
-
DELETE FROM mfa_codes WHERE expires < CURRENT_TIMESTAMP;
|
|
354
|
-
INSERT INTO mfa_codes(user_id, code)
|
|
355
|
-
VALUES (input_user_id, v_code)
|
|
356
|
-
ON CONFLICT (user_id) DO UPDATE
|
|
357
|
-
SET code = EXCLUDED.code,
|
|
358
|
-
expires = CURRENT_TIMESTAMP + INTERVAL '10 minutes';
|
|
359
|
-
RETURN v_code;
|
|
360
|
-
END;
|
|
361
|
-
$BODY$;
|
|
362
|
-
|
|
363
|
-
ALTER FUNCTION public.create_mfa_code (integer) OWNER TO auth;
|
|
364
|
-
|
|
365
|
-
-- Verify an MFA code for a given email address.
|
|
366
|
-
-- Returns the user_id on success (and deletes the code), or NULL on failure.
|
|
367
|
-
CREATE OR REPLACE FUNCTION public.verify_mfa_code (input_email text, input_code text) RETURNS integer LANGUAGE plpgsql AS $BODY$
|
|
368
|
-
DECLARE
|
|
369
|
-
v_user_id integer;
|
|
370
|
-
BEGIN
|
|
371
|
-
SELECT mfa_codes.user_id INTO v_user_id
|
|
372
|
-
FROM mfa_codes
|
|
373
|
-
INNER JOIN users ON mfa_codes.user_id = users.id
|
|
374
|
-
WHERE users.email = input_email
|
|
375
|
-
AND mfa_codes.code = input_code
|
|
376
|
-
AND mfa_codes.expires > CURRENT_TIMESTAMP;
|
|
377
|
-
|
|
378
|
-
IF FOUND THEN
|
|
379
|
-
DELETE FROM mfa_codes WHERE user_id = v_user_id;
|
|
380
|
-
END IF;
|
|
381
|
-
|
|
382
|
-
RETURN v_user_id;
|
|
383
|
-
END;
|
|
384
|
-
$BODY$;
|
|
385
|
-
|
|
386
|
-
ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
|
|
387
|
-
|
|
388
|
-
CALL public.upsert_user (
|
|
389
|
-
'{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
CALL public.upsert_user (
|
|
393
|
-
'{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
|
|
394
|
-
);
|
|
395
|
-
|
|
396
|
-
CALL public.upsert_user (
|
|
397
|
-
'{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
|
|
398
|
-
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-auth-example",
|
|
3
3
|
"description": "SvelteKit Authentication Example",
|
|
4
|
-
"version": "5.6.
|
|
4
|
+
"version": "5.6.1",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
30
30
|
"lint": "prettier --check . && eslint .",
|
|
31
31
|
"format": "prettier --write .",
|
|
32
|
+
"coverage": "vitest run --coverage",
|
|
32
33
|
"test:e2e": "playwright test",
|
|
33
34
|
"test:unit": "vitest"
|
|
34
35
|
},
|
package/src/app.d.ts
CHANGED
|
@@ -4,42 +4,60 @@
|
|
|
4
4
|
// for information about these interfaces
|
|
5
5
|
// and what to do when importing types
|
|
6
6
|
declare namespace App {
|
|
7
|
+
/** Per-request server-side locals populated by `hooks.server.ts`. */
|
|
7
8
|
interface Locals {
|
|
9
|
+
/** The authenticated user, or `undefined` when no valid session exists. */
|
|
8
10
|
user: User | undefined
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
// interface Platform {}
|
|
12
14
|
|
|
15
|
+
/** Private environment variables (server-side only). */
|
|
13
16
|
interface PrivateEnv {
|
|
14
17
|
// $env/static/private
|
|
18
|
+
/** PostgreSQL connection string. */
|
|
15
19
|
DATABASE_URL: string
|
|
20
|
+
/** Public-facing domain used to construct email links (e.g. `https://example.com`). */
|
|
16
21
|
DOMAIN: string
|
|
22
|
+
/** Secret key used to sign and verify JWTs. */
|
|
17
23
|
JWT_SECRET: string
|
|
24
|
+
/** SendGrid API key. */
|
|
18
25
|
SENDGRID_KEY: string
|
|
26
|
+
/** Default sender email address for outgoing mail. */
|
|
19
27
|
SENDGRID_SENDER: string
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/** Public environment variables (safe to expose to the client). */
|
|
22
31
|
interface PublicEnv {
|
|
23
32
|
// $env/static/public
|
|
33
|
+
/** Google OAuth 2.0 client ID for Google Sign-In. */
|
|
24
34
|
PUBLIC_GOOGLE_CLIENT_ID: string
|
|
25
35
|
}
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
/** Result returned by the `authenticate` and `register` SQL functions. */
|
|
28
39
|
interface AuthenticationResult {
|
|
40
|
+
/** HTTP status code to use when the operation fails. */
|
|
29
41
|
statusCode: NumericRange<400, 599>
|
|
42
|
+
/** Human-readable status message. */
|
|
30
43
|
status: string
|
|
44
|
+
/** The authenticated or registered user, if successful. */
|
|
31
45
|
user: User
|
|
46
|
+
/** The newly created session ID. */
|
|
32
47
|
sessionId: string
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
/** Raw login credentials submitted by the user. */
|
|
35
51
|
interface Credentials {
|
|
36
52
|
email: string
|
|
37
53
|
password: string
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
/** Persistent properties stored in the database for a user account. */
|
|
40
57
|
interface UserProperties {
|
|
41
58
|
id: number
|
|
42
|
-
|
|
59
|
+
/** ISO-8601 datetime at which the current session expires. */
|
|
60
|
+
expires?: string
|
|
43
61
|
role: 'student' | 'teacher' | 'admin'
|
|
44
62
|
password?: string
|
|
45
63
|
firstName?: string
|
|
@@ -48,9 +66,12 @@ interface UserProperties {
|
|
|
48
66
|
phone?: string
|
|
49
67
|
}
|
|
50
68
|
|
|
69
|
+
/** A user record, or `undefined`/`null` when unauthenticated. */
|
|
51
70
|
type User = UserProperties | undefined | null
|
|
52
71
|
|
|
72
|
+
/** A database session paired with its associated user. */
|
|
53
73
|
interface UserSession {
|
|
74
|
+
/** UUID session identifier stored in the `session` cookie. */
|
|
54
75
|
id: string
|
|
55
76
|
user: User
|
|
56
77
|
}
|
package/src/hooks.server.ts
CHANGED
|
@@ -2,13 +2,28 @@ import type { Handle, RequestEvent } from '@sveltejs/kit'
|
|
|
2
2
|
import { error } from '@sveltejs/kit'
|
|
3
3
|
import { query } from '$lib/server/db'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* In-memory IP-based rate limiter for sensitive auth endpoints.
|
|
7
|
+
* @remarks For multi-instance deployments, replace with a shared store like Redis.
|
|
8
|
+
*/
|
|
7
9
|
const ipRateLimit = new Map<string, { count: number; resetAt: number }>()
|
|
10
|
+
/** Duration of each rate-limit window in milliseconds (15 minutes). */
|
|
8
11
|
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
|
|
12
|
+
/** Maximum number of requests allowed per IP within {@link RATE_LIMIT_WINDOW_MS}. */
|
|
9
13
|
const RATE_LIMIT_MAX_REQUESTS = 20
|
|
14
|
+
/** Set of path prefixes that are subject to IP-based rate limiting. */
|
|
10
15
|
const RATE_LIMITED_PATHS = new Set(['/auth/login', '/auth/register', '/auth/forgot', '/auth/mfa'])
|
|
11
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Checks whether the given IP address is within its rate-limit allowance.
|
|
19
|
+
*
|
|
20
|
+
* Starts a new window if none exists or the previous one has expired. Once
|
|
21
|
+
* {@link RATE_LIMIT_MAX_REQUESTS} is reached within the window, subsequent
|
|
22
|
+
* calls return `false` until the window resets.
|
|
23
|
+
*
|
|
24
|
+
* @param ip - The client IP address to check.
|
|
25
|
+
* @returns `true` if the request is allowed, `false` if the limit is exceeded.
|
|
26
|
+
*/
|
|
12
27
|
function checkRateLimit(ip: string): boolean {
|
|
13
28
|
const now = Date.now()
|
|
14
29
|
const entry = ipRateLimit.get(ip)
|
|
@@ -21,7 +36,7 @@ function checkRateLimit(ip: string): boolean {
|
|
|
21
36
|
return true
|
|
22
37
|
}
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
/** Periodically removes expired entries from {@link ipRateLimit} to prevent unbounded memory growth. */
|
|
25
40
|
setInterval(
|
|
26
41
|
() => {
|
|
27
42
|
const now = Date.now()
|
|
@@ -32,7 +47,15 @@ setInterval(
|
|
|
32
47
|
60 * 60 * 1000
|
|
33
48
|
)
|
|
34
49
|
|
|
35
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Looks up the session in the database and attaches the associated user to
|
|
52
|
+
* `event.locals`. Also updates the session's last-active timestamp.
|
|
53
|
+
*
|
|
54
|
+
* Sets `event.locals.user` to `undefined` if the session is not found.
|
|
55
|
+
*
|
|
56
|
+
* @param sessionId - The session UUID read from the request cookie.
|
|
57
|
+
* @param event - The SvelteKit request event to attach the user to.
|
|
58
|
+
*/
|
|
36
59
|
async function attachUserToRequestEvent(sessionId: string, event: RequestEvent) {
|
|
37
60
|
const result = await query(
|
|
38
61
|
'SELECT get_and_update_session($1::uuid)',
|
|
@@ -42,7 +65,16 @@ async function attachUserToRequestEvent(sessionId: string, event: RequestEvent)
|
|
|
42
65
|
event.locals.user = result.rows[0]?.get_and_update_session // undefined if not found
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
|
|
68
|
+
/**
|
|
69
|
+
* SvelteKit server hook — invoked for every request before the endpoint or
|
|
70
|
+
* page load function runs.
|
|
71
|
+
*
|
|
72
|
+
* Responsibilities:
|
|
73
|
+
* - Short-circuits static asset requests (`/_app/`) immediately.
|
|
74
|
+
* - Enforces IP-based rate limiting on sensitive auth paths.
|
|
75
|
+
* - Resolves the session cookie to a user and populates `event.locals.user`.
|
|
76
|
+
* - Deletes a stale session cookie when no matching session is found.
|
|
77
|
+
*/
|
|
46
78
|
export const handle = (async ({ event, resolve }) => {
|
|
47
79
|
const { cookies, url } = event
|
|
48
80
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
/** Represents a toast notification shown to the user. */
|
|
1
2
|
interface Toast {
|
|
3
|
+
/** Short heading displayed at the top of the toast. */
|
|
2
4
|
title: string
|
|
5
|
+
/** Main message content of the toast. */
|
|
3
6
|
body: string
|
|
7
|
+
/** Whether the toast is currently visible. */
|
|
4
8
|
isOpen: boolean
|
|
5
9
|
}
|
|
6
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Reactive singleton holding global application state.
|
|
13
|
+
* Access via the exported {@link appState} instance.
|
|
14
|
+
*/
|
|
7
15
|
class AppState {
|
|
8
16
|
/** Currently logged-in user, undefined when not authenticated */
|
|
9
17
|
user = $state<User | undefined>(undefined)
|
package/src/lib/auth-redirect.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { page } from '$app/state'
|
|
|
4
4
|
/**
|
|
5
5
|
* Redirect the user to the appropriate page after a successful login,
|
|
6
6
|
* respecting an optional ?referrer= query parameter.
|
|
7
|
+
*
|
|
8
|
+
* @param user - The authenticated user. Redirects to a role-based default route
|
|
9
|
+
* (`/teachers`, `/admin`, or `/`) unless a valid same-origin `referrer` query
|
|
10
|
+
* parameter is present, in which case that path is used instead.
|
|
7
11
|
*/
|
|
8
12
|
export function redirectAfterLogin(user: User): void {
|
|
9
13
|
if (!user) return
|
package/src/lib/focus.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focuses the first invalid input element within a form.
|
|
3
|
+
*
|
|
4
|
+
* Iterates through the form's elements in DOM order and calls `focus()` on the
|
|
5
|
+
* first `HTMLInputElement` that fails constraint validation, then stops.
|
|
6
|
+
*
|
|
7
|
+
* @param form - The form element to search for invalid inputs.
|
|
8
|
+
*/
|
|
1
9
|
export const focusOnFirstError = (form: HTMLFormElement) => {
|
|
2
10
|
for (const field of form.elements) {
|
|
3
11
|
if (field instanceof HTMLInputElement && !field.checkValidity()) {
|
package/src/lib/google.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
|
|
|
2
2
|
import { appState } from '$lib/app-state.svelte'
|
|
3
3
|
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Renders the Google Sign-In button inside the element with id `googleButton`.
|
|
7
|
+
*
|
|
8
|
+
* Reads the element's width (falling back to its parent's width, then 400px)
|
|
9
|
+
* and passes it to the Google Identity Services SDK so the button scales
|
|
10
|
+
* correctly within its container.
|
|
11
|
+
*/
|
|
5
12
|
export function renderGoogleButton() {
|
|
6
13
|
const btn = document.getElementById('googleButton')
|
|
7
14
|
if (btn) {
|
|
@@ -15,6 +22,16 @@ export function renderGoogleButton() {
|
|
|
15
22
|
}
|
|
16
23
|
}
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Initializes the Google Identity Services SDK (once per session) and
|
|
27
|
+
* registers a callback that handles the credential response after the user
|
|
28
|
+
* signs in with Google.
|
|
29
|
+
*
|
|
30
|
+
* On a successful sign-in the callback:
|
|
31
|
+
* 1. POSTs the Google credential token to `/auth/google`.
|
|
32
|
+
* 2. Updates {@link appState.user} with the returned user.
|
|
33
|
+
* 3. Redirects the user via {@link redirectAfterLogin}.
|
|
34
|
+
*/
|
|
18
35
|
export function initializeGoogleAccounts() {
|
|
19
36
|
if (!appState.googleInitialized) {
|
|
20
37
|
google.accounts.id.initialize({
|
package/src/lib/server/db.ts
CHANGED
|
@@ -30,6 +30,15 @@ pool.on('error', (err: Error) => {
|
|
|
30
30
|
console.error('Unexpected error on idle client', err)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Executes a parameterized SQL query using the connection pool.
|
|
35
|
+
*
|
|
36
|
+
* @template T - The expected row type, extending QueryResultRow.
|
|
37
|
+
* @param sql - The SQL query string with optional $1, $2, ... placeholders.
|
|
38
|
+
* @param params - Optional positional parameter values to bind to the query.
|
|
39
|
+
* @param name - Optional name for the query to use a prepared statement.
|
|
40
|
+
* @returns A promise resolving to the typed QueryResult.
|
|
41
|
+
*/
|
|
33
42
|
queryFn = <T extends QueryResultRow>(
|
|
34
43
|
sql: string,
|
|
35
44
|
params?: (string | number | boolean | object | null)[],
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { SENDGRID_SENDER } from '$env/static/private'
|
|
2
2
|
import { sendMessage } from '$lib/server/sendgrid'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Sends a multi-factor authentication (MFA) verification code email to the user.
|
|
6
|
+
*
|
|
7
|
+
* @param toEmail - The recipient's email address.
|
|
8
|
+
* @param code - The one-time verification code to include in the email.
|
|
9
|
+
*/
|
|
4
10
|
export const sendMfaCodeEmail = async (toEmail: string, code: string) => {
|
|
5
11
|
await sendMessage({
|
|
6
12
|
to: { email: toEmail },
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { DOMAIN, SENDGRID_SENDER } from '$env/static/private'
|
|
2
2
|
import { sendMessage } from '$lib/server/sendgrid'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Sends a password reset email containing a link with a one-time reset token.
|
|
6
|
+
*
|
|
7
|
+
* @param toEmail - The recipient's email address.
|
|
8
|
+
* @param token - The password reset token to embed in the reset link.
|
|
9
|
+
*/
|
|
4
10
|
export const sendPasswordResetEmail = async (toEmail: string, token: string) => {
|
|
5
11
|
await sendMessage({
|
|
6
12
|
to: { email: toEmail },
|