Redis والتخزين المؤقت المتقدم
إدارة الجلسات مع Redis
إدارة الجلسات مع Redis
إدارة الجلسات هي مكون حاسم لتطبيقات الويب التي تتطلب مصادقة المستخدم واستمرار الحالة. Redis هو خيار ممتاز لتخزين الجلسات بسبب سرعته وانتهاء الصلاحية المدمج وقدرته على التعامل مع التزامن العالي. يغطي هذا الدرس تطبيق إدارة جلسات آمنة وقابلة للتوسع مع Redis في Node.js.
لماذا Redis للجلسات؟
طرق تخزين الجلسات التقليدية لها قيود:
- تخزين الذاكرة: غير قابل للتوسع عبر خوادم متعددة، يُفقد عند إعادة التشغيل
- نظام الملفات: بطيء، يصعب تنظيف الجلسات المنتهية الصلاحية
- قاعدة البيانات: أبطأ من Redis، يضيف حملاً على قاعدة البيانات الأساسية
مزايا Redis:
- سرعة الذاكرة (وصول أقل من ميلي ثانية)
- TTL مدمج (انتهاء صلاحية الجلسة التلقائي)
- قابلية التوسع الأفقي (يمكن لخوادم التطبيقات المتعددة مشاركة الجلسات)
- خيارات الثبات (AOF/RDB للمتانة)
- عمليات ذرية (منع حالات السباق)
تثبيت التبعيات
قم بتثبيت الحزم المطلوبة لإدارة الجلسات:
npm install express express-session connect-redis ioredis
إعداد الجلسة الأساسي
قم بتكوين Express مع مخزن جلسات Redis:
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const Redis = require('ioredis');
const app = express();
const redisClient = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD
});
// تكوين middleware الجلسة
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS فقط في الإنتاج
httpOnly: true, // منع XSS
maxAge: 1000 * 60 * 60 * 24 // 24 ساعة
}
}));
// نقطة نهاية اختبار
app.get('/', (req, res) => {
if (req.session.views) {
req.session.views++;
} else {
req.session.views = 1;
}
res.json({
message: 'الجلسة تعمل',
views: req.session.views,
sessionID: req.sessionID
});
});
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const Redis = require('ioredis');
const app = express();
const redisClient = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD
});
// تكوين middleware الجلسة
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS فقط في الإنتاج
httpOnly: true, // منع XSS
maxAge: 1000 * 60 * 60 * 24 // 24 ساعة
}
}));
// نقطة نهاية اختبار
app.get('/', (req, res) => {
if (req.session.views) {
req.session.views++;
} else {
req.session.views = 1;
}
res.json({
message: 'الجلسة تعمل',
views: req.session.views,
sessionID: req.sessionID
});
});
ملاحظة: يُستخدم خيار
secret لتوقيع cookie معرف الجلسة. استخدم سراً قوياً وعشوائياً في الإنتاج وقم بتخزينه في متغيرات البيئة، ولا تكتبه مطلقاً في الكود.خيارات تكوين الجلسة
فهم خيارات تكوين الجلسة المهمة:
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:', // بادئة المفتاح في Redis
ttl: 86400 // TTL الجلسة بالثواني (24 ساعة)
}),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // اسم Cookie (افتراضي: connect.sid)
resave: false, // لا تحفظ الجلسات غير المتغيرة
saveUninitialized: false, // لا تنشئ جلسات حتى يتم تخزين البيانات
rolling: true, // إعادة تعيين انتهاء الصلاحية على كل استجابة (جلسات منزلقة)
cookie: {
secure: true, // HTTPS فقط
httpOnly: true, // لا يوجد وصول JavaScript
sameSite: 'strict', // حماية CSRF
maxAge: 1000 * 60 * 60 * 24, // 24 ساعة
domain: '.example.com' // مشاركة عبر النطاقات الفرعية
}
}));
store: new RedisStore({
client: redisClient,
prefix: 'sess:', // بادئة المفتاح في Redis
ttl: 86400 // TTL الجلسة بالثواني (24 ساعة)
}),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // اسم Cookie (افتراضي: connect.sid)
resave: false, // لا تحفظ الجلسات غير المتغيرة
saveUninitialized: false, // لا تنشئ جلسات حتى يتم تخزين البيانات
rolling: true, // إعادة تعيين انتهاء الصلاحية على كل استجابة (جلسات منزلقة)
cookie: {
secure: true, // HTTPS فقط
httpOnly: true, // لا يوجد وصول JavaScript
sameSite: 'strict', // حماية CSRF
maxAge: 1000 * 60 * 60 * 24, // 24 ساعة
domain: '.example.com' // مشاركة عبر النطاقات الفرعية
}
}));
نصيحة: اضبط
rolling: true لتنفيذ جلسات منزلقة - تنتهي صلاحية الجلسة تعيد تعيينها مع كل طلب، مما يبقي المستخدمين النشطين مسجلين الدخول إلى أجل غير مسمى.مصادقة المستخدم مع الجلسات
نفذ تسجيل الدخول وتسجيل الخروج وmiddleware المصادقة:
const bcrypt = require('bcrypt');
// نقطة نهاية تسجيل الدخول
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// العثور على المستخدم في قاعدة البيانات
const user = await db.users.findOne({ username });
if (!user) {
return res.status(401).json({ error: 'بيانات اعتماد غير صالحة' });
}
// التحقق من كلمة المرور
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'بيانات اعتماد غير صالحة' });
}
// تخزين بيانات المستخدم في الجلسة
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({
message: 'تسجيل الدخول ناجح',
user: {
id: user.id,
username: user.username,
role: user.role
}
});
});
// نقطة نهاية تسجيل الخروج
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'فشل تسجيل الخروج' });
}
res.clearCookie('sessionId');
res.json({ message: 'تسجيل الخروج ناجح' });
});
});
// middleware المصادقة
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'المصادقة مطلوبة' });
}
next();
}
// مسار محمي
app.get('/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json({ user });
});
// نقطة نهاية تسجيل الدخول
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// العثور على المستخدم في قاعدة البيانات
const user = await db.users.findOne({ username });
if (!user) {
return res.status(401).json({ error: 'بيانات اعتماد غير صالحة' });
}
// التحقق من كلمة المرور
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'بيانات اعتماد غير صالحة' });
}
// تخزين بيانات المستخدم في الجلسة
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({
message: 'تسجيل الدخول ناجح',
user: {
id: user.id,
username: user.username,
role: user.role
}
});
});
// نقطة نهاية تسجيل الخروج
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'فشل تسجيل الخروج' });
}
res.clearCookie('sessionId');
res.json({ message: 'تسجيل الخروج ناجح' });
});
});
// middleware المصادقة
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'المصادقة مطلوبة' });
}
next();
}
// مسار محمي
app.get('/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json({ user });
});
أفضل ممارسات أمان الجلسة
نفذ إجراءات أمنية لحماية الجلسات:
// 1. إعادة توليد معرف الجلسة بعد تسجيل الدخول (منع تثبيت الجلسة)
app.post('/login', async (req, res) => {
// ... منطق المصادقة ...
// إعادة توليد معرف الجلسة
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'فشل تسجيل الدخول' });
}
// تعيين بيانات الجلسة
req.session.userId = user.id;
req.session.username = user.username;
res.json({ message: 'تسجيل الدخول ناجح' });
});
});
// 2. تخزين وقت إنشاء الجلسة (كشف اختطاف الجلسة)
app.post('/login', async (req, res) => {
// ... المصادقة ...
req.session.userId = user.id;
req.session.createdAt = Date.now();
req.session.ipAddress = req.ip;
res.json({ message: 'تسجيل الدخول ناجح' });
});
// 3. التحقق من الجلسة في كل طلب
function validateSession(req, res, next) {
if (!req.session.userId) {
return next();
}
// تحقق مما إذا كان عنوان IP قد تغير (اختطاف محتمل)
if (req.session.ipAddress && req.session.ipAddress !== req.ip) {
req.session.destroy();
return res.status(401).json({ error: 'جلسة غير صالحة' });
}
// تحقق من عمر الجلسة (إجبار إعادة تسجيل الدخول بعد 7 أيام)
const maxAge = 1000 * 60 * 60 * 24 * 7; // 7 أيام
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'انتهت صلاحية الجلسة' });
}
next();
}
app.use(validateSession);
app.post('/login', async (req, res) => {
// ... منطق المصادقة ...
// إعادة توليد معرف الجلسة
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'فشل تسجيل الدخول' });
}
// تعيين بيانات الجلسة
req.session.userId = user.id;
req.session.username = user.username;
res.json({ message: 'تسجيل الدخول ناجح' });
});
});
// 2. تخزين وقت إنشاء الجلسة (كشف اختطاف الجلسة)
app.post('/login', async (req, res) => {
// ... المصادقة ...
req.session.userId = user.id;
req.session.createdAt = Date.now();
req.session.ipAddress = req.ip;
res.json({ message: 'تسجيل الدخول ناجح' });
});
// 3. التحقق من الجلسة في كل طلب
function validateSession(req, res, next) {
if (!req.session.userId) {
return next();
}
// تحقق مما إذا كان عنوان IP قد تغير (اختطاف محتمل)
if (req.session.ipAddress && req.session.ipAddress !== req.ip) {
req.session.destroy();
return res.status(401).json({ error: 'جلسة غير صالحة' });
}
// تحقق من عمر الجلسة (إجبار إعادة تسجيل الدخول بعد 7 أيام)
const maxAge = 1000 * 60 * 60 * 24 * 7; // 7 أيام
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'انتهت صلاحية الجلسة' });
}
next();
}
app.use(validateSession);
تحذير: لا تخزن أبداً بيانات حساسة مثل كلمات المرور أو أرقام بطاقات الائتمان في الجلسات. قم بتخزين معرفات المستخدمين فقط وقم بتحميل البيانات الحساسة من قاعدة البيانات عند الحاجة.
الجلسات المنزلقة (انتهاء الصلاحية على أساس النشاط)
نفذ جلسات منزلقة تمدد انتهاء الصلاحية على نشاط المستخدم:
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true, // إعادة تعيين maxAge على كل طلب
cookie: {
maxAge: 1000 * 60 * 30 // 30 دقيقة من عدم النشاط
}
}));
// أو نفذ منطق انزلاق مخصص
function slidingSession(inactivityMinutes) {
const maxAge = 1000 * 60 * inactivityMinutes;
return (req, res, next) => {
if (!req.session.userId) {
return next();
}
const lastActivity = req.session.lastActivity || Date.now();
const timeSinceActivity = Date.now() - lastActivity;
if (timeSinceActivity > maxAge) {
// انتهت صلاحية الجلسة بسبب عدم النشاط
req.session.destroy();
return res.status(401).json({ error: 'انتهت صلاحية الجلسة' });
}
// تحديث طابع زمني لآخر نشاط
req.session.lastActivity = Date.now();
next();
};
}
app.use(slidingSession(30)); // مهلة عدم نشاط 30 دقيقة
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true, // إعادة تعيين maxAge على كل طلب
cookie: {
maxAge: 1000 * 60 * 30 // 30 دقيقة من عدم النشاط
}
}));
// أو نفذ منطق انزلاق مخصص
function slidingSession(inactivityMinutes) {
const maxAge = 1000 * 60 * inactivityMinutes;
return (req, res, next) => {
if (!req.session.userId) {
return next();
}
const lastActivity = req.session.lastActivity || Date.now();
const timeSinceActivity = Date.now() - lastActivity;
if (timeSinceActivity > maxAge) {
// انتهت صلاحية الجلسة بسبب عدم النشاط
req.session.destroy();
return res.status(401).json({ error: 'انتهت صلاحية الجلسة' });
}
// تحديث طابع زمني لآخر نشاط
req.session.lastActivity = Date.now();
next();
};
}
app.use(slidingSession(30)); // مهلة عدم نشاط 30 دقيقة
تنظيف الجلسات ومراقبتها
راقب وقم بتنظيف الجلسات المنتهية الصلاحية:
// الحصول على عدد الجلسات النشطة
async function getActiveSessionCount() {
const keys = await redisClient.keys('sess:*');
return keys.length;
}
// الحصول على عدد جلسات المستخدم (كشف تسجيلات دخول متعددة)
async function getUserSessionCount(userId) {
const keys = await redisClient.keys('sess:*');
let count = 0;
for (const key of keys) {
const sessionData = await redisClient.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.userId === userId) {
count++;
}
}
}
return count;
}
// تدمير جميع جلسات المستخدم (إجبار تسجيل الخروج)
async function destroyUserSessions(userId) {
const keys = await redisClient.keys('sess:*');
for (const key of keys) {
const sessionData = await redisClient.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.userId === userId) {
await redisClient.del(key);
}
}
}
}
// نقطة نهاية المسؤول لعرض إحصائيات الجلسة
app.get('/admin/sessions', requireAdmin, async (req, res) => {
const totalSessions = await getActiveSessionCount();
res.json({ totalSessions });
});
async function getActiveSessionCount() {
const keys = await redisClient.keys('sess:*');
return keys.length;
}
// الحصول على عدد جلسات المستخدم (كشف تسجيلات دخول متعددة)
async function getUserSessionCount(userId) {
const keys = await redisClient.keys('sess:*');
let count = 0;
for (const key of keys) {
const sessionData = await redisClient.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.userId === userId) {
count++;
}
}
}
return count;
}
// تدمير جميع جلسات المستخدم (إجبار تسجيل الخروج)
async function destroyUserSessions(userId) {
const keys = await redisClient.keys('sess:*');
for (const key of keys) {
const sessionData = await redisClient.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.userId === userId) {
await redisClient.del(key);
}
}
}
}
// نقطة نهاية المسؤول لعرض إحصائيات الجلسة
app.get('/admin/sessions', requireAdmin, async (req, res) => {
const totalSessions = await getActiveSessionCount();
res.json({ totalSessions });
});
ملاحظة: يزيل Redis تلقائياً الجلسات المنتهية الصلاحية بناءً على TTL. لا تحتاج إلى وظائف تنظيف يدوية، ولكن مراقبة الجلسات النشطة تساعد في تتبع نشاط المستخدم واكتشاف الشذوذات.
بنية بيانات الجلسة في Redis
فهم كيفية تخزين الجلسات في Redis:
// تنسيق مفتاح الجلسة: sess:<sessionID>
// مثال: sess:abc123def456
// بيانات الجلسة (سلسلة JSON):
{
"cookie": {
"originalMaxAge": 86400000,
"expires": "2025-02-17T12:00:00.000Z",
"secure": true,
"httpOnly": true,
"path": "/"
},
"userId": 1001,
"username": "john_doe",
"role": "admin",
"createdAt": 1708171200000,
"lastActivity": 1708174800000
}
// عرض الجلسة في Redis CLI:
// redis-cli
// KEYS sess:*
// GET sess:abc123def456
// TTL sess:abc123def456
// مثال: sess:abc123def456
// بيانات الجلسة (سلسلة JSON):
{
"cookie": {
"originalMaxAge": 86400000,
"expires": "2025-02-17T12:00:00.000Z",
"secure": true,
"httpOnly": true,
"path": "/"
},
"userId": 1001,
"username": "john_doe",
"role": "admin",
"createdAt": 1708171200000,
"lastActivity": 1708174800000
}
// عرض الجلسة في Redis CLI:
// redis-cli
// KEYS sess:*
// GET sess:abc123def456
// TTL sess:abc123def456
تمرين: أنشئ نظام مصادقة كامل مع التسجيل وتسجيل الدخول وتسجيل الخروج والمسارات المحمية. نفذ جلسات منزلقة (مهلة عدم نشاط 15 دقيقة)، وmiddleware التحقق من الجلسة (تحقق من عنوان IP)، ونقطة نهاية المسؤول لعرض الجلسات النشطة. اختبر بتسجيل الدخول من متصفحين مختلفين وتحقق من أن الجلسات تعمل بشكل مستقل.