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

تأمين نقاط نهاية REST باستخدام JWT

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

تأمين نقاط نهاية REST باستخدام JWT

أنتجت الدروس السابقة لبنتَين أساسيتَين: JwtUtil التي تُنشئ الرموز وتتحقق منها، وJwtAuthenticationFilter التي تقرأ الرمز من كل طلب وارد وتُحمّل كائن Authentication المناسب في SecurityContext. يربط هذا الدرس كل شيء معًا بتهيئة Spring Security لتطبيق تلك الهويات — تحديد أيّ نقاط النهاية عامة، وأيّها تتطلب JWT صالحًا، وأيّها تشترط دورًا محددًا.

حبة SecurityFilterChain

استبدل Spring Security 6 الصنف الفرعي القديم WebSecurityConfigurerAdapter بـ @Bean بسيط من نوع SecurityFilterChain. تُعلن القواعد مرة واحدة في فئة @Configuration ويُسجّل Spring سلسلة الفلاتر الخاصة بك إلى جانب افتراضياته.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @EnableMethodSecurity // يُفعّل @PreAuthorize على دوال التحكم public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; public SecurityConfig(JwtAuthenticationFilter jwtFilter) { this.jwtFilter = jwtFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. تعطيل CSRF — غير مطلوب لواجهات برمجة JWT عديمة الحالة .csrf(csrf -> csrf.disable()) // 2. جلسة عديمة الحالة — يجب ألّا يُنشئ Spring أي HttpSession .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 3. قواعد التفويض — الأنماط الأكثر تحديدًا أولًا .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) // 4. تعطيل تسجيل الدخول عبر النموذج والمصادقة الأساسية .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) // 5. إدراج فلتر JWT قبل فلتر اسم المستخدم/كلمة المرور المعتاد .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
لماذا تهمّ سياسة SessionCreationPolicy.STATELESS: بدون هذه السياسة قد يُنشئ Spring Security HttpSession ويخزّن فيها SecurityContext، مما يُبطل مزية JWT عديمة الحالة — إذ سيبدأ الخادم في تراكم حالة الجلسة وستتعارض الحالات الموزّعة خلف موازن الحِمل حول هوية المستخدم المصادَق. تُخبر STATELESS Spring بعدم لمس مخزن الجلسة أبدًا.

كيف يعمل ترتيب الفلاتر

يبني Spring Security أمانه كسلسلة من فلاتر الـ servlet. عند وصول الطلب تُنفَّذ الفلاتر بالترتيب. تضمن addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) تشغيل فلتر JWT أولًا — إذ يملأ SecurityContextHolder — حتى يجد فلتر التفويض المدمج لاحقًا Authentication صالحًا في السياق.

إذا وضعت فلتر JWT بعد فلتر التفويض، ستُرفض الطلبات المصادَقة لأن السياق فارغ وقت الفحص. الترتيب مهمّ.

قواعد التفويض — نصائح مطابقة الأنماط

