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

المصادقة عديمة الحالة لواجهات REST

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

المصادقة عديمة الحالة لواجهات REST

قبل أن تكتب سطرًا واحدًا من كود JWT عليك أن تفهم لماذا تخلّت الصناعة عن الجلسات المُخزَّنة على الخادم في واجهات REST. بدون هذا السياق، يبدو JWT مجرد كوكيز معقّدة — وستتخذ قرارات تصميمية خاطئة في اللحظات التي تهمّ.

كيف تعمل المصادقة الكلاسيكية القائمة على الجلسات

في تطبيق ويب أحادي البنية تقليدي، تعمل المصادقة على النحو التالي:

  1. يرسل المستخدم بيانات الاعتماد (اسم المستخدم وكلمة المرور).
  2. يتحقق الخادم منها، ثم يُنشئ كائن جلسة في الذاكرة (أو في قاعدة البيانات) ويخزّن فيه هوية المستخدم وأدواره.
  3. يُرسل الخادم استجابةً تحمل الترويسة Set-Cookie: JSESSIONID=abc123.
  4. كل طلب لاحق من المتصفح يحمل هذه الكوكيز تلقائيًا.
  5. عند كل طلب يبحث الخادم عن abc123 في مخزن الجلسات ليعرف مَن يُقدِّم الطلب.

يعمل هذا بشكل رائع مع خادم واحد وعميل متصفح. المشكلة تكمن في عبارة "يبحث في مخزن الجلسات" — هذا البحث هو جذر كل المتاعب التي ستصادفها في الأنظمة الموزّعة.

الافتراض الجوهري لمصادقة الجلسة: الخادم الذي أنشأ الجلسة هو نفسه الذي سيخدم الطلبات المستقبلية — أو أن جميع الخوادم تشترك في نفس مخزن الجلسات. إذا انكسر أي من هذين الافتراضين، تنكسر المصادقة أيضًا.

لماذا لا تصلح الجلسات لواجهات REST

1. REST مُعرَّف كخدمة عديمة الحالة

تُصنّف أطروحة Roy Fielding الأصلية عن REST انعدام الحالة قيدًا معماريًا صارمًا: يجب أن يحتوي كل طلب من العميل على كل المعلومات اللازمة لكي يفهم الخادم الطلب ويعالجه. يجب على الخادم ألّا يخزّن أي سياق للعميل بين الطلبات. مخزن الجلسات ينتهك هذا القيد مباشرةً — لأن الخادم يحتاج إلى قناة جانبية للبحث كي يفهم الطلب.

هذا ليس فلسفيًا فقط. انعدام الحالة هو ما يجعل خدمات REST قابلةً للتوسع المستقل وقابلةً للتخزين المؤقت وسهلةَ الاستدلال.

2. التوسع الأفقي يستلزم حالةً مشتركة

تُشغّل عمليات النشر الحديثة نسخًا متعددة من كل خدمة خلف موازن حمل. تأمّل واجهة Spring Boot API منتشرة كثلاثة pods في Kubernetes:

[ Load Balancer ] / | \ [ Pod A ] [ Pod B ] [ Pod C ] (session (session (session store A) store B) store C)

يُصادق المستخدم على Pod A. جلسته في مخزن Pod A في الذاكرة. يُوجَّه الطلب التالي إلى Pod B من قِبَل موازن الحمل — Pod B لا يعرف شيئًا عن تلك الجلسة ويعيد 401 Unauthorized. المستخدم فجأةً خرج من حسابه في منتصف عمله.

الحل القياسي — الجلسات الثابتة (تثبيت كل مستخدم على pod واحد) أو مخزن جلسات مشترك (Redis أو قاعدة بيانات) — يُعالج العَرَض لكنه يُدخل مشاكل جديدة:

  • الجلسات الثابتة تنكسر عند إعادة تشغيل pod أو عند التوسع التلقائي للمجموعة. عقدة فاشلة واحدة تُخرج كل المستخدمين المرتبطين بها من حساباتهم.
  • مخزن الجلسات المشترك يحوّل مخزن الجلسات إلى عنق زجاجة مركزي ونقطة فشل وحيدة. كل فحص للمصادقة يستلزم جولة شبكية إلى Redis أو قاعدة البيانات.
Spring Session + Redis لا تحل المشكلة المعمارية. إنها تنقل عنق الزجاجة من مخزن داخلي إلى مخزن خارجي. تحت حمل عالٍ يصبح مجموعة Redis القيد الجديد، وانقطاع Redis يعني انقطاعًا كاملًا في المصادقة. الرموز عديمة الحالة تُلغي المخزن المشترك بالكامل.

3. الخدمات المصغّرة تمتد عبر حدود الثقة

في بنية الخدمات المصغّرة قد يتفرّع طلب واحد إلى خدمة الطلبات وخدمة المخزون وخدمة الإشعارات — كل منها عملية مستقلة، ربما يمتلكها فريق مختلف. لا يمكن لكوكيز الجلسات أن تنتقل عبر حدود الخدمات لأن:

  • الكوكيز مقيّدة بنطاق معيّن؛ أما استدعاءات الخدمة إلى الخدمة فهي HTTP وليست تنقّل متصفح.
  • لا يوجد مخزن جلسات وحيد يمكن لجميع الخدمات الاستعلام عنه دون اقتران وثيق.
  • ستحتاج كل خدمة إلى استدعاء خدمة مصادقة عند كل طلب، مما يُضاعف زمن الاستجابة والاقتران.

