From b3080fb763ee632fd99e64ea756e4b2757a08952 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 9 Mar 2026 11:11:34 +0100 Subject: [PATCH] =?UTF-8?q?Refactor:=20Zentralisiertes=20Admin-Login=20f?= =?UTF-8?q?=C3=BCr=20alle=20Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-Auth aus Soundboard-Plugin in core/auth.ts extrahiert. Ein Login-Button im Header gilt jetzt für die gesamte Webseite. Cookie-basiert (HMAC-SHA256, 7 Tage) — überlebt Page-Reload. Co-Authored-By: Claude Opus 4.6 --- server/src/core/auth.ts | 61 +++++++++++++++++++ server/src/core/middleware.ts | 20 +------ server/src/index.ts | 12 +++- server/src/plugins/soundboard/index.ts | 51 +--------------- web/src/App.tsx | 27 +++++++-- web/src/plugins/soundboard/SoundboardTab.tsx | 63 +------------------- 6 files changed, 101 insertions(+), 133 deletions(-) create mode 100644 server/src/core/auth.ts diff --git a/server/src/core/auth.ts b/server/src/core/auth.ts new file mode 100644 index 0000000..4373913 --- /dev/null +++ b/server/src/core/auth.ts @@ -0,0 +1,61 @@ +import crypto from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; + +const COOKIE_NAME = 'admin_token'; +const TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // 7 days + +type AdminPayload = { iat: number; exp: number }; + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export function signAdminToken(adminPwd: string): string { + const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + TOKEN_TTL_MS }; + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +export function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { + if (!token || !adminPwd) return false; + const [body, sig] = token.split('.'); + if (!body || !sig) return false; + const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + if (expected !== sig) return false; + try { + const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload; + return typeof payload.exp === 'number' && Date.now() < payload.exp; + } catch { return false; } +} + +export function readCookie(req: Request, key: string): string | undefined { + const c = req.headers.cookie; + if (!c) return undefined; + for (const part of c.split(';')) { + const [k, v] = part.trim().split('='); + if (k === key) return decodeURIComponent(v || ''); + } + return undefined; +} + +export function setAdminCookie(res: Response, token: string): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); +} + +export function clearAdminCookie(res: Response): void { + res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`); +} + +export function requireAdmin(adminPwd: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!adminPwd) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } + if (!verifyAdminToken(adminPwd, readCookie(req, COOKIE_NAME))) { + res.status(401).json({ error: 'Nicht eingeloggt' }); + return; + } + next(); + }; +} + +export { COOKIE_NAME }; diff --git a/server/src/core/middleware.ts b/server/src/core/middleware.ts index fc29a4b..7c5e7cd 100644 --- a/server/src/core/middleware.ts +++ b/server/src/core/middleware.ts @@ -1,24 +1,8 @@ import { Request, Response, NextFunction } from 'express'; import type { PluginContext } from './plugin.js'; -/** - * Admin authentication middleware. - * Checks `x-admin-password` header against ADMIN_PWD env var. - */ -export function adminAuth(ctx: PluginContext) { - return (req: Request, res: Response, next: NextFunction): void => { - if (!ctx.adminPwd) { - res.status(503).json({ error: 'ADMIN_PWD not configured' }); - return; - } - const pwd = req.headers['x-admin-password'] as string | undefined; - if (pwd !== ctx.adminPwd) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - next(); - }; -} +// Re-export centralised admin auth +export { requireAdmin } from './auth.js'; /** * Guild filter middleware. diff --git a/server/src/index.ts b/server/src/index.ts index 6c322c2..9ccfa47 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { createClient } from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; +import { signAdminToken, verifyAdminToken, readCookie, setAdminCookie, clearAdminCookie, COOKIE_NAME } from './core/auth.js'; import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js'; @@ -93,16 +94,25 @@ app.get('/api/health', (_req, res) => { }); }); -// ── Admin Login ── +// ── Admin Auth (centralised) ── app.post('/api/admin/login', (req, res) => { if (!ADMIN_PWD) { res.status(503).json({ error: 'ADMIN_PWD not configured' }); return; } const { password } = req.body ?? {}; if (password === ADMIN_PWD) { + const token = signAdminToken(ADMIN_PWD); + setAdminCookie(res, token); res.json({ ok: true }); } else { res.status(401).json({ error: 'Invalid password' }); } }); +app.post('/api/admin/logout', (_req, res) => { + clearAdminCookie(res); + res.json({ ok: true }); +}); +app.get('/api/admin/status', (req, res) => { + res.json({ authenticated: verifyAdminToken(ADMIN_PWD, readCookie(req, COOKIE_NAME)) }); +}); // ── API: List plugins ── app.get('/api/plugins', (_req, res) => { diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index d53238e..777b195 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -17,6 +17,7 @@ import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { requireAdmin as requireAdminFactory } from '../../core/auth.js'; // ── Config (env) ── const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; @@ -583,33 +584,6 @@ async function playFilePath(guildId: string, channelId: string, filePath: string if (relativeKey) incrementPlaysFor(relativeKey); } -// ── Admin Auth (JWT-like with HMAC) ── -type AdminPayload = { iat: number; exp: number }; -function b64url(input: Buffer | string): string { - return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); -} -function signAdminToken(adminPwd: string, payload: AdminPayload): string { - const body = b64url(JSON.stringify(payload)); - const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - return `${body}.${sig}`; -} -function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { - if (!token || !adminPwd) return false; - const [body, sig] = token.split('.'); - if (!body || !sig) return false; - const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); - if (expected !== sig) return false; - try { - const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload; - return typeof payload.exp === 'number' && Date.now() < payload.exp; - } catch { return false; } -} -function readCookie(req: express.Request, key: string): string | undefined { - const c = req.headers.cookie; - if (!c) return undefined; - for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); } - return undefined; -} // ── Party Mode ── function schedulePartyPlayback(guildId: string, channelId: string) { @@ -775,28 +749,7 @@ const soundboardPlugin: Plugin = { }, registerRoutes(app: express.Application, ctx: PluginContext) { - const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } - next(); - }; - - // ── Admin Auth ── - app.post('/api/soundboard/admin/login', (req, res) => { - if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } - const { password } = req.body ?? {}; - if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } - const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 }); - res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); - res.json({ ok: true }); - }); - app.post('/api/soundboard/admin/logout', (_req, res) => { - res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); - res.json({ ok: true }); - }); - app.get('/api/soundboard/admin/status', (req, res) => { - res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); - }); + const requireAdmin = requireAdminFactory(ctx.adminPwd); // ── Sounds ── app.get('/api/soundboard/sounds', (req, res) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index fb12660..e87ee2b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ interface PluginInfo { } // Plugin tab components -const tabComponents: Record> = { +const tabComponents: Record> = { radio: RadioTab, soundboard: SoundboardTab, lolstats: LolstatsTab, @@ -22,7 +22,7 @@ const tabComponents: Record> = { 'game-library': GameLibraryTab, }; -export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { +export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) { tabComponents[pluginName] = component; } @@ -149,12 +149,21 @@ export default function App() { return () => window.removeEventListener('keydown', handler); }, [showVersionModal, showAdminModal]); + // Check admin status on mount (cookie-based, survives reload) + useEffect(() => { + fetch('/api/admin/status', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(d => { if (d?.authenticated) setAdminLoggedIn(true); }) + .catch(() => {}); + }, []); + // Admin login handler const handleAdminLogin = () => { if (!adminPassword) return; fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ password: adminPassword }), }) .then(r => { @@ -162,6 +171,7 @@ export default function App() { setAdminLoggedIn(true); setAdminPassword(''); setAdminError(''); + setShowAdminModal(false); } else { setAdminError('Falsches Passwort'); } @@ -170,8 +180,15 @@ export default function App() { }; const handleAdminLogout = () => { - setAdminLoggedIn(false); - setShowAdminModal(false); + fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) + .then(() => { + setAdminLoggedIn(false); + setShowAdminModal(false); + }) + .catch(() => { + setAdminLoggedIn(false); + setShowAdminModal(false); + }); }; @@ -414,7 +431,7 @@ export default function App() { : { display: 'none' } } > - + ); }) diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index 064951b..dbe0103 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -186,24 +186,6 @@ async function apiGetVolume(guildId: string): Promise { return typeof data?.volume === 'number' ? data.volume : 1; } -async function apiAdminStatus(): Promise { - const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' }); - if (!res.ok) return false; - const data = await res.json(); - return !!data?.authenticated; -} - -async function apiAdminLogin(password: string): Promise { - const res = await fetch(`${API_BASE}/admin/login`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ password }) - }); - return res.ok; -} - -async function apiAdminLogout(): Promise { - await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' }); -} async function apiAdminDelete(paths: string[]): Promise { const res = await fetch(`${API_BASE}/admin/sounds/delete`, { @@ -324,13 +306,14 @@ interface VoiceStats { interface SoundboardTabProps { data: any; + isAdmin?: boolean; } /* ══════════════════════════════════════════════════════════════════ COMPONENT ══════════════════════════════════════════════════════════════════ */ -export default function SoundboardTab({ data }: SoundboardTabProps) { +export default function SoundboardTab({ data, isAdmin: isAdminProp = false }: SoundboardTabProps) { /* ── Data ── */ const [sounds, setSounds] = useState([]); const [total, setTotal] = useState(0); @@ -378,9 +361,8 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { const volDebounceRef = useRef>(undefined); /* ── Admin ── */ - const [isAdmin, setIsAdmin] = useState(false); + const isAdmin = isAdminProp; const [showAdmin, setShowAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); const [adminSounds, setAdminSounds] = useState([]); const [adminLoading, setAdminLoading] = useState(false); const [adminQuery, setAdminQuery] = useState(''); @@ -521,7 +503,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); } } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } - try { setIsAdmin(await apiAdminStatus()); } catch { } try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -879,27 +860,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { } } - async function handleAdminLogin() { - try { - const ok = await apiAdminLogin(adminPwd); - if (ok) { - setIsAdmin(true); - setAdminPwd(''); - notify('Admin eingeloggt'); - } - else notify('Falsches Passwort', 'error'); - } catch { notify('Login fehlgeschlagen', 'error'); } - } - - async function handleAdminLogout() { - try { - await apiAdminLogout(); - setIsAdmin(false); - setAdminSelection({}); - cancelRename(); - notify('Ausgeloggt'); - } catch { } - } /* ── Computed ── */ const displaySounds = useMemo(() => { @@ -1447,21 +1407,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { close - {!isAdmin ? ( -
-
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - placeholder="Admin-Passwort..." - /> -
- -
- ) : (

Eingeloggt als Admin

@@ -1473,7 +1418,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { > Aktualisieren -
@@ -1585,7 +1529,6 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { )} - )} )}