Add DragonflyDB integration for JWT revocation and implement rate limiting for authentication routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s

This commit is contained in:
2026-02-28 13:37:27 +01:00
parent ed97587248
commit 3556aaede7
8 changed files with 251 additions and 5 deletions

25
server/config/redis.js Normal file
View File

@@ -0,0 +1,25 @@
import Redis from 'ioredis';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const redis = new Redis(REDIS_URL, {
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
retryStrategy: (times) => {
if (times > 3) return null; // stop retrying after 3 attempts
return Math.min(times * 200, 1000);
},
});
redis.on('error', (err) => {
// Suppress ECONNREFUSED noise after initial failure — only warn
if (err.code !== 'ECONNREFUSED') {
console.warn('⚠️ DragonflyDB error:', err.message);
}
});
redis.on('connect', () => {
console.log('🐉 DragonflyDB connected');
});
export default redis;

View File

@@ -1,5 +1,7 @@
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
@@ -13,6 +15,20 @@ export async function authenticateToken(req, res, next) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Check JWT blacklist in DragonflyDB (revoked tokens via logout)
if (decoded.jti) {
try {
const revoked = await redis.get(`blacklist:${decoded.jti}`);
if (revoked) {
return res.status(401).json({ error: 'Token has been revoked' });
}
} catch (redisErr) {
// Graceful degradation: if Redis is unavailable, allow the request
console.warn('Redis blacklist check skipped:', redisErr.message);
}
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]);
if (!user) {
@@ -33,5 +49,6 @@ export function requireAdmin(req, res, next) {
}
export function generateToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
const jti = uuidv4();
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
}

View File

@@ -1,13 +1,49 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
// ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) {
try {
return new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
return undefined; // falls back to in-memory if Redis unavailable
}
}
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
store: makeRedisStore('rl:login:'),
});
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many registration attempts. Please try again later.' },
store: makeRedisStore('rl:register:'),
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -20,7 +56,7 @@ if (!fs.existsSync(uploadsDir)) {
const router = Router();
// POST /api/auth/register
router.post('/register', async (req, res) => {
router.post('/register', registerLimiter, async (req, res) => {
try {
const { username, display_name, email, password } = req.body;
@@ -194,7 +230,7 @@ router.post('/resend-verification', async (req, res) => {
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
router.post('/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body;
@@ -223,6 +259,31 @@ router.post('/login', async (req, res) => {
}
});
// POST /api/auth/logout revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const decoded = jwt.decode(token);
if (decoded?.jti && decoded?.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
try {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
} catch (redisErr) {
console.warn('Redis blacklist write failed:', redisErr.message);
}
}
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
res.status(500).json({ error: 'Logout failed' });
}
});
// GET /api/auth/me
router.get('/me', authenticateToken, (req, res) => {
res.json({ user: req.user });