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

فلتر مصادقة JWT

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

فلتر مصادقة JWT

في تطبيق Spring Security عديم الحالة (stateless)، يجب أن يحمل كل طلب HTTP وارد دليلًا على هوية المرسل — إذ لا توجد جلسة على جانب الخادم يمكن البحث فيها. المكوّن المسؤول عن فحص هذا الدليل في كل طلب هو فلتر servlet. في هذا الدرس ستبني JwtAuthenticationFilter بجودة إنتاجية يجلس في مرحلة مبكرة من سلسلة فلاتر Spring Security، يستخرج التوكن من ترويسة Authorization، يتحقق منه، ثم يملأ SecurityContext الخاص بـ Spring حتى يتصرف باقي الإطار كما لو كان المستخدم موثّقًا عبر جلسة.

موقع الفلتر داخل السلسلة

تعالج Spring Security كل طلب عبر سلسلة مرتّبة من الفلاتر قبل أن يصل إلى أي متحكم. بتوسيع OncePerRequestFilter — وهي فئة مساعدة في Spring تضمن تنفيذًا واحدًا لكل طلب حتى عند إعادة التوجيه الداخلي — يندمج فلترك بسلاسة مع تلك السلسلة. ثم تخبر Spring Security بتشغيله قبل UsernamePasswordAuthenticationFilter حتى تُحلَّ بيانات الاعتماد قبل أن يحاول أي فلتر لاحق إعادة التوجيه إلى صفحة تسجيل دخول.

لماذا OncePerRequestFilter وليس Filter عاديًا؟ يمكن استدعاء javax.servlet.Filter الخام أكثر من مرة في تبادل HTTP واحد إذا أُعيد توجيه الطلب داخليًا (كأخطاء الإرسال). يستخدم OncePerRequestFilter علامة مرتبطة بنطاق الطلب لمنع ذلك، وهو ما يهم أمنيًا: لا تريد تشغيل منطق المصادقة مرتين بحالة قد تكون غير متسقة.

استخراج التوكن من الطلب

تحدد مواصفة OAuth 2.0 Bearer Token (RFC 6750) كيفية إرسال التوكن: في ترويسة HTTP باسم Authorization بقيمة Bearer <token>. يجب على فلترك قراءة تلك الترويسة وإزالة البادئة.

private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); // إزالة "Bearer " } return null; }

إعادة null هنا مقصودة: كثير من الطلبات (الأصول الثابتة، نقاط النهاية العامة) لن تحمل توكنًا، ويجب على الفلتر ببساطة السماح بمرورها دون خطأ. المصادقة تُحاوَل؛ تكوين الأمان هو من يقرر ما إذا كان التوكن المفقود يمثّل مشكلة.

التنفيذ الكامل للفلتر

إليك الفلتر كاملًا. اقرأه من البداية إلى النهاية؛ كل قرار تصميمي موضّح أدناه.

