Login über beide Domains reparieren, Hub-Link auf der Bot-Seite korrigieren
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Zwei Fehler aus dem ersten Praxistest:

1. "Ungültiger OAuth-State" beim Anmelden auf dem Hub. Der Login startete
   auf hub.d4rkst3r.de, aber die Rücksprung-Adresse für Discord war fest
   auf die öffentliche URL gesetzt — Discord schickte also zur Bot-Domain
   zurück. Das Schutz-Cookie gegen Sitzungsübernahme gilt aber nur für die
   Domain, auf der es gesetzt wurde, und war dort nicht lesbar.
   Jetzt bleibt der ganze Ablauf auf der Domain, von der er gestartet ist.
   Damit entfällt auch der Umweg über ein zusätzliches Cookie, das sich die
   Ursprungsseite merken sollte.

2. Der Link "zum Community-Hub" auf der Bot-Seite zeigte auf die Bot-Seite
   selbst — er benutzte die alte öffentliche URL statt der Hub-Adresse.
   /api/legal liefert jetzt beide Adressen getrennt; die Verweise zwischen
   den Seiten erscheinen nur, wenn die Domains wirklich verschieden sind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:29:31 +02:00
co-authored by Claude Fable 5
parent 079c75b184
commit 478e47684a
4 changed files with 32 additions and 32 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ export default function BotApp() {
apiGet('/api/me') apiGet('/api/me')
.then((data) => setMe({ scopes: [], ...data, loading: false })) .then((data) => setMe({ scopes: [], ...data, loading: false }))
.catch(() => setMe({ user: null, admin: false, scopes: [], 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(() => { useEffect(() => {
+4 -3
View File
@@ -25,7 +25,8 @@ export default function BotLanding() {
const [hub, setHub] = useState(null); const [hub, setHub] = useState(null);
useEffect(() => { 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(() => {}); apiGet('/api/serverstats').then(setStatus).catch(() => {});
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, []); }, []);
@@ -106,11 +107,11 @@ export default function BotLanding() {
</div> </div>
</div> </div>
{hub?.publicUrl && ( {hub && (
<p className="notice" style={{ marginTop: '2rem' }}> <p className="notice" style={{ marginTop: '2rem' }}>
Der Bot läuft für die D4RKST3R-Community Der Bot läuft für die D4RKST3R-Community
{status?.members ? ` mit ${status.members} Mitgliedern` : ''} {status?.members ? ` mit ${status.members} Mitgliedern` : ''}
{' '}<a href={hub.publicUrl} style={{ textDecoration: 'underline' }}>zum Community-Hub</a>. {' '}<a href={hub} style={{ textDecoration: 'underline' }}>zum Community-Hub</a>.
</p> </p>
)} )}
</section> </section>
+5 -1
View File
@@ -27,7 +27,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
import { computeNextRun } from './scheduled-posts.js'; import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.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 { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js'; import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin, isGuildMember } from './auth.js'; import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
@@ -1261,6 +1261,10 @@ ${rssItems}
legal: legalInfo(), legal: legalInfo(),
brand: brandName(), brand: brandName(),
publicUrl: publicUrl(), publicUrl: publicUrl(),
// Für Querverweise zwischen den beiden Seiten
hubUrl: hubUrl(),
botUrl: botUrl(),
split: hubUrl() !== botUrl(),
guildConfigured: Boolean(discordGuildId()), guildConfigured: Boolean(discordGuildId()),
})); }));
+22 -27
View File
@@ -6,10 +6,23 @@ import { publicUrl, discordGuildId, memberGateEnabled, cookieDomain, hubUrl, bot
const DISCORD_API = 'https://discord.com/api/v10'; const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'd4rkbot_session'; const SESSION_COOKIE = 'd4rkbot_session';
const STATE_COOKIE = 'd4rkbot_oauth_state'; 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) */ /** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */
export function getSessionUser(request) { export function getSessionUser(request) {
@@ -46,25 +59,16 @@ export function registerAuthRoutes(app, client) {
const state = crypto.randomBytes(16).toString('hex'); const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: config.discordClientId, client_id: config.discordClientId,
redirect_uri: redirectUri(), redirect_uri: redirectUri(request),
response_type: 'code', response_type: 'code',
scope: 'identify', scope: 'identify',
state, 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 return reply
.setCookie(STATE_COOKIE, state, { .setCookie(STATE_COOKIE, state, {
path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true, 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}`); .redirect(`https://discord.com/oauth2/authorize?${params}`);
}); });
@@ -86,7 +90,8 @@ export function registerAuthRoutes(app, client) {
client_secret: config.discordClientSecret, client_secret: config.discordClientSecret,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
redirect_uri: redirectUri(), // Muss exakt dieselbe Adresse sein wie beim Weiterleiten
redirect_uri: redirectUri(request),
}), }),
}); });
if (!tokenRes.ok) { if (!tokenRes.ok) {
@@ -120,28 +125,18 @@ export function registerAuthRoutes(app, client) {
const { takeReturnTo } = await import('./sso.js'); const { takeReturnTo } = await import('./sso.js');
const returnTo = takeReturnTo(request, reply); 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 // Mit gesetzter Cookie-Domain (z. B. .d4rkst3r.de) gilt die Anmeldung
// auf Hub und Bot-Seite gleichzeitig // auf Hub und Bot-Seite gleichzeitig
const domain = cookieDomain(); const domain = cookieDomain();
return reply return reply
.clearCookie(STATE_COOKIE, { path: '/auth' }) .clearCookie(STATE_COOKIE, { path: '/auth' })
.clearCookie(ORIGIN_COOKIE, { path: '/' })
.setCookie(SESSION_COOKIE, JSON.stringify(session), { .setCookie(SESSION_COOKIE, JSON.stringify(session), {
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true, path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true,
...(domain ? { domain } : {}), ...(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) => { app.get('/auth/logout', async (request, reply) => {