feat: Discord OAuth Login + User Settings GUI
All checks were successful
Build & Deploy / build (push) Successful in 44s
Build & Deploy / deploy (push) Successful in 5s
Build & Deploy / bump-version (push) Successful in 2s

- Neues unified Login-Modal (Discord, Steam, Admin) ersetzt alten Admin-Login
- Discord OAuth2 Backend (server/src/core/discord-auth.ts)
- User Settings Panel: Entrance/Exit Sounds per Web-GUI konfigurierbar
- API-Endpoints: /api/soundboard/user/{sounds,entrance,exit}
- Session-Management via HMAC-signierte Cookies (hub_session)
- Steam-Button als Platzhalter (bald verfuegbar)
- Backward-kompatibel mit bestehendem Admin-Cookie

Benoetigte neue Env-Vars: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
Discord Redirect URI: PUBLIC_URL/api/auth/discord/callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-10 20:41:16 +01:00
parent a7e8407996
commit 99d69f30ba
7 changed files with 1435 additions and 60 deletions

View file

@ -0,0 +1,247 @@
// ──────────────────────────────────────────────────────────────────────────────
// Discord OAuth2 Authentication + Unified Session Management
// ──────────────────────────────────────────────────────────────────────────────
import crypto from 'node:crypto';
import type express from 'express';
// ── Config ──
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? '';
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? '';
const DISCORD_API = 'https://discord.com/api/v10';
const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize';
const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds
const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds
// ── Types ──
export interface DiscordUser {
id: string;
username: string;
discriminator: string;
avatar: string | null;
global_name: string | null;
}
export interface UserSession {
provider: 'discord' | 'admin';
discordId?: string;
username?: string;
avatar?: string | null;
globalName?: string | null;
iat: number;
exp: number;
}
// ── Helpers ──
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
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;
}
// ── Session Token (HMAC-SHA256) ──
// Uses ADMIN_PWD as base secret, with a salt to differentiate from admin tokens
const SESSION_SECRET = (process.env.ADMIN_PWD ?? '') + ':hub_session_v1';
export function signSession(session: UserSession): string {
const body = b64url(JSON.stringify(session));
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verifySession(token: string | undefined): UserSession | null {
if (!token) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
if (expected !== sig) return null;
try {
const session = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as UserSession;
if (typeof session.exp === 'number' && Date.now() < session.exp) return session;
return null;
} catch { return null; }
}
export function getSession(req: express.Request): UserSession | null {
return verifySession(readCookie(req, 'hub_session'));
}
// ── Admin Token (backward compat with soundboard plugin) ──
function signAdminTokenCompat(adminPwd: string): string {
const payload = { iat: Date.now(), exp: Date.now() + ADMIN_MAX_AGE * 1000 };
const body = b64url(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url');
return `${body}.${sig}`;
}
// ── Discord OAuth2 ──
function getRedirectUri(): string {
const publicUrl = process.env.PUBLIC_URL ?? '';
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/discord/callback`;
return `http://localhost:${process.env.PORT ?? 8080}/api/auth/discord/callback`;
}
export function isDiscordConfigured(): boolean {
return !!(DISCORD_CLIENT_ID && DISCORD_CLIENT_SECRET);
}
function getDiscordAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: 'identify',
state,
});
return `${DISCORD_AUTH_URL}?${params.toString()}`;
}
async function exchangeDiscordCode(code: string): Promise<string> {
const res = await fetch(DISCORD_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri(),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
}
const data = await res.json() as { access_token: string };
return data.access_token;
}
async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Discord user fetch failed (${res.status}): ${text}`);
}
return await res.json() as DiscordUser;
}
// ── Register Routes ──
export function registerAuthRoutes(app: express.Application, adminPwd: string): void {
// Available providers
app.get('/api/auth/providers', (_req, res) => {
res.json({
discord: isDiscordConfigured(),
steam: false, // Steam OpenID — planned
admin: !!adminPwd,
});
});
// Current session
app.get('/api/auth/me', (req, res) => {
const session = getSession(req);
if (!session) {
res.json({ authenticated: false });
return;
}
res.json({
authenticated: true,
provider: session.provider,
discordId: session.discordId ?? null,
username: session.username ?? null,
avatar: session.avatar ?? null,
globalName: session.globalName ?? null,
isAdmin: session.provider === 'admin',
});
});
// Discord OAuth2 — start
app.get('/api/auth/discord', (_req, res) => {
if (!isDiscordConfigured()) {
res.status(503).json({ error: 'Discord OAuth nicht konfiguriert (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET fehlen)' });
return;
}
const state = crypto.randomBytes(16).toString('hex');
console.log(`[Auth] Discord OAuth2 redirect → ${getRedirectUri()}`);
res.redirect(getDiscordAuthUrl(state));
});
// Discord OAuth2 — callback
app.get('/api/auth/discord/callback', async (req, res) => {
const code = req.query.code as string | undefined;
if (!code) {
res.status(400).send('Kein Authorization-Code erhalten.');
return;
}
try {
const accessToken = await exchangeDiscordCode(code);
const user = await fetchDiscordUser(accessToken);
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
: null;
const session: UserSession = {
provider: 'discord',
discordId: user.id,
username: user.username,
avatar: avatarUrl,
globalName: user.global_name,
iat: Date.now(),
exp: Date.now() + SESSION_MAX_AGE * 1000,
};
const token = signSession(session);
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
console.log(`[Auth] Discord login: ${user.username} (${user.id})`);
res.redirect('/');
} catch (e) {
console.error('[Auth] Discord callback error:', e);
res.status(500).send('Discord Login fehlgeschlagen. Bitte erneut versuchen.');
}
});
// Admin login (via unified modal)
app.post('/api/auth/admin', (req, res) => {
if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }
const { password } = req.body ?? {};
if (!password || password !== adminPwd) {
res.status(401).json({ error: 'Falsches Passwort' });
return;
}
const session: UserSession = {
provider: 'admin',
username: 'Admin',
iat: Date.now(),
exp: Date.now() + ADMIN_MAX_AGE * 1000,
};
const hubToken = signSession(session);
const adminToken = signAdminTokenCompat(adminPwd);
// Set hub_session AND legacy admin cookie (soundboard plugin reads 'admin' cookie)
res.setHeader('Set-Cookie', [
`hub_session=${encodeURIComponent(hubToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
`admin=${encodeURIComponent(adminToken)}; HttpOnly; Path=/; Max-Age=${ADMIN_MAX_AGE}; SameSite=Lax`,
]);
console.log('[Auth] Admin login');
res.json({ ok: true });
});
// Logout (clears all session cookies)
app.post('/api/auth/logout', (_req, res) => {
res.setHeader('Set-Cookie', [
'hub_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax',
]);
res.json({ ok: true });
});
}

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 { registerAuthRoutes } from './core/discord-auth.js';
import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js';
import lolstatsPlugin from './plugins/lolstats/index.js';
@ -130,6 +131,9 @@ function onClientReady(botName: string, client: Client): void {
// ── Init ──
async function boot(): Promise<void> {
// ── Auth routes (before plugins so /api/auth/* is available) ──
registerAuthRoutes(app, ADMIN_PWD);
// ── Register plugins with their bot contexts ──
registerPlugin(soundboardPlugin, ctxJukebox);
registerPlugin(radioPlugin, ctxRadio);

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 { getSession } from '../../core/discord-auth.js';
// ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
@ -1244,6 +1245,101 @@ const soundboardPlugin: Plugin = {
});
});
// ── User Sound Preferences (Discord-authenticated) ──
// Get current user's entrance/exit sounds
app.get('/api/soundboard/user/sounds', (req, res) => {
const session = getSession(req);
if (!session?.discordId) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
const userId = session.discordId;
const entrance = persistedState.entranceSounds?.[userId] ?? null;
const exit = persistedState.exitSounds?.[userId] ?? null;
res.json({ entrance, exit });
});
// Set entrance sound
app.post('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
// Resolve file path (same logic as DM handler)
const resolve = (() => {
try {
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
}
return '';
} catch { return ''; }
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
persistedState.entranceSounds[session.discordId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set entrance: ${resolve}`);
res.json({ ok: true, entrance: resolve });
});
// Set exit sound
app.post('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
const resolve = (() => {
try {
if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName;
for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`;
}
return '';
} catch { return ''; }
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.exitSounds = persistedState.exitSounds ?? {};
persistedState.exitSounds[session.discordId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set exit: ${resolve}`);
res.json({ ok: true, exit: resolve });
});
// Remove entrance sound
app.delete('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.entranceSounds) {
delete persistedState.entranceSounds[session.discordId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed entrance sound`);
res.json({ ok: true });
});
// Remove exit sound
app.delete('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.exitSounds) {
delete persistedState.exitSounds[session.discordId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed exit sound`);
res.json({ ok: true });
});
// List available sounds (for user settings dropdown) - no auth required
app.get('/api/soundboard/user/available-sounds', (_req, res) => {
const allSounds = listAllSounds();
res.json(allSounds.map(s => ({ name: s.name, fileName: s.fileName, folder: s.folder, relativePath: s.relativePath })));
});
// ── Health ──
app.get('/api/soundboard/health', (_req, res) => {
res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length });