From cb43b4a7b57279af25ff31bff952e82bdde41e6b Mon Sep 17 00:00:00 2001 From: Kenzo Date: Sat, 8 Nov 2025 16:12:33 +0100 Subject: [PATCH] Implement OAuth authentication and admin panel - Introduced OAuth-based login flow with session management and CSRF protection. - Added admin panel for managing events and gallery content with real-time editing functionality. - Integrated Gitea API for saving files and updating repository content. - Updated `.env.example` to include OAuth and Gitea-related configurations. - Added example event and gallery JSON files for demonstration. --- .env.example | 31 +++++++++ src/content/events.json | 26 ++++++++ src/content/gallery.json | 12 ++++ src/pages/admin/index.astro | 94 ++++++++++++++++++++++++++ src/pages/api/auth/callback.ts | 116 +++++++++++++++++++++++++++++++++ src/pages/api/auth/login.ts | 53 +++++++++++++++ src/pages/api/auth/logout.ts | 11 ++++ src/pages/api/save.ts | 97 +++++++++++++++++++++++++++ src/utils/session.ts | 74 +++++++++++++++++++++ 9 files changed, 514 insertions(+) create mode 100644 .env.example create mode 100644 src/content/events.json create mode 100644 src/content/gallery.json create mode 100644 src/pages/admin/index.astro create mode 100644 src/pages/api/auth/callback.ts create mode 100644 src/pages/api/auth/login.ts create mode 100644 src/pages/api/auth/logout.ts create mode 100644 src/pages/api/save.ts create mode 100644 src/utils/session.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..386bf75 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy this file to .env.local for local development +# Then run: npm run dev:local + +# Public base URL for your local dev server +PUBLIC_BASE_URL=http://localhost:4321 + +# OAuth (Gitea) settings for local development +# Create an OAuth2 Application in your Gitea with Redirect URI: +# http://localhost:4321/api/auth/callback +# Then paste the resulting Client ID/Secret below +OAUTH_PROVIDER=gitea +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize +OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token +OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user + +# Optional access control +# OAUTH_ALLOWED_USERS=user1,user2 +# OAUTH_ALLOWED_ORG=your-org + +# Gitea API for committing content changes (service account PAT) +GITEA_BASE=https://git.bookageek.ch +GITEA_OWNER= +GITEA_REPO= +GITEA_TOKEN= +GIT_BRANCH=main + +# Session and CSRF secrets (use random long strings in .env.local) +SESSION_SECRET= +CSRF_SECRET= diff --git a/src/content/events.json b/src/content/events.json new file mode 100644 index 0000000..86794fd --- /dev/null +++ b/src/content/events.json @@ -0,0 +1,26 @@ +[ + { + "image": "/images/karaoke.jpg", + "title": "Karaoke", + "date": "Mittwoch - Samstag", + "description": "Bei uns gibt es Karaoke Mi-Sa!!
\nSeid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;)
\nReserviere am besten gleich per Whatsapp 077 232 27 70" + }, + { + "image": "/images/pub_quiz.jpg", + "title": "Pub Quiz", + "date": "Jeden Freitag", + "description": "Jeden Freitag findet unser Pub Quiz statt. Gespielt wird tischweise in 3-4 Runden.
\nJede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3
\nAuch Einzelpersonen sind herzlich willkommen!
\n*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF" + }, + { + "image": "/images/crepes_sucette.jpg", + "title": "Crepes Sucette
Live Music im Gallus Pub!", + "date": "Do, 04. September 2025", + "description": "20:00 Uhr
\nMetzgergasse 13, 9000 St. Gallen
\nErlebt einen musikalischen Abend mit der Band Crepes Sucette
\nJetzt reservieren: 077 232 27 70" + }, + { + "image": "/images/kevin_mcflannigan.jpeg", + "title": "Kevin McFlannigan
Live Music im Gallus Pub!", + "date": "Sa, 27. September 2025", + "description": "ab 20:00 Uhr
\nSinger & Songwriter Kevin McFlannigan
\nEintritt ist Frei / Hutkollekte
" + } +] diff --git a/src/content/gallery.json b/src/content/gallery.json new file mode 100644 index 0000000..509b933 --- /dev/null +++ b/src/content/gallery.json @@ -0,0 +1,12 @@ +[ + { "src": "/images/Logo.png", "alt": "Erstes Bild" }, + { "src": "/images/Logo.png", "alt": "Zweites Bild" }, + { "src": "/images/Logo.png", "alt": "Drittes Bild" }, + { "src": "/images/Logo.png", "alt": "Viertes Bild" }, + { "src": "/images/Logo.png", "alt": "Fünftes Bild" }, + { "src": "/images/Logo.png", "alt": "Sechstes Bild" }, + { "src": "/images/Logo.png", "alt": "Siebtes Bild" }, + { "src": "/images/Logo.png", "alt": "Achtes Bild" }, + { "src": "/images/Logo.png", "alt": "Neuntes Bild" }, + { "src": "/images/Logo.png", "alt": "Zehntes Bild" } +] diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..39c52c3 --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,94 @@ +--- +import Layout from "../../components/Layout.astro"; +import eventsData from "../../content/events.json"; +import imagesData from "../../content/gallery.json"; +import { getSessionFromRequest } from "../../utils/session"; + +const session = getSessionFromRequest(Astro.request); +if (!session?.user) { + // Not logged in: redirect to OAuth login + return Astro.redirect("/api/auth/login"); +} +const csrf = session.csrf; +const events = eventsData; +const images = imagesData; +--- + + +
+

