أساسيات Spring Security

مشروع: تأمين تطبيق ويب

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

مشروع: تأمين تطبيق ويب

طوال هذا البرنامج التعليمي درست كل ركيزة من ركائز Spring Security بمعزل عن غيرها: سلسلة الفلاتر، وUserDetailsService، وتشفير كلمات المرور، وتسجيل الدخول بنموذج، وHTTP Basic، وقواعد التفويض، وأمان مستوى التوابع. الآن ستربط كل هذه العناصر معًا في تطبيق Spring Boot 3 واحد واقعي يصلح للإنتاج. الهدف ليس مجرد جعل الأشياء تعمل، بل فهم السبب وراء كل قرار وما هي الآثار الأمنية عند الانحراف عنه.

نظرة عامة على المشروع

التطبيق النموذجي هو واجهة برمجية لإدارة المهام مع واجهة أمامية صغيرة بـ Thymeleaf. يعرض:

  • GET / — صفحة ترحيب عامة
  • GET /tasks — عرض مهام المستخدم المصادَق (ROLE_USER أو ROLE_ADMIN)
  • POST /tasks — إنشاء مهمة (ROLE_USER أو ROLE_ADMIN)
  • DELETE /tasks/{id} — حذف أي مهمة (ROLE_ADMIN فقط)
  • GET /admin/dashboard — صفحة إحصاءات المدير (ROLE_ADMIN فقط)
  • GET /api/tasks — نقطة نهاية REST تعيد JSON (عديمة الحالة، HTTP Basic)

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

الخطوة 1 — التبعيات وإعداد قاعدة البيانات

ابدأ من مشروع Spring Initializr يحتوي على: spring-boot-starter-security، وspring-boot-starter-web، وspring-boot-starter-thymeleaf، وthymeleaf-extras-springsecurity6، وspring-boot-starter-data-jpa، وقاعدة بيانات H2 في الذاكرة للتطوير.

عرّف كيان AppUser الذي سيُشغّل UserDetailsService:

@Entity @Table(name = "app_users") public class AppUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String username; @Column(nullable = false) private String password; // مشفّر بـ BCrypt @Column(nullable = false) private String roles; // مفصولة بفواصل: "ROLE_USER" أو "ROLE_USER,ROLE_ADMIN" // الجالبون والمحددون محذوفون للاختصار }
لماذا نخزّن الأدوار كسلسلة نصية عادية هنا؟ يُطبّع التطبيق الإنتاجي الأدوار في جدول منفصل. السلسلة المسطّحة مستخدمة هنا للتركيز على ربط Spring Security لا على تصميم مخطط JPA — وهو موضوع يندرج في درس Spring Data.

الخطوة 2 — تنفيذ UserDetailsService

أنشئ خدمة تحمّل المستخدمين من قاعدة البيانات وتكيّفهم مع عقد UserDetails الذي يتوقعه Spring Security:

