diff --git a/apps/api/package.json b/apps/api/package.json index 7c8884e..1941c08 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/domain": "workspace:*", + "@elysia/jwt": "^1.4.2", "@elysia/server-timing": "^1.4.1", "@elysiajs/cors": "^1.4.2", "elysia": "^1.4.29", diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 37dc2e9..68c0cf0 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -17,6 +17,8 @@ export const env = { WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"), WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), + AUTH_SECRET: requireEnv("AUTH_SECRET"), + ADMIN_PASSWORD: requireEnv("ADMIN_PASSWORD"), NODE_ENV: optionEnv("NODE_ENV", "development"), LOG_LEVEL: optionEnv("LOG_LEVEL", "info"), }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d5add7b..2f32e23 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -24,6 +24,7 @@ import z from "zod"; import pino from "pino"; import { env } from "./env"; import serverTiming from "@elysia/server-timing"; +import jwt from "@elysia/jwt"; const log = pino({ level: env.LOG_LEVEL, @@ -75,7 +76,7 @@ export const app = new Elysia() "http://dev.portal.bladeandbrawn.com/" ], }), - ) + ).use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .error({ PrintfulError, @@ -116,8 +117,29 @@ export const app = new Elysia() .get("/", () => ({ status: "ok" })) .get("/health", () => ({ status: "ok" })) - // CALCULATOR + // AUTHENTICATION + .post("/auth/login", async ({ jwt, body, cookie: { auth } }) => { + if (body.password !== env.ADMIN_PASSWORD) throw status(401, "Invalid credentials"); + auth.set({ + value: await jwt.sign({ id: "admin", exp: "7d" }), + path: "/", + maxAge: 60 * 60 * 24 * 7, + sameSite: "lax", + httpOnly: true, + secure: env.NODE_ENV === "production", + domain: env.NODE_ENV === "production" ? + ".bladeandbrawn.com" : + undefined + }); + }, { + body: z.object({ + password: z.coerce.string() + }), + cookie: t.Cookie({ auth: t.Optional(t.String()) }) + }) + + // CALCULATOR .post("/calculate", async ({ body }) => { return { levels: levelCalculator.calculate(body.player, body.activityPerformances), @@ -130,11 +152,12 @@ export const app = new Elysia() }) // COMMERCE - // TODO: protect with api key/token .group("/products", (app) => app - .onBeforeHandle(({ }) => { - // authenticate cookie + .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) + .onBeforeHandle(async ({ jwt, cookie: { auth } }) => { + const token = auth.value && await jwt.verify(auth.value); + if (!token) throw status(401, "Unauthorized"); }) .group("/sync", (app) => app diff --git a/apps/portal/package.json b/apps/portal/package.json index b496a81..e9b7c72 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -27,6 +27,7 @@ "@elysia/eden": "^1.4.10", "@tailwindcss/vite": "^4.3.2", "daisyui": "^5.6.6", + "jose": "^6.2.3", "tailwindcss": "^4.3.2" } } diff --git a/apps/portal/src/hooks.server.ts b/apps/portal/src/hooks.server.ts index fe40560..2784c58 100644 --- a/apps/portal/src/hooks.server.ts +++ b/apps/portal/src/hooks.server.ts @@ -1,15 +1,16 @@ import { env } from '$env/dynamic/private'; import { redirect, type Handle } from '@sveltejs/kit'; -import { createHmac } from 'node:crypto'; +import { jwtVerify } from 'jose'; export const handle: Handle = async ({ event, resolve }) => { - const cookieHash = event.cookies.get("session"); - const computedHash = createHmac("sha256", env.PORTAL_SECRET) - .update("authenticated") - .digest("hex"); - if (event.url.pathname !== "/login" && cookieHash !== computedHash) - redirect(307, "/login"); + if (event.url.pathname !== "/login") { + const token = event.cookies.get("auth"); + const secret = new TextEncoder().encode(env.AUTH_SECRET); + const authenticated = token != null && await jwtVerify(token, secret) + .then(() => true) + .catch(() => false); + if (!authenticated) redirect(307, "/login"); + } - const response = await resolve(event); - return response; + return await resolve(event); }; diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 928b450..bf33ea7 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -2,4 +2,8 @@ import { treaty } from "@elysia/eden" import type { API } from "@blade-and-brawn/api" import { PUBLIC_API_URL } from "$env/static/public" -export const api = treaty(PUBLIC_API_URL) +export const api = treaty(PUBLIC_API_URL, { + fetch: { + credentials: "include", + }, +}) diff --git a/apps/portal/src/routes/login/+page.server.ts b/apps/portal/src/routes/login/+page.server.ts deleted file mode 100644 index 31416db..0000000 --- a/apps/portal/src/routes/login/+page.server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { fail, redirect } from "@sveltejs/kit"; -import type { Actions } from "./$types"; -import { env } from "$env/dynamic/private"; -import { createHmac } from "node:crypto"; - -export const actions: Actions = { - default: async ({ request, cookies }) => { - const data = await request.formData(); - const password = data.get("password"); - - if (password !== env.PORTAL_PASSWORD) - return fail(401, { error: "Invalid password" }); - - const hash = createHmac("sha256", env.PORTAL_SECRET) - .update("authenticated") - .digest("hex"); - - cookies.set("session", hash, { path: "/", maxAge: 60 * 60 * 24 * 7 }); - redirect(303, "/"); - }, -}; diff --git a/apps/portal/src/routes/login/+page.svelte b/apps/portal/src/routes/login/+page.svelte index 57a72dc..484eb86 100644 --- a/apps/portal/src/routes/login/+page.svelte +++ b/apps/portal/src/routes/login/+page.svelte @@ -1,20 +1,40 @@
- {#if form?.error} + {#if error} {/if} -
+ -
diff --git a/bun.lock b/bun.lock index a4f81bd..1dcff9d 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/domain": "workspace:*", + "@elysia/jwt": "^1.4.2", "@elysia/server-timing": "^1.4.1", "@elysiajs/cors": "^1.4.2", "elysia": "^1.4.29", @@ -36,6 +37,7 @@ "@elysia/eden": "^1.4.10", "@tailwindcss/vite": "^4.3.2", "daisyui": "^5.6.6", + "jose": "^6.2.3", "tailwindcss": "^4.3.2", }, "devDependencies": { @@ -89,6 +91,8 @@ "@elysia/eden": ["@elysia/eden@1.4.10", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-vcZXQcW6wZj6rhTxaiTkuCbVuS/yAJQ9jqCM6b83a5hu99F5Aj3HOyWXL17iod2Rz+pJpYsQGX0XaeoCsMpw2g=="], + "@elysia/jwt": ["@elysia/jwt@1.4.2", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-KhLe6XxI+NHoYZiHqPhWg8SU0v+pegsxXsZSa/CXic/BrjlFbO26uTERtOQNlnByp/IGUB4qpLF6kQhvfYvGyg=="], + "@elysia/server-timing": ["@elysia/server-timing@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-+NDGhslxN0HZuyT8o4dt+hgib9bzsMuvxTE5bPrDhFFRl1duCNosseW3wE76pmwle8+m9+1aOeZ28mZAv51ggQ=="], "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], @@ -339,6 +343,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],