أساسيات Spring Security

المستخدمون وخدمة تفاصيلهم UserDetailsService

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

المستخدمون وخدمة تفاصيلهم UserDetailsService

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

العقود الأساسية

تحمل واجهتان تقريبًا كل الثقل:

  • UserDetailsService — واجهة بمنهج واحد. تُطبّق loadUserByUsername(String username) وتُعيد كائن UserDetails. يستدعيها Spring Security أثناء المصادقة.
  • UserDetails — تُمثّل الكيان الرئيسي (principal). تكشف عن كلمة المرور المُشفَّرة ومجموعة كائنات GrantedAuthority (الأدوار والصلاحيات) وأربعة أعلام منطقية: isEnabled وisAccountNonExpired وisAccountNonLocked وisCredentialsNonExpired.

إذا لم يكن اسم المستخدم موجودًا، يجب أن يرمي loadUserByUsername استثناء UsernameNotFoundException ولا يُعيد null أبدًا. إعادة null تتسبب في NullPointerException مدفون داخل الإطار — ارمِ الاستثناء دائمًا.

المستخدمون في الذاكرة — إعداد سريع للتطوير

أسرع طريقة لتهيئة المستخدمين هي توفير حبة InMemoryUserDetailsManager. هذا مناسب للعروض التوضيحية والاختبارات الآلية والتطوير المحلي — لكن ليس للإنتاج.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @Configuration public class InMemorySecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails alice = User.builder() .username("alice") .password(encoder.encode("aliceSecret")) .roles("USER") .build(); UserDetails admin = User.builder() .username("admin") .password(encoder.encode("adminSecret")) .roles("USER", "ADMIN") .build(); return new InMemoryUserDetailsManager(alice, admin); } }
لماذا نُشفّر كلمات المرور حتى في الذاكرة؟ يشترط Spring Security 6 أن تحمل كل كلمة مرور مخزّنة بادئة ترميز (مثل {bcrypt}$2a$...) أو أن تُمرَّر عبر حبة PasswordEncoder. إذا استخدمت User.withDefaultPasswordEncoder() ستظهر لك رسالة إهمال (deprecation warning) — هي للنماذج فحسب، ليس للكود الحقيقي. اربط دائمًا حبة PasswordEncoder صريحة.

مساعد roles("USER") هو اختصار نحوي: ينشئ كائن GrantedAuthority بالقيمة ROLE_USER. إذا احتجت أسماء صلاحيات لا تتبع اتفاقية البادئة ROLE_، استخدم .authorities("READ_REPORTS", "WRITE_REPORTS") بدلًا من ذلك.

تحميل المستخدمين من قاعدة البيانات — النمط الحقيقي

في تطبيق الإنتاج، يعيش مستخدموك في جدول قاعدة بيانات تديره JPA. تُطبّق UserDetailsService وتحقن مستودع Spring Data الخاص بك:

// 1 – كيان JPA الخاص بك @Entity @Table(name = "app_users") public class AppUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; // تجزئة BCrypt private boolean enabled; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "role") private Set<String> roles = new HashSet<>(); // getters / setters محذوفة للإيجاز } // 2 – مستودع Spring Data public interface AppUserRepository extends JpaRepository<AppUser, Long> { Optional<AppUser> findByUsername(String username); } // 3 – تطبيق UserDetailsService الخاص بك @Service public class DatabaseUserDetailsService implements UserDetailsService { private final AppUserRepository userRepo; public DatabaseUserDetailsService(AppUserRepository userRepo) { this.userRepo = userRepo; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { AppUser user = userRepo.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException( "User not found: " + username)); List<GrantedAuthority> authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.isEnabled(), true, true, true, // الحساب غير منتهٍ، بيانات الاعتماد غير منتهية، غير مقفل authorities); } }

يكتشف Spring Security تلقائيًا حبة UserDetailsService الوحيدة ويربطها بمزوّد المصادقة. لا تحتاج إلى أي تهيئة إضافية لتوصيل الاثنين.

احمِّل الأدوار بشكل فوري أو داخل المعاملة. يُستدعى loadUserByUsername داخل آلية المصادقة في Spring Security، وهي خارج أي جلسة Hibernate مفتوحة. إذا حمّلت الأدوار بشكل كسول ستصطدم بـ LazyInitializationException وقت التشغيل. ضع علامة FetchType.EAGER على مجموعة الأدوار، أو استدعِ Hibernate.initialize(user.getRoles()) بينما المعاملة لا تزال مفتوحة، أو استخدم استعلام JPQL مع JOIN FETCH.

دمج UserDetailsService مع SecurityFilterChain

تربط UserDetailsService المخصّصة بتهيئة الأمان بحقنها وبناء AuthenticationProvider:

@Configuration @EnableWebSecurity public class SecurityConfig { private final DatabaseUserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; public SecurityConfig(DatabaseUserDetailsService uds, PasswordEncoder pe) { this.userDetailsService = uds; this.passwordEncoder = pe; } @Bean public AuthenticationProvider authProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authenticationProvider(authProvider()) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); } }

DaoAuthenticationProvider هو التطبيق القياسي الذي يستدعي loadUserByUsername، ثم يتحقق من كلمة المرور المُدخَلة مقابل التجزئة المخزّنة باستخدام PasswordEncoder الخاص بك. كما يتحقق من الأعلام الأربعة في UserDetails قبل منح الوصول — هنا تُطبّق قفل الحساب والانتهاء وتعطيله دون كتابة أي منطق مصادقة بنفسك.

أعلام UserDetails ولماذا تهم

  • isEnabled() — تُعيد false لمنع تسجيل الدخول للحسابات المحذوفة ناعمًا أو غير الموثّقة.
  • isAccountNonLocked() — تُعيد false بعد عدد كبير من محاولات تسجيل الدخول الفاشلة.
  • isAccountNonExpired() — تُعيد false للحسابات التي انتهى اشتراكها.
  • isCredentialsNonExpired() — تُعيد false لإجبار المستخدم على تغيير كلمة المرور بعد فترة محددة.

كل علم يرمي فئة فرعية مختلفة من AuthenticationException، لذا يمكن لكود معالجة الأخطاء التمييز بين حساب مقفل وكلمة مرور منتهية وعرض رسالة مناسبة للمستخدم.

لا تكشف أبدًا عن معلومات وجود المستخدم. عندما لا يُوجد اسم مستخدم، ارمِ UsernameNotFoundException برسالة عامة ("بيانات اعتماد خاطئة" لا "المستخدم alice غير موجود"). يُخفي DaoAuthenticationProvider في Spring Security استثناء UsernameNotFoundException افتراضيًا (يُعيد رميه كـ BadCredentialsException عام) لمنع هجمات تعداد المستخدمين. لا تُعطّل هذا السلوك.

اعتبار الأنظمة الموزّعة: الخدمات عديمة الحالة

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

الخلاصة

UserDetailsService هي نقطة التوسّع الوحيدة التي يمنحك إياها Spring Security لتحميل بيانات المستخدم. طبّقها لدمج أي مخزن: InMemoryUserDetailsManager للاختبارات، وخدمة مدعومة بـ JPA للإنتاج. أعِد كائن UserDetails مُعبَّأ بصحة — بكلمة مرور مُشفَّرة وصلاحيات وقيم أعلام دقيقة — واربطه بـ DaoAuthenticationProvider. من تلك النقطة، يتولّى الإطار التحقق من كلمة المرور وفحص الأعلام وتوجيه استثناءات المصادقة نيابةً عنك.