Single Sign-On: der Bot als Identity-Provider für die anderen Dienste
Andere Apps (Kanban, Platform …) nutzen ab sofort den Discord-Login des Bots mit, statt jeweils eigenes OAuth zu bauen — und bekommen die Discord-Rollen des Users gleich mitgeliefert. Ablauf: App leitet auf /sso/authorize weiter, der Bot prüft die Session (ggf. erst Discord-Login) und schickt einen signierten Token zurück, den die App serverseitig per POST /sso/verify gegen die Nutzerdaten tauscht. Sicherheit: - Rücksprung-Ziele müssen einem registrierten Präfix entsprechen (kein Open Redirect, kein Token-Abgriff über fremde Hosts) - Token HMAC-signiert, 60 Sekunden gültig, nur einmal einlösbar - Verify braucht das App-Secret (timing-safe verglichen) - SSO bleibt Mitgliedern des Discord-Servers vorbehalten - App-Verwaltung ist Owner-only, Secret wird nur einmal angezeigt Dazu: sso_apps-Tabelle, Verwaltung im API-Tab, Rücksprung nach dem Login (return-Cookie, nur interne Pfade), Anleitung in docs/sso.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -516,6 +516,31 @@ export function deleteUserData(userId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sso_apps (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
redirect_prefix TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used TEXT
|
||||
);
|
||||
`);
|
||||
const upsertSsoApp = db.prepare(`
|
||||
INSERT INTO sso_apps (slug, name, redirect_prefix, secret) VALUES (@slug, @name, @redirect_prefix, @secret)
|
||||
ON CONFLICT(slug) DO UPDATE SET name = excluded.name, redirect_prefix = excluded.redirect_prefix
|
||||
`);
|
||||
const getSsoAppStmt = db.prepare('SELECT * FROM sso_apps WHERE slug = ?');
|
||||
const listSsoAppsStmt = db.prepare('SELECT slug, name, redirect_prefix, created_at, last_used FROM sso_apps ORDER BY name');
|
||||
const deleteSsoAppStmt = db.prepare('DELETE FROM sso_apps WHERE slug = ?');
|
||||
const touchSsoAppStmt = db.prepare(`UPDATE sso_apps SET last_used = datetime('now') WHERE slug = ?`);
|
||||
export const saveSsoApp = (app) => upsertSsoApp.run(app);
|
||||
export const getSsoApp = (slug) => getSsoAppStmt.get(slug) ?? null;
|
||||
export const listSsoApps = () => listSsoAppsStmt.all();
|
||||
export const deleteSsoApp = (slug) => deleteSsoAppStmt.run(slug).changes > 0;
|
||||
export const touchSsoApp = (slug) => touchSsoAppStmt.run(slug);
|
||||
|
||||
// Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
saveTemplate, listTemplates, deleteTemplate,
|
||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||
logAudit, listAudit, playerHistory, deleteUserData,
|
||||
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||
} from '../db.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { sanitizeEmbed } from './api-v1.js';
|
||||
@@ -1310,6 +1312,37 @@ ${rssItems}
|
||||
return { entries: listAudit(100) };
|
||||
});
|
||||
|
||||
// --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---
|
||||
|
||||
app.get('/api/ssoapps', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { apps: listSsoApps() };
|
||||
});
|
||||
|
||||
app.post('/api/ssoapps', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const slug = String(request.body?.slug ?? '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
const name = String(request.body?.name ?? '').trim();
|
||||
const prefix = String(request.body?.redirect_prefix ?? '').trim();
|
||||
if (!slug || !name) return reply.code(400).send({ error: 'slug und name nötig' });
|
||||
if (!prefix.split(/[\s,]+/).filter(Boolean).every((p) => /^https?:\/\/.+/.test(p))) {
|
||||
return reply.code(400).send({ error: 'redirect_prefix: http(s)-URL(s) erwartet' });
|
||||
}
|
||||
if (getSsoApp(slug)) return reply.code(409).send({ error: 'slug schon vergeben' });
|
||||
|
||||
const secret = `sso_${crypto.randomBytes(24).toString('hex')}`;
|
||||
saveSsoApp({ slug, name, redirect_prefix: prefix, secret });
|
||||
logAudit(getSessionUser(request), 'sso-app angelegt', `${name} (${slug})`);
|
||||
// Secret nur in dieser einen Antwort!
|
||||
return { slug, name, redirect_prefix: prefix, secret };
|
||||
});
|
||||
|
||||
app.delete('/api/ssoapps/:slug', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
logAudit(getSessionUser(request), 'sso-app gelöscht', String(request.params.slug));
|
||||
return { deleted: deleteSsoApp(String(request.params.slug)), apps: listSsoApps() };
|
||||
});
|
||||
|
||||
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||
|
||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||||
|
||||
+5
-1
@@ -105,12 +105,16 @@ export function registerAuthRoutes(app, client) {
|
||||
? `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);
|
||||
|
||||
return reply
|
||||
.clearCookie(STATE_COOKIE, { path: '/auth' })
|
||||
.setCookie(SESSION_COOKIE, JSON.stringify(session), {
|
||||
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true,
|
||||
})
|
||||
.redirect('/');
|
||||
.redirect(returnTo ?? '/');
|
||||
});
|
||||
|
||||
app.get('/auth/logout', async (request, reply) => {
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ import { registerAuthRoutes } from './auth.js';
|
||||
import { registerApiRoutes } from './api.js';
|
||||
import { registerApiV1 } from './api-v1.js';
|
||||
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
||||
import { registerSsoRoutes } from './sso.js';
|
||||
import { imagesDir } from '../bot/devlog-archive.js';
|
||||
|
||||
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
||||
@@ -67,6 +68,7 @@ export async function startWebServer(client) {
|
||||
registerApiRoutes(app, client);
|
||||
registerApiV1(app, client);
|
||||
registerDevlogEndpoint(app, client);
|
||||
registerSsoRoutes(app, client);
|
||||
|
||||
// Healthcheck (für Portainer/NPM)
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
@@ -149,7 +151,7 @@ export async function startWebServer(client) {
|
||||
await app.register(fastifyStatic, { root: frontendDist, index: false });
|
||||
app.get('/', sendInjected);
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
|
||||
const isApiPath = ['/api', '/auth', '/webhooks', '/sso'].some((p) =>
|
||||
request.url.startsWith(p)
|
||||
);
|
||||
if (request.method === 'GET' && !isApiPath) {
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// Single Sign-On: Der Bot ist Identity-Provider für die anderen D4RKST3R-Dienste
|
||||
// (Kanban, Platform …). Ablauf:
|
||||
//
|
||||
// 1. Fremd-App leitet weiter zu /sso/authorize?app=<slug>&redirect=<url>
|
||||
// 2. Bot prüft die Session (ggf. erst Discord-Login) und leitet zurück
|
||||
// zu <url>?sso_token=<token>
|
||||
// 3. Fremd-App tauscht den Token per POST /sso/verify (mit ihrem Secret)
|
||||
// gegen die Nutzerdaten inkl. Discord-Rollen ein
|
||||
//
|
||||
// Der Token ist HMAC-signiert, 60 Sekunden gültig und nur einmal einlösbar.
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { getSsoApp, touchSsoApp, webAdminScopes } from '../db.js';
|
||||
import { discordGuildId } from '../runtime-settings.js';
|
||||
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
||||
|
||||
const TOKEN_TTL_MS = 60_000;
|
||||
const RETURN_COOKIE = 'd4rkbot_return';
|
||||
|
||||
// Bereits eingelöste Tokens (nonce → Ablaufzeit), damit keiner zweimal geht
|
||||
const usedNonces = new Map();
|
||||
function rememberNonce(nonce, expiresAt) {
|
||||
usedNonces.set(nonce, expiresAt);
|
||||
for (const [key, exp] of usedNonces) {
|
||||
if (exp < Date.now()) usedNonces.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function sign(payloadB64) {
|
||||
return crypto.createHmac('sha256', config.sessionSecret).update(payloadB64).digest('hex');
|
||||
}
|
||||
|
||||
/** Token für einen User + eine App erzeugen */
|
||||
function issueToken(userId, appSlug) {
|
||||
const payload = {
|
||||
u: userId,
|
||||
a: appSlug,
|
||||
e: Date.now() + TOKEN_TTL_MS,
|
||||
n: crypto.randomBytes(9).toString('base64url'),
|
||||
};
|
||||
const b64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${b64}.${sign(b64)}`;
|
||||
}
|
||||
|
||||
/** Token prüfen → Payload oder null */
|
||||
function readToken(token) {
|
||||
const [b64, mac] = String(token ?? '').split('.');
|
||||
if (!b64 || !mac) return null;
|
||||
const expected = sign(b64);
|
||||
const a = Buffer.from(mac);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8'));
|
||||
if (payload.e < Date.now()) return null;
|
||||
if (usedNonces.has(payload.n)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ziel-URL gegen den registrierten Prefix prüfen (verhindert Open Redirect) */
|
||||
function redirectAllowed(app, target) {
|
||||
if (!/^https?:\/\//.test(target)) return false;
|
||||
return app.redirect_prefix.split(/[\s,]+/).filter(Boolean).some((prefix) => target.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function registerSsoRoutes(app, client) {
|
||||
// Schritt 1+2: Login prüfen und mit Token zurückschicken
|
||||
app.get('/sso/authorize', async (request, reply) => {
|
||||
const slug = String(request.query.app ?? '');
|
||||
const target = String(request.query.redirect ?? '');
|
||||
const ssoApp = getSsoApp(slug);
|
||||
|
||||
if (!ssoApp) return reply.code(404).send({ error: 'unbekannte App' });
|
||||
if (!redirectAllowed(ssoApp, target)) {
|
||||
request.log.warn(`SSO: unerlaubtes Redirect-Ziel für ${slug}: ${target}`);
|
||||
return reply.code(400).send({ error: 'redirect nicht erlaubt' });
|
||||
}
|
||||
|
||||
const user = getSessionUser(request);
|
||||
if (!user) {
|
||||
// Erst einloggen, danach hierher zurück
|
||||
return reply
|
||||
.setCookie(RETURN_COOKIE, request.url, {
|
||||
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true,
|
||||
})
|
||||
.redirect('/auth/login');
|
||||
}
|
||||
|
||||
// SSO bleibt Mitgliedern des Discord-Servers vorbehalten
|
||||
if (discordGuildId() && !(await isGuildMember(client, user.id))) {
|
||||
return reply.redirect('/?gate=1');
|
||||
}
|
||||
|
||||
touchSsoApp(slug);
|
||||
const token = issueToken(user.id, slug);
|
||||
const url = new URL(target);
|
||||
url.searchParams.set('sso_token', token);
|
||||
return reply.redirect(url.toString());
|
||||
});
|
||||
|
||||
// Schritt 3: Token gegen Nutzerdaten tauschen (App authentifiziert sich mit ihrem Secret)
|
||||
app.post('/sso/verify', async (request, reply) => {
|
||||
const token = String(request.body?.token ?? '');
|
||||
const payload = readToken(token);
|
||||
if (!payload) return reply.code(401).send({ error: 'token ungültig oder abgelaufen' });
|
||||
|
||||
const ssoApp = getSsoApp(payload.a);
|
||||
const given = (request.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||
if (!ssoApp) return reply.code(404).send({ error: 'unbekannte App' });
|
||||
const a = Buffer.from(given);
|
||||
const b = Buffer.from(ssoApp.secret);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||
return reply.code(401).send({ error: 'app secret falsch' });
|
||||
}
|
||||
|
||||
rememberNonce(payload.n, payload.e);
|
||||
|
||||
// Discord-Daten frisch holen (Name, Avatar, Rollen)
|
||||
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
|
||||
const guild = guildId
|
||||
? client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null)
|
||||
: null;
|
||||
const member = guild ? await guild.members.fetch(payload.u).catch(() => null) : null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: payload.u,
|
||||
username: member?.user?.username ?? null,
|
||||
displayName: member?.displayName ?? null,
|
||||
avatar: member?.displayAvatarURL?.({ size: 128 }) ?? null,
|
||||
joinedAt: member?.joinedAt?.toISOString?.() ?? null,
|
||||
},
|
||||
roles: member
|
||||
? [...member.roles.cache.values()]
|
||||
.filter((r) => r.id !== member.guild.id)
|
||||
.map((r) => ({ id: r.id, name: r.name }))
|
||||
: [],
|
||||
owner: payload.u === config.adminDiscordId,
|
||||
scopes: payload.u === config.adminDiscordId ? ['*'] : webAdminScopes(payload.u),
|
||||
app: ssoApp.slug,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Vom OAuth-Callback genutzt: wohin nach dem Login? (nur interne Pfade) */
|
||||
export function takeReturnTo(request, reply) {
|
||||
const raw = request.cookies[RETURN_COOKIE];
|
||||
if (!raw) return null;
|
||||
reply.clearCookie(RETURN_COOKIE, { path: '/' });
|
||||
const unsigned = request.unsignCookie(raw);
|
||||
if (!unsigned.valid) return null;
|
||||
// Nur relative Pfade zulassen — keine fremden Hosts
|
||||
return /^\/[^/\\]/.test(unsigned.value) ? unsigned.value : null;
|
||||
}
|
||||
Reference in New Issue
Block a user