Add multi-guild Discord OAuth support
- Users can now login via Bacanaks OR Piccadilly Discord server - Highest role from all servers is used (superadmin > moderator > user) - Lazy initialization fixes env loading timing issue - Updated documentation with implementation details and troubleshooting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
23
auth.js
23
auth.js
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getUserRole } from '../services/discord.js';
|
||||
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -38,15 +38,18 @@ router.get('/discord/callback', async (req, res) => {
|
||||
// Get Discord user info
|
||||
const discordUser = await getDiscordUser(tokenData.access_token);
|
||||
|
||||
// Check if user is in the guild
|
||||
const member = await getGuildMember(discordUser.id);
|
||||
// Check if user is in any of the configured guilds
|
||||
const memberships = await getGuildMemberships(discordUser.id);
|
||||
|
||||
if (!member) {
|
||||
if (!memberships) {
|
||||
return res.redirect(`${frontendUrl}/login?error=not_in_guild`);
|
||||
}
|
||||
|
||||
// Determine role based on Discord roles
|
||||
const role = getUserRole(member.roles);
|
||||
// Determine role based on Discord roles (highest role from all servers)
|
||||
const role = getUserRoleFromMemberships(memberships);
|
||||
|
||||
// Use first membership for display name
|
||||
const member = memberships[0].member;
|
||||
|
||||
// Get display name (nickname or username)
|
||||
const displayName = member.nick || discordUser.global_name || discordUser.username;
|
||||
@@ -126,13 +129,13 @@ router.post('/refresh-role', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await getGuildMember(req.user.discordId);
|
||||
const memberships = await getGuildMemberships(req.user.discordId);
|
||||
|
||||
if (!member) {
|
||||
return res.status(403).json({ error: 'No longer in guild' });
|
||||
if (!memberships) {
|
||||
return res.status(403).json({ error: 'No longer in any guild' });
|
||||
}
|
||||
|
||||
const newRole = getUserRole(member.roles);
|
||||
const newRole = getUserRoleFromMemberships(memberships);
|
||||
|
||||
db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?')
|
||||
.run(newRole, req.user.discordId);
|
||||
|
||||
97
discord.js
97
discord.js
@@ -1,6 +1,29 @@
|
||||
// Discord OAuth2 Service
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
|
||||
// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv)
|
||||
let _guildConfigs = null;
|
||||
|
||||
function getGuildConfigs() {
|
||||
if (_guildConfigs === null) {
|
||||
_guildConfigs = [
|
||||
{
|
||||
name: 'Bacanaks',
|
||||
guildId: process.env.DISCORD_GUILD_ID_1,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_1
|
||||
},
|
||||
{
|
||||
name: 'Piccadilly',
|
||||
guildId: process.env.DISCORD_GUILD_ID_2,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_2
|
||||
}
|
||||
].filter(config => config.guildId);
|
||||
}
|
||||
return _guildConfigs;
|
||||
}
|
||||
|
||||
export function getDiscordAuthUrl() {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
@@ -48,9 +71,10 @@ export async function getDiscordUser(accessToken) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGuildMember(userId) {
|
||||
// Prüft einen einzelnen Server
|
||||
async function fetchGuildMember(guildId, userId) {
|
||||
const response = await fetch(
|
||||
`${DISCORD_API}/guilds/${process.env.DISCORD_GUILD_ID}/members/${userId}`,
|
||||
`${DISCORD_API}/guilds/${guildId}/members/${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
|
||||
@@ -60,17 +84,78 @@ export async function getGuildMember(userId) {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null; // User not in guild
|
||||
return null;
|
||||
}
|
||||
throw new Error('Failed to get guild member');
|
||||
throw new Error(`Failed to get guild member from ${guildId}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Prüft alle konfigurierten Server und gibt Memberships zurück
|
||||
export async function getGuildMemberships(userId) {
|
||||
const configs = getGuildConfigs();
|
||||
const memberships = [];
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const member = await fetchGuildMember(config.guildId, userId);
|
||||
if (member) {
|
||||
memberships.push({
|
||||
config,
|
||||
member,
|
||||
roles: member.roles || []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return memberships.length > 0 ? memberships : null;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export async function getGuildMember(userId) {
|
||||
const memberships = await getGuildMemberships(userId);
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return memberships[0].member;
|
||||
}
|
||||
|
||||
// Rollen-Priorität: superadmin > moderator > user
|
||||
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
|
||||
|
||||
// Bestimmt die höchste Rolle aus allen Server-Memberships
|
||||
export function getUserRoleFromMemberships(memberships) {
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
let highestRole = 'user';
|
||||
|
||||
for (const { config, roles } of memberships) {
|
||||
let role = 'user';
|
||||
|
||||
if (roles.includes(config.adminRoleId)) {
|
||||
role = 'superadmin';
|
||||
} else if (roles.includes(config.modRoleId)) {
|
||||
role = 'moderator';
|
||||
}
|
||||
|
||||
if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) {
|
||||
highestRole = role;
|
||||
}
|
||||
}
|
||||
|
||||
return highestRole;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export function getUserRole(memberRoles) {
|
||||
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID;
|
||||
const modRoleId = process.env.DISCORD_MOD_ROLE_ID;
|
||||
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1;
|
||||
const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1;
|
||||
|
||||
if (memberRoles.includes(adminRoleId)) {
|
||||
return 'superadmin';
|
||||
|
||||
@@ -49,13 +49,15 @@ Wenn der Bot einem Server beitritt, erstellt er automatisch folgende Struktur:
|
||||
|
||||
| Channel | @everyone | Bot |
|
||||
|---------|-----------|-----|
|
||||
| Kategorie | Lesen | Schreiben |
|
||||
| info | Lesen | Schreiben |
|
||||
| status | Lesen | Schreiben |
|
||||
| alerts | Lesen | Schreiben |
|
||||
| updates | Lesen | Schreiben |
|
||||
| diskussion | Lesen + Schreiben | Schreiben |
|
||||
| requests | Lesen + Threads erstellen | Schreiben |
|
||||
| Kategorie | Lesen | ViewChannel + Schreiben |
|
||||
| info | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| status | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| alerts | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| updates | Lesen (kein Schreiben) | ViewChannel + Schreiben |
|
||||
| diskussion | Lesen + Schreiben | ViewChannel + Schreiben |
|
||||
| requests | Lesen + Threads erstellen | ViewChannel + Schreiben |
|
||||
|
||||
**Wichtig**: Der Bot braucht explizit `ViewChannel` Permission für jeden Channel, auch wenn @everyone den Channel sehen kann. Ohne `ViewChannel` kann der Bot nicht in den Channel schreiben ("Missing Access" Fehler).
|
||||
|
||||
## Datenbank
|
||||
|
||||
@@ -80,6 +82,8 @@ CREATE TABLE guild_settings (
|
||||
);
|
||||
```
|
||||
|
||||
Die Datenbank liegt in `backend/db/users.sqlite`.
|
||||
|
||||
### DB-Funktionen
|
||||
|
||||
In `backend/db/init.js`:
|
||||
@@ -137,12 +141,43 @@ Jeder Server bekommt ein eigenes Embed mit:
|
||||
DISCORD_CLIENT_ID=1458251194806833306
|
||||
DISCORD_CLIENT_SECRET=xxx
|
||||
DISCORD_BOT_TOKEN=xxx
|
||||
DISCORD_GUILD_ID=729865854329815051 # Haupt-Server für Login
|
||||
DISCORD_ADMIN_ROLE_ID=1024693717434650736
|
||||
DISCORD_MOD_ROLE_ID=1024693170958766141
|
||||
|
||||
# Multi-Server OAuth Login
|
||||
# User muss nur in EINEM der Server Mitglied sein
|
||||
# Rollen werden pro Server geprüft, höchste Berechtigung zählt
|
||||
|
||||
# Server 1: Bacanaks
|
||||
DISCORD_GUILD_ID_1=729865854329815051
|
||||
DISCORD_ADMIN_ROLE_ID_1=1024693717434650736
|
||||
DISCORD_MOD_ROLE_ID_1=1024693170958766141
|
||||
|
||||
# Server 2: Piccadilly
|
||||
DISCORD_GUILD_ID_2=730907665802330224
|
||||
DISCORD_ADMIN_ROLE_ID_2=1458595551514988584
|
||||
DISCORD_MOD_ROLE_ID_2=1458591909210488914
|
||||
```
|
||||
|
||||
**Hinweis**: `DISCORD_GUILD_ID` wird nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
|
||||
**Hinweis**: Die Guild-IDs werden nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
|
||||
|
||||
### OAuth Login-Logik
|
||||
|
||||
```
|
||||
1. User loggt sich via Discord OAuth ein
|
||||
2. Für jeden konfigurierten Server:
|
||||
- Ist User Mitglied? → Rollen prüfen
|
||||
- Admin-Rolle → superadmin
|
||||
- Mod-Rolle → moderator
|
||||
- Nur Mitglied → user
|
||||
3. Höchste Berechtigung aus allen Servern wird verwendet
|
||||
4. Nicht in mindestens einem Server → Login verweigert
|
||||
```
|
||||
|
||||
| User ist in... | Bacanaks Rolle | Piccadilly Rolle | GSM Rolle |
|
||||
|----------------|----------------|------------------|-----------|
|
||||
| Nur Bacanaks | Admin | - | superadmin |
|
||||
| Nur Piccadilly | - | Mod | moderator |
|
||||
| Beide | Mitglied | Admin | superadmin |
|
||||
| Keinem | - | - | ❌ Kein Login |
|
||||
|
||||
### Developer Portal Einstellungen
|
||||
|
||||
@@ -158,12 +193,84 @@ DISCORD_MOD_ROLE_ID=1024693170958766141
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `backend/services/discord.js` | OAuth-Logik, Multi-Guild Membership-Prüfung |
|
||||
| `backend/services/discordBot.js` | Bot-Logik und Event-Handler |
|
||||
| `backend/routes/auth.js` | Auth-Endpoints (Login, Callback, Refresh) |
|
||||
| `backend/db/init.js` | Guild-Settings DB-Funktionen |
|
||||
| `frontend/src/pages/Dashboard.jsx` | Invite-Button im Dashboard |
|
||||
|
||||
## Implementierungsdetails
|
||||
|
||||
### Multi-Guild OAuth
|
||||
|
||||
Die OAuth-Implementierung in `discord.js` verwendet **lazy initialization** für die Guild-Konfigurationen:
|
||||
|
||||
```javascript
|
||||
let _guildConfigs = null;
|
||||
|
||||
function getGuildConfigs() {
|
||||
if (_guildConfigs === null) {
|
||||
_guildConfigs = [
|
||||
{ name: 'Bacanaks', guildId: process.env.DISCORD_GUILD_ID_1, ... },
|
||||
{ name: 'Piccadilly', guildId: process.env.DISCORD_GUILD_ID_2, ... }
|
||||
].filter(config => config.guildId);
|
||||
}
|
||||
return _guildConfigs;
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig**: Die Konfiguration darf NICHT beim Modul-Import initialisiert werden, da zu diesem Zeitpunkt dotenv die `.env` noch nicht geladen hat. Die lazy initialization stellt sicher, dass die Umgebungsvariablen verfügbar sind.
|
||||
|
||||
### Funktionen
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `getGuildMemberships(userId)` | Prüft alle konfigurierten Server, gibt Array von Memberships zurück |
|
||||
| `getUserRoleFromMemberships(memberships)` | Bestimmt höchste Rolle aus allen Memberships |
|
||||
| `getGuildMember(userId)` | Legacy-Funktion, gibt ersten Match zurück |
|
||||
| `getUserRole(memberRoles)` | Legacy-Funktion für einzelne Rollen-Liste |
|
||||
|
||||
### Rollen-Priorität
|
||||
|
||||
```javascript
|
||||
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
|
||||
```
|
||||
|
||||
Bei mehreren Memberships wird immer die höchste Rolle verwendet.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Der Bot muss auf **allen** konfigurierten Discord-Servern sein
|
||||
- Der Bot braucht Zugriff auf die Guild Members API
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login schlägt fehl mit "nicht Mitglied"
|
||||
|
||||
1. **Bot auf allen Servern?** Der Bot muss auf Bacanaks UND Piccadilly eingeladen sein
|
||||
2. **Env-Variablen prüfen**:
|
||||
```bash
|
||||
grep GUILD /opt/gameserver-monitor/backend/.env
|
||||
```
|
||||
3. **Mit --update-env neustarten**:
|
||||
```bash
|
||||
pm2 restart gameserver-backend --update-env
|
||||
```
|
||||
4. **Logs prüfen**:
|
||||
```bash
|
||||
pm2 logs gameserver-backend --lines 30 | grep -i discord
|
||||
```
|
||||
|
||||
### Rolle wird nicht erkannt
|
||||
|
||||
1. **Rollen-IDs prüfen** - Discord Developer Mode aktivieren, Rechtsklick auf Rolle → ID kopieren
|
||||
2. **User-Rollen abfragen**:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bot BOT_TOKEN" \
|
||||
"https://discord.com/api/v10/guilds/GUILD_ID/members/USER_ID" | jq '.roles'
|
||||
```
|
||||
3. **Konfigurierte IDs vergleichen** mit den tatsächlichen Rollen des Users
|
||||
|
||||
### Bot erstellt keine Channels
|
||||
|
||||
- Prüfen ob Bot "Manage Channels" Permission hat
|
||||
@@ -180,18 +287,52 @@ Suche nach `[DiscordBot]` Log-Einträgen.
|
||||
### Bot aus Datenbank entfernen
|
||||
|
||||
```bash
|
||||
sqlite3 /opt/gameserver-monitor/backend/users.sqlite
|
||||
cd /opt/gameserver-monitor/backend
|
||||
node -e "
|
||||
import Database from 'better-sqlite3';
|
||||
const db = new Database('./db/users.sqlite');
|
||||
db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run('GUILD_ID_HIER');
|
||||
console.log('Deleted');
|
||||
"
|
||||
```
|
||||
|
||||
Alternativ mit sqlite3 (falls installiert):
|
||||
```bash
|
||||
sqlite3 /opt/gameserver-monitor/backend/db/users.sqlite
|
||||
DELETE FROM guild_settings WHERE guild_id = 'xxx';
|
||||
```
|
||||
|
||||
### Missing Access Fehler
|
||||
|
||||
Wenn der Bot "Missing Access" meldet obwohl er eingeladen wurde:
|
||||
|
||||
1. **ViewChannel Permission prüfen**: Der Bot braucht explizit `ViewChannel` für jeden Channel
|
||||
2. Im Discord: Rechtsklick auf Channel → Bearbeiten → Berechtigungen → Bot auswählen → "Kanal ansehen" aktivieren
|
||||
3. Logs prüfen: `pm2 logs gameserver-backend --lines 50 | grep -i "missing\|access"`
|
||||
|
||||
### Status-Nachricht wurde gelöscht
|
||||
|
||||
Wenn die Status-Nachricht manuell gelöscht wurde, erscheint "Unknown Message" in den Logs. Fix:
|
||||
|
||||
```bash
|
||||
cd /opt/gameserver-monitor/backend
|
||||
node -e "
|
||||
import Database from 'better-sqlite3';
|
||||
const db = new Database('./db/users.sqlite');
|
||||
// Status Message ID auf NULL setzen, Bot erstellt neue beim nächsten Update
|
||||
db.prepare('UPDATE guild_settings SET status_message_id = NULL WHERE guild_id = ?').run('GUILD_ID_HIER');
|
||||
console.log('Reset status_message_id');
|
||||
"
|
||||
```
|
||||
|
||||
## Login vs. Bot
|
||||
|
||||
| Feature | Login (OAuth) | Bot |
|
||||
|---------|--------------|-----|
|
||||
| Erfordert Mitgliedschaft | Haupt-Discord | Nein |
|
||||
| Erfordert Mitgliedschaft | Bacanaks oder Piccadilly | Nein |
|
||||
| Server-Steuerung | Ja (je nach Rolle) | Nein |
|
||||
| Status sehen | Ja | Ja |
|
||||
| Alerts erhalten | Nein | Ja |
|
||||
| Verfügbar für | Haupt-Discord Mitglieder | Alle mit Bot |
|
||||
| Verfügbar für | Mitglieder beider Discord-Server | Alle mit Bot |
|
||||
|
||||
Der Login zur Webapp erfordert Mitgliedschaft im Haupt-Discord-Server (DISCORD_GUILD_ID). Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.
|
||||
Der Login zur Webapp erfordert Mitgliedschaft in mindestens einem der konfigurierten Discord-Server (Bacanaks oder Piccadilly). Die höchste Rolle aus beiden Servern bestimmt die GSM-Berechtigung. Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.
|
||||
|
||||
Reference in New Issue
Block a user