Refactor: Zentralisiertes Admin-Login für alle Tabs

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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 11:11:34 +01:00
parent 8abe0775a5
commit b3080fb763
6 changed files with 101 additions and 133 deletions

61
server/src/core/auth.ts Normal file
View file

@ -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 };

View file

@ -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.

View file

@ -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) => {

View file

@ -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) => {