Files
d4rkbot/src/web/auth.js
T
D4rkst3randClaude Fable 5 8fa43a3e47
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Code-Review: drei Fehler behoben
Session-Cookie ohne secure-Flag (src/web/auth.js)
    Das Anmelde-Cookie war httpOnly, sameSite und signiert — aber ohne secure.
    Beim ersten Aufruf ueber http, bevor der Proxy auf https umleitet, geht es
    damit im Klartext mit. Auf einem fremden WLAN ist das die Sitzung.

    Das Flag laesst sich nicht aus request.protocol ableiten: hinter dem
    Reverse-Proxy kommt jede Anfrage als http an, trustProxy ist nicht gesetzt.
    Stattdessen aus der konfigurierten Adresse des Hosts, mit derselben
    Zuordnung, die der OAuth-Redirect schon benutzt. Lokal ueber
    http://localhost bleibt es aus, sonst kaeme man beim Entwickeln nicht rein.

    Geprueft gegen sechs Hosts: hub und bot ergeben secure, localhost nicht.
    Gilt auch fuer das OAuth-State- und das SSO-Ruecksprung-Cookie.

Unbehandelte Rejection beim Start (src/bot/client.js)
    registerCommands() faengt nichts ab, der Aufrufer im ClientReady-Handler
    auch nicht. Seit Node 15 beendet eine unbehandelte Rejection den Prozess —
    ein Rate-Limit von Discord beim Start haette den Bot also abgeschossen und
    mit restart: unless-stopped in eine Neustart-Schleife geschickt. Dabei
    stehen die zuvor registrierten Befehle bei Discord weiter, und alles
    andere koennte laufen. Jetzt wird der Fehler geloggt und der Bot bleibt an.

Number('') ist 0 (src/bot/anti-raid.js)
    Beim Aufheben der Raid-Sperre wird die gemerkte Verifizierungsstufe
    zurueckgesetzt. Fehlt die Notiz, ergab Number('') die 0 — und 0 ist eine
    gueltige Stufe, naemlich "keine". Der Server waere also auf offen gestellt
    worden statt so gelassen, wie er war. Jetzt wird nur zurueckgesetzt, was
    auch wirklich als Zahl notiert ist.

Ohne Befund geprueft: alle 168 SQL-Abfragen gegen das echte Schema inklusive
Migrationen, alle 109 Routen auf Rechtepruefung (53 von 54 schreibenden haben
eine, die 54. haengt am timing-safe verglichenen Secret), Farbwerte vor dem
Einbetten in ausgeliefertes CSS, das Escaping der OG-Meta-Tags, Timer-Cleanup
im Frontend und die uebrigen async-Handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 04:48:52 +02:00

177 lines
7.6 KiB
JavaScript

// 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('/');
});
}