diff --git a/server/src/core/discord-auth.ts b/server/src/core/discord-auth.ts index 253a10a..53e8679 100644 --- a/server/src/core/discord-auth.ts +++ b/server/src/core/discord-auth.ts @@ -1,5 +1,5 @@ // ────────────────────────────────────────────────────────────────────────────── -// Discord OAuth2 Authentication + Unified Session Management +// Unified Authentication: Discord OAuth2, Steam OpenID 2.0, Admin // ────────────────────────────────────────────────────────────────────────────── import crypto from 'node:crypto'; import type express from 'express'; @@ -10,6 +10,7 @@ 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 STEAM_API_KEY = process.env.STEAM_API_KEY ?? ''; const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds @@ -23,8 +24,9 @@ export interface DiscordUser { } export interface UserSession { - provider: 'discord' | 'admin'; + provider: 'discord' | 'steam' | 'admin'; discordId?: string; + steamId?: string; username?: string; avatar?: string | null; globalName?: string | null; @@ -32,6 +34,14 @@ export interface UserSession { exp: number; } +/** Returns the generic user ID regardless of provider (discordId, steam:steamId, or 'admin') */ +export function getUserId(session: UserSession): string | null { + if (session.discordId) return session.discordId; + if (session.steamId) return `steam:${session.steamId}`; + if (session.provider === 'admin') return 'admin'; + return null; +} + // ── Helpers ── function b64url(input: Buffer | string): string { return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); @@ -135,6 +145,55 @@ async function fetchDiscordUser(accessToken: string): Promise { return await res.json() as DiscordUser; } +// ── Steam OpenID 2.0 ── +export function isSteamConfigured(): boolean { + return !!STEAM_API_KEY; +} + +function getSteamReturnUrl(req: express.Request): string { + const publicUrl = process.env.PUBLIC_URL ?? ''; + if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/steam/callback`; + const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'; + return `${proto}://${host}/api/auth/steam/callback`; +} + +function getSteamRealm(req: express.Request): string { + const publicUrl = process.env.PUBLIC_URL ?? ''; + if (publicUrl) return publicUrl.replace(/\/$/, ''); + const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'; + return `${proto}://${host}`; +} + +async function verifySteamOpenId(query: Record): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + params.set(key, String(value)); + } + params.set('openid.mode', 'check_authentication'); + + const resp = await fetch(`https://steamcommunity.com/openid/login?${params.toString()}`); + const text = await resp.text(); + if (!text.includes('is_valid:true')) return null; + + const claimedId = String(query['openid.claimed_id'] || ''); + const match = claimedId.match(/\/id\/(\d+)$/); + return match ? match[1] : null; +} + +async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string }> { + const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`; + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Steam API error: ${resp.status}`); + const json = await resp.json() as any; + const player = json?.response?.players?.[0]; + return { + personaName: player?.personaname || steamId, + avatarUrl: player?.avatarfull || '', + }; +} + // ── Register Routes ── export function registerAuthRoutes(app: express.Application, adminPwd: string): void { @@ -142,7 +201,7 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string): app.get('/api/auth/providers', (_req, res) => { res.json({ discord: isDiscordConfigured(), - steam: false, // Steam OpenID — planned + steam: isSteamConfigured(), admin: !!adminPwd, }); }); @@ -158,6 +217,7 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string): authenticated: true, provider: session.provider, discordId: session.discordId ?? null, + steamId: session.steamId ?? null, username: session.username ?? null, avatar: session.avatar ?? null, globalName: session.globalName ?? null, @@ -211,6 +271,58 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string): } }); + // Steam OpenID 2.0 — start + app.get('/api/auth/steam', (req, res) => { + if (!isSteamConfigured()) { + res.status(503).json({ error: 'Steam nicht konfiguriert (STEAM_API_KEY fehlt)' }); + return; + } + const realm = getSteamRealm(req); + const returnTo = getSteamReturnUrl(req); + + const params = new URLSearchParams({ + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.mode': 'checkid_setup', + 'openid.return_to': returnTo, + 'openid.realm': realm, + 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', + }); + + console.log(`[Auth] Steam OpenID redirect → ${returnTo}`); + res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`); + }); + + // Steam OpenID 2.0 — callback + app.get('/api/auth/steam/callback', async (req, res) => { + try { + const steamId = await verifySteamOpenId(req.query as Record); + if (!steamId) { + res.status(403).send('Steam-Verifizierung fehlgeschlagen.'); + return; + } + + const profile = await fetchSteamProfile(steamId); + + const session: UserSession = { + provider: 'steam', + steamId, + username: profile.personaName, + avatar: profile.avatarUrl || null, + 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] Steam login: ${profile.personaName} (${steamId})`); + res.redirect('/'); + } catch (e) { + console.error('[Auth] Steam callback error:', e); + res.status(500).send('Steam 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; } diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index ea050d7..bb9f855 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -17,7 +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'; +import { getSession, getUserId } from '../../core/discord-auth.js'; // ── Config (env) ── const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; @@ -1245,15 +1245,15 @@ const soundboardPlugin: Plugin = { }); }); - // ── User Sound Preferences (Discord-authenticated) ── + // ── User Sound Preferences (Discord / Steam authenticated) ── // Get current user's entrance/exit sounds app.get('/api/soundboard/user/sounds', (req, res) => { const session = getSession(req); - if (!session?.discordId) { + const userId = session ? getUserId(session) : null; + if (!userId) { 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 }); @@ -1262,7 +1262,8 @@ const soundboardPlugin: Plugin = { // 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 userId = session ? getUserId(session) : null; + if (!userId) { 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; } @@ -1279,16 +1280,17 @@ const soundboardPlugin: Plugin = { })(); if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; } persistedState.entranceSounds = persistedState.entranceSounds ?? {}; - persistedState.entranceSounds[session.discordId] = resolve; + persistedState.entranceSounds[userId] = resolve; writeState(); - console.log(`[Soundboard] User ${session.username} (${session.discordId}) set entrance: ${resolve}`); + console.log(`[Soundboard] User ${session!.username} (${userId}) 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 userId = session ? getUserId(session) : null; + if (!userId) { 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; } @@ -1304,33 +1306,35 @@ const soundboardPlugin: Plugin = { })(); if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; } persistedState.exitSounds = persistedState.exitSounds ?? {}; - persistedState.exitSounds[session.discordId] = resolve; + persistedState.exitSounds[userId] = resolve; writeState(); - console.log(`[Soundboard] User ${session.username} (${session.discordId}) set exit: ${resolve}`); + console.log(`[Soundboard] User ${session!.username} (${userId}) 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; } + const userId = session ? getUserId(session) : null; + if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } if (persistedState.entranceSounds) { - delete persistedState.entranceSounds[session.discordId]; + delete persistedState.entranceSounds[userId]; writeState(); } - console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed entrance sound`); + console.log(`[Soundboard] User ${session!.username} (${userId}) 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; } + const userId = session ? getUserId(session) : null; + if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } if (persistedState.exitSounds) { - delete persistedState.exitSounds[session.discordId]; + delete persistedState.exitSounds[userId]; writeState(); } - console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed exit sound`); + console.log(`[Soundboard] User ${session!.username} (${userId}) removed exit sound`); res.json({ ok: true }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 065dc9b..2347765 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,8 +17,9 @@ interface PluginInfo { interface AuthUser { authenticated: boolean; - provider?: 'discord' | 'admin'; + provider?: 'discord' | 'steam' | 'admin'; discordId?: string; + steamId?: string; username?: string; avatar?: string | null; globalName?: string | null; @@ -69,6 +70,8 @@ export default function App() { // Derived state const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true); const isDiscordUser = user.authenticated && user.provider === 'discord'; + const isSteamUser = user.authenticated && user.provider === 'steam'; + const isRegularUser = isDiscordUser || isSteamUser; // Electron auto-update state const isElectron = !!(window as any).electronAPI?.isElectron; @@ -239,7 +242,7 @@ export default function App() { setShowLoginModal(true); } else if (isAdmin) { setShowAdminPanel(true); - } else if (isDiscordUser) { + } else if (isRegularUser) { setShowUserSettings(true); } } @@ -294,7 +297,7 @@ export default function App() { } > {user.authenticated ? ( - isDiscordUser && user.avatar ? ( + isRegularUser && user.avatar ? ( ) : ( {isAdmin ? '\uD83D\uDD27' : '\uD83D\uDC64'} @@ -435,11 +438,12 @@ export default function App() { /> )} - {/* User Settings (Discord users) */} - {showUserSettings && isDiscordUser && user.discordId && ( + {/* User Settings (Discord + Steam users) */} + {showUserSettings && isRegularUser && ( )} - {/* Steam — placeholder */} - + {/* Steam */} + {providers.steam && ( + + + + + Mit Steam anmelden + + )} {/* Admin */} {providers.admin && ( diff --git a/web/src/UserSettings.tsx b/web/src/UserSettings.tsx index 1720c46..cb7ce65 100644 --- a/web/src/UserSettings.tsx +++ b/web/src/UserSettings.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; interface UserInfo { - discordId: string; + id: string; + provider: 'discord' | 'steam'; username: string; avatar: string | null; globalName: string | null; @@ -133,7 +134,9 @@ export default function UserSettings({ user, onClose, onLogout }: UserSettingsPr )}
{user.globalName || user.username} - @{user.username} + + {user.provider === 'steam' ? 'Steam' : `@${user.username}`} +
diff --git a/web/src/styles.css b/web/src/styles.css index 8acdfbc..3b1c050 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2456,9 +2456,9 @@ html, body { border-color: #5865F2; background: rgba(88, 101, 242, 0.08); } -.hub-login-provider-btn.steam:hover:not(:disabled) { - border-color: #1b2838; - background: rgba(27, 40, 56, 0.15); +.hub-login-provider-btn.steam:hover { + border-color: #66c0f4; + background: rgba(102, 192, 244, 0.08); } .hub-login-provider-btn.admin:hover { border-color: var(--accent);