feat: Discord OAuth Login + User Settings GUI
- 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:
parent
a7e8407996
commit
99d69f30ba
7 changed files with 1435 additions and 60 deletions
247
server/src/core/discord-auth.ts
Normal file
247
server/src/core/discord-auth.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue