sveltekit-auth-example 5.1.3 → 5.6.0

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 CHANGED
@@ -2,6 +2,25 @@
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.0
6
+
7
+ - Split `db_create.sql` into `db_create.sql` (role + database creation) and `db_schema.sql` (schema, functions, seed data)
8
+ - Add `db_create.sh` shell wrapper to run both files in sequence
9
+ - `db_schema.sql` is pure SQL and fully prettier-formattable
10
+ - README updated to use `bash db_create.sh`
11
+
12
+ # 5.5.0
13
+
14
+ - Multi-factor authentication (MFA) via email for local accounts (Google Sign In is exempt)
15
+ - 6-digit OTP code sent via SendGrid, expires in 10 minutes
16
+ - 30-day trusted-device cookie (signed JWT) suppresses MFA on subsequent logins from the same device
17
+ - New `mfa_codes` table in PostgreSQL with `create_mfa_code()` and `verify_mfa_code()` functions
18
+ - New `/auth/mfa` endpoint verifies the code, creates the session, and issues the trusted-device cookie
19
+ - Login page shows an inline MFA step when a code is required
20
+ - `/auth/mfa` added to rate-limited paths
21
+ - Extract email templates into dedicated files under `src/lib/server/email/` (`password-reset.ts`, `verify-email.ts`, `mfa-code.ts`)
22
+ - `src/lib/server/email/index.ts` re-exports all templates for clean imports
23
+
5
24
  # 5.1.3
6
25
 
7
26
  - Session timeout: automatically redirect to /login on session expiry via fetch interceptor (`src/lib/fetch-interceptor.ts`)
package/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # SvelteKit Authentication and Authorization Example
2
2
 
