Feature 4: Webinterface — React-Frontend, Discord-OAuth2, REST-API
- Discord-OAuth2-Login (identify-Scope, CSRF-State, signierte Session-Cookies, keine Token-Speicherung) - REST-API: /api/devlogs (öffentlich), /api/commits (nur ADMIN_DISCORD_ID), /api/me - React + Vite Frontend: Devlog-Archiv mit Mini-Markdown-Renderer, Commit-Tabelle, dunkles EcoGame-Theme - Fastify liefert frontend/dist mit SPA-Fallback aus; Vite-Dev-Proxy für lokale Entwicklung - Multi-Stage-Dockerfile (Frontend-Build im Image), neue Env-Vars in Compose + .env.example - README: OAuth2-Setup (Redirect-URLs, Client Secret) und Frontend-Workflow Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const SESSION_COOKIE = 'ecobot_session';
|
||||
const STATE_COOKIE = 'ecobot_oauth_state';
|
||||
|
||||
const redirectUri = `${config.publicUrl}/auth/callback`;
|
||||
|
||||
/** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */
|
||||
export function getSessionUser(request) {
|
||||
const raw = request.cookies[SESSION_COOKIE];
|
||||
if (!raw) return null;
|
||||
const unsigned = request.unsignCookie(raw);
|
||||
if (!unsigned.valid) return null;
|
||||
try {
|
||||
return JSON.parse(unsigned.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** true, wenn der eingeloggte User der Admin (du) ist */
|
||||
export function isAdmin(user) {
|
||||
return user?.id === config.adminDiscordId;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app) {
|
||||
// Login: mit CSRF-State zu Discord weiterleiten
|
||||
app.get('/auth/login', async (request, reply) => {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.discordClientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'identify',
|
||||
state,
|
||||
});
|
||||
return reply
|
||||
.setCookie(STATE_COOKIE, state, {
|
||||
path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true,
|
||||
})
|
||||
.redirect(`https://discord.com/oauth2/authorize?${params}`);
|
||||
});
|
||||
|
||||
// Callback: Code gegen Token tauschen, User holen, Session-Cookie setzen
|
||||
app.get('/auth/callback', async (request, reply) => {
|
||||
const { code, state } = request.query;
|
||||
const stateCookie = request.cookies[STATE_COOKIE]
|
||||
? request.unsignCookie(request.cookies[STATE_COOKIE])
|
||||
: null;
|
||||
if (!code || !state || !stateCookie?.valid || stateCookie.value !== state) {
|
||||
return reply.code(400).send('Ungültiger OAuth-State — bitte erneut einloggen.');
|
||||
}
|
||||
|
||||
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.discordClientId,
|
||||
client_secret: config.discordClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
request.log.error(`OAuth-Token-Tausch fehlgeschlagen: ${tokenRes.status}`);
|
||||
return reply.code(502).send('Discord-Login fehlgeschlagen — bitte erneut versuchen.');
|
||||
}
|
||||
const { access_token: accessToken } = await tokenRes.json();
|
||||
|
||||
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!userRes.ok) {
|
||||
return reply.code(502).send('Discord-Userdaten nicht abrufbar — bitte erneut versuchen.');
|
||||
}
|
||||
const me = await userRes.json();
|
||||
|
||||
// Nur das Nötigste in die Session — keine Tokens speichern
|
||||
const session = {
|
||||
id: me.id,
|
||||
username: me.global_name || me.username,
|
||||
avatar: me.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=64`
|
||||
: null,
|
||||
};
|
||||
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('/');
|
||||
});
|
||||
|
||||
app.get('/auth/logout', async (request, reply) => {
|
||||
return reply.clearCookie(SESSION_COOKIE, { path: '/' }).redirect('/');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user