يُقيّم Spring Security قواعد requestMatchers بالترتيب الذي تُعلن فيه، ويتوقف عند أول تطابق. ضع ذلك في الاعتبار:

  • أعلن المسارات العامة أولًا، ثم القواعد الأكثر تقييدًا تدريجيًا، وأنهِ بـ anyRequest().authenticated() كقاعدة شاملة.
  • استخدم متغيرات HttpMethod عندما يكون نفس مسار URL قابلًا للقراءة علنًا لكن محميًا عند التعديل. مثلًا، GET /api/products/** يمكن أن يكون مفتوحًا بينما يستلزم POST /api/products/** مصادقة.
  • hasRole("ADMIN") اختصار لـ hasAuthority("ROLE_ADMIN"). يُضيف Spring البادئة ROLE_ تلقائيًا عند استخدام hasRole.
// مثال دقيق — اشتراط المصادقة على POST دون GET .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/products/**").authenticated() .requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN") .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated())

الأمان على مستوى الدوال مع @PreAuthorize

يُفعّل @EnableMethodSecurity (المضاف على مستوى الفئة أعلاه) تعليقات Spring Expression Language (SpEL) مباشرةً على دوال التحكم أو الخدمة. هذا مكمّل قوي لقواعد URL — تستطيع التعبير عن شروط عمل محددة لا تستطيع أنماط URL وحدها التقاطها.

import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/orders") public class OrderController { // أي مستخدم مصادَق يمكنه سرد طلباته الخاصة @GetMapping @PreAuthorize("isAuthenticated()") public List<Order> myOrders(Authentication auth) { return orderService.findByUsername(auth.getName()); } // المدراء فقط يمكنهم عرض جميع الطلبات @GetMapping("/all") @PreAuthorize("hasRole('ADMIN')") public List<Order> allOrders() { return orderService.findAll(); } // مالك الطلب أو المدير فقط يمكنه الإلغاء @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication)") public void cancel(@PathVariable Long id) { orderService.cancel(id); } }

يُفوّض التعبير @orderSecurity.isOwner(#id, authentication) إلى حبة Spring باسم orderSecurity — وهي مجرد @Component بسيط يحتوي على دالة isOwner(Long id, Authentication auth). يُبقي ذلك تفويض منطق الأعمال بعيدًا عن قواعد URL العامة.

إعادة أخطاء HTTP الصحيحة للطلبات غير المصادَقة والمحظورة

بشكل افتراضي يُعيد Spring Security التوجيه إلى صفحة تسجيل دخول عند فشل المصادقة — وهو أمر غير مجدٍ لواجهة REST. تحتاج إلى تهيئة نقاط دخول مخصصة تُعيد JSON بأكواد حالة HTTP الصحيحة.

// أضف داخل http.exceptionHandling(...) في SecurityConfig.filterChain() .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 response.setContentType("application/json"); response.getWriter().write( "{\"error\":\"Unauthorized\",\"message\":\"" + authException.getMessage() + "\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 response.setContentType("application/json"); response.getWriter().write( "{\"error\":\"Forbidden\",\"message\":\"Insufficient privileges\"}"); }))
401 مقابل 403 — احرص على الفرق. HTTP 401 (Unauthorized) يعني أن الطلب لا يحمل هوية صالحة — يجب على العميل المصادقة ثم المحاولة مجددًا. أما HTTP 403 (Forbidden) فيعني أن الهوية معروفة لكنها تفتقر إلى الإذن. إعادة 403 للطلبات غير المصادَقة يُفشي وجود المورد؛ يفضّل كثير من فرق الأمان إعادة 404 في تلك الحالة.

تهيئة CORS لعملاء الواجهة الأمامية

عندما تعمل واجهتك الأمامية (React أو Angular) على أصل مختلف (مثل http://localhost:3000) عن الواجهة البرمجية (مثل http://localhost:8080)، تحجب المتصفحات الطلب إلا إذا أرسل الخادم ترويسات CORS الصحيحة. هيّئ CORS داخل سلسلة Spring Security حتى تُضاف الترويسات حتى للطلبات المرفوضة قبل الوصول إلى وحدات التحكم.

import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; // داخل SecurityConfig: @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOrigins(List.of("http://localhost:3000", "https://myapp.com")); cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); cfg.setAllowedHeaders(List.of("Authorization","Content-Type")); cfg.setAllowCredentials(false); // true فقط عند استخدام الكوكيز وليس JWT Bearer cfg.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return source; } // ثم في filterChain(): http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
لا تستخدم allowedOrigins("*") مع allowCredentials(true) أبدًا. تحظر المتصفحات هذا التوليف لسبب وجيه — إذ سيسمح لأي أصل بإرسال طلبات عابرة للأصول ببيانات اعتماد إلى واجهتك البرمجية. إذا احتجت إلى بيانات الاعتماد فحدّد الأصول المسموح بها صراحةً.

تجميع كل شيء — تتبّع مسار الطلب

تتبّع طلب POST /api/orders يحمل رمز JWT صالحًا عبر السلسلة الكاملة:

  1. يدخل الطلب حاوية الـ servlet ويبدأ اجتياز SecurityFilterChain.
  2. يستخرج JwtAuthenticationFilter ترويسة Authorization: Bearer <token>، يتحقق من JWT، يستدعي UserDetailsService.loadUserByUsername()، يبني UsernamePasswordAuthenticationToken ويخزّنه في SecurityContextHolder.
  3. يُقيّم AuthorizationFilter المدمج قاعدة anyRequest().authenticated() — السياق مُعبَّأ، فيمرّ الطلب.
  4. يُوجّه DispatcherServlet إلى OrderController.createOrder()، حيث يُتحقق من @PreAuthorize("isAuthenticated()") على مستوى الدالة — يمرّ أيضًا.
  5. تُنفَّذ منطق وحدة التحكم ويُعاد استجابة 201.

إذا كان الرمز مفقودًا أو منتهي الصلاحية، لا يُعيّن الخطوة 2 أي مصادقة، وتُطلق الخطوة 3 authenticationEntryPoint، وتُعاد استجابة JSON 401 فورًا — دون بلوغ وحدة التحكم.

الخلاصة

تتطلب تهيئة أمان JWT للإنتاج أربعة عناصر تعمل معًا: حبة SecurityFilterChain تُعلن قواعد URL-level وتُعطّل الجلسات؛ فلتر JWT مُدرَج قبل فلتر اسم المستخدم/كلمة المرور؛ تعليقات @PreAuthorize على مستوى الدوال لقواعد الأعمال الدقيقة؛ ونقاط دخول مخصصة تُعيد أخطاء JSON قابلة للقراءة آليًا. أضف تهيئة CORS هنا — في طبقة الأمان — حتى تشمل جميع المسارات بما فيها الطلبات المرفوضة. في الدرس التالي ستوسّع هذا الأساس ليشمل الأدوار والصلاحيات المحمولة داخل مطالبات JWT نفسها.