diff --git a/README.md b/README.md index 0f752ab..b2eef48 100644 --- a/README.md +++ b/README.md @@ -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 | | 🎮 **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 | +| 🔑 **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 | | 🎉 **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 | @@ -271,6 +273,8 @@ urllib.request.urlopen(req) | `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 | | `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 /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 0000000..9a6b72f --- /dev/null +++ b/docs/sso.md @@ -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= + + ↓ (App tauscht den Token serverseitig ein) + +POST https://bot.d4rkst3r.de/sso/verify + Authorization: Bearer + { "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. diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index ac4941c..fc5fb02 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -161,6 +161,10 @@ export default function Settings({ me }) { const [botDesc, setBotDesc] = useState(''); const [team, setTeam] = 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 canTab = (id) => { @@ -203,6 +207,7 @@ export default function Settings({ me }) { apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {}); apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {}); apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {}); + apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); }, [me.admin, me.scopes?.length]); useEffect(() => { @@ -236,6 +241,28 @@ export default function Settings({ me }) { 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); async function saveServer() { if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) { @@ -1325,6 +1352,7 @@ export default function Settings({ me }) { ); const tabApi = ( + <>

// API-Keys (/api/v1)

{keys.length > 0 && ( @@ -1373,6 +1401,52 @@ export default function Settings({ me }) {
+ +
+

// Single Sign-On

+

+ Andere Dienste (Kanban, Platform …) können den Discord-Login mitnutzen: Sie + leiten auf /sso/authorize weiter und tauschen den Token per + POST /sso/verify gegen Nutzerdaten samt Discord-Rollen ein. +

+ {ssoApps.length > 0 && ( +
+ {ssoApps.map((a) => ( +
+ {a.name} + {a.slug} + + {a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'} + + +
+ ))} +
+ )} + {createdSso && ( +
+

+ App {createdSso.name} registriert — Secret jetzt kopieren, + es wird nie wieder angezeigt: +

+ {createdSso.secret} +
+ )} +
+ +
+ setSsoDraft({ ...ssoDraft, slug: e.target.value })} /> + setSsoDraft({ ...ssoDraft, name: e.target.value })} /> + setSsoDraft({ ...ssoDraft, redirect_prefix: e.target.value })} /> + +
+

+ Mehrere URLs kommagetrennt. Der Bot leitet nur auf Ziele zurück, die mit + einem dieser Präfixe beginnen. +

+
+
+ ); const tabServer = ( diff --git a/src/db.js b/src/db.js index ce5954b..c8f80fa 100644 --- a/src/db.js +++ b/src/db.js @@ -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 ( diff --git a/src/web/api.js b/src/web/api.js index 126a9bb..154c538 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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']; diff --git a/src/web/auth.js b/src/web/auth.js index 9ffcc95..95149af 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -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) => { diff --git a/src/web/server.js b/src/web/server.js index 2246d4c..b4a6a99 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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) { diff --git a/src/web/sso.js b/src/web/sso.js new file mode 100644 index 0000000..3b0f12d --- /dev/null +++ b/src/web/sso.js @@ -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=&redirect= +// 2. Bot prüft die Session (ggf. erst Discord-Login) und leitet zurück +// zu ?sso_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; +}