JWT وOAuth2 وتأمين الواجهات

رموز JSON المميزة (JWT) شرح مفصّل

18 دقيقة الدرس 2 من 13

رموز JSON المميزة (JWT) شرح مفصّل

في الدرس السابق رأيتَ لماذا تُعدّ المصادقة عديمة الحالة النموذجَ الصحيح لواجهات REST. يُركّز هذا الدرس على تنسيق الرمز المميز الذي يُتيح ذلك: JSON Web Token. بنهاية الدرس ستتمكّن من قراءة أي JWT بعينيك مباشرةً، ومعرفة ما ينتمي إليه بالضبط ولماذا، والتعرّف على الآثار الأمنية لكل خيار تصميمي.

شكل رمز JWT

الـ JWT سلسلة نصية مدمجة وآمنة للاستخدام في عناوين URL، مقسّمة إلى ثلاثة أقسام مشفَّرة بـ Base64URL مفصولة بنقاط:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyLTQyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTcxNzAwMDAwMCwiZXhwIjoxNzE3MDAzNjAwfQ . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

الأجزاء الثلاثة هي: الترويسة (Header) · الحمولة (Payload) · التوقيع (Signature). إذا أزلتَ تشفير Base64URL ستحصل على كائنَي JSON وكتلة ثنائية.

الجزء الأول — الترويسة

الترويسة كائن JSON صغير يصف الرمز نفسه — لا المستخدم ولا الصلاحيات، بل تنسيق الرمز فحسب.

{ "alg": "HS256", "typ": "JWT" }

الحقلان الإلزاميان هما:

  • alg — الخوارزمية التشفيرية المستخدمة لإنشاء التوقيع. القيم الشائعة: HS256 (HMAC-SHA-256، خوارزمية متماثلة تستخدم سرًّا مشتركًا) وRS256 (RSA-SHA-256، خوارزمية غير متماثلة تستخدم زوج مفاتيح خاص/عام).
  • typ — نوع الرمز. قيمته دائمًا "JWT" لرموز JSON Web Token.
هجوم alg: none. ثغرة أمنية شهيرة في مكتبات JWT المبكّرة سمحت للمهاجمين بضبط "alg": "none"، ممّا جعل المكتبة تتخطّى التحقق من التوقيع كليًّا. اضبط مكتبتك دائمًا لقبول الخوارزميات المحدّدة التي يستخدمها تطبيقك فقط — ولا تسمح بـ none في بيئة الإنتاج أبدًا.

الجزء الثاني — الحمولة (المطالبات)

الحمولة هي قلب الرمز. إنها كائن JSON يحتوي على مطالبات (claims) — تصريحات تتعلق بالموضوع (عادةً المستخدم المسجّل دخوله) وبيانات وصفية حول الرمز نفسه. تنقسم المطالبات إلى ثلاث فئات.

المطالبات المسجَّلة

أسماء موحَّدة معرَّفة في RFC 7519. استخدامها باتساق يُتيح التشغيل البيني بين الأنظمة المختلفة. أهمّها في تطبيق Spring Security:

  • sub (الموضوع) — معرّف فريد للمبدأ الذي يمثّله الرمز، عادةً معرّف مستخدم أو اسم مستخدم.
  • iss (المصدر) — من أصدر الرمز، مثل "https://api.myapp.com". ينبغي لخوادم الموارد رفض الرموز الصادرة من مصادر غير متوقّعة.
  • aud (الجمهور) — المستلم أو المستلمون المقصودون للرمز. ينبغي رفض رمز مُعدّ لـ "mobile-app" من قِبل خدمة "admin-dashboard".
  • iat (وقت الإصدار) — طابع زمني Unix يسجّل وقت إنشاء الرمز.
  • exp (وقت انتهاء الصلاحية) — طابع زمني Unix يجب بعده رفض الرمز. هذا خطّ دفاعك الأول: الرموز قصيرة العمر تُقلّل بشكل كبير من الضرر في حالة سرقة الرمز.
  • nbf (ليس قبل) — طابع زمني Unix يجب قبله رفض الرمز. نادر الاستخدام لكن يُحتاج إليه أحيانًا في سيناريوهات التفعيل المؤجَّل.
  • jti (معرّف JWT) — معرّف فريد للرمز. تخزين قيم jti والتحقق منها يُتيح تنفيذ إلغاء صلاحية الرمز دون الحاجة إلى مخزن جلسات كامل.