@Service @RequiredArgsConstructor public class AppUserDetailsService implements UserDetailsService { private final AppUserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { AppUser user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException( "No user found: " + username)); String[] roles = user.getRoles().split(","); return User.withUsername(user.getUsername()) .password(user.getPassword()) .roles(roles) // يحذف البادئة "ROLE_" تلقائيًا .build(); } }
لا تكشف أبدًا سبب فشل تسجيل الدخول في رسائل الخطأ. إعادة "كلمة المرور خاطئة" مقابل "المستخدم غير موجود" ثغرة تعداد — يستطيع المهاجمون تحديد أسماء المستخدمين الموجودة. اعرض دائمًا رسالة عامة واحدة مثل "بيانات الاعتماد غير صحيحة."

الخطوة 3 — تشفير كلمات المرور وتهيئة البيانات

أعلن عن حبّة PasswordEncoder (يتطلبها DaoAuthenticationProvider) وأضف مستخدمَين للتطوير المحلي:

@Configuration public class SecurityBeans { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); // معامل التكلفة 12 } } @Component @RequiredArgsConstructor public class DataInitializer implements ApplicationRunner { private final AppUserRepository repo; private final PasswordEncoder encoder; @Override public void run(ApplicationArguments args) { if (repo.count() == 0) { repo.save(new AppUser(null, "alice", encoder.encode("secret123"), "ROLE_USER")); repo.save(new AppUser(null, "bob", encoder.encode("admin456"), "ROLE_USER,ROLE_ADMIN")); } } }

الخطوة 4 — إعداد الأمان

هذا هو محور التطبيق. تتولى حبّتا SecurityFilterChain التعامل مع سطحَي أمان مختلفَين — واجهة REST عديمة الحالة وواجهة ويب تعتمد الجلسة — بقواعد وآليات مصادقة منفصلة:

@Configuration @EnableWebSecurity @EnableMethodSecurity // يفعّل @PreAuthorize / @PostAuthorize public class SecurityConfig { private final AppUserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; public SecurityConfig(AppUserDetailsService uds, PasswordEncoder pe) { this.userDetailsService = uds; this.passwordEncoder = pe; } // ── السلسلة 1: REST API (عديمة الحالة، HTTP Basic) ────────────────── @Bean @Order(1) public SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()); // لا عملاء متصفح على /api return http.build(); } // ── السلسلة 2: واجهة الويب (تعتمد الجلسة، تسجيل دخول بنموذج) ─────── @Bean @Order(2) public SecurityFilterChain webChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/css/**", "/js/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/tasks", true) .permitAll()) .logout(logout -> logout .logoutSuccessUrl("/") .invalidateHttpSession(true) .deleteCookies("JSESSIONID")) .sessionManagement(sm -> sm .sessionFixation().migrateSession() // منع تثبيت الجلسة .maximumSessions(1)); // جلسة نشطة واحدة لكل مستخدم return http.build(); } @Bean public DaoAuthenticationProvider authProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } }
الترتيب مهم. السلسلة الأولى مُصنَّفة بـ @Order(1) لتُقيَّم أولًا. يحصر securityMatcher("/api/**") نطاقها في مسارات تلك البادئة. أي طلب لا يطابق /api/** يمرّ إلى سلسلة الويب. بدون securityMatcher ستعترض السلسلة الأولى كل شيء وتصبح الثانية غير قابلة للوصول.

الخطوة 5 — أمان مستوى التوابع في طبقة الخدمة

بدلًا من تكرار أنماط URL في كل متحكم، فرّض الملكية على حدود الخدمة باستخدام @PreAuthorize:

@Service @RequiredArgsConstructor public class TaskService { private final TaskRepository taskRepository; public List<Task> getTasksForCurrentUser(String username) { return taskRepository.findByOwnerUsername(username); } @PreAuthorize("hasRole('ADMIN')") public void deleteTask(Long taskId) { taskRepository.deleteById(taskId); } @PostAuthorize("returnObject.ownerUsername == authentication.name" + " or hasRole('ADMIN')") public Task getById(Long id) { return taskRepository.findById(id) .orElseThrow(EntityNotFoundException::new); } }

يُوضّح @PostAuthorize على getById نمطًا قويًا: جلب الكيان أولًا ثم التحقق من أن المستدعي هو مالكه أو مدير. هذا أكثر أمانًا من الفحص المسبق للمعرّفات، لأن فحص الملكية يجري على بيانات مُستمرة حقيقية لا على معرّف يُقدّمه المستخدم.

الخطوة 6 — حماية CSRF لواجهة الويب

يُفعّل Spring Security حماية CSRF افتراضيًا لسلسلة الويب. تكتسبها نماذج Thymeleaf تلقائيًا عبر السمة th:action التي تحقن رمز الحماية المخفي:

<!-- نموذج Thymeleaf — رمز CSRF يُحقَن تلقائيًا --> <form th:action="@{/tasks}" method="post"> <input type="text" name="title" placeholder="مهمة جديدة" /> <button type="submit">إضافة</button> </form>
تعطيل CSRF على سلسلة الويب خطأ جسيم. يعرّض كل نقطة نهاية تُغيّر الحالة لهجمات تزوير الطلبات عبر المواقع. عطّله فقط لنقاط النهاية عديمة الحالة (مثل /api/**) التي تُحمى برموز لا بملفات تعريف الارتباط.

الخطوة 7 — تكامل Thymeleaf مع الأمان

استخدم مساحة الاسم sec: من thymeleaf-extras-springsecurity6 لعرض عناصر واجهة المستخدم بشكل مشروط بناءً على الأدوار — حتى يظهر رابط "لوحة التحكم" فقط للمدراء:

<!-- يظهر للمستخدمين المصادَقين فقط --> <span sec:authentication="name"></span> <!-- زر الحذف للمدراء فقط --> <form th:if="${#authorization.expression('hasRole(''ADMIN'')')}" th:action="@{/tasks/{id}(id=${task.id})}" method="post"> <input type="hidden" name="_method" value="DELETE" /> <button>حذف</button> </form>

ربط كل شيء — مراجعة قرارات الأمان

قبل شحن تطبيق مؤمَّن، تحقق من هذه القائمة:

  1. كلمات المرور: BCrypt بتكلفة ≥ 10. لا MD5 ولا SHA-1 ولا نص عادي.
  2. تثبيت الجلسة: migrateSession() تدوير معرف الجلسة عند تسجيل الدخول.
  3. الحد الأقصى للجلسات: تقييد الجلسات المتزامنة لمنع مشاركة بيانات الاعتماد.
  4. CSRF: مفعّل لعملاء المتصفح، معطّل فقط لمسارات API عديمة الحالة.
  5. HTTPS: أضف http.requiresChannel().anyRequest().requiresSecure() أو فرّضه على موازن الأحمال.
  6. رؤوس الأمان: يضيف Spring Security رؤوس X-Frame-Options وX-Content-Type-Options وCache-Control افتراضيًا — لا تعطّلها دون سبب.
  7. الحد الأدنى من الصلاحيات: ابدأ بـ denyAll() وافتح بشكل صريح، لا العكس.

الخلاصة

لقد بنيت تطبيق ويب مؤمَّنًا من البداية للنهاية: UserDetailsService مدعوم بقاعدة بيانات، وتشفير BCrypt لكلمات المرور، وسلسلتا فلتر أمان — إحداهما لواجهة ويب تعتمد الجلسة والأخرى لـ REST API عديمة الحالة — وتفويض على مستوى التوابع بـ @PreAuthorize و@PostAuthorize، وحماية CSRF، وعرض واجهة Thymeleaf بوعي بالأدوار. تدافع كل طبقة من هذه الطبقات عن سطح هجوم مختلف، وتشكّل معًا الوضع الأمني متعدد الطبقات (defence-in-depth) الذي تتطلبه تطبيقات الإنتاج. تنطبق هذه الأنماط مباشرةً على الخدمات المصغّرة أيضًا — الفرق الرئيسي أن السلسلة عديمة الحالة تصبح الافتراضية ويحلّ JWT محل ملف تعريف ارتباط الجلسة.