Refresh-Token-System: kurzlebiger Access-Token (15m) + rotierender Refresh-Token (90d)
All checks were successful
Deploy GSM / deploy (push) Successful in 29s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:21:38 +02:00
parent dfbcc2da82
commit 7bc93f3b70
7 changed files with 328 additions and 55 deletions

View File

@@ -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(`

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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)
}

View File

@@ -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', {

View File

@@ -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

View File

@@ -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) {