Refresh-Token-System: kurzlebiger Access-Token (15m) + rotierender Refresh-Token (90d)
All checks were successful
Deploy GSM / deploy (push) Successful in 29s
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:
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
61
gsm-backend/services/refreshTokens.js
Normal file
61
gsm-backend/services/refreshTokens.js
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user