أساسيات Spring Security

ترميز كلمات المرور

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

ترميز كلمات المرور

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

عقد PasswordEncoder

PasswordEncoder واجهة بسيطة في الحزمة org.springframework.security.crypto.password تحتوي على ثلاث توابع:

public interface PasswordEncoder { String encode(CharSequence rawPassword); boolean matches(CharSequence rawPassword, String encodedPassword); default boolean upgradeEncoding(String encodedPassword) { return false; } }
  • encode() — تُجري تجزئة (hash) لكلمة المرور الخام. استدعِها مرةً واحدة فقط: عند تسجيل المستخدم أو تغيير كلمة مروره. لا تستدعِها مجددًا للقيمة ذاتها.
  • matches() — تأخذ كلمة المرور الخام من محاولة تسجيل الدخول والتجزئة المخزّنة، وتعيد true إذا تطابقتا. هكذا تتحقق Spring Security من بيانات الاعتماد.
  • upgradeEncoding() — تُشير إلى ما إذا كان ينبغي إعادة ترميز التجزئة المخزّنة بالخوارزمية الحالية (تستخدمها DelegatingPasswordEncoder أثناء عمليات الترحيل المرحلية).
المُرمِّز عديم الحالة وآمن للخيوط المتعددة. أعلنه كـ bean لـ Spring (نطاق المفرد singleton) وحقنه في أي مكان تحتاج فيه إلى تجزئة كلمات المرور. لا داعي لإنشاء نسخ متعددة منه.

لماذا يُعدّ BCrypt الخيار الافتراضي

تأتي Spring Security مع عدة تنفيذات — MD5 وSHA-256 وPBKDF2 وSCrypt وArgon2 وBCrypt — لكن BCryptPasswordEncoder يظل الخيار العملي الافتراضي لمعظم التطبيقات لأن:

  • ملح مدمج: يُولّد BCrypt ملحًا عشوائيًا مؤلفًا من 16 بايت لكل استدعاء لـ encode() ويضمّنه في سلسلة الإخراج. استدعاءان بكلمة المرور الخام ذاتها يُنتجان تجزئتين مختلفتين — لا حاجة لإدارة الملوح بنفسك، وجداول قوس قزح عديمة الفائدة.
  • عامل عمل قابل للضبط: معامل الشدة (القيمة الافتراضية 10) أسّي: تُجري الخوارزمية 2strength تكرارًا. زيادته بمقدار 1 تضاعف الوقت. كلما سرّعت الأجهزة يمكنك رفع العامل وإعادة التجزئة عند تسجيل الدخول التالي.
  • إخراج ثابت الطول: ينتج BCrypt دائمًا سلسلة من 60 حرفًا بصرف النظر عن طول المدخل — يكفي تخزينها في عمود من النوع VARCHAR(60).
  • مقارنة بوقت ثابت: لا تُوقف matches() عملها عند أول بايت مختلف، مما يمنع هجمات التوقيت.

تسجيل BCryptPasswordEncoder كـ Bean

