Implement auth as source of truth on server and protect routes. Integrate with portal

This commit is contained in:
Dominic Ferrando
2026-06-30 23:46:35 -04:00
parent 235c072421
commit f5fb64e467
9 changed files with 83 additions and 42 deletions
+1
View File
@@ -17,6 +17,7 @@
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/commerce": "workspace:*",
"@blade-and-brawn/domain": "workspace:*", "@blade-and-brawn/domain": "workspace:*",
"@elysia/jwt": "^1.4.2",
"@elysia/server-timing": "^1.4.1", "@elysia/server-timing": "^1.4.1",
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.29", "elysia": "^1.4.29",
+2
View File
@@ -17,6 +17,8 @@ export const env = {
WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"), WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"),
WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"),
WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"),
AUTH_SECRET: requireEnv("AUTH_SECRET"),
ADMIN_PASSWORD: requireEnv("ADMIN_PASSWORD"),
NODE_ENV: optionEnv("NODE_ENV", "development"), NODE_ENV: optionEnv("NODE_ENV", "development"),
LOG_LEVEL: optionEnv("LOG_LEVEL", "info"), LOG_LEVEL: optionEnv("LOG_LEVEL", "info"),
}; };
+28 -5
View File
@@ -24,6 +24,7 @@ import z from "zod";
import pino from "pino"; import pino from "pino";
import { env } from "./env"; import { env } from "./env";
import serverTiming from "@elysia/server-timing"; import serverTiming from "@elysia/server-timing";
import jwt from "@elysia/jwt";
const log = pino({ const log = pino({
level: env.LOG_LEVEL, level: env.LOG_LEVEL,
@@ -75,7 +76,7 @@ export const app = new Elysia()
"http://dev.portal.bladeandbrawn.com/" "http://dev.portal.bladeandbrawn.com/"
], ],
}), }),
) ).use(jwt({ name: "jwt", secret: env.AUTH_SECRET }))
.error({ .error({
PrintfulError, PrintfulError,
@@ -116,8 +117,29 @@ export const app = new Elysia()
.get("/", () => ({ status: "ok" })) .get("/", () => ({ status: "ok" }))
.get("/health", () => ({ 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 }) => { .post("/calculate", async ({ body }) => {
return { return {
levels: levelCalculator.calculate(body.player, body.activityPerformances), levels: levelCalculator.calculate(body.player, body.activityPerformances),
@@ -130,11 +152,12 @@ export const app = new Elysia()
}) })
// COMMERCE // COMMERCE
// TODO: protect with api key/token
.group("/products", (app) => .group("/products", (app) =>
app app
.onBeforeHandle(({ }) => { .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) })
// authenticate cookie .onBeforeHandle(async ({ jwt, cookie: { auth } }) => {
const token = auth.value && await jwt.verify(auth.value);
if (!token) throw status(401, "Unauthorized");
}) })
.group("/sync", (app) => .group("/sync", (app) =>
app app
+1
View File
@@ -27,6 +27,7 @@
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"daisyui": "^5.6.6", "daisyui": "^5.6.6",
"jose": "^6.2.3",
"tailwindcss": "^4.3.2" "tailwindcss": "^4.3.2"
} }
} }
+10 -9
View File
@@ -1,15 +1,16 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { redirect, type Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
import { createHmac } from 'node:crypto'; import { jwtVerify } from 'jose';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const cookieHash = event.cookies.get("session"); if (event.url.pathname !== "/login") {
const computedHash = createHmac("sha256", env.PORTAL_SECRET) const token = event.cookies.get("auth");
.update("authenticated") const secret = new TextEncoder().encode(env.AUTH_SECRET);
.digest("hex"); const authenticated = token != null && await jwtVerify(token, secret)
if (event.url.pathname !== "/login" && cookieHash !== computedHash) .then(() => true)
redirect(307, "/login"); .catch(() => false);
if (!authenticated) redirect(307, "/login");
}
const response = await resolve(event); return await resolve(event);
return response;
}; };
+5 -1
View File
@@ -2,4 +2,8 @@ import { treaty } from "@elysia/eden"
import type { API } from "@blade-and-brawn/api" import type { API } from "@blade-and-brawn/api"
import { PUBLIC_API_URL } from "$env/static/public" import { PUBLIC_API_URL } from "$env/static/public"
export const api = treaty<API>(PUBLIC_API_URL) export const api = treaty<API>(PUBLIC_API_URL, {
fetch: {
credentials: "include",
},
})
@@ -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, "/");
},
};
+30 -6
View File
@@ -1,20 +1,40 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import type { PageProps } from "./$types"; import { goto } from "$app/navigation";
import { api } from "$lib/api";
let { form }: PageProps = $props(); let error = $state("");
let loading = $state(false);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const password = new FormData(form).get("password")?.toString() ?? "";
loading = true;
const { status } = await api.auth.login.post({ password });
loading = false;
if (status === 401) {
error = "Invalid password";
return;
}
await goto("/");
}
</script> </script>
<div class="min-h-screen flex items-center justify-center bg-base-200"> <div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card bg-base-100 w-full max-w-sm shadow-xl"> <div class="card bg-base-100 w-full max-w-sm shadow-xl">
<div class="card-body gap-4"> <div class="card-body gap-4">
{#if form?.error} {#if error}
<div role="alert" class="alert alert-error"> <div role="alert" class="alert alert-error">
<span>{form.error}</span> <span>{error}</span>
</div> </div>
{/if} {/if}
<form method="POST" class="flex flex-col gap-4"> <form onsubmit={handleSubmit} class="flex flex-col gap-4">
<label class="floating-label"> <label class="floating-label">
<input <input
type="password" type="password"
@@ -26,7 +46,11 @@
<span>Password</span> <span>Password</span>
</label> </label>
<button type="submit" class="btn btn-primary w-full"> <button
type="submit"
class="btn btn-primary w-full"
disabled={loading}
>
Sign in Sign in
</button> </button>
</form> </form>
+6
View File
@@ -15,6 +15,7 @@
"@blade-and-brawn/calculator": "workspace:*", "@blade-and-brawn/calculator": "workspace:*",
"@blade-and-brawn/commerce": "workspace:*", "@blade-and-brawn/commerce": "workspace:*",
"@blade-and-brawn/domain": "workspace:*", "@blade-and-brawn/domain": "workspace:*",
"@elysia/jwt": "^1.4.2",
"@elysia/server-timing": "^1.4.1", "@elysia/server-timing": "^1.4.1",
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.29", "elysia": "^1.4.29",
@@ -36,6 +37,7 @@
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"daisyui": "^5.6.6", "daisyui": "^5.6.6",
"jose": "^6.2.3",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
}, },
"devDependencies": { "devDependencies": {
@@ -89,6 +91,8 @@
"@elysia/eden": ["@elysia/eden@1.4.10", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-vcZXQcW6wZj6rhTxaiTkuCbVuS/yAJQ9jqCM6b83a5hu99F5Aj3HOyWXL17iod2Rz+pJpYsQGX0XaeoCsMpw2g=="], "@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=="], "@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=="], "@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=="], "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=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],