package com.example.security; import com.example.service.JwtService; import com.example.service.UserDetailsServiceImpl; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsServiceImpl userDetailsService; public JwtAuthenticationFilter(JwtService jwtService, UserDetailsServiceImpl userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { // 1. استخرج سلسلة التوكن الخام String token = extractToken(request); // 2. إذا لم يوجد توكن، تجاهل واتركه للسلسلة if (token == null) { filterChain.doFilter(request, response); return; } // 3. تجنّب إعادة مصادقة طلب موثّق مسبقًا if (SecurityContextHolder.getContext().getAuthentication() != null) { filterChain.doFilter(request, response); return; } // 4. استخرج الموضوع (اسم المستخدم / المعرّف) من التوكن String username = jwtService.extractUsername(token); if (username != null) { // 5. حمّل UserDetails الكاملة من قاعدة البيانات UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 6. تحقق من التوكن مقابل UserDetails if (jwtService.isTokenValid(token, userDetails)) { // 7. أنشئ كائن Authentication واملأ SecurityContext UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, // بيانات الاعتماد — null لـ JWT userDetails.getAuthorities() ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authToken); } } // 8. تابع السلسلة دائمًا filterChain.doFilter(request, response); } private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } return null; } }

شرح كل خطوة

الخطوتان 1–2 — الاستخراج أو المرور. إذا لم تكن ترويسة Authorization: Bearer … موجودة، لا يفعل الفلتر شيئًا ويستدعي filterChain.doFilter(). ستفرض قواعد الأمان اللاحقة (في SecurityFilterChain) المصادقة حيث يلزم.

الخطوة 3 — فحص التكرار. إذا كان SecurityContext يحتوي بالفعل على كائن مصادقة — مثلًا لأن فلترًا آخر نفّذ أولًا — فلا شيء يجب فعله. تخطّي هذا الفحص سيؤدي إلى استبدال مصادقة صالحة دون داعٍ.

الخطوة 4 — استخراج الموضوع. تُحلّل jwtService.extractUsername() رمز JWT وتُعيد claim اسمه sub. هذه العملية لا تثبت بعد صحة التوكن؛ إنها تقرأه فقط.

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

الخطوة 6 — التحقق. تفحص jwtService.isTokenValid() التوقيع وانتهاء الصلاحية، وتتأكد أن اسم المستخدم في التوكن يطابق الـ UserDetails المحمّلة. فقط حين تجتاز جميع الفحوصات تتقدم عملية المصادقة.

الخطوة 7 — ملء SecurityContext. يُنشأ UsernamePasswordAuthenticationToken بثلاث وسائط: المبدأ (UserDetails)، بيانات الاعتماد (null — لا توجد كلمة مرور تُحمل في هذه المرحلة)، والصلاحيات. تمرير مجموعة الصلاحيات هو ما يخبر Spring Security بأن المستخدم موثّق؛ المُنشئ بدون وسائط ينشئ توكن غير موثّق.

قم دائمًا بضبط WebAuthenticationDetailsSource. إرفاق تفاصيل الطلب (عنوان IP، معرف الجلسة) بكائن المصادقة يتيح تسجيل التدقيق ويُستخدم من قِبل بعض مستمعي أحداث الأمان. لا يكلف شيئًا ويوفر سياقًا قيّمًا.

الخطوة 8 — تابع دائمًا. الفلتر لا يقطع السلسلة بإرسال 401 مباشرة (إلا بالإغفال — إذا فشل التحقق لا يُضبط أي كائن مصادقة، وتتولى AuthenticationEntryPoint في Spring Security معالجة استجابة 401 لاحقًا). هذا يحافظ على منطق معالجة الأخطاء في مكان واحد.

تسجيل الفلتر في تكوين الأمان

في Spring Security 6 تسجّل الفلتر داخل حبّة SecurityFilterChain باستخدام addFilterBefore:

@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // لا CSRF لواجهات API عديمة الحالة .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
عطّل CSRF لواجهات API عديمة الحالة — لكن افهم السبب. تستغل هجمات CSRF سلوك المتصفح في إرسال ملفات تعريف الارتباط تلقائيًا. نظرًا لأن JWT يُرسل في ترويسة Authorization (وليس في كوكي)، لن يرسله المتصفح تلقائيًا عبر مواقع أخرى، لذا لا يشكّل CSRF تهديدًا هنا. إن تحولت يومًا إلى تخزين JWT في الكوكيز، يجب إعادة تفعيل حماية CSRF.

التداعيات الأمنية والمقايضات في الأنظمة الموزّعة

البحث في قاعدة البيانات في الخطوة 5 هو مقايضة متعمدة. التحقق البحت من JWT عديم الحالة سيتخطاها، لكن حينها سيظل توكن مستخدم محذوف أو معطّل صالحًا حتى انتهاء صلاحيته. تحميل UserDetails في كل طلب يعالج تلك الحالة بتكلفة استعلام قاعدة بيانات إضافي لكل استدعاء. خفّف هذه التكلفة بذاكرة تخزين مؤقت قصيرة العمر في الذاكرة أو في Redis مُفهرسة باسم المستخدم.

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

الخلاصة

إن JwtAuthenticationFilter هو الجسر بين طلب HTTP الخام ونموذج مصادقة Spring Security. يستخرج التوكن، يتحقق منه عبر JwtService، يحمّل المستخدم من قاعدة البيانات، ويملأ SecurityContext — كل ذلك في تمريرة واحدة لا تتكرر ولا تُنتج آثارًا جانبية. سجّله قبل UsernamePasswordAuthenticationFilter مع سياسة جلسة STATELESS وستتولى Spring Security الباقي: قواعد التفويض واستجابات 401 وأمان مستوى الأسلوب تعمل جميعها دون أي تغييرات إضافية.