أعلن المُرمِّز في فئة تهيئة — عادةً إلى جانب bean الـ SecurityFilterChain:

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { // الشدة 12 ≈ 250 مللي ثانية على خادم حديث — توازن جيد لعام 2024+ return new BCryptPasswordEncoder(12); } // ... beans الـ SecurityFilterChain و UserDetailsService }
اختيار الشدة المناسبة: قم بالاختبار المعياري على أجهزتك الإنتاجية باستخدام BCryptPasswordEncoder.upgradeEncoding() أو حلقة بسيطة. استهدف من 150 إلى 300 مللي ثانية لكل تجزئة — بطيء بما يكفي لإحباط هجمات القوة الغاشمة، وسريع بما يكفي لعدم التأثير على تجربة المستخدم. كانت الشدة 10 كافية عام 2010؛ 12 هو الحد الأدنى المعقول اليوم.

الترميز عند التسجيل

حقن PasswordEncoder في الخدمة التي تُنشئ المستخدمين، ورمّز كلمة المرور قبل الحفظ:

import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class UserService { private final UserRepository userRepo; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepo, PasswordEncoder passwordEncoder) { this.userRepo = userRepo; this.passwordEncoder = passwordEncoder; } public User registerUser(String username, String rawPassword) { if (userRepo.existsByUsername(username)) { throw new UserAlreadyExistsException("Username taken: " + username); } User user = new User(); user.setUsername(username); user.setPassword(passwordEncoder.encode(rawPassword)); // <-- الترميز هنا user.setRole("ROLE_USER"); return userRepo.save(user); } }

بعد save() تخرج كلمة المرور الخام من النطاق. لا يُحفظ إلا تجزئة BCrypt. إذا كانت خدمتك تتلقى كلمة المرور الخام عبر كائن DTO، فأفرغه أو تخلَّص منه بعد عملية الترميز.

كيف تستخدم Spring Security المُرمِّز عند تسجيل الدخول

حين يُرسل المستخدم بيانات تسجيل الدخول، يستدعي DaoAuthenticationProvider في Spring Security التابع userDetailsService.loadUserByUsername() لجلب التجزئة المخزّنة، ثم يستدعي passwordEncoder.matches(submittedPassword, storedHash). لا تكتب هذا المنطق بنفسك — يتولّى الموفّر ذلك تلقائيًا طالما أن bean الـ PasswordEncoder وbean الـ UserDetailsService مسجّلان في سياق التطبيق ذاته.

// تفعل Spring Security هذا داخليًا — لا تستدعي matches() بنفسك: boolean ok = passwordEncoder.matches(rawPasswordFromForm, user.getPassword()); if (!ok) throw new BadCredentialsException("Invalid credentials");
لا تستدعِ encode() مرةً ثانية عند تسجيل الدخول. تُولّد encode() ملحًا عشوائيًا جديدًا في كل استدعاء، لذا ستُعيد مقارنة نسختين مُرمَّزتين من كلمة المرور ذاتها false في الغالب. استخدم دائمًا matches(rawInput, storedHash) للتحقق.

DelegatingPasswordEncoder — ضمان توافق المستقبل لتجزئاتك

تحتاج التطبيقات الحقيقية في نهاية المطاف إلى الانتقال من خوارزمية إلى أخرى دون إجبار جميع المستخدمين على إعادة تعيين كلمات مرورهم. تُخزّن DelegatingPasswordEncoder في Spring Security بادئةً إلى جانب كل تجزئة تُحدّد الخوارزمية المُستخدمة:

// المُخزَّن في قاعدة البيانات: {bcrypt}$2a$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW // تقرأ DelegatingPasswordEncoder البادئة {bcrypt} وتوجّه // استدعاء matches() إلى BCryptPasswordEncoder داخليًا.

أنشئها عبر التابع المصنعي لا يدويًا:

import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; @Bean public PasswordEncoder passwordEncoder() { // يُنشئ DelegatingPasswordEncoder مع bcrypt كافتراضي return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }

يتيح ذلك إضافة خوارزمية أقوى لاحقًا (مثل Argon2) كافتراضي للتسجيلات الجديدة مع الاستمرار في التحقق من تجزئات BCrypt القديمة — تعيد upgradeEncoding() القيمة true للبادئة القديمة كي تُعيد Spring إعادة التجزئة عند تسجيل الدخول الناجح.

ماذا عن MD5 أو SHA-256؟

كلاهما دالتا تجزئة تشفيرية للأغراض العامة، وليستا خوارزميتَي تجزئة لكلمات المرور. صُمِّمتا لتكونا سريعتَين — تستطيع وحدات معالجة الرسومات (GPU) حساب مليارات تجزئات MD5 في الثانية. يمكن لعنقود GPU حديث استنفاد جميع كلمات المرور الشائعة في قاعدة بيانات MD5 مسرَّبة في غضون ساعات. صُمِّمت BCrypt وSCrypt وArgon2 لتكون بطيئة وكثيفة الذاكرة، مما يجعل هجمات القوة الغاشمة غير مجدية اقتصاديًا. تأتي Spring Security مع MessageDigestPasswordEncoder لـ MD5/SHA-256 لكنها تضع عليه علامة @Deprecated — لا تستخدمه في الكود الجديد.

الخلاصة

أعلن bean من نوع BCryptPasswordEncoder بشدة لا تقل عن 12 للمشاريع الجديدة، استدعِ encode() مرةً واحدة عند التسجيل، ودع DaoAuthenticationProvider في Spring Security يستدعي matches() عند تسجيل الدخول. استخدم DelegatingPasswordEncoder إذا احتجت مسارًا للترحيل بين الخوارزميات. لا تُخزّن كلمات المرور الصريحة أبدًا، ولا تستخدم MD5 أو SHA-256 لكلمات المرور، ولا تستدعِ encode() للتحقق من كلمة مرور. هذه القواعد بسيطة — لكن عواقب تجاهلها ليست كذلك.