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:
parent
c8da9362a7
commit
fcbd34b0c6
4 changed files with 153 additions and 33 deletions
|
|
@ -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)); }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue