diff --git a/frontend/src/BotApp.jsx b/frontend/src/BotApp.jsx index 9a1bc22..a55b613 100644 --- a/frontend/src/BotApp.jsx +++ b/frontend/src/BotApp.jsx @@ -23,7 +23,7 @@ export default function BotApp() { apiGet('/api/me') .then((data) => setMe({ scopes: [], ...data, loading: false })) .catch(() => setMe({ user: null, admin: false, scopes: [], loading: false })); - apiGet('/api/legal').then((d) => setHub(d.publicUrl)).catch(() => {}); + apiGet('/api/legal').then((d) => setHub(d.split ? d.hubUrl : null)).catch(() => {}); }, []); useEffect(() => { diff --git a/frontend/src/pages/BotLanding.jsx b/frontend/src/pages/BotLanding.jsx index 3039493..c578eaa 100644 --- a/frontend/src/pages/BotLanding.jsx +++ b/frontend/src/pages/BotLanding.jsx @@ -25,7 +25,8 @@ export default function BotLanding() { const [hub, setHub] = useState(null); useEffect(() => { - apiGet('/api/legal').then((d) => setHub(d)).catch(() => {}); + // Nur verlinken, wenn der Hub wirklich woanders liegt + apiGet('/api/legal').then((d) => setHub(d.split ? d.hubUrl : null)).catch(() => {}); apiGet('/api/serverstats').then(setStatus).catch(() => {}); window.scrollTo(0, 0); }, []); @@ -106,11 +107,11 @@ export default function BotLanding() { - {hub?.publicUrl && ( + {hub && (
Der Bot läuft für die D4RKST3R-Community {status?.members ? ` mit ${status.members} Mitgliedern` : ''} — - {' '}zum Community-Hub. + {' '}zum Community-Hub.
)} diff --git a/src/web/api.js b/src/web/api.js index a4383f5..d11bb65 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -27,7 +27,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men import { computeNextRun } from './scheduled-posts.js'; import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; -import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo } from '../runtime-settings.js'; +import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl } from '../runtime-settings.js'; import { xpForLevel } from '../bot/levels.js'; import { getMilestones } from '../gitea-api.js'; import { getSessionUser, isAdmin, isGuildMember } from './auth.js'; @@ -1261,6 +1261,10 @@ ${rssItems} legal: legalInfo(), brand: brandName(), publicUrl: publicUrl(), + // Für Querverweise zwischen den beiden Seiten + hubUrl: hubUrl(), + botUrl: botUrl(), + split: hubUrl() !== botUrl(), guildConfigured: Boolean(discordGuildId()), })); diff --git a/src/web/auth.js b/src/web/auth.js index a1e9e60..d89c90e 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -6,10 +6,23 @@ import { publicUrl, discordGuildId, memberGateEnabled, cookieDomain, hubUrl, bot const DISCORD_API = 'https://discord.com/api/v10'; const SESSION_COOKIE = 'd4rkbot_session'; const STATE_COOKIE = 'd4rkbot_oauth_state'; -const ORIGIN_COOKIE = 'd4rkbot_origin'; -// Dynamisch, damit die Setup-Seite die URL ändern kann (Redirect auch im Dev-Portal eintragen!) -const redirectUri = () => `${publicUrl()}/auth/callback`; +/** + * 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 redirectUri(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()}/auth/callback`; +} /** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */ export function getSessionUser(request) { @@ -46,25 +59,16 @@ export function registerAuthRoutes(app, client) { const state = crypto.randomBytes(16).toString('hex'); const params = new URLSearchParams({ client_id: config.discordClientId, - redirect_uri: redirectUri(), + redirect_uri: redirectUri(request), response_type: 'code', scope: 'identify', state, }); - // Bei getrennten Domains merken, von welcher Seite der Login kam — - // Discord schickt immer zur selben Rücksprung-Adresse zurück. - const host = String(request.headers.host ?? '').toLowerCase().split(':')[0]; - const origin = [hubUrl(), botUrl()].find((u) => { - try { return new URL(u).hostname === host; } catch { return false; } - }); return reply .setCookie(STATE_COOKIE, state, { path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true, }) - .setCookie(ORIGIN_COOKIE, origin ?? '', { - path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true, - }) .redirect(`https://discord.com/oauth2/authorize?${params}`); }); @@ -86,7 +90,8 @@ export function registerAuthRoutes(app, client) { client_secret: config.discordClientSecret, grant_type: 'authorization_code', code, - redirect_uri: redirectUri(), + // Muss exakt dieselbe Adresse sein wie beim Weiterleiten + redirect_uri: redirectUri(request), }), }); if (!tokenRes.ok) { @@ -120,28 +125,18 @@ export function registerAuthRoutes(app, client) { const { takeReturnTo } = await import('./sso.js'); const returnTo = takeReturnTo(request, reply); - // Sonst zurück zu der Seite, von der der Login gestartet wurde - let origin = null; - const rawOrigin = request.cookies[ORIGIN_COOKIE]; - if (rawOrigin) { - const unsigned = request.unsignCookie(rawOrigin); - // Nur die beiden bekannten Adressen zulassen - if (unsigned.valid && [hubUrl(), botUrl()].includes(unsigned.value)) { - origin = unsigned.value; - } - } - // Mit gesetzter Cookie-Domain (z. B. .d4rkst3r.de) gilt die Anmeldung // auf Hub und Bot-Seite gleichzeitig const domain = cookieDomain(); return reply .clearCookie(STATE_COOKIE, { path: '/auth' }) - .clearCookie(ORIGIN_COOKIE, { path: '/' }) .setCookie(SESSION_COOKIE, JSON.stringify(session), { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true, ...(domain ? { domain } : {}), }) - .redirect(returnTo ?? (origin ? `${origin}/` : '/')); + // 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) => {