Basic auth

This commit is contained in:
Drake Marino 2025-01-15 18:20:27 -06:00
parent 28c18e249c
commit f0fc8b09ab
15 changed files with 332 additions and 26 deletions

139
package-lock.json generated
View File

@ -12,7 +12,10 @@
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"dotenv": "^16.4.7",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.5",
"vitest": "^2.0.4" "vitest": "^2.0.4"
}, },
"devDependencies": { "devDependencies": {
@ -21,6 +24,7 @@
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"eslint": "^9.7.0", "eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
@ -1304,6 +1308,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz",
"integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
@ -1931,6 +1945,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -2210,12 +2230,33 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.71", "version": "1.5.71",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.71.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.71.tgz",
@ -3179,6 +3220,49 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3257,6 +3341,42 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3264,6 +3384,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
@ -3937,6 +4063,19 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.5.tgz",
"integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==",
"license": "Unlicense",
"engines": {
"node": ">=12"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@ -19,6 +19,7 @@
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"eslint": "^9.7.0", "eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
@ -38,7 +39,10 @@
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"dotenv": "^16.4.7",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.5",
"vitest": "^2.0.4" "vitest": "^2.0.4"
} }
} }

9
permissions.md Normal file
View File

@ -0,0 +1,9 @@
## Table of Permissions
| Permission Value | Description |
|------------------|------------------------|
| `00000001` | Read access |
| `00000010` | Submit postings access |
| `00000100` | Manage postings |
| `00001000` | Manage users |
| `00010000` | Apply |

View File

@ -44,4 +44,3 @@ h1 {
background-color: var(--hover-bg-color); background-color: var(--hover-bg-color);
} }

0
src/hooks.client.ts Normal file
View File

View File

@ -1,5 +1,24 @@
import jwt from 'jsonwebtoken';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
export const handle = async ({ event, resolve }) => { export const handle = async ({ event, resolve }) => {
const theme = event.cookies.get('theme'); const theme = event.cookies.get('theme');
const JWT = event.cookies.get('jwt');
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
} catch (err) {
event.cookies.delete('jwt', { path: '/' });
}
}
if (!theme) { if (!theme) {
return await resolve(event); return await resolve(event);
} }

14
src/lib/db/db.server.ts Normal file
View File

@ -0,0 +1,14 @@
import postgres from 'postgres';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
const sql = postgres({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT!),
database: process.env.POSTGRES_DB,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD
});
export default sql;

View File

@ -0,0 +1,28 @@
import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
export async function createUser(username: string, password: string): Promise<void> {
const password_hash: string = await bcrypt.hash(password, 12);
const response = await sql`
INSERT INTO users (username, password_hash, perms)
VALUES (${username}, ${password_hash}, 2);
`;
console.log(response);
}
export async function checkUserCreds(username: string, password: string): Promise<number> {
const [user] = await sql`
SELECT password_hash, perms
FROM users
WHERE username = ${username}
`;
if (!user) {
return -1;
}
if (await bcrypt.compare(password, user.password_hash)) {
return user['perms'];
}
return -1;
}

View File

@ -1,8 +0,0 @@
import bcrypt from 'bcrypt';
export async function createUser(username: string, password: string): Promise<void> {
const sql = `INSERT INTO users (username, password, perms)
VALUES ($username, $password, 0)`;
const hash = await bcrypt.hash(password, 12);
}

1
src/lib/shared.svelte.ts Normal file
View File

@ -0,0 +1 @@
export let userState = $state({ permissions: 0b00000001 });

View File

