تدوير رموز التحديث في NestJS وNext.js: كشف إعادة الاستخدام وعائلات الرموز وسباق التحديث المتوازي — ملاحظات ميدانية
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗تدوير رمز التحديث (refresh token rotation) يعني أن الخادم يُصدر رمز تحديث جديدًا في كل عملية تحديث ويُبطل الرمز القديم. أما كشف إعادة الاستخدام (reuse detection) فيعني أنه إذا عاد رمز تحديث سبق تدويره، يُلغي الخادم كل الرموز الصادرة من جلسة الدخول نفسها. في Devya نستخدم الأسلوبين معًا في واجهات NestJS البرمجية التي تخدم واجهات Next.js App Router: نخزّن في Postgres بصمات SHA-256 فقط لرموز تحديث عشوائية معتمة، وندوّرها بأمر UPDATE واحد من نوع compare-and-set، ونسمح بنافذة سماح مدتها 10 ثوانٍ حتى لا تُعامَل عمليات التحديث المتوازية من المتصفح نفسه على أنها سرقة.
التصميم الذي بدأ منه فريقنا شائع: رمز وصول JWT قصير العمر ورمز تحديث صالح 30 يومًا، كلاهما في ملفات تعريف ارتباط httpOnly، ونقطة تحديث توقّع رمز وصول جديدًا وتترك رمز التحديث كما هو. يعمل هذا التصميم إلى أن يتسرّب رمز التحديث؛ فمن يملك نسخة منه يملك جلسة مدتها 30 يومًا، ولا يستطيع الخادم التمييز بين الحاملَين. هذه ملاحظاتنا عن استبدال ذلك التصميم بالتدوير وكشف إعادة الاستخدام، وعن خلل التزامن الذي يظهر فور تطبيقهما.
أهم النقاط
تدوير رمز التحديث يستبدل الرمز في كل استخدام، فيبقى الرمز المسرّب صالحًا فقط إلى أن يستخدمه أحد.
كشف إعادة الاستخدام يعامل الاستخدام الثاني لرمز سبق تدويره على أنه سرقة، ويُلغي عائلة الرموز (token family) كاملة، أي كل رموز التحديث المنحدرة من عملية تسجيل دخول واحدة.
يجب أن تكون رموز التحديث سلاسل عشوائية معتمة تُخزَّن كبصمات SHA-256 لا رموز JWT، لأن الخادم مضطر إلى البحث عن كل رمز لتدويره وإلغائه في كل الأحوال.
يجب أن يكون التدوير عملية compare-and-set ذرّية، أي أمر UPDATE لا يطابق إلا الصفوف التي ما زال فيها rotated_at فارغًا ويُعيد الصف، وإلا استطاع طلبان متزامنان استهلاك رمز التحديث نفسه.
عمليات التحديث المتوازية من متصفح واحد تبدو تمامًا مثل سرقة الرمز، لذلك يحتاج العميل إلى تحديث single-flight ويحتاج الخادم إلى نافذة سماح قصيرة.
مما يحمي تدوير رموز التحديث وكشف إعادة استخدامها؟
يحدّ التدوير وكشف إعادة الاستخدام من المدة التي يبقى فيها رمز التحديث المسروق مفيدًا، ويجعلان استخدامه قابلًا للكشف. رمز التحديث بيانات اعتماد طويلة العمر وظيفتها الوحيدة الحصول على رموز وصول جديدة قصيرة العمر، ورمز الوصول هو ما تتحقق منه الواجهة البرمجية في كل طلب.
من دون تدوير، يعمل رمز التحديث نفسه طوال عمره، فتصبح أي نسخة مسرّبة عبر سطر في السجلات أو جهاز مخترق أو وكيل (proxy) سيئ الإعداد جلسةً ثانية صامتة. مع التدوير، تُعيد كل عملية تحديث ناجحة رمز تحديث جديدًا وتعلّم القديم بأنه مستخدَم. إذا حدّث المهاجم أولًا، يقدّم العميل الشرعي في تحديثه التالي رمزًا مستخدَمًا، وإذا حدّث العميل أولًا فإن محاولة المهاجم هي التي تقدّم الرمز المستخدَم. في الحالتين يرى الخادم رمزًا مُدوَّرًا يعود، ولا يستطيع معرفة الطرف الشرعي، فيُلغي العائلة كلها. تنتهي الجلستان، ويسجّل المستخدم الحقيقي دخوله من جديد.
تشترط RFC 9700، وهي وثيقة أفضل الممارسات الأمنية لـ OAuth 2.0 المنشورة في يناير 2025، أن تكون رموز التحديث الممنوحة للعملاء العامّين إما مقيّدة بالمُرسِل (sender-constrained) أو مُدوَّرة. جلسة Next.js وNestJS الخاصة بالتطبيق نفسه ليست تدفق OAuth، لكن ملف تعريف الارتباط الذي يحمل رمز التحديث يواجه نموذج التهديد نفسه.
كيف نخزّن رموز التحديث في Postgres؟
نخزّن كل رمز تحديث في صف مفتاحه بصمة SHA-256 لسلسلة عشوائية معتمة، وتُجمَع الرموز بواسطة family_id يُنشأ عند تسجيل الدخول. الرمز الخام موجود فقط في ملف تعريف الارتباط، لذلك فإن تسرّب قاعدة البيانات يكشف بصمات لا يمكن إعادة استخدامها.
يحتوي جدول refresh_tokens لدينا على صف لكل رمز: family_id المشترك بين كل رموز جلسة الدخول الواحدة، وuser_id، وعمود token_hash فريد، وexpires_at لمهلة الخمول الخاصة بهذا الرمز، وfamily_expires_at للعمر المطلق للجلسة، وrotated_at الذي يُضبط عند استخدام الرمز، وrevoked_at الذي يُضبط عند إعادة الاستخدام أو تسجيل الخروج. فهرس على family_id يجعل إلغاء عائلة كاملة أمر UPDATE واحدًا رخيصًا.
كل رمز خام هو 32 بايت من crypto.randomBytes() في Node.js بترميز base64url. تكفي هنا دالة بصمة سريعة مثل SHA-256 لأن المدخل يحمل أصلًا 256 بت من العشوائية؛ أما دوال تجزئة كلمات المرور البطيئة مثل bcrypt فهي مصممة لكلمات مرور ضعيفة العشوائية، كما أنها تمنع البحث عبر فهرس فريد.
لعمودَي الانتهاء وظيفتان مختلفتان. نقيّد قيمة expires_at لكل رمز جديد بحيث لا تتجاوز family_expires_at، فيُبقي التدوير الجلسة النشطة حيّة دون أن يمدّها أبدًا بعد الحد المطلق. القيم التي نستخدمها: 10 دقائق لرموز الوصول، و7 أيام للخمول، و30 يومًا عمرًا مطلقًا.
كيف ندوّر رمز التحديث بشكل ذرّي في NestJS؟
التدوير الذرّي هو أمر UPDATE واحد يضبط rotated_at فقط عندما تطابق البصمة ويكون rotated_at وrevoked_at ما زالا فارغين، ثم يُعيد الصف، فيستطيع طلب واحد فقط استهلاك رمز تحديث معيّن. أما نسخة القراءة ثم الكتابة، حيث تقرأ الخدمة الصف وتتحقق منه في TypeScript ثم تحدّثه، فتسمح لطلبين متزامنين بأن يجتازا التحقق معًا ويحصل كل منهما على رمز لاحق.
عندما لا يطابق أمر UPDATE أي صف، تبحث خدمة RefreshTokenService في NestJS عن البصمة مرة أخرى وتترك Postgres يحسب قيمتين منطقيتين: هل دُوِّر الرمز من قبل، وهل دُوِّر قبل أقل من 10 ثوانٍ في عائلة غير ملغاة. الرمز الواقع داخل نافذة السماح يحصل على رمز لاحق، والرمز المُدوَّر خارجها يُلغي كل صفوف العائلة، والرمز المجهول أو المنتهي يحصل على استجابة 401 فقط. الرموز المنتهية لا تؤدي أبدًا إلى الإلغاء، لأن ملف تعريف ارتباط قديمًا على حاسوب بقي في وضع السكون شهرًا ليس هجومًا.
إصدار رمز لاحق يعني 32 بايت عشوائية جديدة، وإدراج بصمتها في العائلة نفسها، ورمز وصول مدته 10 دقائق موقّعًا بـ @nestjs/jwt يحمل الحقل sub للمستخدم والحقل sid للعائلة. في بيئة الإنتاج ننفّذ أمر UPDATE المستهلِك وأمر INSERT للرمز اللاحق داخل معاملة واحدة، حتى لا يترك تعطّلٌ بينهما جلسةً بلا رمز صالح.
تقرأ وحدة التحكم الخاصة بالتحديث ملف تعريف الارتباط عبر cookie-parser، وتكتب ملفَّي تعريف الارتباط باستخدام @Res({ passthrough: true }) الذي يحافظ على المعالجة الطبيعية للاستجابة في NestJS. نسمّيهما بالبادئة __Secure- التي تجعل المتصفحات ترفض أي ملف تعريف ارتباط لم يُضبط بخاصية Secure من مصدر HTTPS؛ أما البادئة الأكثر صرامة __Host- فتشترط Path=/، فلا يمكن جمعها مع ملف تحديث مقيّد بمسار.
لكشف إعادة الاستخدام ثغرة معروفة واحدة: رمز الوصول الذي صدر للمهاجم قبل الإلغاء يبقى صالحًا حتى قيمة exp الخاصة به. رموز الوصول القصيرة تحدّ من هذه الثغرة، ويمكن لنقاط النهاية الحساسة أن تتحقق من الحقل sid مقابل العائلات الملغاة.
لماذا يؤدي تدوير رموز التحديث إلى تسجيل خروج المستخدمين بشكل عشوائي؟
يسجّل التدوير خروج المستخدمين عندما يحدّث طلبان من المتصفح نفسه بالرمز نفسه في اللحظة نفسها: الطلب الأول يدوّر الرمز، والثاني يقدّم رمزًا مُدوَّرًا بالفعل، وهذا بالضبط شكل السرقة. كل التبويبات تتشارك مخزن ملفات تعريف الارتباط نفسه، لذلك يقع هذا السباق كلما استيقظ تبويبان معًا، أو أطلقت صفحة طلبات متوازية بعد انتهاء رمز الوصول، أو جلب موجّه Next.js عدة مسارات مسبقًا في الوقت نفسه.
نعالج المشكلة من الجهتين. في المتصفح، تمر كل استجابة 401 عبر عملية تحديث single-flight واحدة، أي وعد (promise) واحد مشترك قيد التنفيذ لكل تبويب، وتقوم Web Locks API عبر navigator.locks.request() بترتيب عمليات التحديث بين تبويبات المصدر نفسه واحدةً تلو الأخرى. Web Locks API متاحة في الإصدارات الحالية من Chrome وEdge وFirefox وSafari.
عندما يحصل التبويب الثاني على القفل، يكون مخزن ملفات تعريف الارتباط قد حمل رمز التحديث الجديد بالفعل، فيصبح تحديثه تدويرًا عاديًا لا إعادة استخدام. وعلى الخادم، تقبل نافذة السماح رمزًا دُوِّر للتو لمدة 10 ثوانٍ وتُصدر رمزًا لاحقًا آخر في العائلة نفسها بدلًا من إلغائها.
نافذة السماح هي أيضًا نافذة سرقة قصيرة، لذلك نبقيها بالثواني. ومزوّدو الهوية المستضافون مثل Auth0 وOkta يعرضون المفاضلة نفسها كإعداد قابل للضبط لهامش زمني أو فترة سماح في تدوير رموز التحديث.
هل يجب أن يحدّث تطبيق Next.js App Router الجلسة في الـ proxy أم في المتصفح؟
مكونات الخادم (Server Components) في Next.js لا تستطيع ضبط ملفات تعريف الارتباط، لذلك لا يستطيع مكوّن خادم وجد رمز وصول منتهيًا أن يدوّر الجلسة بنفسه. وحدها Route Handlers وServer Actions والـ proxy، وهو الملف الذي كان اسمه middleware.ts قبل Next.js 16، تستطيع كتابة ملفات تعريف الارتباط، وهذا يترك تصميمين عمليين.
التحديث من المتصفح: يُقيَّد ملف رمز التحديث بالمسار Path=/api/auth/refresh مع SameSite=Strict، ويكتشف غلاف fetch انتهاء الصلاحية بعد استجابة 401، وتعرض مكونات الخادم ذات رمز الوصول المنتهي حالة غير مسجّلة إلى أن يحدّث العميل الجلسة ويستدعي router.refresh()، ويُعالَج التزامن بتحديث single-flight مع Web Locks، ولا يُرسَل ملف رمز التحديث إلا إلى نقطة نهاية واحدة.
التحديث من الـ proxy: يستخدم ملف رمز التحديث Path=/ مع SameSite=Lax، ويكتشف الـ proxy انتهاء الصلاحية قبل عرض كل صفحة، وتتلقى مكونات الخادم دائمًا رمز وصول جديدًا، ويُعالَج التزامن بنافذة السماح على الخادم، ويُرسَل ملف رمز التحديث مع كل طلب صفحة.
نستخدم التحديث من الـ proxy عندما تُحمَّل معظم البيانات في مكونات الخادم. يقرأ الـ proxy الحقل exp بواسطة decodeJwt() من مكتبة jose، ولا يحدّث إلا إذا كان رمز الوصول مفقودًا أو سينتهي خلال 60 ثانية، ثم ينسخ ملفات تعريف الارتباط الجديدة إلى الطلب الحالي عبر request.cookies.set()، ويُعيد NextResponse.next({ request }) ليراها العرض الحالي، ويمرّر ترويسات Set-Cookie القادمة من NestJS إلى المتصفح كما هي.
تقرأ decodeJwt() الحقل exp فقط لتقرير موعد التحديث، بينما يواصل NestJS التحقق من التوقيع في كل استدعاء للواجهة البرمجية. يمكن لعمليات الجلب المسبق المتوازية لمكونات React الخادمية (RSC) أن تشغّل الـ proxy في الوقت نفسه بملف رمز التحديث نفسه، وهذه بالضبط الحالة التي وُجدت نافذة السماح على الخادم من أجلها. أما في لوحات التحكم التي تجلب معظم بياناتها من المتصفح، فنستخدم التحديث من المتصفح حتى لا تُرسَل بيانات الاعتماد طويلة العمر إلى أي مكان آخر.
الأسئلة الشائعة
س: هل يجب أن تكون رموز التحديث من نوع JWT؟
ج: لا. يجب أن يكون رمز التحديث سلسلة عشوائية معتمة تُخزَّن كبصمة، لأن الخادم مضطر إلى البحث عنه في كل استخدام لتدويره وإلغائه. رمز تحديث بصيغة JWT يضيف حجمًا ومفتاح توقيع دون أن يلغي ذلك البحث.
س: هل يكفي تدوير رموز التحديث أم نحتاج إلى DPoP؟
ج: التدوير يكشف رمز التحديث المسروق بعد استخدامه، أما DPoP (إثبات الحيازة، RFC 9449) فيربط الرموز بزوج مفاتيح يحمله العميل، فلا يمكن استخدام الرمز المنسوخ أصلًا. لتطبيق متصفح خاص يستخدم ملفات تعريف ارتباط httpOnly، يُعد التدوير مع رموز وصول قصيرة العمر خط أساس معقولًا، والرموز المقيّدة بالمُرسِل هي الخطوة التالية للتطبيقات عالية المخاطر.
س: ماذا يجب أن يفعل تسجيل الخروج برموز التحديث؟
ج: يجب أن يُلغي تسجيل الخروج عائلة الرموز كاملة ويمسح ملفَّي تعريف الارتباط. إلغاء الصف الحالي فقط يترك أي رمز شقيق صدر أثناء تحديث داخل نافذة السماح صالحًا حتى انتهائه.
س: كم يجب أن تكون مدة نافذة السماح لإعادة الاستخدام؟
ج: يجب أن تغطي نافذة السماح دفعة الطلبات المتوازية الناتجة عن تحميل صفحة واحدة ولا أكثر. نحن نستخدم 10 ثوانٍ؛ راجع الفاصل الزمني بين عمليات التحديث المكررة في سجلاتك قبل إطالتها.
س: لماذا لا نحفظ رمز التحديث في localStorage؟
ج: أي سكربت يعمل على الصفحة يستطيع قراءة localStorage، لذلك يكفي خلل XSS واحد لتسريب رمز التحديث وإعادة استخدامه من جهاز آخر. أما ملف تعريف الارتباط httpOnly فلا يمكن قراءته من JavaScript، فيستطيع خلل XSS التصرف داخل التبويب المفتوح لكنه لا يستطيع سرقة بيانات الاعتماد طويلة العمر.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.