diff --git a/gasta/.env.template b/gasta/.env.template index d3c710b..e06c596 100644 --- a/gasta/.env.template +++ b/gasta/.env.template @@ -1,3 +1,4 @@ SERVER_URL="" # Example for localhost: "http://127.0.0.1:8080" LDAP_URL="" # Example for localhost: "ldap://127.0.0.1:3000" -UUID5_NAMESPACE="" # A Uuid to use when generating session ids (keep secret) \ No newline at end of file +UUID5_NAMESPACE="" # A Uuid to use when generating session ids (keep secret) +REDIS_URL="" # Example for localhost: "redis://127.0.0.1:6381" \ No newline at end of file diff --git a/gasta/dev/docker-compose.yaml b/gasta/dev/docker-compose.yaml new file mode 100644 index 0000000..fda3719 --- /dev/null +++ b/gasta/dev/docker-compose.yaml @@ -0,0 +1,8 @@ +version: "3.2" +services: + redis-gasta-dev: + restart: "always" + image: "redis:alpine" + command: redis-server --save "" + ports: + - "6381:6379" \ No newline at end of file diff --git a/gasta/package.json b/gasta/package.json index 2091d65..6873035 100644 --- a/gasta/package.json +++ b/gasta/package.json @@ -20,6 +20,7 @@ "@sveltejs/adapter-node": "^1.3.1", "@sveltejs/kit": "^1.25.0", "@tailwindcss/forms": "^0.5.6", + "@types/cookie": "^0.5.3", "@types/ldapjs": "^3.0.2", "@types/node": "^20.6.0", "@typescript-eslint/eslint-plugin": "^6.7.0", @@ -45,6 +46,7 @@ }, "dependencies": { "js-sha3": "^0.9.1", - "ldapjs": "^3.0.5" + "ldapjs": "^3.0.5", + "ioredis": "^5.3.2" } } diff --git a/gasta/pnpm-lock.yaml b/gasta/pnpm-lock.yaml index 7c1126d..1baecca 100644 --- a/gasta/pnpm-lock.yaml +++ b/gasta/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + ioredis: + specifier: ^5.3.2 + version: 5.3.2 js-sha3: specifier: ^0.9.1 version: 0.9.1 @@ -34,6 +37,9 @@ devDependencies: '@tailwindcss/forms': specifier: ^0.5.6 version: 0.5.6(tailwindcss@3.3.3) + '@types/cookie': + specifier: ^0.5.3 + version: 0.5.3 '@types/ldapjs': specifier: ^3.0.2 version: 3.0.2 @@ -398,6 +404,10 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + /@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} engines: {node: '>=6.0.0'} @@ -618,7 +628,7 @@ packages: vite: ^4.0.0 dependencies: '@sveltejs/vite-plugin-svelte': 2.4.6(svelte@4.2.0)(vite@4.4.9) - '@types/cookie': 0.5.2 + '@types/cookie': 0.5.3 cookie: 0.5.0 devalue: 4.3.2 esm-env: 1.0.0 @@ -681,8 +691,8 @@ packages: tailwindcss: 3.3.3 dev: true - /@types/cookie@0.5.2: - resolution: {integrity: sha512-DBpRoJGKJZn7RY92dPrgoMew8xCWc2P71beqsjyhEI/Ds9mOyVmBwtekyfhpwFIVt1WrxTonFifiOZ62V8CnNA==} + /@types/cookie@0.5.3: + resolution: {integrity: sha512-SLg07AS9z1Ab2LU+QxzU8RCmzsja80ywjf/t5oqw+4NSH20gIGlhLOrBDm1L3PBWzPa4+wkgFQVZAjE6Ioj2ug==} dev: true /@types/estree@1.0.1: @@ -1043,6 +1053,11 @@ packages: fsevents: 2.3.3 dev: true + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /code-red@1.0.4: resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} dependencies: @@ -1124,7 +1139,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1135,6 +1149,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1574,6 +1593,23 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true + /ioredis@5.3.2: + resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.4 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1726,6 +1762,14 @@ packages: p-locate: 5.0.0 dev: true + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -1820,7 +1864,6 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2126,6 +2169,18 @@ packages: picomatch: 2.3.1 dev: true + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2246,6 +2301,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} diff --git a/gasta/src/app.d.ts b/gasta/src/app.d.ts index 3ebeffe..f07b601 100644 --- a/gasta/src/app.d.ts +++ b/gasta/src/app.d.ts @@ -15,6 +15,7 @@ declare global { SERVER_URL: string, LDAP_URL: string, UUID5_NAMESPACE: string, + REDIS_URL: string, } interface Locals { diff --git a/gasta/src/hooks.server.ts b/gasta/src/hooks.server.ts index 5327f77..7db67f5 100644 --- a/gasta/src/hooks.server.ts +++ b/gasta/src/hooks.server.ts @@ -15,22 +15,26 @@ if (env.LDAP_URL) { throw new Error("LDAP_URL environment variable is not defined, can't connect to LDAP") } +if (env.REDIS_URL) { + console.log(`Listening to Redis on ${env.REDIS_URL}`) +} else if (!building) { + throw new Error("REDIS_URL environment variable is not defined, can't connect to Redis") +} + export const handle: Handle = async ({ event, resolve }) => { // Ensure browser security - console.log(event.request.headers.get("SEC-CH-UA")); - if (!event.url.pathname.startsWith('/not-supported') && event.request.headers.get("SEC-CH-UA")?.includes(`"Edge"`)) { throw redirect(303, "/not-supported") } else if (event.url.pathname.startsWith('/not-supported') && !event.request.headers.get("SEC-CH-UA")?.includes(`"Edge"`)) { throw redirect(303, "/") } - const valid = valid_session(event.cookies.get('session-id')!, event.request.headers.get("User-Agent")!); + const valid = await valid_session(event.cookies.get('session-id')!, event.request.headers.get("User-Agent")!); if (!event.url.pathname.startsWith("/login") && !event.url.pathname.startsWith("/not-supported")) { if (valid) { - event.locals.user = session_username(event.cookies.get('session-id')!) - event.locals.name = session_display_name(event.cookies.get('session-id')!) + event.locals.user = await session_username(event.cookies.get('session-id')!) + event.locals.name = await session_display_name(event.cookies.get('session-id')!) // console.log("Valid req, will not redirect") } else { // console.log("Invalid req, will redirect to login") diff --git a/gasta/src/lib/auth.ts b/gasta/src/lib/auth.ts index 5ed4daf..5bdc449 100644 --- a/gasta/src/lib/auth.ts +++ b/gasta/src/lib/auth.ts @@ -2,52 +2,81 @@ import { env } from "$env/dynamic/private"; import ldapjs, { type Client } from "ldapjs"; const { createClient } = ldapjs; import pkg from "js-sha3"; -import { building } from "$app/environment"; +import { building, dev } from "$app/environment"; +import type { CookieSerializeOptions } from 'cookie'; +import { Redis } from "ioredis"; +import { REDIS_URL } from "$env/static/private"; const { sha3_512 } = pkg; export type Login = { result: "success", - session_id: string + session_id: string, + cookie: CookieSerializeOptions } | { result: "failure", msg: string } export interface Session { - session_id: string, + // session_id: string, + name: string username: string, user_agent: string, - expires: string, - name: string + // expires: string, } // TODO: Set setInterval to remove old sessions from map -const users: Map = new Map() +const session_valid_time = 60 * 60 * 24 * 7 // Valid for a week + +let redis: Redis; +let ldap: Client; + +if (!building) { + ldap = createClient({ + url: [env.LDAP_URL], + timeout: 2000, + connectTimeout: 2000, + reconnect: true, + }); + + ldap.on('error', err => { + console.debug({msg: 'connection failed, retrying', err}); + }); + + redis = new Redis(REDIS_URL); +} export const login = async (username: string, password: string, user_agent: string): Promise => { let res: Login let auth: Authenticate = await authenticate(username, password) - + if (auth.result === "success" || (process.env.NODE_ENV === "development" && username === "admin" && password === "admin")) { const message = `${user_agent}${Math.random()}${Date.now()}` + const session_id = sha3_512(`${env.UUID5_NAMESPACE}${username}${message}`); let session: Session = { - session_id: sha3_512(`${env.UUID5_NAMESPACE}${username}${message}`), user_agent, username, - expires: "someday", name: auth.result === "success" ? auth.name : "Rosa Pantern" }; - if (users.has(session.session_id)) { + if (await redis.exists(session_id)) { res = { result: "failure", msg: "Session id is already in use??? (contact person responsible)" } } else { - users.set(session.session_id, session) + redis.set(session_id, JSON.stringify(session)); + redis.expire(session_id, session_valid_time); res = { result: "success", - session_id: session.session_id + session_id, + cookie: { + path: "/", + httpOnly: true, + secure: !dev, + sameSite: 'strict', + maxAge: session_valid_time + } } } } else { @@ -56,17 +85,30 @@ export const login = async (username: string, password: string, user_agent: stri return res } -export const valid_session = (session_id: string, user_agent: string): boolean => - users.has(session_id) && users.get(session_id)?.user_agent === user_agent +export const valid_session = async (session_id: string, user_agent: string): Promise => { + const str = await redis.get(session_id); + if (!str) return false; + const session: Session = JSON.parse(str); + return session.user_agent == user_agent; +} + +export const session_username = async (session_id: string): Promise => { + const str = await redis.get(session_id); + if (!str) return ""; + const session: Session = JSON.parse(str); + return session.username; +} -export const session_username = (session_id: string): string => -users.get(session_id)?.username ?? "" +export const session_display_name = async (session_id: string): Promise => { + const str = await redis.get(session_id); + if (!str) return ""; + const session: Session = JSON.parse(str); + return session.name; +} -export const session_display_name = (session_id: string): string => - users.get(session_id)?.name ?? "" -export const invalidate_session = (session_id: string) => - users.delete(session_id) +export const invalidate_session = async (session_id: string) => + await redis.del(session_id) export type Authenticate = { result: "success", @@ -76,21 +118,6 @@ export type Authenticate = { msg: string } -let client: Client - -if (!building) { - client = createClient({ - url: [env.LDAP_URL], - timeout: 2000, - connectTimeout: 2000, - reconnect: true, - }); - - client.on('error', err => { - console.debug({msg: 'connection failed, retrying', err}); - }); -} - // (|(*group logic*)(*another group logic*)) const filter = `(|${['dsek.km', 'dsek.cafe', 'dsek.sex'] @@ -104,38 +131,48 @@ const authenticate = async (username: string, password: string): Promise { + ldap.bind(`uid=${username},cn=users,cn=accounts,dc=dsek,dc=se`, password, (err, _) => { if (err) { - console.log({err}); - client.unbind(); - resolve({ - result: "failure", - msg: `Ye 'ave forgotten yer username or yer password` - }) + console.log({err: err.name}); + ldap.unbind(); + if (err.name === "ConnectionError") { + resolve({ + result: "failure", + msg: `Aye be tryin' to reach the LDAP server, but it be as elusive as buried treasure on a deserted island!` + }) + } else if (err.name === "InvalidCredentialsError") { + resolve({ + result: "failure", + msg: `Ye 'ave forgotten yer username or yer password` + }) + } else { + resolve({ + result: "failure", + msg: `Arrr, ye scallywag! We've hit a rough sea on the LDAP voyage - ${err.name}, the treasure map to that directory be lost to the depths of Davy Jones' locker!` + }) + } } else { - client.search(`uid=${username},cn=users,cn=accounts,dc=dsek,dc=se`, { }, (searchError, searchResponse) => { + ldap.search(`uid=${username},cn=users,cn=accounts,dc=dsek,dc=se`, { }, (searchError, searchResponse) => { if (searchError) { console.error('LDAP search error:', searchError); - client.unbind(); + ldap.unbind(); resolve({ result: "failure", - msg: `LDAP error1: ${searchError.message}` + msg: `LDAP error: ${searchError.message}` }) } - searchResponse.on('searchEntry', (entry) => { // The entry object contains information about the group const display_name = entry.attributes.find(a => a.type === "givenName")?.values[0] ?? "" res.push(display_name) }); - searchResponse.on('end', () => { - client.unbind(); + ldap.unbind(); if (res.length > 0) { resolve({ result: "success", @@ -153,12 +190,11 @@ const authenticate = async (username: string, password: string): Promise Promise = async ({ result }) => { - const toastStore = getToastStore() - if (result.type === "success") { if (result.data?.message) { - toastStore.trigger({ + get(toastStore).trigger({ message: result.data.message, background: 'variant-filled-success', timeout: 2000 @@ -24,13 +24,13 @@ export const form_action: (_: any) => Promise = async ({ result }) => { await applyAction(result) } else if (result.type === "failure") { - toastStore.trigger({ + get(toastStore).trigger({ message: result.data.message, background: 'variant-filled-warning', autohide: false }) } else { - toastStore.trigger({ + get(toastStore).trigger({ message: result.message ?? "Something went wrong with the Request", background: 'variant-filled-error', autohide: false diff --git a/gasta/src/lib/stores.ts b/gasta/src/lib/stores.ts new file mode 100644 index 0000000..ee6162e --- /dev/null +++ b/gasta/src/lib/stores.ts @@ -0,0 +1,4 @@ +import type { ToastStore } from "@skeletonlabs/skeleton"; +import { writable, type Writable } from "svelte/store"; + +export const toastStore: Writable = writable(); \ No newline at end of file diff --git a/gasta/src/routes/+layout.svelte b/gasta/src/routes/+layout.svelte index 596c640..25da749 100644 --- a/gasta/src/routes/+layout.svelte +++ b/gasta/src/routes/+layout.svelte @@ -4,13 +4,14 @@ import { AppBar, AppShell, Toast, Drawer, getDrawerStore, Modal, Avatar, initializeStores, getToastStore } from '@skeletonlabs/skeleton'; import type { LayoutData } from './$types'; import Navigation from '$lib/Navigation.svelte'; + import { toastStore } from '$lib/stores'; export let data: LayoutData; initializeStores(); const drawerStore = getDrawerStore(); - const toastStore = getToastStore(); + $toastStore = getToastStore(); @@ -44,7 +45,7 @@ - + @@ -59,7 +60,7 @@ title="Toggle Light Mode" tabindex="0" on:click={() => { - toastStore.trigger({ + $toastStore.trigger({ message: "Feature not yet implemented", timeout: 2000, background: "variant-filled-warning", diff --git a/gasta/src/routes/login/+page.server.ts b/gasta/src/routes/login/+page.server.ts index 1830124..836fd18 100644 --- a/gasta/src/routes/login/+page.server.ts +++ b/gasta/src/routes/login/+page.server.ts @@ -4,44 +4,38 @@ import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { if (process.env.NODE_ENV === "development") - return { banner: `Development mode in use, admin account with password 'admin' is usable`} + return { banner: `Development mode in use, admin account with password 'admin' is usable`}; }; export const actions = { - login: async ({ request, cookies }) => { - const data = await request.formData(); - const username = data.get('username')?.toString(); - const password = data.get('password')?.toString(); - const user_agent = request.headers.get("User-Agent")?.toString(); + login: async ({ request, cookies }) => { + const data = await request.formData(); + const username = data.get('username')?.toString(); + const password = data.get('password')?.toString(); + const user_agent = request.headers.get("User-Agent")?.toString(); - if (!username) { - throw Error("no username?") - } - if (!password) { - throw Error("no password?") - } - if (!user_agent) { - throw Error("no user-agent?") - } + if (!username) { + return fail(400, { msg: "Username required" }); + } + if (!password) { + return fail(400, { msg: "Passwords required" }); + } + if (!user_agent) { + return fail(400, { msg: "User Agent required" }); + } - const res = await login(username, password, user_agent) + const res = await login(username, password, user_agent); - if (res.result === "success") { - cookies.set("session-id", res.session_id, { - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 60 * 60 * 24 * 7 // Valid for a week - }) - } else { - return fail(400, { msg: res.msg }) + if (res.result === "success") { + cookies.set("session-id", res.session_id, res.cookie); + } else { + return { msg: res.msg, username }; + } + throw redirect(303, "/"); + }, + logout: ({ cookies }) => { + invalidate_session(cookies.get("session-id")!); + cookies.delete("session-id"); + throw redirect(303, "/login"); } - throw redirect(303, "/") - }, - logout: async ({ cookies }) => { - invalidate_session(cookies.get("session-id")!) - cookies.delete("session-id") - throw redirect(303, "/login") - } } satisfies Actions; \ No newline at end of file diff --git a/gasta/src/routes/login/+page.svelte b/gasta/src/routes/login/+page.svelte index b0fdc78..97b085d 100644 --- a/gasta/src/routes/login/+page.svelte +++ b/gasta/src/routes/login/+page.svelte @@ -1,17 +1,14 @@