3
- [![GitHub release](https://img.shields.io/github/v/release/nstuyvesant/sveltekit-auth-example)](https://github.com/nstuyvesant/sveltekit-auth-example/releases)
4
3
  [![License](https://img.shields.io/github/license/nstuyvesant/sveltekit-auth-example)](https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE)
5
4
  [![Node](https://img.shields.io/node/v/sveltekit-auth-example)](https://nodejs.org)
6
5
  [![Svelte](https://img.shields.io/badge/Svelte-5-orange)](https://svelte.dev)
@@ -9,14 +8,14 @@ A complete, production-ready authentication and authorization starter for **Svel
9
8
 
10
9
  ## Features
11
10
 
12
- | | |
13
- |---|---|
14
- | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
15
- | ✅ Multi-factor authentication (MFA) | ✅ Email verification |
16
- | ✅ Forgot password / email reset (SendGrid) | ✅ User profile management |
17
- | ✅ Session management + timeout | ✅ Rate limiting |
18
- | ✅ Role-based access control | ✅ Password complexity enforcement |
19
- | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
11
+ | | |
12
+ | ---------------------------------------------- | --------------------------------------- |
13
+ | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
+ | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
+ | ✅ Forgot password / email reset (SendGrid) | ✅ User profile management |
16
+ | ✅ Session management + timeout | ✅ Rate limiting |
17
+ | ✅ Role-based access control | ✅ Password complexity enforcement |
18
+ | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
20
19
 
21
20
  ## Stack
22
21
 
@@ -45,7 +44,7 @@ cd sveltekit-auth-example
45
44
  yarn install
46
45
 
47
46
  # Create PostgreSQL database (only works if you have PostgreSQL installed)
48
- psql -d postgres -f db_create.sql
47
+ bash db_create.sh
49
48
  ```
50
49
 
51
50
  2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). Make sure you include `http://localhost:3000` and `http://localhost` in the Authorized JavaScript origins, and `http://localhost:3000/auth/google/callback` in the Authorized redirect URIs for your Client ID for Web application. **Do not access the site using http://127.0.0.1:3000** — use `http://localhost:3000` or it will not work.
@@ -85,9 +84,9 @@ yarn preview
85
84
 
86
85
  The db_create.sql script adds three users to the database with obvious roles:
87
86
 
88
- | Email | Password | Role |
89
- |---|---|---|
90
- | admin@example.com | admin123 | admin |
87
+ | Email | Password | Role |
88
+ | ------------------- | ---------- | ------- |
89
+ | admin@example.com | admin123 | admin |
91
90
  | teacher@example.com | teacher123 | teacher |
92
91
  | student@example.com | student123 | student |
93
92
 
package/db_create.sh ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Creates the auth role and database, then applies the schema.
5
+ # Usage: bash db_create.sh
6
+ #
7
+ # Optionally override the superuser with PGUSER env var:
8
+ # PGUSER=myuser bash db_create.sh
9
+
10
+ psql -d postgres -f db_create.sql
11
+ psql -d auth -f db_schema.sql
12
+
13
+ echo "Database created successfully."
package/db_create.sql CHANGED
@@ -1,9 +1,7 @@
1
- -- Invoke this script via
2
- -- $ psql -d postgres -f db_create.sql
3
-
1
+ -- Run via db_create.sh or:
2
+ -- $ psql -d postgres -f db_create.sql && psql -d auth -f db_schema.sql
4
3
  -- Create role if not already there
5
- DO
6
- $do$
4
+ DO $do$
7
5
  BEGIN
8
6
  IF NOT EXISTS (
9
7
  SELECT -- SELECT list can stay empty for this
@@ -16,26 +14,25 @@ END
16
14
  $do$;
17
15
 
18
16
  -- Forcefully disconnect anyone
19
- SELECT pid, pg_terminate_backend(pid)
20
- FROM pg_stat_activity
21
- WHERE datname = 'auth' AND pid <> pg_backend_pid();
17
+ SELECT
18
+ pid,
19
+ pg_terminate_backend(pid)
20
+ FROM
21
+ pg_stat_activity
22
+ WHERE
23
+ datname = 'auth'
24
+ AND pid <> pg_backend_pid();
22
25
 
23
26
  DROP DATABASE IF EXISTS auth;
24
27
 
25
28
  CREATE DATABASE auth
26
- WITH
27
- OWNER = auth
28
- ENCODING = 'UTF8'
29
- CONNECTION LIMIT = -1;
29
+ WITH
30
+ OWNER = auth ENCODING = 'UTF8' CONNECTION
31
+ LIMIT
32
+ = -1;
30
33
 
31
34
  COMMENT ON DATABASE auth IS 'SvelteKit Auth Example';
32
35
 
33
- -- Connect to auth database
34
- \connect auth
35
-
36
- -- Required for password hashing
37
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
38
-
39
36
  -- Required to generate UUIDs for sessions
40
37
  CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
41
38
 
@@ -43,16 +40,15 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
43
40
  CREATE EXTENSION IF NOT EXISTS citext;
44
41
 
45
42
  -- Using hard-coded roles (often this would be a table)
46
- CREATE TYPE public.roles AS ENUM
47
- ('student', 'teacher', 'admin');
43
+ CREATE TYPE public.roles AS ENUM('student', 'teacher', 'admin');
48
44
 
49
45
  ALTER TYPE public.roles OWNER TO auth;
50
46
 
51
47
  -- Domains
52
48
  CREATE DOMAIN public.email_address AS citext CHECK (
53
- length(VALUE) <= 254
54
- AND VALUE = btrim(VALUE)
55
- AND VALUE ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
49
+ length(VALUE) <= 254
50
+ AND VALUE = btrim(VALUE)
51
+ AND VALUE ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
56
52
  );
57
53
 
58
54
  COMMENT ON DOMAIN public.email_address IS 'RFC-compliant email address (case-insensitive, max 254 chars)';
@@ -62,65 +58,54 @@ CREATE DOMAIN public.persons_name AS text CHECK (length(VALUE) <= 20) NOT NULL;
62
58
  COMMENT ON DOMAIN public.persons_name IS 'Person first or last name (max 20 characters)';
63
59
 
64
60
  CREATE DOMAIN public.phone_number AS text CHECK (
65
- VALUE IS NULL
66
- OR length(VALUE) <= 50
61
+ VALUE IS NULL
62
+ OR length(VALUE) <= 50
67
63
  );
68
64
 
69
65
  COMMENT ON DOMAIN public.phone_number IS 'Phone number (max 50 characters)';
70
66
 
71
67
  CREATE TABLE IF NOT EXISTS public.users (
72
- id integer NOT NULL GENERATED ALWAYS AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
73
- role roles NOT NULL DEFAULT 'student'::roles,
74
- email email_address NOT NULL,
75
- password character varying(80) COLLATE pg_catalog."default",
76
- first_name persons_name,
77
- last_name persons_name,
78
- opt_out boolean NOT NULL DEFAULT false,
79
- email_verified boolean NOT NULL DEFAULT false,
80
- phone phone_number,
81
- CONSTRAINT users_pkey PRIMARY KEY (id),
82
- CONSTRAINT users_email_unique UNIQUE (email)
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)
83
81
  ) TABLESPACE pg_default;
84
82
 
85
83
  ALTER TABLE public.users OWNER to auth;
86
84
 
87
- CREATE INDEX users_first_name_index
88
- ON public.users USING btree
89
- (first_name COLLATE pg_catalog."default" ASC NULLS LAST)
90
- TABLESPACE pg_default;
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;
91
88
 
92
- CREATE INDEX users_last_name_index
93
- ON public.users USING btree
94
- (last_name COLLATE pg_catalog."default" ASC NULLS LAST)
95
- TABLESPACE pg_default;
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;
96
92
 
97
- CREATE INDEX users_password
98
- ON public.users USING btree
99
- (password COLLATE pg_catalog."default" ASC NULLS LAST)
100
- TABLESPACE pg_default;
93
+ CREATE INDEX users_password ON public.users USING btree (
94
+ password COLLATE pg_catalog."default" ASC NULLS LAST
95
+ ) TABLESPACE pg_default;
101
96
 
102
97
  CREATE TABLE IF NOT EXISTS public.sessions (
103
- id uuid NOT NULL DEFAULT uuid_generate_v4(),
104
- user_id integer NOT NULL,
105
- expires timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '2 hours'),
106
- CONSTRAINT sessions_pkey PRIMARY KEY (id),
107
- CONSTRAINT sessions_user_fkey FOREIGN KEY (user_id)
108
- REFERENCES public.users (id) MATCH SIMPLE
109
- ON UPDATE CASCADE
110
- ON DELETE CASCADE,
111
- CONSTRAINT sessions_one_per_user UNIQUE (user_id)
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)
112
104
  ) TABLESPACE pg_default;
113
105
 
114
106
  ALTER TABLE public.sessions OWNER to auth;
115
107
 
116
- CREATE OR REPLACE FUNCTION public.authenticate(
117
- input json,
118
- OUT response json)
119
- RETURNS json
120
- LANGUAGE 'plpgsql'
121
- COST 100
122
- VOLATILE PARALLEL UNSAFE
123
- AS $BODY$
108
+ CREATE OR REPLACE FUNCTION public.authenticate (input json, OUT response json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
124
109
  DECLARE
125
110
  input_email text := trim(input->>'email');
126
111
  input_password text := input->>'password';
@@ -149,15 +134,9 @@ BEGIN
149
134
  END;
150
135
  $BODY$;
151
136
 
152
- ALTER FUNCTION public.authenticate(json) OWNER TO auth;
137
+ ALTER FUNCTION public.authenticate (json) OWNER TO auth;
153
138
 
154
- CREATE OR REPLACE FUNCTION public.create_session(
155
- input_user_id integer)
156
- RETURNS uuid
157
- LANGUAGE 'sql'
158
- COST 100
159
- VOLATILE PARALLEL UNSAFE
160
- AS $BODY$
139
+ CREATE OR REPLACE FUNCTION public.create_session (input_user_id integer) RETURNS uuid LANGUAGE 'sql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
161
140
  -- Remove expired sessions (index-friendly cleanup)
162
141
  DELETE FROM sessions WHERE expires < CURRENT_TIMESTAMP;
163
142
  -- Remove any existing session(s) for this user
@@ -166,12 +145,9 @@ AS $BODY$
166
145
  INSERT INTO sessions(user_id) VALUES (input_user_id) RETURNING sessions.id;
167
146
  $BODY$;
168
147
 
169
- ALTER FUNCTION public.create_session(integer) OWNER TO auth;
148
+ ALTER FUNCTION public.create_session (integer) OWNER TO auth;
170
149
 
171
- CREATE OR REPLACE FUNCTION public.get_session(input_session_id uuid)
172
- RETURNS json
173
- LANGUAGE 'sql'
174
- AS $BODY$
150
+ CREATE OR REPLACE FUNCTION public.get_session (input_session_id uuid) RETURNS json LANGUAGE 'sql' AS $BODY$
175
151
  SELECT json_build_object(
176
152
  'id', sessions.user_id,
177
153
  'role', users.role,
@@ -187,14 +163,11 @@ FROM sessions
187
163
  WHERE sessions.id = input_session_id AND expires > CURRENT_TIMESTAMP LIMIT 1;
188
164
  $BODY$;
189
165
 
190
- ALTER FUNCTION public.get_session(uuid) OWNER TO auth;
166
+ ALTER FUNCTION public.get_session (uuid) OWNER TO auth;
191
167
 
192
168
  -- Like get_session but also bumps the expiry (sliding sessions).
193
169
  -- Returns NULL if the session is expired or does not exist.
194
- CREATE OR REPLACE FUNCTION public.get_and_update_session(input_session_id uuid)
195
- RETURNS json
196
- LANGUAGE 'plpgsql'
197
- AS $BODY$
170
+ CREATE OR REPLACE FUNCTION public.get_and_update_session (input_session_id uuid) RETURNS json LANGUAGE 'plpgsql' AS $BODY$
198
171
  DECLARE
199
172
  result json;
200
173
  BEGIN
@@ -224,14 +197,9 @@ BEGIN
224
197
  END;
225
198
  $BODY$;
226
199
 
227
- ALTER FUNCTION public.get_and_update_session(uuid) OWNER TO auth;
200
+ ALTER FUNCTION public.get_and_update_session (uuid) OWNER TO auth;
228
201
 
229
- CREATE OR REPLACE FUNCTION public.verify_email_and_create_session(input_id integer)
230
- RETURNS uuid
231
- LANGUAGE 'plpgsql'
232
- COST 100
233
- VOLATILE PARALLEL UNSAFE
234
- AS $BODY$
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$
235
203
  DECLARE
236
204
  session_id uuid;
237
205
  BEGIN
@@ -241,16 +209,9 @@ BEGIN
241
209
  END;
242
210
  $BODY$;
243
211
 
244
- ALTER FUNCTION public.verify_email_and_create_session(integer) OWNER TO auth;
212
+ ALTER FUNCTION public.verify_email_and_create_session (integer) OWNER TO auth;
245
213
 
246
- CREATE OR REPLACE FUNCTION public.register(
247
- input json,
248
- OUT user_session json)
249
- RETURNS json
250
- LANGUAGE 'plpgsql'
251
- COST 100
252
- VOLATILE PARALLEL UNSAFE
253
- AS $BODY$
214
+ CREATE OR REPLACE FUNCTION public.register (input json, OUT user_session json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
254
215
  DECLARE
255
216
  input_email text := trim(input->>'email');
256
217
  input_first_name text := trim(input->>'firstName');
@@ -273,16 +234,9 @@ BEGIN
273
234
  END;
274
235
  $BODY$;
275
236
 
276
- ALTER FUNCTION public.register(json) OWNER TO auth;
237
+ ALTER FUNCTION public.register (json) OWNER TO auth;
277
238
 
278
- CREATE OR REPLACE FUNCTION public.start_gmail_user_session(
279
- input json,
280
- OUT user_session json)
281
- RETURNS json
282
- LANGUAGE 'plpgsql'
283
- COST 100
284
- VOLATILE PARALLEL UNSAFE
285
- AS $BODY$
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$
286
240
  DECLARE
287
241
  input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
288
242
  input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
@@ -303,36 +257,27 @@ BEGIN
303
257
  END;
304
258
  $BODY$;
305
259
 
306
- ALTER FUNCTION public.start_gmail_user_session(json) OWNER TO auth;
260
+ ALTER FUNCTION public.start_gmail_user_session (json) OWNER TO auth;
307
261
 
308
- CREATE PROCEDURE public.delete_session(input_id integer)
309
- LANGUAGE sql
310
- AS $$
262
+ CREATE PROCEDURE public.delete_session (input_id integer) LANGUAGE sql AS $$
311
263
  DELETE FROM sessions WHERE user_id = input_id;
312
264
  $$;
313
265
 
314
- CREATE OR REPLACE PROCEDURE public.delete_user(input_id integer)
315
- LANGUAGE sql
316
- AS $$
266
+ CREATE OR REPLACE PROCEDURE public.delete_user (input_id integer) LANGUAGE sql AS $$
317
267
  DELETE FROM users WHERE id = input_id;
318
268
  $$;
319
269
 
320
- ALTER PROCEDURE public.delete_user(integer) OWNER TO auth;
270
+ ALTER PROCEDURE public.delete_user (integer) OWNER TO auth;
321
271
 
322
- CREATE OR REPLACE PROCEDURE public.reset_password(IN input_id integer, IN input_password text)
323
- LANGUAGE plpgsql
324
- AS $procedure$
272
+ CREATE OR REPLACE PROCEDURE public.reset_password (IN input_id integer, IN input_password text) LANGUAGE plpgsql AS $procedure$
325
273
  BEGIN
326
274
  UPDATE users SET password = crypt(input_password, gen_salt('bf', 12)) WHERE id = input_id;
327
275
  END;
328
- $procedure$
329
- ;
276
+ $procedure$;
330
277
 
331
- ALTER PROCEDURE public.reset_password(integer, text) OWNER TO auth;
278
+ ALTER PROCEDURE public.reset_password (integer, text) OWNER TO auth;
332
279
 
333
- CREATE OR REPLACE PROCEDURE public.upsert_user(input json)
334
- LANGUAGE plpgsql
335
- AS $BODY$
280
+ CREATE OR REPLACE PROCEDURE public.upsert_user (input json) LANGUAGE plpgsql AS $BODY$
336
281
  DECLARE
337
282
  input_id integer := COALESCE((input->>'id')::integer,0);
338
283
  input_role roles := COALESCE((input->>'role')::roles, 'student');
@@ -364,11 +309,9 @@ BEGIN
364
309
  END;
365
310
  $BODY$;
366
311
 
367
- ALTER PROCEDURE public.upsert_user(json) OWNER TO auth;
312
+ ALTER PROCEDURE public.upsert_user (json) OWNER TO auth;
368
313
 
369
- CREATE OR REPLACE PROCEDURE public.update_user(input_id integer, input json)
370
- LANGUAGE plpgsql
371
- AS $BODY$
314
+ CREATE OR REPLACE PROCEDURE public.update_user (input_id integer, input json) LANGUAGE plpgsql AS $BODY$
372
315
  DECLARE
373
316
  input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
374
317
  input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
@@ -389,8 +332,67 @@ BEGIN
389
332
  END;
390
333
  $BODY$;
391
334
 
392
- ALTER PROCEDURE public.update_user(integer, json) OWNER TO auth;
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
+ );
393
391
 
394
- CALL public.upsert_user('{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json);
395
- CALL public.upsert_user('{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json);
396
- CALL public.upsert_user('{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json);
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
+ );