المطالبات العامة

مطالبات خاصة بالتطبيق تُسجَّل في سجل مطالبات IANA JWT لتجنّب التعارضات، أو تُعطى نطاقًا بـ URI: "https://myapp.com/roles". عمليًّا، تتخطّى معظم الفِرق التسجيل الرسمي في الخدمات الداخلية.

المطالبات الخاصة

مطالبات متّفق عليها بين المنتج والمستهلك — غير مسجَّلة في أيّ مكان. أمثلة نموذجية: roles، tenantId، plan. هذه هي المطالبات التي ستقرأها فلترة Spring Security لبناء كائن Authentication.

حمولة واقعية لواجهة برمجية متعددة المستأجرين (SaaS):

{ "sub": "user-42", "iss": "https://auth.myapp.com", "aud": "api.myapp.com", "iat": 1717000000, "exp": 1717003600, "roles": ["ROLE_USER", "ROLE_BILLING_ADMIN"], "tenantId": "acme-corp", "plan": "pro" }
الحمولة ليست مشفَّرة — بل موقَّعة فحسب. ترميز Base64URL قابل للعكس ببساطة. أيّ شخص يمتلك الرمز يمكنه فك تشفير الحمولة وقراءة كل مطالبة. لا تضع أبدًا كلمة مرور أو رقم بطاقة ائتمان أو مفتاحًا سريًّا أو أيّ معلومات شخصية حساسة في حمولة JWT. التوقيع يُثبت أن الحمولة لم تُعبَّث بها؛ لكنه لا يُخفي البيانات.

الجزء الثالث — التوقيع

يربط التوقيع الترويسة والحمولة معًا ويُثبت أنّهما صدرا من طرف يمتلك المفتاح الصحيح. يُحسَب على الترويسة والحمولة المشفَّرتين:

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

بالنسبة لـ RS256 العملية هي نفسها غير أن المُوقّع يستخدم مفتاحه الخاص والمتحقّقون يستخدمون المفتاح العام المقابل. هذا التباين غير المتماثل محوري في أمان الخدمات المصغَّرة:

  • خدمة المصادقة وحدها تعرف المفتاح الخاص — إذًا هي وحدها تستطيع إصدار الرموز.
  • كل خدمة مصغَّرة مصبَّ عليها تمتلك المفتاح العام فقط — يمكنها التحقق من الرموز لكن لا يمكنها تزويرها.
  • اختراق خدمة مصب لا يعرّض مفتاح توقيع خدمة المصادقة للخطر.
فضّل RS256 (أو ES256) في الأنظمة الموزَّعة. مع HS256 كل خدمة تحتاج إلى التحقق من الرموز يجب أن تشارك السرّ ذاته — ممّا يعني أن كل خدمة خطرها مساوٍ لخدمة المصادقة إذا اختُرقت. مع RS256 لا توزّع إلا مفتاحًا عامًّا؛ وسطح الهجوم أصغر بكثير.

تجميع الأجزاء — الترميز وفك الترميز يدويًّا

فهم عملية الترميز يُزيل الغموض. إليك ما يحدث عند إنشاء رمز والتحقق منه، معبَّرًا عنه بشبه كود بمصطلحات Java:

// الإنشاء (خدمة المصادقة) String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; String payloadJson = "{\"sub\":\"user-42\",\"exp\":1717003600,...}"; String encodedHeader = Base64.getUrlEncoder().withoutPadding() .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); String encodedPayload = Base64.getUrlEncoder().withoutPadding() .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); String signingInput = encodedHeader + "." + encodedPayload; byte[] signature = HMAC_SHA256(signingInput, secretKey); String encodedSig = Base64.getUrlEncoder().withoutPadding() .encodeToString(signature); String jwt = encodedHeader + "." + encodedPayload + "." + encodedSig; // التحقق (خادم الموارد) String[] parts = jwt.split("\\."); // [header, payload, sig] byte[] expectedSig = HMAC_SHA256(parts[0]+"."+parts[1], secretKey); byte[] actualSig = Base64.getUrlDecoder().decode(parts[2]); if (!MessageDigest.isEqual(expectedSig, actualSig)) { throw new SecurityException("التوقيع غير صالح — تمّت العبث بالرمز"); } // فقط الآن يمكن فك تشفير مطالبات الحمولة والوثوق بها
استخدم مقارنة بوقت ثابت. الكود أعلاه يستدعي MessageDigest.isEqual() لسبب وجيه. المقارنة الساذجة بـ Arrays.equals() تتوقف عند أول عدم تطابق، مما يُسرّب معلومات توقيت يمكن للمهاجم استغلالها لتزوير التوقيعات بايت بايت. استخدم دائمًا مقارنة بوقت ثابت عند مقارنة القيم التشفيرية.

مقايضات التصميم الأساسية التي يجب على كل مطوّر معرفتها

قبل إضافة مطالبة أو تعديل أوقات انتهاء الصلاحية، افهم هذه المقايضات:

  • حجم الرمز مقابل ثراء المطالبات. كل مطالبة تُضيفها تُكبّر الرمز الذي يُرسَل كترويسة في كل طلب HTTP. أبقِ الحمولات خفيفة — المعرّفات والأدوار مناسبة؛ كائنات الملف الشخصي الكاملة ليست كذلك.
  • انتهاء الصلاحية مقابل الإلغاء. الـ JWT عديمة الحالة، لذا لا يوجد إلغاء مدمج. بمجرد الإصدار يظل الرمز صالحًا حتى انتهاء صلاحيته. انتهاء الصلاحية خلال ساعة يعني أن رمزًا مسروقًا يمكن استخدامه لمدة ساعة. عشر دقائق تُقلّل تلك النافزة لكن تستلزم المزيد من رحلات تحديث الرمز. وازن بين تجربة المستخدم والمخاطر.
  • الإلغاء القائم على JTI. إذا احتجتَ إلى إلغاء فوري (تسجيل خروج، استجابة لاختراق)، خزّن قائمة حجب لقيم jti في Redis أو قاعدة بيانات، مع التحقق منها في كل طلب. هذا يُعيد الحالة لكن فقط للرموز المحجوبة — المسار السعيد يبقى عديم الحالة.
  • انحراف الساعة. الخوادم في النظام الموزَّع نادرًا ما تكون ساعاتها متزامنة تمامًا. مكتبات مثل JJWT وNimbus تُتيح ضبط انحراف ساعة مسموح به (عادةً 60 ثانية) حتى يُقبل رمز بـ exp = T بعد ثوانٍ قليلة من T.

الخلاصة

الـ JWT ثلاثة أقسام مشفَّرة بـ Base64URL: ترويسة تصف الخوارزمية، وحمولة تحتوي على المطالبات المسجَّلة والمخصَّصة، وتوقيع يربط الجزأين الآخرين تشفيريًّا. الحمولة قابلة للقراءة من أيّ شخص لكنها محمية من العبث. استخدم HS256 للأنظمة ذات الخدمة الواحدة وRS256/ES256 للخدمات المصغَّرة. أبقِ الحمولات خفيفة، واضبط أوقات انتهاء صلاحية قصيرة، ولا تضع أبدًا بيانات حساسة في رمز. مع هذا الأساس في مكانه، يُريك الدرس التالي كيفية توليد رموز JWT والتحقق منها في تطبيق Spring Boot 3 حقيقي باستخدام مكتبة JJWT.