Admin

+

Eingeloggt als {session.user.login}

+
+

Events (JSON)

+ + +

Galerie (JSON)

+ + +

Bilder hochladen

+ + +
+ + +
+
+
+ + + +
diff --git a/src/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts new file mode 100644 index 0000000..3af3e64 --- /dev/null +++ b/src/pages/api/auth/callback.ts @@ -0,0 +1,116 @@ +import type { APIRoute } from "astro"; +import { createSessionCookie, sessionCookieHeader, randomToken } from "../../../utils/session"; + +export const GET: APIRoute = async ({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const cookie = request.headers.get("cookie") || ""; + const stateCookie = cookie.match(/(?:^|; )oauth_state=([^;]+)/)?.[1]; + + if (!code || !state || !stateCookie || stateCookie !== state) { + return new Response("Invalid OAuth state", { status: 400 }); + } + + const clientId = process.env.OAUTH_CLIENT_ID; + const clientSecret = process.env.OAUTH_CLIENT_SECRET; + const tokenUrlRaw = process.env.OAUTH_TOKEN_URL; + const userinfoUrl = process.env.OAUTH_USERINFO_URL; + if (!clientId || !clientSecret || !tokenUrlRaw || !userinfoUrl) { + return new Response("OAuth not fully configured. Please set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_URL, OAUTH_USERINFO_URL.", { status: 500 }); + } + + // Compute redirect_uri consistent with login, robust against invalid PUBLIC_BASE_URL + let redirectUri: string; + try { + if (process.env.PUBLIC_BASE_URL) { + const base = new URL(process.env.PUBLIC_BASE_URL); + redirectUri = new URL("/api/auth/callback", base).toString(); + } else { + const reqUrl = new URL(request.url); + redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString(); + } + } catch { + const reqUrl = new URL(request.url); + redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString(); + } + + // Validate token URL + let tokenUrl: URL; + try { + tokenUrl = new URL(tokenUrlRaw); + } catch { + return new Response("Invalid OAUTH_TOKEN_URL", { status: 500 }); + } + + // Exchange code for token + const params = new URLSearchParams(); + params.set("client_id", clientId); + params.set("client_secret", clientSecret); + params.set("code", code); + params.set("grant_type", "authorization_code"); + params.set("redirect_uri", redirectUri); + + const tokenRes = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!tokenRes.ok) { + const t = await tokenRes.text(); + return new Response(`OAuth token error: ${tokenRes.status} ${t}`, { status: 500 }); + } + + const tokenData = await tokenRes.json().catch(async () => { + // Some Gitea versions return application/x-www-form-urlencoded + const text = await tokenRes.text(); + const usp = new URLSearchParams(text); + return Object.fromEntries(usp.entries()); + }); + const accessToken = tokenData.access_token || tokenData["access_token"]; + if (!accessToken) { + return new Response("No access token", { status: 500 }); + } + + const userRes = await fetch(userinfoUrl, { + headers: { Authorization: `token ${accessToken}` }, + }); + if (!userRes.ok) { + const t = await userRes.text(); + return new Response(`Userinfo error: ${userRes.status} ${t}`, { status: 500 }); + } + const user = await userRes.json(); + + // Optional allowlist + const allowedUsers = (process.env.OAUTH_ALLOWED_USERS || "").split(/[\,\s]+/).filter(Boolean); + const allowedOrg = process.env.OAUTH_ALLOWED_ORG; + if (allowedUsers.length && !allowedUsers.includes(user.login)) { + return new Response("Forbidden", { status: 403 }); + } + if (allowedOrg) { + // Best-effort org check + try { + const orgsRes = await fetch(process.env.GITEA_BASE + "/api/v1/user/orgs", { + headers: { Authorization: `token ${accessToken}` }, + }); + if (orgsRes.ok) { + const orgs = await orgsRes.json(); + const inOrg = Array.isArray(orgs) && orgs.some((o: any) => o.username === allowedOrg || o.login === allowedOrg || o.name === allowedOrg); + if (!inOrg) return new Response("Forbidden (org)", { status: 403 }); + } + } catch {} + } + + const csrf = randomToken(16); + const sessionValue = createSessionCookie({ + user: { id: user.id, login: user.login, name: user.full_name || user.username || user.login, email: user.email }, + csrf, + }); + + const headers = new Headers(); + headers.set("Set-Cookie", sessionCookieHeader(sessionValue)); + headers.append("Set-Cookie", "oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"); + headers.set("Location", "/admin"); + return new Response(null, { status: 302, headers }); +}; diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts new file mode 100644 index 0000000..5c415a8 --- /dev/null +++ b/src/pages/api/auth/login.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from "astro"; +import { randomToken, setTempCookie } from "../../../utils/session"; + +export const GET: APIRoute = async ({ request }) => { + const clientId = process.env.OAUTH_CLIENT_ID; + const authorizeUrlRaw = process.env.OAUTH_AUTHORIZE_URL; + if (!clientId || !authorizeUrlRaw) { + return new Response( + "OAuth not configured. Please set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL (and related secrets) for local dev.", + { status: 500 } + ); + } + + // Determine callback URL + let finalRedirect: string; + try { + if (process.env.PUBLIC_BASE_URL) { + // Ensure PUBLIC_BASE_URL is an absolute URL + const base = new URL(process.env.PUBLIC_BASE_URL); + finalRedirect = new URL("/api/auth/callback", base).toString(); + } else { + // Fallback to current request origin + const reqUrl = new URL(request.url); + finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString(); + } + } catch { + // As a last resort, use request URL + const reqUrl = new URL(request.url); + finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString(); + } + + // Validate authorize URL + let authorizeUrl: URL; + try { + authorizeUrl = new URL(authorizeUrlRaw); + } catch { + return new Response("Invalid OAUTH_AUTHORIZE_URL", { status: 500 }); + } + + const state = randomToken(16); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", finalRedirect); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("state", state); + + return new Response(null, { + status: 302, + headers: { + Location: authorizeUrl.toString(), + "Set-Cookie": setTempCookie("oauth_state", state), + }, + }); +}; diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts new file mode 100644 index 0000000..8d9ee35 --- /dev/null +++ b/src/pages/api/auth/logout.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from "astro"; +import { clearCookieHeader } from "../../../utils/session"; + +export const POST: APIRoute = async () => { + return new Response(null, { + status: 204, + headers: { + "Set-Cookie": clearCookieHeader(), + }, + }); +}; diff --git a/src/pages/api/save.ts b/src/pages/api/save.ts new file mode 100644 index 0000000..c6f1c2d --- /dev/null +++ b/src/pages/api/save.ts @@ -0,0 +1,97 @@ +import type { APIRoute } from "astro"; +import { getSessionFromRequest } from "../../utils/session"; + +const GITEA_BASE = process.env.GITEA_BASE!; +const GITEA_OWNER = process.env.GITEA_OWNER!; +const GITEA_REPO = process.env.GITEA_REPO!; +const GITEA_TOKEN = process.env.GITEA_TOKEN!; +const DEFAULT_BRANCH = process.env.GIT_BRANCH || "main"; + +function isAllowedPath(path: string) { + if (path === "src/content/events.json") return true; + if (path === "src/content/gallery.json") return true; + if (path.startsWith("public/images/")) { + return /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path); + } + return false; +} + +async function getShaIfExists(path: string): Promise { + const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(DEFAULT_BRANCH)}`; + const res = await fetch(url, { + headers: { Authorization: `token ${GITEA_TOKEN}` }, + }); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`Gitea get sha error ${res.status}`); + const data = await res.json(); + return data.sha; +} + +export const POST: APIRoute = async ({ request }) => { + const session = getSessionFromRequest(request); + if (!session?.user) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); + + // CSRF header required + const csrfHeader = request.headers.get("x-csrf") || request.headers.get("X-CSRF"); + if (!csrfHeader || csrfHeader !== session.csrf) { + return new Response(JSON.stringify({ error: "invalid csrf" }), { status: 403 }); + } + + let payload: any; + try { + payload = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "invalid json" }), { status: 400 }); + } + if (!payload || !Array.isArray(payload.files)) { + return new Response(JSON.stringify({ error: "missing files" }), { status: 400 }); + } + + const results: any[] = []; + for (const file of payload.files) { + const path = String(file.path || ""); + if (!isAllowedPath(path)) { + return new Response(JSON.stringify({ error: `path not allowed: ${path}` }), { status: 400 }); + } + let contentBase64: string; + if (path.startsWith("public/images/")) { + // Expect already base64 string of binary + contentBase64 = String(file.content || ""); + // Remove possible data URL prefix + const match = contentBase64.match(/^data:[^;]+;base64,(.*)$/); + if (match) contentBase64 = match[1]; + } else { + // Text file + contentBase64 = Buffer.from(String(file.content ?? ""), "utf-8").toString("base64"); + } + + const sha = await getShaIfExists(path).catch(() => undefined); + const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}`; + const body: any = { + content: contentBase64, + message: payload.message || `Update ${path}`, + branch: DEFAULT_BRANCH, + }; + if (sha) body.sha = sha; + if (session.user) { + body.author = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` }; + body.committer = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` }; + } + + const res = await fetch(url, { + method: "PUT", + headers: { + Authorization: `token ${GITEA_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const t = await res.text(); + return new Response(JSON.stringify({ error: `Gitea error for ${path}: ${res.status} ${t}` }), { status: 500 }); + } + results.push(await res.json()); + } + + return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { "Content-Type": "application/json" } }); +}; diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..c171b4a --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,74 @@ +import crypto from "node:crypto"; + +export type SessionData = { + user?: { + id: number; + login: string; + name?: string; + email?: string; + }; + csrf?: string; +}; + +const COOKIE_NAME = "gp_session"; + +function b64url(input: Buffer | string) { + return Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function sign(payload: string, secret: string) { + return crypto.createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function createSessionCookie(data: SessionData, secret = process.env.SESSION_SECRET || "") { + const payload = b64url(JSON.stringify(data)); + const sig = sign(payload, secret); + return `${payload}.${sig}`; +} + +export function parseSessionCookie(cookieValue: string | undefined, secret = process.env.SESSION_SECRET || ""): SessionData | undefined { + if (!cookieValue) return undefined; + const [payload, sig] = cookieValue.split("."); + if (!payload || !sig) return undefined; + const expected = sign(payload, secret); + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return undefined; + try { + const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + return JSON.parse(json); + } catch { + return undefined; + } +} + +export function clearCookieHeader(name = COOKIE_NAME) { + return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; +} + +export function sessionCookieHeader(value: string, name = COOKIE_NAME) { + // 7 days + const maxAge = 60 * 60 * 24 * 7; + return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; +} + +export function getSessionFromRequest(req: Request, secret = process.env.SESSION_SECRET || ""): SessionData | undefined { + const cookie = req.headers.get("cookie") || ""; + const match = cookie.match(/(?:^|; )gp_session=([^;]+)/); + if (!match) return undefined; + return parseSessionCookie(match[1], secret); +} + +export function randomToken(bytes = 32) { + return crypto.randomBytes(bytes).toString("hex"); +} + +export const COOKIE_NAME_STATE = "oauth_state"; + +export function setTempCookie(name: string, value: string) { + // short lived: 10 minutes + const maxAge = 60 * 10; + return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; +}