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:
@@ -49,6 +49,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
||||||
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
|
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
|
||||||
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen |
|
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen |
|
||||||
|
| 🔑 **Single Sign-On** | Andere Dienste (Kanban, Platform …) nutzen den Discord-Login des Bots mit und bekommen die Discord-Rollen gleich mitgeliefert — [Anleitung](docs/sso.md) |
|
||||||
|
| ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` aus dem Setup gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) |
|
||||||
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
|
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
|
||||||
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste |
|
||||||
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
|
| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite |
|
||||||
@@ -271,6 +273,8 @@ urllib.request.urlopen(req)
|
|||||||
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
|
| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs |
|
||||||
| `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen |
|
| `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen |
|
||||||
| `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
|
| `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
|
||||||
|
| `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) |
|
||||||
|
| `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
|
||||||
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
|
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
|
||||||
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
|
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
|
||||||
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
|
| `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 |
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Single Sign-On mit d4rkbot
|
||||||
|
|
||||||
|
Der Bot ist Identity-Provider für die anderen D4RKST3R-Dienste (Kanban, Platform …).
|
||||||
|
Statt in jeder App einen eigenen Discord-OAuth2-Flow zu bauen, fragst du beim Bot nach —
|
||||||
|
und bekommst dabei gleich die **Discord-Rollen** des Users mitgeliefert.
|
||||||
|
|
||||||
|
## 1. App registrieren
|
||||||
|
|
||||||
|
Setup → **API** → *Single Sign-On*:
|
||||||
|
|
||||||
|
| Feld | Beispiel | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| Kürzel | `kanban` | interner Slug, taucht in der URL auf |
|
||||||
|
| Name | `Kanban-Board` | Anzeigename |
|
||||||
|
| Rücksprung-URL | `https://kanban.d4rkst3r.de/` | erlaubte Ziele (mehrere kommagetrennt) |
|
||||||
|
|
||||||
|
Beim Anlegen wird ein **Secret** angezeigt — einmalig, danach nie wieder.
|
||||||
|
In der App als Umgebungsvariable hinterlegen (`SSO_SECRET`), niemals ins Repo.
|
||||||
|
|
||||||
|
> Der Bot leitet ausschließlich auf Adressen zurück, die mit einer der hinterlegten
|
||||||
|
> Rücksprung-URLs beginnen. Das verhindert, dass jemand den Login-Flow auf eine
|
||||||
|
> fremde Seite umbiegt und den Token abgreift.
|
||||||
|
|
||||||
|
## 2. Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
Nutzer klickt "Login" → https://bot.d4rkst3r.de/sso/authorize
|
||||||
|
?app=kanban
|
||||||
|
&redirect=https://kanban.d4rkst3r.de/auth/callback
|
||||||
|
|
||||||
|
↓ (Bot prüft Session, ggf. erst Discord-Login)
|
||||||
|
|
||||||
|
Zurück zu https://kanban.d4rkst3r.de/auth/callback?sso_token=<token>
|
||||||
|
|
||||||
|
↓ (App tauscht den Token serverseitig ein)
|
||||||
|
|
||||||
|
POST https://bot.d4rkst3r.de/sso/verify
|
||||||
|
Authorization: Bearer <SSO_SECRET>
|
||||||
|
{ "token": "<token>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Token ist **60 Sekunden** gültig und nur **einmal** einlösbar. Er selbst enthält
|
||||||
|
keine Nutzerdaten — die gibt es erst beim Verify-Aufruf, der serverseitig passieren muss.
|
||||||
|
|
||||||
|
## 3. Antwort von `/sso/verify`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": "123456789012345678",
|
||||||
|
"username": "d4rkst3r",
|
||||||
|
"displayName": "Alexander",
|
||||||
|
"avatar": "https://cdn.discordapp.com/avatars/…",
|
||||||
|
"joinedAt": "2026-02-01T12:00:00.000Z"
|
||||||
|
},
|
||||||
|
"roles": [
|
||||||
|
{ "id": "111…", "name": "Entwickler" },
|
||||||
|
{ "id": "222…", "name": "Playtester" }
|
||||||
|
],
|
||||||
|
"owner": false,
|
||||||
|
"scopes": ["content", "server"],
|
||||||
|
"app": "kanban"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `owner` — ist der Server-Owner (`ADMIN_DISCORD_ID`)
|
||||||
|
* `scopes` — Team-Bereiche aus dem Bot (`['*']` beim Owner, sonst z. B. `['content']`)
|
||||||
|
* `roles` — alle Discord-Rollen; damit kannst du in der App Rechte vergeben,
|
||||||
|
ohne eine eigene Nutzerverwaltung zu pflegen
|
||||||
|
|
||||||
|
## 4. Beispiel (Node / Express)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const BOT = 'https://bot.d4rkst3r.de';
|
||||||
|
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
const redirect = `${process.env.PUBLIC_URL}/auth/callback`;
|
||||||
|
res.redirect(`${BOT}/sso/authorize?app=kanban&redirect=${encodeURIComponent(redirect)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/callback', async (req, res) => {
|
||||||
|
const result = await fetch(`${BOT}/sso/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${process.env.SSO_SECRET}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: req.query.sso_token }),
|
||||||
|
});
|
||||||
|
if (!result.ok) return res.status(401).send('Login fehlgeschlagen');
|
||||||
|
|
||||||
|
const { user, roles, owner } = await result.json();
|
||||||
|
req.session.user = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.displayName ?? user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
// Rechte direkt aus den Discord-Rollen ableiten
|
||||||
|
canEdit: owner || roles.some((r) => r.name === 'Entwickler'),
|
||||||
|
};
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Hinweise
|
||||||
|
|
||||||
|
* Der Verify-Aufruf muss **serverseitig** erfolgen — das Secret darf nie im Browser landen.
|
||||||
|
* Nur Mitglieder des konfigurierten Discord-Servers können sich anmelden; alle anderen
|
||||||
|
landen auf der Beitritts-Seite des Bots.
|
||||||
|
* Rollen werden bei jedem Login frisch von Discord geholt. Wer eine Rolle verliert,
|
||||||
|
verliert die Rechte beim nächsten Login — für sofortige Wirkung die Sitzung in der
|
||||||
|
App kurz halten oder periodisch neu prüfen.
|
||||||
|
* Wird eine App im Setup gelöscht, funktioniert ihr Login sofort nicht mehr.
|
||||||
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
|
|||||||
const [botDesc, setBotDesc] = useState('');
|
const [botDesc, setBotDesc] = useState('');
|
||||||
const [team, setTeam] = useState([]);
|
const [team, setTeam] = useState([]);
|
||||||
const [audit, setAudit] = useState([]);
|
const [audit, setAudit] = useState([]);
|
||||||
|
// SSO-Apps (Fremd-Dienste mit Discord-Login)
|
||||||
|
const [ssoApps, setSsoApps] = useState([]);
|
||||||
|
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
|
||||||
|
const [createdSso, setCreatedSso] = useState(null);
|
||||||
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||||||
|
|
||||||
const canTab = (id) => {
|
const canTab = (id) => {
|
||||||
@@ -203,6 +207,7 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||||
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||||
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
||||||
|
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
}, [me.admin, me.scopes?.length]);
|
}, [me.admin, me.scopes?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,6 +241,28 @@ export default function Settings({ me }) {
|
|||||||
if (res) setTeam(res.admins);
|
if (res) setTeam(res.admins);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createSsoApp() {
|
||||||
|
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
|
||||||
|
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/api/ssoapps', ssoDraft);
|
||||||
|
setCreatedSso(res);
|
||||||
|
setSsoDraft({ slug: '', name: '', redirect_prefix: '' });
|
||||||
|
setSsoApps((await apiGet('/api/ssoapps')).apps);
|
||||||
|
flash('✓ App registriert');
|
||||||
|
} catch (e) {
|
||||||
|
flash(e.status === 409 ? '✗ Kürzel schon vergeben' : '✗ Registrierung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSsoApp(slug) {
|
||||||
|
if (!window.confirm(`App „${slug}" entfernen? Der Login über diese App funktioniert danach nicht mehr.`)) return;
|
||||||
|
const res = await apiDelete(`/api/ssoapps/${slug}`).catch(() => null);
|
||||||
|
if (res) setSsoApps(res.apps);
|
||||||
|
}
|
||||||
|
|
||||||
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
|
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
|
||||||
async function saveServer() {
|
async function saveServer() {
|
||||||
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
|
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
|
||||||
@@ -1325,6 +1352,7 @@ export default function Settings({ me }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabApi = (
|
const tabApi = (
|
||||||
|
<>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
|
||||||
{keys.length > 0 && (
|
{keys.length > 0 && (
|
||||||
@@ -1373,6 +1401,52 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Single Sign-On</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Andere Dienste (Kanban, Platform …) können den Discord-Login mitnutzen: Sie
|
||||||
|
leiten auf <code>/sso/authorize</code> weiter und tauschen den Token per
|
||||||
|
<code>POST /sso/verify</code> gegen Nutzerdaten samt Discord-Rollen ein.
|
||||||
|
</p>
|
||||||
|
{ssoApps.length > 0 && (
|
||||||
|
<div className="key-list">
|
||||||
|
{ssoApps.map((a) => (
|
||||||
|
<div className="key-row" key={a.slug}>
|
||||||
|
<span className="key-name">{a.name}</span>
|
||||||
|
<span className="key-scopes">{a.slug}</span>
|
||||||
|
<span className="key-used" title={a.redirect_prefix}>
|
||||||
|
{a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'}
|
||||||
|
</span>
|
||||||
|
<button className="card-delete" title="App entfernen" onClick={() => removeSsoApp(a.slug)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{createdSso && (
|
||||||
|
<div className="key-created">
|
||||||
|
<p>
|
||||||
|
App <strong>{createdSso.name}</strong> registriert — Secret <strong>jetzt kopieren</strong>,
|
||||||
|
es wird nie wieder angezeigt:
|
||||||
|
</p>
|
||||||
|
<code>{createdSso.secret}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<label>Neue App <span className="field-hint">Kürzel, Anzeigename und erlaubte Rücksprung-URL(s)</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" style={{ flex: 'none', width: '9rem' }} placeholder="kanban" value={ssoDraft.slug} onChange={(e) => setSsoDraft({ ...ssoDraft, slug: e.target.value })} />
|
||||||
|
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Kanban-Board" value={ssoDraft.name} onChange={(e) => setSsoDraft({ ...ssoDraft, name: e.target.value })} />
|
||||||
|
<input type="text" placeholder="https://kanban.d4rkst3r.de/" value={ssoDraft.redirect_prefix} onChange={(e) => setSsoDraft({ ...ssoDraft, redirect_prefix: e.target.value })} />
|
||||||
|
<button className="btn" onClick={createSsoApp}>Registrieren</button>
|
||||||
|
</div>
|
||||||
|
<p className="field-hint" style={{ marginTop: '.4rem' }}>
|
||||||
|
Mehrere URLs kommagetrennt. Der Bot leitet nur auf Ziele zurück, die mit
|
||||||
|
einem dieser Präfixe beginnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabServer = (
|
const tabServer = (
|
||||||
|
|||||||
@@ -516,6 +516,31 @@ export function deleteUserData(userId) {
|
|||||||
return result;
|
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
|
// Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
logAudit, listAudit, playerHistory, deleteUserData,
|
logAudit, listAudit, playerHistory, deleteUserData,
|
||||||
|
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { sanitizeEmbed } from './api-v1.js';
|
import { sanitizeEmbed } from './api-v1.js';
|
||||||
@@ -1310,6 +1312,37 @@ ${rssItems}
|
|||||||
return { entries: listAudit(100) };
|
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/* ---
|
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||||
|
|
||||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
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`
|
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=64`
|
||||||
: null,
|
: 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
|
return reply
|
||||||
.clearCookie(STATE_COOKIE, { path: '/auth' })
|
.clearCookie(STATE_COOKIE, { path: '/auth' })
|
||||||
.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,
|
||||||
})
|
})
|
||||||
.redirect('/');
|
.redirect(returnTo ?? '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/logout', async (request, reply) => {
|
app.get('/auth/logout', async (request, reply) => {
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,7 @@ import { registerAuthRoutes } from './auth.js';
|
|||||||
import { registerApiRoutes } from './api.js';
|
import { registerApiRoutes } from './api.js';
|
||||||
import { registerApiV1 } from './api-v1.js';
|
import { registerApiV1 } from './api-v1.js';
|
||||||
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
||||||
|
import { registerSsoRoutes } from './sso.js';
|
||||||
import { imagesDir } from '../bot/devlog-archive.js';
|
import { imagesDir } from '../bot/devlog-archive.js';
|
||||||
|
|
||||||
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
|
||||||
@@ -67,6 +68,7 @@ export async function startWebServer(client) {
|
|||||||
registerApiRoutes(app, client);
|
registerApiRoutes(app, client);
|
||||||
registerApiV1(app, client);
|
registerApiV1(app, client);
|
||||||
registerDevlogEndpoint(app, client);
|
registerDevlogEndpoint(app, client);
|
||||||
|
registerSsoRoutes(app, client);
|
||||||
|
|
||||||
// Healthcheck (für Portainer/NPM)
|
// Healthcheck (für Portainer/NPM)
|
||||||
app.get('/health', async () => ({ status: 'ok' }));
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
@@ -149,7 +151,7 @@ export async function startWebServer(client) {
|
|||||||
await app.register(fastifyStatic, { root: frontendDist, index: false });
|
await app.register(fastifyStatic, { root: frontendDist, index: false });
|
||||||
app.get('/', sendInjected);
|
app.get('/', sendInjected);
|
||||||
app.setNotFoundHandler((request, reply) => {
|
app.setNotFoundHandler((request, reply) => {
|
||||||
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
|
const isApiPath = ['/api', '/auth', '/webhooks', '/sso'].some((p) =>
|
||||||
request.url.startsWith(p)
|
request.url.startsWith(p)
|
||||||
);
|
);
|
||||||
if (request.method === 'GET' && !isApiPath) {
|
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