feat(discord): Kunden-Kontoverknüpfung per Discord-OAuth

Erster Teil des größeren Discord-Ausbaus (Konto verknüpfen, Tickets
über Discord sehen/beantworten). Dieser Schritt: Kunden können unter
"Mein Konto" ihr Discord-Konto per OAuth verknüpfen/trennen.

- Migration 027: discord_settings um client_id/client_secret_enc/
  ticket_channel_id erweitert; users um discord_user_id/
  discord_username (unique); discord_oauth_states (kurzlebiger
  CSRF-Schutz für den Redirect-Flow, 10 Min. gültig); neue Tabelle
  ticket_discord_threads für den nächsten Schritt (Ticket-Threads).
- Neue Routen: GET/POST /discord/link, /discord/unlink,
  GET /discord/oauth/start (leitet zu Discord weiter),
  GET /discord/oauth/callback (tauscht Code gegen Discord-Nutzer-ID,
  verknüpft das Konto, verhindert doppelte Verknüpfung).
- Einstellungen > Discord: neue Felder für Client ID/Secret, Anzeige
  der einzutragenden Redirect-URI, aktualisierte Anleitung inkl.
  OAuth-Einrichtung und Hinweis auf die Message Content Intent (für
  den nächsten Schritt: Ticket-Threads).

Noch offen (nächster Schritt): Ticket-Threads je Ticket, bidirektionale
Synchronisierung der Nachrichten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:41:04 +02:00
parent c8da9362a7
commit fcbd34b0c6
4 changed files with 153 additions and 33 deletions

View file

@ -2,18 +2,20 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { encrypt, decrypt } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { rl } from '../../core/config.js';
import { rl, config } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
staffUserIds: String(s.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean),
clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id,
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
});
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
export const discordModule: KcModule = {
name: 'discord',
@ -28,13 +30,16 @@ export const discordModule: KcModule = {
const b = z.object({
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
ticketChannelId: z.string().trim().regex(ID).nullable(),
staffUserIds: z.array(z.string().trim().regex(ID)).max(50), enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body);
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, a.user.id];
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, b.clientId, a.user.id];
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); }
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined } });
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined, clientSecret: b.clientSecret !== undefined ? (b.clientSecret ? '***' : null) : undefined } });
return { ok: true };
});
/** Prüft nur den Token gegen die Discord-API (GET /users/@me), ohne den laufenden Bot zu berühren. */
@ -73,5 +78,62 @@ export const discordModule: KcModule = {
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_message', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok } });
return { ok, error };
});
// ---- Konto-Verknüpfung (Mein Konto): jeder angemeldete Benutzer darf sein eigenes Discord-Konto verknüpfen ----
app.get('/discord/link', async (req) => {
const a = requireAuth(req);
const [u, s] = await Promise.all([
one('SELECT discord_user_id, discord_username FROM users WHERE id = ?', [a.user.id]),
one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1'),
]);
return { linked: !!u?.discord_user_id, username: u?.discord_username ?? null, available: !!(s?.client_id && s?.client_secret_enc) };
});
app.post('/discord/unlink', async (req) => {
const a = requireAuth(req);
await run('UPDATE users SET discord_user_id = NULL, discord_username = NULL WHERE id = ?', [a.user.id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.unlink', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { ok: true };
});
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
app.get('/discord/oauth/start', async (req, reply) => {
const a = requireAuth(req);
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
const state = randomToken(32);
await run('DELETE FROM discord_oauth_states WHERE user_id = ?', [a.user.id]); // nur ein offener Versuch je Benutzer
await run('INSERT INTO discord_oauth_states (state, user_id) VALUES (?,?)', [state, a.user.id]);
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', s.client_id); url.searchParams.set('redirect_uri', redirectUri());
url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'identify'); url.searchParams.set('state', state); url.searchParams.set('prompt', 'consent');
return reply.redirect(url.toString());
});
/** Discord leitet den Browser hierher zurück; tauscht den Code gegen die Discord-Nutzer-ID und verknüpft das Konto. */
app.get('/discord/oauth/callback', async (req, reply) => {
const q = z.object({ code: z.string().optional(), state: z.string().optional(), error: z.string().optional() }).parse(req.query);
const fail = (msg: string) => reply.redirect(`${config.baseUrl}/konto?discord=error&msg=${encodeURIComponent(msg)}`);
if (q.error || !q.code || !q.state) return fail(q.error === 'access_denied' ? 'Verknüpfung abgebrochen.' : 'Ungültige Antwort von Discord.');
const st = await one('SELECT user_id FROM discord_oauth_states WHERE state = ? AND created_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE)', [q.state]);
if (!st) return fail('Der Verknüpfungsversuch ist abgelaufen. Bitte erneut starten.');
await run('DELETE FROM discord_oauth_states WHERE state = ?', [q.state]);
const s = await one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1');
if (!s?.client_id || !s.client_secret_enc) return fail('Discord-Anmeldung ist nicht eingerichtet.');
const clientSecret = JSON.parse(decrypt(s.client_secret_enc)).secret as string;
try {
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: s.client_id, client_secret: clientSecret, grant_type: 'authorization_code', code: q.code, redirect_uri: redirectUri() }),
});
if (!tokenRes.ok) throw new Error(`Discord antwortete mit Status ${tokenRes.status}`);
const { access_token } = await tokenRes.json() as { access_token: string };
const meRes = await fetch('https://discord.com/api/v10/users/@me', { headers: { authorization: `Bearer ${access_token}` } });
if (!meRes.ok) throw new Error(`Discord antwortete mit Status ${meRes.status}`);
const me = await meRes.json() as { id: string; username: string };
const other = await one('SELECT id FROM users WHERE discord_user_id = ? AND id <> ?', [me.id, st.user_id]);
if (other) return fail('Dieses Discord-Konto ist bereits mit einem anderen Zugang verknüpft.');
await run('UPDATE users SET discord_user_id = ?, discord_username = ? WHERE id = ?', [me.id, me.username, st.user_id]);
await audit({ actorType: 'user', actorId: st.user_id, action: 'discord.link', resourceType: 'user', resourceId: st.user_id, correlationId: req.correlationId, ip: clientIp(req), after: { discordUsername: me.username } });
return reply.redirect(`${config.baseUrl}/konto?discord=linked`);
} catch (e) { return fail(String((e as Error).message).slice(0, 200)); }
});
},
};