رمز موقَّع تشفيريًا يحمل معلوماته بذاته، ويمكن لكل خدمة التحقق منه باستقلالية، هو الحل العملي الوحيد.

4. العملاء من غير المتصفحات لا يتعاملون جيدًا مع الكوكيز

تستهلك واجهات REST API تطبيقاتُ الجوال وأدواتُ سطر الأوامر وأجهزةُ إنترنت الأشياء وخدماتٌ خلفية أخرى. لا تمتلك أيٌّ من هذه إدارةَ كوكيز تلقائية كالمتصفح. تطبيق كوكيز الجلسة في تطبيق Android أو سكريبت Python أمر محرج ومعرّض للأخطاء. أما الترويسة Authorization: Bearer <token> فمدعومة عالميًا من كل عميل HTTP في كل لغة.

كيف تبدو المصادقة عديمة الحالة

مع المصادقة عديمة الحالة لا يخزّن الخادم أي بيانات جلسة على الإطلاق. بدلًا من ذلك:

  1. يُصادق العميل مرة واحدة ويحصل على رمز موقَّع.
  2. يُرسل العميل ذلك الرمز في ترويسة Authorization مع كل طلب لاحق.
  3. يتحقق الخادم من توقيع الرمز ويستخرج الهوية والأدوار مباشرةً منه — دون الحاجة لأي بحث في قاعدة البيانات.
// ما يُرسله العميل مع كل طلب GET /api/orders HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... // ما يفعله الخادم — بدون مخزن جلسات على الإطلاق String token = request.getHeader("Authorization").substring(7); Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); String userId = claims.getSubject(); // "user-42" List<String> roles = claims.get("roles", List.class); // ["ROLE_USER"]

التحقق على الخادم حوسبي بحت — يتحقق من توقيع تشفيري باستخدام مفتاح يحمله في الذاكرة. لا إدخال/إخراج شبكي، لا مخزن مشترك، لا توجيه ثابت مطلوب.

المقايضات التي يجب أن تعرفها

المصادقة عديمة الحالة ليست مجانية. أنت تتداول مجموعة مشاكل بأخرى. يحتاج المطوّر العملي أن يكون واضح النظر تجاه الجانبين:

  • إلغاء صلاحية الرموز أمر صعب. يمكن إبطال الجلسة فورًا بحذفها من المخزن. أما الرمز عديم الحالة فيبقى صالحًا حتى انتهاء صلاحيته. إذا خرج مستخدم من حسابه أو تعرّض للاختراق، يظل الرمز يعمل حتى انتهاء الصلاحية. الحلول القياسية — رموز قصيرة العمر (5-15 دقيقة) مع رموز تحديث، أو قائمة حظر للرموز — مُغطّاة في دروس لاحقة.
  • حجم الرمز يكبر مع المطالبات. معرّف الجلسة سلسلة صغيرة جدًا؛ أما JWT يحمل الأدوار والصلاحيات وبيانات المستخدم الوصفية فهو مئات البايتات تُرسَل مع كل طلب. هذا يهمّ عند التوسع أو على الشبكات المحدودة.
  • إدارة المفاتيح السرية تصبح بالغة الأهمية. أي شخص يمتلك مفتاح التوقيع يمكنه تزوير رموز. تناوب المفاتيح والتخزين الآمن (وليس في كود المصدر) واختيار الخوارزمية — هذه مسؤوليتك الآن.
مبدأ التصميم: اجعل رموز الوصول قصيرة العمر (15 دقيقة أو أقل). هذا يحدّ من حجم الضرر الناتج عن رمز مسروق دون الحاجة إلى فحص إلغاء مركزي في كل طلب. ستُطبّق هذا النمط مع رموز التحديث في الدرس السابع.

سياسة الجلسة عديمة الحالة في Spring Security

بشكل افتراضي يُنشئ Spring Security HttpSession ويخزّن فيه SecurityContext. لواجهة REST API عليك تعطيل هذا صراحةً. الإعداد التالي هو نقطة البداية لكل API عديمة الحالة ستبنيها في هذا البرنامج التعليمي:

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // تعطيل CSRF — واجهات API عديمة الحالة لا تكون عرضة لـ CSRF // لأنه لا توجد كوكيز جلسة يمكن للمتصفح إرفاقها تلقائيًا .csrf(csrf -> csrf.disable()) // إخبار Spring Security بعدم إنشاء HttpSession أو استخدامها .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ); return http.build(); } }

السطر الأساسي هو SessionCreationPolicy.STATELESS. بهذا الإعداد لن يكتب Spring Security كوكيز JSESSIONID أبدًا ولن يبحث عنها. يجب ملء SecurityContext في كل طلب من الرمز — وهو بالضبط ما يفعله مُرشّح JWT الذي ستبنيه في الدرس الرابع.

لماذا تُعطَّل حماية CSRF لواجهة API عديمة الحالة؟ تعمل هجمات CSRF باستغلال الإرفاق التلقائي للكوكيز من قِبَل المتصفح. إذا كانت واجهة API الخاصة بك لا تستخدم كوكيز الجلسة، فلا توجد كوكيز لاختطافها. إبقاء حماية CSRF مُفعَّلة على واجهة JWT خالصة يُضيف عبئًا دون أن يُضيف أمانًا.

الخلاصة

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