From 7bc93f3b704b985d2ac9b5e75b4a316b03f73400 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Wed, 22 Apr 2026 11:21:38 +0200 Subject: [PATCH] Refresh-Token-System: kurzlebiger Access-Token (15m) + rotierender Refresh-Token (90d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher: 7d JWT im localStorage. Jeder 403 loggte den User aus — auch "Insufficient permissions"-Responses fuer Background-Requests — wodurch User sich effektiv taeglich neu einloggen mussten. Backend: - Neue refresh_tokens Tabelle (SHA256-gehasht, mit Rotation + Revoke) - /auth/refresh tauscht Refresh-Token gegen frischen Access-Token (rotiert beide) - /auth/logout revoked den Refresh-Token - Access-Token-TTL auf 15m; Guest-Refresh 7d, Discord-Refresh 90d - Daily cleanup-Job fuer abgelaufene/revoked Tokens Frontend: - api.js refreshed bei 401 automatisch (single-flight) und retryt die Anfrage - 403 wird nicht mehr als Session-Ablauf behandelt - Discord-Callback + Guest-Login liefern beide Tokens - App.jsx synced React-State per gsm_token_refreshed CustomEvent Co-Authored-By: Claude Opus 4.7 (1M context) --- gsm-backend/db/init.js | 30 +++++ gsm-backend/routes/auth.js | 166 ++++++++++++++++++------ gsm-backend/services/refreshTokens.js | 61 +++++++++ gsm-frontend/src/App.jsx | 26 +++- gsm-frontend/src/api.js | 95 ++++++++++++-- gsm-frontend/src/pages/AuthCallback.jsx | 3 +- gsm-frontend/src/pages/LoginPage.jsx | 2 +- 7 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 gsm-backend/services/refreshTokens.js diff --git a/gsm-backend/db/init.js b/gsm-backend/db/init.js index 3daff3e..4914aaa 100644 --- a/gsm-backend/db/init.js +++ b/gsm-backend/db/init.js @@ -175,6 +175,36 @@ export function initDiscordUsers() { `); } +// Refresh tokens table +export function initRefreshTokens() { + db.exec(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT UNIQUE NOT NULL, + user_type TEXT NOT NULL CHECK(user_type IN ('discord', 'guest')), + discord_user_id INTEGER, + guest_id TEXT, + guest_username TEXT, + expires_at DATETIME NOT NULL, + revoked INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME, + FOREIGN KEY (discord_user_id) REFERENCES discord_users(id) ON DELETE CASCADE + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_discord_user ON refresh_tokens(discord_user_id)`); +} + +// Housekeeping: delete revoked or expired tokens older than 7 days +export function cleanupRefreshTokens() { + return db.prepare(` + DELETE FROM refresh_tokens + WHERE revoked = 1 + OR expires_at < datetime('now', '-7 days') + `).run(); +} + // Activity Log export function initActivityLog() { db.exec(` diff --git a/gsm-backend/routes/auth.js b/gsm-backend/routes/auth.js index 49d8612..9ec0b77 100644 --- a/gsm-backend/routes/auth.js +++ b/gsm-backend/routes/auth.js @@ -1,32 +1,61 @@ import { Router } from 'express'; import jwt from 'jsonwebtoken'; -import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js'; +import { db, VALID_ROLES, initDiscordUsers, initRefreshTokens, cleanupRefreshTokens } from '../db/init.js'; import { authenticateToken, requireRole } from '../middleware/auth.js'; import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js'; +import { createRefreshToken, verifyRefreshToken, revokeRefreshToken, revokeRefreshTokenById, touchRefreshToken } from '../services/refreshTokens.js'; const router = Router(); -// Initialize Discord users table +// Access token lifetime (short-lived; client refreshes automatically) +const ACCESS_TOKEN_TTL = '15m'; + +// Initialize Discord + refresh token tables initDiscordUsers(); +initRefreshTokens(); + +// Periodic cleanup of old revoked/expired tokens +setInterval(() => { + try { cleanupRefreshTokens(); } catch (e) { console.error('refresh token cleanup failed', e); } +}, 24 * 60 * 60 * 1000); + +function signDiscordAccessToken(user) { + return jwt.sign( + { + id: user.id, + discordId: user.discord_id || user.discordId, + username: user.username, + role: user.role, + avatar: user.avatar + }, + process.env.JWT_SECRET, + { expiresIn: ACCESS_TOKEN_TTL } + ); +} + +function signGuestAccessToken(guestId, username) { + return jwt.sign( + { id: guestId, username, role: 'guest', isGuest: true }, + process.env.JWT_SECRET, + { expiresIn: ACCESS_TOKEN_TTL } + ); +} // ===== Guest Login ===== -// Create guest token (view-only, expires in 24h) +// Create guest token + refresh token router.post('/guest', (req, res) => { const guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); + const username = 'Gast'; - const token = jwt.sign( - { - id: guestId, - username: 'Gast', - role: 'guest', - isGuest: true - }, - process.env.JWT_SECRET, - { expiresIn: '24h' } - ); + const token = signGuestAccessToken(guestId, username); + const refreshToken = createRefreshToken({ + userType: 'guest', + guestId, + guestUsername: username + }); - res.json({ token }); + res.json({ token, refreshToken }); }); // ===== Discord OAuth2 ===== @@ -95,21 +124,21 @@ router.get('/discord/callback', async (req, res) => { userId = result.lastInsertRowid; } - // Create JWT token - const token = jwt.sign( - { - id: userId, - discordId: discordUser.id, - username: displayName, - role, - avatar: discordUser.avatar - }, - process.env.JWT_SECRET, - { expiresIn: '7d' } - ); + // Create access + refresh tokens + const token = signDiscordAccessToken({ + id: userId, + discord_id: discordUser.id, + username: displayName, + role, + avatar: discordUser.avatar + }); + const refreshToken = createRefreshToken({ + userType: 'discord', + discordUserId: userId + }); - // Redirect to frontend with token - res.redirect(`${frontendUrl}/auth/callback?token=${token}`); + // Redirect to frontend with both tokens + res.redirect(`${frontendUrl}/auth/callback?token=${token}&refreshToken=${refreshToken}`); } catch (err) { console.error('Discord OAuth error:', err); @@ -170,18 +199,14 @@ router.post('/refresh-role', authenticateToken, async (req, res) => { db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?') .run(newRole, req.user.discordId); - // Generate new token with updated role - const token = jwt.sign( - { - id: req.user.id, - discordId: req.user.discordId, - username: req.user.username, - role: newRole, - avatar: req.user.avatar - }, - process.env.JWT_SECRET, - { expiresIn: '7d' } - ); + // Generate new access token with updated role (refresh token stays) + const token = signDiscordAccessToken({ + id: req.user.id, + discord_id: req.user.discordId, + username: req.user.username, + role: newRole, + avatar: req.user.avatar + }); res.json({ token, role: newRole }); } catch (err) { @@ -190,6 +215,67 @@ router.post('/refresh-role', authenticateToken, async (req, res) => { } }); +// ===== Refresh Token ===== + +// Exchange a refresh token for a new access token (with rotation) +router.post('/refresh', (req, res) => { + const { refreshToken } = req.body || {}; + if (!refreshToken) { + return res.status(400).json({ error: 'refreshToken required' }); + } + + const record = verifyRefreshToken(refreshToken); + if (!record) { + return res.status(401).json({ error: 'Invalid or expired refresh token' }); + } + + try { + let accessToken; + + if (record.user_type === 'discord') { + const user = db.prepare( + 'SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?' + ).get(record.discord_user_id); + + if (!user) { + // User was deleted — revoke token + revokeRefreshTokenById(record.id); + return res.status(401).json({ error: 'User no longer exists' }); + } + + accessToken = signDiscordAccessToken(user); + } else { + // Guest + accessToken = signGuestAccessToken(record.guest_id, record.guest_username || 'Gast'); + } + + // Rotate: revoke old refresh token, issue a new one + revokeRefreshTokenById(record.id); + const newRefreshToken = createRefreshToken({ + userType: record.user_type, + discordUserId: record.discord_user_id, + guestId: record.guest_id, + guestUsername: record.guest_username + }); + + touchRefreshToken(record.id); + + res.json({ token: accessToken, refreshToken: newRefreshToken }); + } catch (err) { + console.error('Refresh token error:', err); + res.status(500).json({ error: 'Refresh failed' }); + } +}); + +// Revoke refresh token (logout) +router.post('/logout', (req, res) => { + const { refreshToken } = req.body || {}; + if (refreshToken) { + revokeRefreshToken(refreshToken); + } + res.json({ ok: true }); +}); + // ===== User Management (superadmin only) ===== // Get all Discord users diff --git a/gsm-backend/services/refreshTokens.js b/gsm-backend/services/refreshTokens.js new file mode 100644 index 0000000..9e5e52f --- /dev/null +++ b/gsm-backend/services/refreshTokens.js @@ -0,0 +1,61 @@ +import crypto from 'crypto'; +import { db } from '../db/init.js'; + +// Refresh token lifetime +const DISCORD_REFRESH_TTL_DAYS = 90; +const GUEST_REFRESH_TTL_DAYS = 7; + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function generateToken() { + return crypto.randomBytes(48).toString('base64url'); +} + +function ttlDaysFor(userType) { + return userType === 'guest' ? GUEST_REFRESH_TTL_DAYS : DISCORD_REFRESH_TTL_DAYS; +} + +export function createRefreshToken({ userType, discordUserId = null, guestId = null, guestUsername = null }) { + const token = generateToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + ttlDaysFor(userType) * 24 * 60 * 60 * 1000).toISOString(); + + db.prepare(` + INSERT INTO refresh_tokens (token_hash, user_type, discord_user_id, guest_id, guest_username, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(tokenHash, userType, discordUserId, guestId, guestUsername, expiresAt); + + return token; +} + +export function verifyRefreshToken(token) { + if (!token) return null; + const tokenHash = hashToken(token); + const row = db.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? + AND revoked = 0 + AND expires_at > CURRENT_TIMESTAMP + `).get(tokenHash); + return row || null; +} + +export function revokeRefreshToken(token) { + if (!token) return; + const tokenHash = hashToken(token); + db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?').run(tokenHash); +} + +export function revokeRefreshTokenById(id) { + db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE id = ?').run(id); +} + +export function touchRefreshToken(id) { + db.prepare('UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); +} + +export function revokeAllForDiscordUser(discordUserId) { + db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE discord_user_id = ?').run(discordUserId); +} diff --git a/gsm-frontend/src/App.jsx b/gsm-frontend/src/App.jsx index ae8e491..47ab45e 100644 --- a/gsm-frontend/src/App.jsx +++ b/gsm-frontend/src/App.jsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { UserProvider, useUser } from './context/UserContext' import Dashboard from './pages/Dashboard' import ServerDetail from './pages/ServerDetail' import LoginPage from './pages/LoginPage' import AuthCallback from './pages/AuthCallback' +import { storeTokens, logout as apiLogout } from './api' function ProtectedServerDetail({ onLogout }) { const { isGuest, loading } = useUser() @@ -18,13 +19,30 @@ function ProtectedServerDetail({ onLogout }) { export default function App() { const [token, setToken] = useState(localStorage.getItem('gsm_token')) - const handleLogin = (newToken) => { - localStorage.setItem('gsm_token', newToken) + // Keep React state in sync when api.js silently refreshes the access token + useEffect(() => { + const onRefreshed = (e) => { + const newToken = e?.detail?.token || localStorage.getItem('gsm_token') + if (newToken) setToken(newToken) + } + const onStorage = (e) => { + if (e.key === 'gsm_token') setToken(e.newValue) + } + window.addEventListener('gsm_token_refreshed', onRefreshed) + window.addEventListener('storage', onStorage) + return () => { + window.removeEventListener('gsm_token_refreshed', onRefreshed) + window.removeEventListener('storage', onStorage) + } + }, []) + + const handleLogin = (newToken, refreshToken) => { + storeTokens(newToken, refreshToken) setToken(newToken) } const handleLogout = () => { - localStorage.removeItem('gsm_token') + apiLogout() setToken(null) } diff --git a/gsm-frontend/src/api.js b/gsm-frontend/src/api.js index 4fa61c5..edff8f3 100644 --- a/gsm-frontend/src/api.js +++ b/gsm-frontend/src/api.js @@ -1,18 +1,74 @@ const API_URL = import.meta.env.VITE_API_URL || '/api' -async function fetchAPI(endpoint, options = {}) { +// In-flight refresh promise so parallel 401s only trigger a single /auth/refresh call +let refreshInFlight = null + +function getStoredRefreshToken() { + return localStorage.getItem('gsm_refresh_token') +} + +function setTokens(accessToken, refreshToken) { + if (accessToken) localStorage.setItem('gsm_token', accessToken) + if (refreshToken) localStorage.setItem('gsm_refresh_token', refreshToken) + // Notify the app so React state can pick up the new access token + window.dispatchEvent(new CustomEvent('gsm_token_refreshed', { + detail: { token: accessToken }, + })) +} + +function clearTokensAndRedirect() { + localStorage.removeItem('gsm_token') + localStorage.removeItem('gsm_refresh_token') + window.location.href = '/login' +} + +async function refreshAccessToken() { + const refreshToken = getStoredRefreshToken() + if (!refreshToken) return null + + if (!refreshInFlight) { + refreshInFlight = fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + .then(async (res) => { + if (!res.ok) return null + const data = await res.json() + if (!data.token) return null + setTokens(data.token, data.refreshToken) + return data.token + }) + .catch(() => null) + .finally(() => { + // Release the lock on next microtask so concurrent awaiters get the same result + setTimeout(() => { refreshInFlight = null }, 0) + }) + } + + return refreshInFlight +} + +async function fetchAPI(endpoint, options = {}, { retry = true } = {}) { + const baseHeaders = { + 'Content-Type': 'application/json', + ...options.headers, + } + const response = await fetch(`${API_URL}${endpoint}`, { ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, + headers: baseHeaders, }) - // Auto-logout on auth errors (invalid/expired token) - if (response.status === 401 || response.status === 403) { - localStorage.removeItem('gsm_token') - window.location.href = '/' + // Only treat 401 on authenticated endpoints as an expired access token. + // 403 means "authenticated but not allowed" — do NOT log the user out. + if (response.status === 401 && retry && baseHeaders.Authorization) { + const newToken = await refreshAccessToken() + if (newToken) { + const retryHeaders = { ...baseHeaders, Authorization: `Bearer ${newToken}` } + return fetchAPI(endpoint, { ...options, headers: retryHeaders }, { retry: false }) + } + clearTokensAndRedirect() throw new Error('Session expired') } @@ -24,6 +80,27 @@ async function fetchAPI(endpoint, options = {}) { return response.json() } +export function storeTokens(accessToken, refreshToken) { + setTokens(accessToken, refreshToken) +} + +export async function logout() { + const refreshToken = getStoredRefreshToken() + localStorage.removeItem('gsm_token') + localStorage.removeItem('gsm_refresh_token') + if (refreshToken) { + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + } catch { + // best-effort; local state is already cleared + } + } +} + // Auth export async function login(username, password) { return fetchAPI('/auth/login', { diff --git a/gsm-frontend/src/pages/AuthCallback.jsx b/gsm-frontend/src/pages/AuthCallback.jsx index 0b936d3..15c0f5f 100644 --- a/gsm-frontend/src/pages/AuthCallback.jsx +++ b/gsm-frontend/src/pages/AuthCallback.jsx @@ -7,9 +7,10 @@ export default function AuthCallback({ onLogin }) { useEffect(() => { const token = searchParams.get('token') + const refreshToken = searchParams.get('refreshToken') if (token) { - onLogin(token) + onLogin(token, refreshToken) navigate('/', { replace: true }) } else { // No token received, redirect to login with error diff --git a/gsm-frontend/src/pages/LoginPage.jsx b/gsm-frontend/src/pages/LoginPage.jsx index 1d26959..93c98eb 100644 --- a/gsm-frontend/src/pages/LoginPage.jsx +++ b/gsm-frontend/src/pages/LoginPage.jsx @@ -41,7 +41,7 @@ export default function LoginPage({ onLogin }) { const res = await fetch(`${API_URL}/auth/guest`, { method: 'POST' }) const data = await res.json() if (data.token) { - onLogin(data.token) + onLogin(data.token, data.refreshToken) navigate('/', { replace: true }) } } catch (err) {