sveltekit-auth-example 5.1.1 → 5.1.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/CHANGELOG.md +28 -1
- package/README.md +70 -40
- package/db_create.sql +42 -5
- package/package.json +2 -2
- package/src/app.html +1 -0
- package/src/hooks.server.ts +6 -2
- package/src/lib/auth-redirect.ts +1 -1
- package/src/lib/fetch-interceptor.ts +23 -0
- package/src/lib/google.ts +3 -2
- package/src/routes/+layout.svelte +17 -15
- package/src/routes/auth/forgot/+server.ts +1 -1
- package/src/routes/auth/google/+server.ts +2 -4
- package/src/routes/auth/register/+server.ts +4 -0
- package/src/routes/auth/reset/+server.ts +1 -0
- package/src/routes/layout.css +7 -3
- package/src/routes/login/+page.svelte +14 -2
- package/src/routes/profile/+page.svelte +1 -1
- package/src/routes/register/+page.svelte +14 -2
- package/svelte.config.js +1 -1
- package/vite.config.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,36 @@
|
|
|
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.1.3
|
|
6
|
+
|
|
7
|
+
- Session timeout: automatically redirect to /login on session expiry via fetch interceptor (`src/lib/fetch-interceptor.ts`)
|
|
8
|
+
- Add database-level session cleanup on expiry
|
|
9
|
+
- Strengthen Content Security Policy (CSP) in svelte.config.js and vite.config.ts
|
|
10
|
+
- Harden auth endpoints (forgot password, Google OAuth, register, password reset)
|
|
11
|
+
|
|
12
|
+
# 5.1.2
|
|
13
|
+
|
|
14
|
+
- Dark mode
|
|
15
|
+
- Rate limiting on auth endpoints
|
|
16
|
+
- Email verification for new registrations
|
|
17
|
+
- Refactor state management: replace `stores.ts` with Svelte 5 `$state`-based `AppState` class (`src/lib/app-state.svelte.ts`)
|
|
18
|
+
- Split single `[slug]` auth handler into dedicated endpoints: login, register, google, logout, reset
|
|
19
|
+
- Add `auth-redirect.ts` helper for consistent post-auth redirects
|
|
20
|
+
- Password complexity enforcement on register and profile pages
|
|
21
|
+
- Add `/api/v1/user` profile update endpoint
|
|
22
|
+
- Service worker improvements
|
|
23
|
+
- Performance improvements in session lookup and DB queries
|
|
24
|
+
- Rename `.env.sample` to `.env.example`
|
|
25
|
+
|
|
5
26
|
# 5.1.0
|
|
6
27
|
|
|
7
|
-
- Convert to Tailwind CSS
|
|
28
|
+
- Convert to Tailwind CSS 4
|
|
29
|
+
- Remove Bootstrap dependency
|
|
30
|
+
- Modernize PostgreSQL schema and stored functions
|
|
31
|
+
- Add Playwright end-to-end test configuration
|
|
32
|
+
- Add MCP configuration (`.vscode/mcp.json`) for Svelte MCP server
|
|
33
|
+
- Add `AGENTS.md` coding agent instructions
|
|
34
|
+
- Bump dependencies
|
|
8
35
|
|
|
9
36
|
# 5.0.4
|
|
10
37
|
|
package/README.md
CHANGED
|
@@ -1,68 +1,62 @@
|
|
|
1
1
|
# SvelteKit Authentication and Authorization Example
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/nstuyvesant/sveltekit-auth-example/releases)
|
|
4
|
+
[](https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](https://svelte.dev)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, and OWASP-compliant password hashing out of the box.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
## Features
|
|
8
11
|
|
|
9
|
-
|
|
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 |
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
## Stack
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- The client stores the user object in the loginSession store.
|
|
18
|
-
- Further requests to the server include the session cookie. The hooks.ts handle() method extracts the session cookie, looks up the user and attaches it to RequestEvent.locals so server-side code can check locals.user.role to see if the request is authorized and return an HTTP 401 status if not.
|
|
19
|
-
2. **Sign in with Google**
|
|
20
|
-
- **Sign in with Google** is initialized in /src/routes/+layout.svelte.
|
|
21
|
-
- **Google One Tap** prompt is displayed on the initially loaded page unless [Intelligent Tracking Prevention is enabled in the browser](https://developers.google.com/identity/gsi/web/guides/features#upgraded_ux_on_itp_browsers).
|
|
22
|
-
- **Sign in with Google** button is on the login page (/src/routes/login/+page.svelte) and register page (/src/routes/register/+page.svelte).
|
|
23
|
-
- Clicking either button opens a new window asking the user to authorize this website. If the user OKs it, a JSON Web Token (JWT) is sent to a callback function.
|
|
24
|
-
- The callback function (in /src/lib/auth.ts) sends the JWT to an endpoint on this server /auth/google.
|
|
25
|
-
- 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.
|
|
26
|
-
- The client stores the user object in the loginSession store.
|
|
27
|
-
- Further requests to the server work identically to local accounts above.
|
|
28
|
-
|
|
29
|
-
> There is some overhead to checking the user session in a database each time versus using a JWT; 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.
|
|
23
|
+
- **[SvelteKit](https://kit.svelte.dev)** — Single Page Application
|
|
24
|
+
- **[PostgreSQL 16+](https://www.postgresql.org)** — database with server-side hashing
|
|
25
|
+
- **[Tailwind CSS 4](https://tailwindcss.com)** — styling
|
|
26
|
+
- **TypeScript** — end-to-end type safety
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
## Requirements
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
- Google API client
|
|
38
|
-
- Twilio SendGrid account (only used for emailing password reset link - the sample can run without it but forgot password will not work)
|
|
30
|
+
- Node.js 24.14.0 or later
|
|
31
|
+
- PostgreSQL 16 or later
|
|
32
|
+
- A [SendGrid](https://sendgrid.com) account (for password reset emails)
|
|
33
|
+
- A [Google API client ID](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) (for Sign in with Google)
|
|
39
34
|
|
|
40
35
|
## Setting up the project
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
1. Get the project and setup the database
|
|
37
|
+
1. Get the project and set up the database
|
|
45
38
|
|
|
46
39
|
```bash
|
|
47
40
|
# Clone the repo to your current directory
|
|
48
41
|
git clone https://github.com/nstuyvesant/sveltekit-auth-example.git
|
|
49
42
|
|
|
50
43
|
# Install the dependencies
|
|
51
|
-
cd
|
|
44
|
+
cd sveltekit-auth-example
|
|
52
45
|
yarn install
|
|
53
46
|
|
|
54
|
-
# Create PostgreSQL database (only works if you
|
|
47
|
+
# Create PostgreSQL database (only works if you have PostgreSQL installed)
|
|
55
48
|
psql -d postgres -f db_create.sql
|
|
56
49
|
```
|
|
57
50
|
|
|
58
|
-
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
|
|
51
|
+
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.
|
|
59
52
|
|
|
60
53
|
3. [Create a free Twilio SendGrid account](https://signup.sendgrid.com) and generate an API Key following [this documentation](https://docs.sendgrid.com/ui/account-and-settings/api-keys) and add a sender as documented [here](https://docs.sendgrid.com/ui/sending-email/senders).
|
|
61
54
|
|
|
62
|
-
4. Create
|
|
55
|
+
4. Create a **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
|
|
63
56
|
|
|
64
57
|
```bash
|
|
65
58
|
DATABASE_URL=postgres://user:password@localhost:5432/auth
|
|
59
|
+
DATABASE_SSL=false
|
|
66
60
|
DOMAIN=http://localhost:3000
|
|
67
61
|
JWT_SECRET=replace_with_your_own
|
|
68
62
|
SENDGRID_KEY=replace_with_your_own
|
|
@@ -73,14 +67,50 @@ PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
|
73
67
|
## Run locally
|
|
74
68
|
|
|
75
69
|
```bash
|
|
76
|
-
# Start the server and open the app in a new browser tab
|
|
70
|
+
# Start the dev server and open the app in a new browser tab
|
|
77
71
|
yarn dev -- --open
|
|
78
72
|
```
|
|
79
73
|
|
|
74
|
+
## Build and preview
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Build for production
|
|
78
|
+
yarn build
|
|
79
|
+
|
|
80
|
+
# Preview the production build
|
|
81
|
+
yarn preview
|
|
82
|
+
```
|
|
83
|
+
|
|
80
84
|
## Valid logins
|
|
81
85
|
|
|
82
86
|
The db_create.sql script adds three users to the database with obvious roles:
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
| Email | Password | Role |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| admin@example.com | admin123 | admin |
|
|
91
|
+
| teacher@example.com | teacher123 | teacher |
|
|
92
|
+
| student@example.com | student123 | student |
|
|
93
|
+
|
|
94
|
+
## How it works
|
|
95
|
+
|
|
96
|
+
The website supports two types of authentication:
|
|
97
|
+
|
|
98
|
+
1. **Local accounts** via username (email) and password
|
|
99
|
+
- The login form (/src/routes/login/+page.svelte) sends the login info as JSON to endpoint /auth/login
|
|
100
|
+
- 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).
|
|
101
|
+
- The endpoint sends this session ID as an httpOnly SameSite cookie and the user object in the body of the response.
|
|
102
|
+
- The client stores the user object in `appState` (see /src/lib/app-state.svelte.ts).
|
|
103
|
+
- Further requests to the server include the session cookie. The hooks.ts handle() method extracts the session cookie, looks up the user and attaches it to RequestEvent.locals so server-side code can check locals.user.role to see if the request is authorized and return an HTTP 401 status if not.
|
|
104
|
+
2. **Sign in with Google**
|
|
105
|
+
- **Sign in with Google** is initialized in /src/routes/+layout.svelte.
|
|
106
|
+
- **Google One Tap** prompt is displayed on the initially loaded page unless [Intelligent Tracking Prevention is enabled in the browser](https://developers.google.com/identity/gsi/web/guides/features#upgraded_ux_on_itp_browsers).
|
|
107
|
+
- **Sign in with Google** button is on the login page (/src/routes/login/+page.svelte) and register page (/src/routes/register/+page.svelte).
|
|
108
|
+
- Clicking either button opens a new window asking the user to authorize this website. If the user OKs it, a JSON Web Token (JWT) is sent to a callback function.
|
|
109
|
+
- The callback function (in /src/lib/google.ts) sends the JWT to an endpoint on this server /auth/google.
|
|
110
|
+
- The endpoint decodes and validates the user information then calls the PostgreSQL function start_gmail_user_session to upsert the user to the database returning a session id in an httpOnly SameSite cookie and user in the body of the response.
|
|
111
|
+
- The client stores the user object in `appState` (see /src/lib/app-state.svelte.ts).
|
|
112
|
+
- Further requests to the server work identically to local accounts above.
|
|
113
|
+
|
|
114
|
+
> There is some overhead to checking the user session in a database each time versus using a JWT; however, validating each request avoids problems discussed in [this article](https://redis.com/blog/json-web-tokens-jwt-are-dangerous-for-user-sessions/). For a high-volume website, I would use Redis or the equivalent.
|
|
115
|
+
|
|
116
|
+
The forgot password / password reset functionality uses a JWT and [**SendGrid**](https://www.sendgrid.com) to send the email. You would need to have a **SendGrid** account and set two environmental variables. Email sending is in /src/routes/auth/forgot/+server.ts. This code could easily be replaced by nodemailer or something similar. Note: I have no affiliation with **SendGrid** (used their API in another project).
|
package/db_create.sql
CHANGED
|
@@ -189,6 +189,43 @@ $BODY$;
|
|
|
189
189
|
|
|
190
190
|
ALTER FUNCTION public.get_session(uuid) OWNER TO auth;
|
|
191
191
|
|
|
192
|
+
-- Like get_session but also bumps the expiry (sliding sessions).
|
|
193
|
+
-- 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$
|
|
198
|
+
DECLARE
|
|
199
|
+
result json;
|
|
200
|
+
BEGIN
|
|
201
|
+
UPDATE sessions
|
|
202
|
+
SET expires = CURRENT_TIMESTAMP + INTERVAL '2 hours'
|
|
203
|
+
WHERE id = input_session_id AND expires > CURRENT_TIMESTAMP;
|
|
204
|
+
|
|
205
|
+
IF NOT FOUND THEN
|
|
206
|
+
RETURN NULL;
|
|
207
|
+
END IF;
|
|
208
|
+
|
|
209
|
+
SELECT json_build_object(
|
|
210
|
+
'id', sessions.user_id,
|
|
211
|
+
'role', users.role,
|
|
212
|
+
'email', users.email,
|
|
213
|
+
'firstName', users.first_name,
|
|
214
|
+
'lastName', users.last_name,
|
|
215
|
+
'phone', users.phone,
|
|
216
|
+
'optOut', users.opt_out,
|
|
217
|
+
'expires', sessions.expires
|
|
218
|
+
) INTO result
|
|
219
|
+
FROM sessions
|
|
220
|
+
INNER JOIN users ON sessions.user_id = users.id
|
|
221
|
+
WHERE sessions.id = input_session_id;
|
|
222
|
+
|
|
223
|
+
RETURN result;
|
|
224
|
+
END;
|
|
225
|
+
$BODY$;
|
|
226
|
+
|
|
227
|
+
ALTER FUNCTION public.get_and_update_session(uuid) OWNER TO auth;
|
|
228
|
+
|
|
192
229
|
CREATE OR REPLACE FUNCTION public.verify_email_and_create_session(input_id integer)
|
|
193
230
|
RETURNS uuid
|
|
194
231
|
LANGUAGE 'plpgsql'
|
|
@@ -224,7 +261,7 @@ BEGIN
|
|
|
224
261
|
PERFORM id FROM users WHERE email = input_email;
|
|
225
262
|
IF NOT FOUND THEN
|
|
226
263
|
INSERT INTO users(role, password, email, first_name, last_name, phone)
|
|
227
|
-
VALUES('student', crypt(input_password, gen_salt('bf',
|
|
264
|
+
VALUES('student', crypt(input_password, gen_salt('bf', 12)), input_email, input_first_name, input_last_name, input_phone)
|
|
228
265
|
RETURNING
|
|
229
266
|
json_build_object(
|
|
230
267
|
'sessionId', create_session(users.id),
|
|
@@ -286,7 +323,7 @@ CREATE OR REPLACE PROCEDURE public.reset_password(IN input_id integer, IN input_
|
|
|
286
323
|
LANGUAGE plpgsql
|
|
287
324
|
AS $procedure$
|
|
288
325
|
BEGIN
|
|
289
|
-
UPDATE users SET password = crypt(input_password, gen_salt('bf',
|
|
326
|
+
UPDATE users SET password = crypt(input_password, gen_salt('bf', 12)) WHERE id = input_id;
|
|
290
327
|
END;
|
|
291
328
|
$procedure$
|
|
292
329
|
;
|
|
@@ -308,7 +345,7 @@ BEGIN
|
|
|
308
345
|
IF input_id = 0 THEN
|
|
309
346
|
INSERT INTO users (role, email, password, first_name, last_name, phone, email_verified)
|
|
310
347
|
VALUES (
|
|
311
|
-
input_role, input_email, crypt(input_password, gen_salt('bf',
|
|
348
|
+
input_role, input_email, crypt(input_password, gen_salt('bf', 12)),
|
|
312
349
|
input_first_name, input_last_name, input_phone, true);
|
|
313
350
|
ELSE
|
|
314
351
|
UPDATE users SET
|
|
@@ -317,7 +354,7 @@ BEGIN
|
|
|
317
354
|
email_verified = true,
|
|
318
355
|
password = CASE WHEN input_password = ''
|
|
319
356
|
THEN password -- leave as is (we are updating fields other than the password)
|
|
320
|
-
ELSE crypt(input_password, gen_salt('bf',
|
|
357
|
+
ELSE crypt(input_password, gen_salt('bf', 12))
|
|
321
358
|
END,
|
|
322
359
|
first_name = input_first_name,
|
|
323
360
|
last_name = input_last_name,
|
|
@@ -343,7 +380,7 @@ BEGIN
|
|
|
343
380
|
email = input_email,
|
|
344
381
|
password = CASE WHEN input_password = ''
|
|
345
382
|
THEN password -- leave as is (we are updating fields other than the password)
|
|
346
|
-
ELSE crypt(input_password, gen_salt('bf',
|
|
383
|
+
ELSE crypt(input_password, gen_salt('bf', 12))
|
|
347
384
|
END,
|
|
348
385
|
first_name = input_first_name,
|
|
349
386
|
last_name = input_last_name,
|
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.1.
|
|
4
|
+
"version": "5.1.3",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@sendgrid/mail": "^8.1.6",
|
|
39
39
|
"google-auth-library": "^10.6.1",
|
|
40
|
+
"jsonwebtoken": "^9.0.3",
|
|
40
41
|
"pg": "^8.20.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
@@ -55,7 +56,6 @@
|
|
|
55
56
|
"eslint-config-prettier": "^10.1.8",
|
|
56
57
|
"eslint-plugin-svelte": "^3.15.2",
|
|
57
58
|
"globals": "^17.4.0",
|
|
58
|
-
"jsonwebtoken": "^9.0.3",
|
|
59
59
|
"playwright": "^1.58.2",
|
|
60
60
|
"prettier": "^3.8.1",
|
|
61
61
|
"prettier-plugin-sql": "^0.19.2",
|
package/src/app.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" sizes="any" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
8
|
<script
|
|
8
9
|
nonce="%sveltekit.nonce%"
|
|
9
10
|
src="https://accounts.google.com/gsi/client"
|
package/src/hooks.server.ts
CHANGED
|
@@ -31,8 +31,12 @@ setInterval(() => {
|
|
|
31
31
|
|
|
32
32
|
// Attach authorization to each server request (role may have changed)
|
|
33
33
|
async function attachUserToRequestEvent(sessionId: string, event: RequestEvent) {
|
|
34
|
-
const result = await query(
|
|
35
|
-
|
|
34
|
+
const result = await query(
|
|
35
|
+
'SELECT get_and_update_session($1::uuid)',
|
|
36
|
+
[sessionId],
|
|
37
|
+
'get-and-update-session'
|
|
38
|
+
)
|
|
39
|
+
event.locals.user = result.rows[0]?.get_and_update_session // undefined if not found
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// Invoked for each endpoint called and initially for SSR router
|
package/src/lib/auth-redirect.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { page } from '$app/state'
|
|
|
8
8
|
export function redirectAfterLogin(user: User): void {
|
|
9
9
|
if (!user) return
|
|
10
10
|
const referrer = page.url.searchParams.get('referrer')
|
|
11
|
-
if (referrer) {
|
|
11
|
+
if (referrer && referrer.startsWith('/') && !referrer.startsWith('//')) {
|
|
12
12
|
goto(referrer)
|
|
13
13
|
return
|
|
14
14
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { goto } from '$app/navigation'
|
|
2
|
+
import { page } from '$app/state'
|
|
3
|
+
import { appState } from '$lib/app-state.svelte'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Monkey-patches window.fetch to intercept 401 responses.
|
|
7
|
+
* When a 401 is received while a user is logged in, the session has expired:
|
|
8
|
+
* clear app state and redirect to /login with the current path as the referrer.
|
|
9
|
+
*
|
|
10
|
+
* Call once from +layout.svelte's onMount.
|
|
11
|
+
*/
|
|
12
|
+
export function setupFetchInterceptor() {
|
|
13
|
+
const originalFetch = window.fetch
|
|
14
|
+
window.fetch = async (...args) => {
|
|
15
|
+
const response = await originalFetch(...args)
|
|
16
|
+
if (response.status === 401 && appState.user) {
|
|
17
|
+
appState.user = undefined
|
|
18
|
+
const referrer = encodeURIComponent(page.url.pathname + page.url.search)
|
|
19
|
+
goto(`/login?referrer=${referrer}`)
|
|
20
|
+
}
|
|
21
|
+
return response
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/lib/google.ts
CHANGED
|
@@ -5,11 +5,12 @@ import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
|
5
5
|
export function renderGoogleButton() {
|
|
6
6
|
const btn = document.getElementById('googleButton')
|
|
7
7
|
if (btn) {
|
|
8
|
+
const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
|
|
8
9
|
google.accounts.id.renderButton(btn, {
|
|
9
10
|
type: 'standard',
|
|
10
|
-
theme: '
|
|
11
|
+
theme: 'outline',
|
|
11
12
|
size: 'large',
|
|
12
|
-
width:
|
|
13
|
+
width: Math.floor(width)
|
|
13
14
|
})
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { goto, beforeNavigate } from '$app/navigation'
|
|
5
5
|
import { appState } from '$lib/app-state.svelte'
|
|
6
6
|
import { initializeGoogleAccounts } from '$lib/google'
|
|
7
|
+
import { setupFetchInterceptor } from '$lib/fetch-interceptor'
|
|
7
8
|
|
|
8
9
|
import './layout.css'
|
|
9
10
|
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
onMount(() => {
|
|
43
|
+
setupFetchInterceptor()
|
|
42
44
|
initializeGoogleAccounts()
|
|
43
45
|
if (!appState.user) google.accounts.id.prompt()
|
|
44
46
|
})
|
|
@@ -55,13 +57,13 @@
|
|
|
55
57
|
|
|
56
58
|
<svelte:window onclick={handleWindowClick} />
|
|
57
59
|
|
|
58
|
-
<nav class="tw:bg-gray-100 tw:border-b tw:border-gray-200">
|
|
60
|
+
<nav class="tw:bg-gray-100 tw:border-b tw:border-gray-200 tw:dark:bg-gray-900 tw:dark:border-gray-700">
|
|
59
61
|
<div class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:flex tw:items-center tw:justify-between tw:h-14">
|
|
60
|
-
<a class="tw:font-semibold tw:text-gray-800 tw:no-underline" href="/">SvelteKit-Auth-Example</a>
|
|
62
|
+
<a class="tw:font-semibold tw:text-gray-800 tw:no-underline tw:dark:text-gray-100" href="/">SvelteKit-Auth-Example</a>
|
|
61
63
|
|
|
62
64
|
<!-- Mobile toggle -->
|
|
63
65
|
<button
|
|
64
|
-
class="tw:sm:hidden tw:p-2 tw:rounded tw:text-gray-600 hover:tw:bg-gray-200"
|
|
66
|
+
class="tw:sm:hidden tw:p-2 tw:rounded tw:text-gray-600 hover:tw:bg-gray-200 tw:dark:text-gray-300 tw:dark:hover:bg-gray-700"
|
|
65
67
|
aria-label="Toggle navigation"
|
|
66
68
|
onclick={() => (navOpen = !navOpen)}
|
|
67
69
|
>
|
|
@@ -71,22 +73,22 @@
|
|
|
71
73
|
</button>
|
|
72
74
|
|
|
73
75
|
<!-- Nav links -->
|
|
74
|
-
<div class="tw:hidden tw:sm:flex tw:items-center tw:gap-6 {navOpen ? '!tw:flex tw:flex-col tw:absolute tw:top-14 tw:left-0 tw:right-0 tw:bg-gray-100 tw:p-4 tw:border-b tw:border-gray-200' : ''}">
|
|
75
|
-
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/">Home</a>
|
|
76
|
-
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/info">Info</a>
|
|
76
|
+
<div class="tw:hidden tw:sm:flex tw:items-center tw:gap-6 {navOpen ? '!tw:flex tw:flex-col tw:absolute tw:top-14 tw:left-0 tw:right-0 tw:bg-gray-100 tw:dark:bg-gray-900 tw:p-4 tw:border-b tw:border-gray-200 tw:dark:border-gray-700' : ''}">
|
|
77
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900 tw:dark:text-gray-300 tw:dark:hover:text-white" href="/">Home</a>
|
|
78
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900 tw:dark:text-gray-300 tw:dark:hover:text-white" href="/info">Info</a>
|
|
77
79
|
|
|
78
80
|
{#if appState.user?.role === 'admin'}
|
|
79
|
-
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/admin">Admin</a>
|
|
81
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900 tw:dark:text-gray-300 tw:dark:hover:text-white" href="/admin">Admin</a>
|
|
80
82
|
{/if}
|
|
81
83
|
{#if appState.user && appState.user.role !== 'student'}
|
|
82
|
-
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/teachers">Teachers</a>
|
|
84
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900 tw:dark:text-gray-300 tw:dark:hover:text-white" href="/teachers">Teachers</a>
|
|
83
85
|
{/if}
|
|
84
86
|
|
|
85
87
|
{#if appState.user}
|
|
86
88
|
<!-- User dropdown -->
|
|
87
89
|
<div class="tw:relative" bind:this={dropdownEl}>
|
|
88
90
|
<button
|
|
89
|
-
class="tw:flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-700 hover:tw:text-gray-900 tw:bg-transparent tw:border-0 tw:cursor-pointer"
|
|
91
|
+
class="tw:flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-700 hover:tw:text-gray-900 tw:bg-transparent tw:border-0 tw:cursor-pointer tw:dark:text-gray-300 tw:dark:hover:text-white"
|
|
90
92
|
onclick={() => (dropdownOpen = !dropdownOpen)}
|
|
91
93
|
aria-expanded={dropdownOpen}
|
|
92
94
|
aria-haspopup="true"
|
|
@@ -101,13 +103,13 @@
|
|
|
101
103
|
</svg>
|
|
102
104
|
</button>
|
|
103
105
|
{#if dropdownOpen}
|
|
104
|
-
<ul class="tw:absolute tw:right-0 tw:mt-1 tw:w-36 tw:rounded tw:border tw:border-gray-200 tw:bg-white tw:shadow-md tw:py-1 tw:z-50 tw:list-none">
|
|
105
|
-
<li><a class="tw:block tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:bg-gray-100" href="/profile">Profile</a></li>
|
|
106
|
+
<ul class="tw:absolute tw:right-0 tw:mt-1 tw:w-36 tw:rounded tw:border tw:border-gray-200 tw:bg-white tw:shadow-md tw:py-1 tw:z-50 tw:list-none tw:dark:bg-gray-800 tw:dark:border-gray-700">
|
|
107
|
+
<li><a class="tw:block tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:bg-gray-100 tw:dark:text-gray-300 tw:dark:hover:bg-gray-700" href="/profile">Profile</a></li>
|
|
106
108
|
{#if appState.user?.id !== 0}
|
|
107
109
|
<li>
|
|
108
110
|
<button
|
|
109
111
|
onclick={logout}
|
|
110
|
-
class="tw:block tw:w-full tw:text-left tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:bg-transparent tw:border-0 tw:cursor-pointer hover:tw:bg-gray-100"
|
|
112
|
+
class="tw:block tw:w-full tw:text-left tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:bg-transparent tw:border-0 tw:cursor-pointer hover:tw:bg-gray-100 tw:dark:text-gray-300 tw:dark:hover:bg-gray-700"
|
|
111
113
|
>Logout</button>
|
|
112
114
|
</li>
|
|
113
115
|
{/if}
|
|
@@ -115,7 +117,7 @@
|
|
|
115
117
|
{/if}
|
|
116
118
|
</div>
|
|
117
119
|
{:else}
|
|
118
|
-
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/login">Login</a>
|
|
120
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900 tw:dark:text-gray-300 tw:dark:hover:text-white" href="/login">Login</a>
|
|
119
121
|
{/if}
|
|
120
122
|
</div>
|
|
121
123
|
</div>
|
|
@@ -131,7 +133,7 @@
|
|
|
131
133
|
role="alert"
|
|
132
134
|
aria-live="assertive"
|
|
133
135
|
aria-atomic="true"
|
|
134
|
-
class="tw:fixed tw:top-4 tw:right-4 tw:z-50 tw:min-w-64 tw:rounded tw:shadow-lg tw:border tw:border-gray-200 tw:bg-white tw:overflow-hidden"
|
|
136
|
+
class="tw:fixed tw:top-4 tw:right-4 tw:z-50 tw:min-w-64 tw:rounded tw:shadow-lg tw:border tw:border-gray-200 tw:bg-white tw:overflow-hidden tw:dark:bg-gray-800 tw:dark:border-gray-700"
|
|
135
137
|
>
|
|
136
138
|
<div class="tw:flex tw:items-center tw:justify-between tw:bg-blue-600 tw:px-4 tw:py-2">
|
|
137
139
|
<strong class="tw:text-white tw:text-sm">{appState.toast.title}</strong>
|
|
@@ -141,6 +143,6 @@
|
|
|
141
143
|
class="tw:text-white tw:bg-transparent tw:border-0 tw:cursor-pointer tw:text-lg tw:leading-none"
|
|
142
144
|
onclick={() => (appState.toast = { ...appState.toast, isOpen: false })}>×</button>
|
|
143
145
|
</div>
|
|
144
|
-
<div class="tw:px-4 tw:py-3 tw:text-sm">{appState.toast.body}</div>
|
|
146
|
+
<div class="tw:px-4 tw:py-3 tw:text-sm tw:dark:text-gray-100">{appState.toast.body}</div>
|
|
145
147
|
</div>
|
|
146
148
|
{/if}
|
|
@@ -15,7 +15,7 @@ export const POST: RequestHandler = async event => {
|
|
|
15
15
|
const { userId } = rows[0]
|
|
16
16
|
// Create JWT with userId expiring in 30 mins
|
|
17
17
|
const secret = JWT_SECRET
|
|
18
|
-
const token = jwt.sign({ subject: userId }, <Secret>secret, {
|
|
18
|
+
const token = jwt.sign({ subject: userId, purpose: 'reset-password' }, <Secret>secret, {
|
|
19
19
|
expiresIn: '30m'
|
|
20
20
|
})
|
|
21
21
|
|
|
@@ -54,9 +54,7 @@ export const POST: RequestHandler = async event => {
|
|
|
54
54
|
|
|
55
55
|
cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' })
|
|
56
56
|
return json({ message: 'Successful Google Sign-In.', user: userSession.user })
|
|
57
|
-
} catch
|
|
58
|
-
|
|
59
|
-
if (err instanceof Error) message = err.message
|
|
60
|
-
error(401, message)
|
|
57
|
+
} catch {
|
|
58
|
+
error(401, 'Google authentication failed.')
|
|
61
59
|
}
|
|
62
60
|
}
|
|
@@ -17,6 +17,10 @@ export const POST: RequestHandler = async event => {
|
|
|
17
17
|
if (!body.email || !body.password || !body.firstName || !body.lastName)
|
|
18
18
|
error(400, 'Please supply all required fields: email, password, first and last name.')
|
|
19
19
|
|
|
20
|
+
const passwordPattern = /(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}/
|
|
21
|
+
if (!passwordPattern.test(body.password))
|
|
22
|
+
error(400, 'Password must be at least 8 characters and include an uppercase letter, a number, and a special character.')
|
|
23
|
+
|
|
20
24
|
let result
|
|
21
25
|
try {
|
|
22
26
|
const sql = `SELECT register($1) AS "authenticationResult";`
|
|
@@ -12,6 +12,7 @@ export const PUT: RequestHandler = async event => {
|
|
|
12
12
|
// Check the validity of the token and extract userId
|
|
13
13
|
try {
|
|
14
14
|
const decoded = <JwtPayload>jwt.verify(token, <jwt.Secret>JWT_SECRET)
|
|
15
|
+
if (decoded.purpose !== 'reset-password') throw new Error('Invalid token purpose.')
|
|
15
16
|
const userId = decoded.subject
|
|
16
17
|
|
|
17
18
|
// Update the database with the new password
|
package/src/routes/layout.css
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
html,
|
|
14
14
|
:host {
|
|
15
|
-
@apply tw:font-sans tw:text-gray-800;
|
|
15
|
+
@apply tw:font-sans tw:text-gray-800 tw:bg-white;
|
|
16
|
+
@variant dark {
|
|
17
|
+
@apply tw:text-gray-100 tw:bg-gray-900;
|
|
18
|
+
}
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -22,8 +25,9 @@
|
|
|
22
25
|
display: block;
|
|
23
26
|
width: 100%;
|
|
24
27
|
margin-top: 0.25rem;
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
appearance: none;
|
|
29
|
+
-webkit-appearance: none;
|
|
30
|
+
@apply tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-gray-900 tw:dark:bg-gray-800 tw:dark:border-gray-600 tw:dark:text-gray-200;
|
|
27
31
|
padding: 0.375rem 0.75rem;
|
|
28
32
|
font-size: var(--text-sm, 0.875rem);
|
|
29
33
|
line-height: var(--tw-leading-sm, 1.25rem);
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
onMount(() => {
|
|
39
39
|
initializeGoogleAccounts()
|
|
40
40
|
renderGoogleButton()
|
|
41
|
-
|
|
42
41
|
focusedField?.focus()
|
|
43
42
|
})
|
|
44
43
|
|
|
@@ -86,7 +85,20 @@
|
|
|
86
85
|
<h4>Sign In</h4>
|
|
87
86
|
<p>Welcome back.</p>
|
|
88
87
|
|
|
89
|
-
<div
|
|
88
|
+
<div class="tw:group tw:relative tw:w-full">
|
|
89
|
+
<!-- Real Google button: invisible but receives clicks -->
|
|
90
|
+
<div id="googleButton" class="tw:opacity-0 tw:w-full"></div>
|
|
91
|
+
<!-- Visual overlay: looks good, no pointer events -->
|
|
92
|
+
<div class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:group-hover:bg-gray-50 tw:text-sm tw:font-medium tw:text-gray-700 tw:dark:bg-gray-800 tw:dark:group-hover:bg-gray-700 tw:dark:border-gray-600 tw:dark:text-gray-200">
|
|
93
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
94
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
95
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
96
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
|
97
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
98
|
+
</svg>
|
|
99
|
+
Sign in with Google
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
90
102
|
|
|
91
103
|
<div class="tw:flex tw:items-center tw:gap-2 tw:text-gray-400 tw:text-sm">
|
|
92
104
|
<span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
<p class="tw:text-sm tw:text-gray-500 tw:mb-2">Danger zone</p>
|
|
209
209
|
<button
|
|
210
210
|
type="button"
|
|
211
|
-
class="tw:w-full tw:rounded tw:border tw:border-red-
|
|
211
|
+
class="tw:w-full tw:rounded tw:border tw:border-red-600 tw:bg-red-600 tw:px-4 tw:py-2 tw:text-sm tw:font-medium tw:text-white tw:cursor-pointer hover:tw:bg-red-700 hover:tw:border-red-700 disabled:tw:opacity-50 disabled:tw:cursor-not-allowed"
|
|
212
212
|
disabled={deleting}
|
|
213
213
|
onclick={deleteAccount}
|
|
214
214
|
>
|
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
onMount(() => {
|
|
55
55
|
initializeGoogleAccounts()
|
|
56
56
|
renderGoogleButton()
|
|
57
|
-
|
|
58
57
|
focusedField?.focus()
|
|
59
58
|
})
|
|
60
59
|
|
|
@@ -117,7 +116,20 @@
|
|
|
117
116
|
<h4>Register</h4>
|
|
118
117
|
<p>Welcome to our community.</p>
|
|
119
118
|
|
|
120
|
-
<div
|
|
119
|
+
<div class="tw:relative tw:w-full">
|
|
120
|
+
<!-- Real Google button: invisible but receives clicks -->
|
|
121
|
+
<div id="googleButton" class="tw:opacity-0 tw:w-full"></div>
|
|
122
|
+
<!-- Visual overlay: looks good, no pointer events -->
|
|
123
|
+
<div class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-sm tw:font-medium tw:text-gray-700 tw:dark:bg-gray-800 tw:dark:border-gray-600 tw:dark:text-gray-200">
|
|
124
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
125
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
126
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
127
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
|
128
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
129
|
+
</svg>
|
|
130
|
+
Sign in with Google
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
121
133
|
|
|
122
134
|
<label class="tw:block tw:text-sm tw:font-medium" for="email">
|
|
123
135
|
Email
|
package/svelte.config.js
CHANGED
|
@@ -37,7 +37,7 @@ const config = {
|
|
|
37
37
|
mode: 'auto',
|
|
38
38
|
directives: {
|
|
39
39
|
'default-src': [...baseCsp],
|
|
40
|
-
'script-src': [
|
|
40
|
+
'script-src': [...baseCsp],
|
|
41
41
|
'img-src': ['data:', 'blob:', ...baseCsp],
|
|
42
42
|
'style-src': ['unsafe-inline', ...baseCsp],
|
|
43
43
|
'object-src': ['none'],
|