Kodax

Refresh Tokens — Authentication & Security

رموز التجديد (Refresh Tokens)

رمز الوصول (JWT) يجب أن يكون قصير العمر (15 دقيقة) لأنه صعب الإلغاء. لكن لا نريد أن يسجل المستخدم دخوله كل ربع ساعة. الحل: رمزان.

| | رمز الوصول (Access) | رمز التجديد (Refresh) |

|---|---|---|

| العمر | دقائق | أيام/أسابيع |

| يُرسل إلى | كل طلب API | مسار /auth/refresh فقط |

| التخزين في الويب | الذاكرة | ملف تعريف ارتباط HttpOnly + Secure + SameSite=Strict مقيّد بالمسار |

| الإلغاء | ينتهي وحده | في قاعدة البيانات (يمكن إلغاؤه فوراً) |

التدفق

تسجيل الدخول ← access (15د) + refresh (7 أيام في cookie)
… طلبات بـ access …
access انتهى ← 401 ← العميل يطلب /auth/refresh (cookie يُرسل تلقائياً)
الخادم يتحقق من refresh ← access جديد + refresh جديد (تدوير) ← يعيد الطلب الأصلي

ثلاث ممارسات ضرورية

1. خزّن **تجزئة** رمز التجديد لا الرمز نفسه

إذا سُرّبت قاعدة البيانات، لا يجد المهاجم رموزاً صالحة. رمز التجديد عشوائي طويل، فتكفيه تجزئة سريعة (SHA-256) — بعكس كلمات المرور.

2. التدوير (Rotation)

كل استخدام لرمز التجديد يُصدر رمزاً جديداً ويُلغي القديم. الرمز صالح لمرة واحدة.

3. كشف إعادة الاستخدام

إذا وصل رمز تجديد مُستخدم مسبقاً، فقد سُرق أحد الرمزين (المهاجم أو المستخدم الحقيقي يستخدم نسخة قديمة). الاستجابة الآمنة: ألغِ كل رموز تلك العائلة/المستخدم وأجبره على تسجيل الدخول من جديد.

const crypto = require("node:crypto");

const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
const db = new Map();   // tokenHash ← { userId, used }

function issue(userId) {
  const token = crypto.randomBytes(32).toString("base64url");
  db.set(sha256(token), { userId, used: false });
  return token;
}

function rotate(token) {
  const rec = db.get(sha256(token));
  if (!rec) return { error: "غير صالح" };
  if (rec.used) {                                  // إعادة استخدام ← اختراق محتمل
    for (const [h, r] of db) if (r.userId === rec.userId) db.delete(h);
    return { error: "إعادة استخدام: أُلغيت كل الجلسات" };
  }
  rec.used = true;
  return { token: issue(rec.userId) };
}

const t1 = issue("u1");
const t2 = rotate(t1).token;
console.log(rotate(t1));          // المهاجم يعيد استخدام القديم
console.log(rotate(t2));          // حتى الجديد أُلغي

تسجيل الخروج

احذف رمز التجديد من قاعدة البيانات وامسح الـ cookie. "الخروج من كل الأجهزة" = حذف كل رموز المستخدم. رموز الوصول الحالية تبقى صالحة حتى انتهائها القصير — هذا هو الثمن المقبول.

تمرين

اربط منطق المثال بمسار Express: POST /auth/refresh يقرأ الـ cookie، يدوّر الرمز، ويضع الجديد في cookie بنفس الخصائص، ويعيد رمز وصول جديداً.

// Refresh Tokens مع التدوير وكشف إعادة الاستخدام وتخزين التجزئة
// npm install express cookie-parser jsonwebtoken
const crypto = require("node:crypto");
const express = require("express");
const cookieParser = require("cookie-parser");
const jwt = require("jsonwebtoken");

const app = express();
app.use(express.json());
app.use(cookieParser());

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");

// في مشروع حقيقي: جدول refresh_tokens (token_hash, user_id, family_id, expires_at, used_at)
const refreshStore = new Map();

function issueAccess(userId) {
  return jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: "15m" });
}

function issueRefresh(userId, familyId = crypto.randomUUID()) {
  const token = crypto.randomBytes(32).toString("base64url");
  refreshStore.set(sha256(token), { userId, familyId, expiresAt: Date.now() + REFRESH_TTL_MS, usedAt: null });
  return token;
}

const cookieOptions = { httpOnly: true, secure: true, sameSite: "strict", path: "/api/auth", maxAge: REFRESH_TTL_MS };

function revokeFamily(familyId) {
  for (const [hash, rec] of refreshStore) if (rec.familyId === familyId) refreshStore.delete(hash);
}

app.post("/api/auth/login", async (req, res) => {
  // ... التحقق من البريد وكلمة المرور (درس التجزئة) ...
  const userId = "user-42";
  res.cookie("refreshToken", issueRefresh(userId), cookieOptions);
  res.json({ accessToken: issueAccess(userId) });
});

app.post("/api/auth/refresh", (req, res) => {
  const token = req.cookies.refreshToken;
  const rec = token && refreshStore.get(sha256(token));
  if (!rec || rec.expiresAt < Date.now()) return res.status(401).json({ error: "سجّل الدخول من جديد" });

  if (rec.usedAt) {
    // رمز مستخدم مسبقاً: سُرق أحد الرمزين — نلغي العائلة كلها
    revokeFamily(rec.familyId);
    res.clearCookie("refreshToken", cookieOptions);
    return res.status(401).json({ error: "تم اكتشاف إعادة استخدام؛ سجّل الدخول من جديد" });
  }

  rec.usedAt = Date.now();                                   // صالح لمرة واحدة
  res.cookie("refreshToken", issueRefresh(rec.userId, rec.familyId), cookieOptions);
  res.json({ accessToken: issueAccess(rec.userId) });
});

app.post("/api/auth/logout", (req, res) => {
  const token = req.cookies.refreshToken;
  const rec = token && refreshStore.get(sha256(token));
  if (rec) revokeFamily(rec.familyId);
  res.clearCookie("refreshToken", cookieOptions);
  res.status(204).end();
});

app.listen(process.env.PORT || 3000);
اختبر فهمك