@ -1,34 +1,46 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { userState } from '$lib/shared.svelte';
// import { userState } from '$lib/shared.svelte';
let currentTheme: string = $state(''); let currentTheme: string = $state('');
function toggleTheme(): void { function toggleTheme(): void {
const theme = currentTheme === 'light' ? 'dark' : 'light'; const theme = currentTheme === 'light' ? 'dark' : 'light';
set_theme(theme); setTheme(theme);
} }
function set_theme(theme: string) { function setTheme(theme: string) {
const one_year = 60 * 60 * 24 * 365; const one_year = 60 * 60 * 24 * 365;
document.cookie = `theme=${theme}; max-age=${one_year}; path=/`; document.cookie = `theme=${theme}; max-age=${one_year}; path=/`;
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
currentTheme = theme; currentTheme = theme;
} }
const getCookieValue = (name: String) =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
onMount(() => { onMount(() => {
const savedTheme = document.documentElement.getAttribute('data-theme'); const savedTheme = document.documentElement.getAttribute('data-theme');
if (savedTheme) { if (savedTheme) {
currentTheme = savedTheme; currentTheme = savedTheme;
return; } else {
const darkPref: boolean = window.matchMedia('(prefers-color-scheme: dark)').matches;
currentTheme = darkPref ? 'dark' : 'light';
setTheme(currentTheme);
} }
const darkPref = window.matchMedia('(prefers-color-scheme: dark)').matches; const JWT = getCookieValue('jwt');
if (JWT !== '') {
const theme = darkPref ? 'dark' : 'light'; userState.permissions = JSON.parse(JWT.split('.')[1]).permissions;
set_theme(theme); }
console.log(userState.permissions);
}); });
// userState.permissions = 0b00000011;
// console.log(userState.permissions);
let { children } = $props(); let { children } = $props();
</script> </script>
@ -54,12 +66,17 @@
</nav> </nav>
<div> <div>
<button onclick={toggleTheme} class="pr-2"> <button onclick={toggleTheme} class="pr-2">
<span class="material-symbols-outlined nav-trailing"> <span class="material-symbols-outlined nav-trailing dark:invisible">
{currentTheme === 'dark' ? 'dark_mode' : 'light_mode'} {'light_mode'}
</span> </span>
</button> </button>
<button> <button onclick={toggleTheme} class="pr-2">
<span class="material-symbols-outlined nav-trailing">account_circle</span> <span class="material-symbols-outlined nav-trailing invisible dark:visible">
{'dark_mode'}
</span>
</button>
<button onclick={() => (window.location.href = '/signin')}>
<span class="material-symbols-outlined nav-trailing">{'account_circle'}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,2 @@
<h1>Hello world</h1> <script lang="ts">
</script>
<div class="text-center text-green-500">
<p class="text-red-800">Test Text</p>
<p>hello</p>
</div>

View File

@ -0,0 +1,61 @@
import { checkUserCreds, createUser } from '$lib/db/index.server';
import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
const payload = {
username: username,
perms: perms
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30m' });
cookies.set('jwt', JWT, { maxAge, path: '/' });
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
if (username && password) {
try {
await createUser(username, password);
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
},
login: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
if (username && password) {
const perms = await checkUserCreds(username, password);
if (perms === -1) {
return fail(401, { errorMessage: 'Invalid username or password' });
}
setJWT(cookies, username, perms);
// redirect to home page
// return { perms: perms };
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
};

View File

@ -0,0 +1,22 @@
<script lang="ts">
import type { ActionData } from './$types';
// receive form data from server
let form: ActionData;
</script>
<div class="container">
<h1 class="is-size-3 has-text-weight-semibold my-4">Sign In or Register</h1>
<form method="POST">
<input class="input my-2" type="text" placeholder="Username" name="username" required />
<input class="input my-2" type="password" placeholder="Password" name="password" required />
<!-- display error message -->
{#if form?.errorMessage}
<div class="has-text-danger my-2">{form.errorMessage}</div>
{/if}
<button class="button mr-3 mt-4" type="submit" formaction="?/register">Register</button>
<button class="button is-primary mt-4" type="submit" formaction="?/login">Sign In</button>
</form>
</div>

View File

@ -2,8 +2,13 @@ import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({ export default defineConfig({
// @ts-ignore
plugins: [sveltekit()], plugins: [sveltekit()],
server: {
host: true
},
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}']
} }