// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie import crypto from 'node:crypto'; import { config } from '../config.js'; import { publicUrl, discordGuildId, memberGateEnabled, cookieDomain, hubUrl, botUrl } from '../runtime-settings.js'; const DISCORD_API = 'https://discord.com/api/v10'; const SESSION_COOKIE = 'd4rkbot_session'; const STATE_COOKIE = 'd4rkbot_oauth_state'; /** * Rücksprung-Adresse für Discord — bleibt auf der Domain, von der der Login * gestartet wurde. Das ist wichtig, weil das Schutz-Cookie gegen * Sitzungsübernahme nur für diese eine Domain gilt: Würde Discord auf eine * andere Adresse zurückschicken, wäre es dort nicht lesbar und der Login * schlüge mit „Ungültiger OAuth-State" fehl. * * Jede hier mögliche Adresse muss im Discord-Portal als Redirect eingetragen sein. */ function passendeBasis(request) { const host = String(request?.headers?.host ?? '').toLowerCase().split(':')[0]; const known = [hubUrl(), botUrl(), publicUrl()].find((u) => { try { return new URL(u).hostname === host; } catch { return false; } }); return known ?? publicUrl(); } function redirectUri(request) { return `${passendeBasis(request)}/auth/callback`; } /** * Darf das Cookie nur über HTTPS zurückkommen? * * Abgeleitet aus der konfigurierten Adresse und nicht aus request.protocol: * hinter dem Reverse-Proxy kommt die Anfrage als HTTP an, das Flag wäre dann * nie gesetzt. Lokal über http://localhost bleibt es aus, sonst käme man * beim Entwickeln nicht mehr rein. */ export function nurUeberHttps(request) { return String(passendeBasis(request)).startsWith('https://'); } /** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */ export function getSessionUser(request) { const raw = request.cookies[SESSION_COOKIE]; if (!raw) return null; const unsigned = request.unsignCookie(raw); if (!unsigned.valid) return null; try { return JSON.parse(unsigned.value); } catch { return null; } } /** true, wenn der eingeloggte User der Admin (du) ist */ export function isAdmin(user) { return user?.id === config.adminDiscordId; } /** Member-Gate: ist die User-ID Mitglied der konfigurierten Guild? (Owner immer) */ export async function isGuildMember(client, userId) { if (userId === config.adminDiscordId) return true; const guildId = discordGuildId(); if (!guildId) return true; // ohne Guild-Konfig kein Gate const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null); if (!guild) return true; // Bot nicht auf der Guild → Gate nicht erzwingbar const member = await guild.members.fetch(userId).catch(() => null); return Boolean(member); } export function registerAuthRoutes(app, client) { // Login: mit CSRF-State zu Discord weiterleiten app.get('/auth/login', async (request, reply) => { const state = crypto.randomBytes(16).toString('hex'); const params = new URLSearchParams({ client_id: config.discordClientId, redirect_uri: redirectUri(request), response_type: 'code', scope: 'identify', state, }); return reply .setCookie(STATE_COOKIE, state, { path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true, secure: nurUeberHttps(request), }) .redirect(`https://discord.com/oauth2/authorize?${params}`); }); // Callback: Code gegen Token tauschen, User holen, Session-Cookie setzen app.get('/auth/callback', async (request, reply) => { const { code, state } = request.query; const stateCookie = request.cookies[STATE_COOKIE] ? request.unsignCookie(request.cookies[STATE_COOKIE]) : null; if (!code || !state || !stateCookie?.valid || stateCookie.value !== state) { return reply.code(400).send('Ungültiger OAuth-State — bitte erneut einloggen.'); } const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.discordClientId, client_secret: config.discordClientSecret, grant_type: 'authorization_code', code, // Muss exakt dieselbe Adresse sein wie beim Weiterleiten redirect_uri: redirectUri(request), }), }); if (!tokenRes.ok) { request.log.error(`OAuth-Token-Tausch fehlgeschlagen: ${tokenRes.status}`); return reply.code(502).send('Discord-Login fehlgeschlagen — bitte erneut versuchen.'); } const { access_token: accessToken } = await tokenRes.json(); const userRes = await fetch(`${DISCORD_API}/users/@me`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!userRes.ok) { return reply.code(502).send('Discord-Userdaten nicht abrufbar — bitte erneut versuchen.'); } const me = await userRes.json(); // Member-Gate: nur Mitglieder des Discord-Servers bekommen eine Session if (memberGateEnabled() && !(await isGuildMember(client, me.id))) { return reply.clearCookie(STATE_COOKIE, { path: '/auth' }).redirect('/?gate=1'); } // Nur das Nötigste in die Session — keine Tokens speichern const session = { id: me.id, username: me.global_name || me.username, avatar: me.avatar ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=64` : null, }; // Kam der Login über /sso/authorize? Dann dorthin zurück statt auf die Startseite const { takeReturnTo } = await import('./sso.js'); const returnTo = takeReturnTo(request, reply); // Mit gesetzter Cookie-Domain (z. B. .d4rkst3r.de) gilt die Anmeldung // auf Hub und Bot-Seite gleichzeitig const domain = cookieDomain(); reply.clearCookie(STATE_COOKIE, { path: '/auth' }); // Altlast aufräumen: ein früher host-gebunden gesetztes Cookie würde // sonst neben dem neuen bestehen bleiben und beim Abmelden übrig sein if (domain) reply.clearCookie(SESSION_COOKIE, { path: '/' }); return reply .setCookie(SESSION_COOKIE, JSON.stringify(session), { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true, // Ohne secure geht das Cookie beim ersten Aufruf über http mit // — noch bevor der Proxy auf https umleitet. secure: nurUeberHttps(request), ...(domain ? { domain } : {}), }) // Der Callback läuft auf derselben Domain wie der Login — ein // relativer Pfad führt also automatisch zurück zur richtigen Seite .redirect(returnTo ?? '/'); }); app.get('/auth/logout', async (request, reply) => { // Beide Varianten löschen: Ein Cookie, das ohne Domain-Angabe gesetzt // wurde, gilt nur für genau diesen Host und ist ein anderes als das // domain-weite. Wer sich vor der Umstellung angemeldet hat, hat noch // das alte — würden wir nur eins löschen, bliebe er angemeldet. reply.clearCookie(SESSION_COOKIE, { path: '/' }); const domain = cookieDomain(); if (domain) reply.clearCookie(SESSION_COOKIE, { path: '/', domain }); return reply.redirect('/'); }); }