16. [الدورة التدريبية]: تأمين الوصول إلى خدمة ويب باستخدام Spring Security
الكلمات المفتاحية: بنية متعددة الطبقات، Spring، حقن التبعية، خدمة ويب آمنة / JSON، العميل / الخادم
16.1. الدعم
![]() | ![]() |
يمكن العثور على مشاريع هذا الفصل في المجلد [support / chap-16]. يُستخدم البرنامج النصي SQL لإنشاء قاعدة البيانات المطلوبة للاختبار.
16.2. دور Spring Security في تطبيق الويب
دعونا نضع Spring Security في سياق تطوير تطبيق ويب. غالبًا ما يتم بناؤه على بنية متعددة الطبقات مثل ما يلي:
![]() |
- تسمح طبقة [Spring Security] بالوصول إلى طبقة [الويب] للمستخدمين المصرح لهم فقط.
16.3. دليل تعليمي حول Spring Security
سنقوم مرة أخرى باستيراد دليل Spring باتباع الخطوات من 1 إلى 3 أدناه:
![]() |
![]() |
يتألف المشروع من العناصر التالية:
- في مجلد [templates]، ستجد صفحات HTML الخاصة بالمشروع؛
- [Application]: هي الفئة القابلة للتنفيذ للمشروع؛
- [MvcConfig]: هي فئة تكوين Spring MVC؛
- [WebSecurityConfig]: هي فئة تكوين Spring Security؛
16.3.1. تكوين Maven
المشروع [3] هو مشروع Maven. دعونا نفحص ملف [pom.xml] الخاص به لنرى تبعياته:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- tag::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- الأسطر 10–14: المشروع هو مشروع Spring Boot؛
- الأسطر 17–20: التبعية لإطار عمل [Thymeleaf]؛
- الأسطر 22–25: التبعية لإطار عمل Spring Security؛
16.3.2. طرق عرض Thymeleaf
![]() |
تبدو طريقة العرض [home.html] كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- السطر 12: ستقوم السمة [th:href="@{/hello}"] بإنشاء السمة [href] لعلامة [<a>]. ستقوم القيمة [@{/hello}] بإنشاء المسار [<context>/hello]، حيث [context] هو سياق تطبيق الويب؛
فيما يلي كود HTML الذي تم إنشاؤه:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
تبدو طريقة العرض [hello.html] كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- السطر 9: ستقوم السمة [th:inline="text"] بإنشاء نص العلامة [<h1>]. يحتوي هذا النص على تعبير $ يجب تقييمه. العنصر [[${#httpServletRequest.remoteUser}]] هو قيمة السمة [RemoteUser] لطلب HTTP الحالي. هذا هو اسم المستخدم الذي قام بتسجيل الدخول؛
- السطر 10: نموذج HTML. ستؤدي السمة [th:action="@{/logout}"] إلى إنشاء السمة [action] لعلامة [form]. وستؤدي القيمة [@{/logout}] إلى إنشاء المسار [<context>/logout]، حيث يمثل [context] سياق تطبيق الويب؛
فيما يلي كود HTML الذي تم إنشاؤه:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- السطر 8: ترجمة Hello [[${#httpServletRequest.remoteUser}]]!;
- السطر 9: ترجمة @{/logout};
- السطر 11: حقل مخفي باسم (سمة الاسم) _csrf؛
عرض [login.html] كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- السطر 9: تضمن السمة [th:if="${param.error}"] أن علامة <div> لن يتم إنشاؤها إلا إذا كان عنوان URL الذي يعرض صفحة تسجيل الدخول يحتوي على المعلمة [error] (http://context/login?error)؛
- السطر 10: تضمن السمة [th:if="${param.logout}"] أن علامة <div> لن يتم إنشاؤها إلا إذا كان عنوان URL الذي يعرض صفحة تسجيل الدخول يحتوي على المعلمة [logout] (http://context/login?logout)؛
- الأسطر 11–23: نموذج HTML؛
- السطر 11: سيتم إرسال النموذج إلى عنوان URL [<context>/login]، حيث <context> هو سياق تطبيق الويب؛
- السطر 13: حقل إدخال باسم [username]؛
- السطر 17: حقل إدخال باسم [password]؛
رمز HTML الذي تم إنشاؤه هو كما يلي:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
لاحظ في السطر 28 أن Thymeleaf قد أضاف حقلًا مخفيًا باسم [_csrf].
16.3.3. تكوين Spring MVC
![]() |
تقوم فئة [MvcConfig] بتكوين إطار عمل Spring MVC:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- السطر 7: تجعل العلامة [@Configuration] فئة [MvcConfig] فئة تكوين؛
- السطر 8: تمتد فئة [MvcConfig] إلى فئة [WebMvcConfigurerAdapter] لتجاوز طرق معينة؛
- السطر 10: إعادة تعريف طريقة من الفئة الأصلية؛
- الأسطر 11–16: تسمح الطريقة [addViewControllers] بربط عناوين URL بعروض HTML. يتم إجراء الروابط التالية هناك:
عرض | |
/templates/home.html | |
/القوالب/hello.html | |
/القوالب/تسجيل_الدخول.html |
اللاحقة [html] والمجلد [templates] هما القيمتان الافتراضيتان اللتان يستخدمهما Thymeleaf. يمكن تغييرهما عبر التكوين. يجب أن يكون المجلد [templates] في جذر مسار فئات المشروع:
![]() |
في [1] أعلاه، مجلدا [java] و [resources] كلاهما مجلدان للمصدر. وهذا يعني أن محتوياتهما ستكون في جذر مسار فئات المشروع. لذلك، في [2]، سيكون مجلدا [hello] و [templates] في جذر مسار فئات المشروع.
16.3.4. تكوين Spring Security
![]() |
تقوم فئة [WebSecurityConfig] بتكوين إطار عمل Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- السطر 9: تجعل العلامة [@Configuration] فئة [WebSecurityConfig] فئة تكوين؛
- السطر 10: تجعل العلامة [@EnableWebSecurity] فئة [WebSecurityConfig] فئة تكوين Spring Security؛
- السطر 11: تمتد فئة [WebSecurity] إلى فئة [WebSecurityConfigurerAdapter] لتجاوز طرق معينة؛
- السطر 12: إعادة تعريف طريقة من الفئة الأصلية؛
- الأسطر 13-16: يتم تجاوز الطريقة [configure(HttpSecurity http)] لتعريف حقوق الوصول لمختلف عناوين URL الخاصة بالتطبيق؛
- السطر 14: تسمح الطريقة [http.authorizeRequests()] بربط عناوين URL بحقوق الوصول. يتم إجراء الروابط التالية هناك:
القاعدة | رمز | |
الوصول بدون مصادقة | | |
الوصول المصادق عليه فقط |
- السطر 15: يحدد طريقة المصادقة. تتم المصادقة عبر نموذج URL [/login] متاح للجميع [http.formLogin().loginPage("/login").permitAll()]. كما أن تسجيل الخروج متاح للجميع؛
- الأسطر 19-21: تعيد تعريف الطريقة [configure(AuthenticationManagerBuilder auth)] التي تدير المستخدمين؛
- السطر 20: تتم المصادقة باستخدام مستخدمين مبرمجين [auth.inMemoryAuthentication()]. يتم تعريف المستخدم هنا باستخدام اسم المستخدم [user] وكلمة المرور [password] والدور [USER]. يمكن منح المستخدمين الذين لديهم نفس الدور نفس الأذونات؛
16.3.5. فئة قابلة للتنفيذ
![]() |
فئة [Application] هي كما يلي:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- السطر 8: توجه العلامة [@EnableAutoConfiguration] Spring Boot (السطر 3) إلى تنفيذ التكوين الذي لم يقم المطور بإعداده صراحةً؛
- السطر 9: يجعل فئة [Application] فئة تكوين Spring؛
- السطر 10: يوجه النظام إلى فحص الدليل الذي يحتوي على فئة [Application] للبحث عن مكونات Spring. وبالتالي سيتم اكتشاف الفئتين [MvcConfig] و [WebSecurityConfig] لأنهما تحتويان على تعليق [@Configuration]؛
- السطر 13: الطريقة [main] للفئة القابلة للتنفيذ؛
- السطر 14: يتم تنفيذ الطريقة الثابتة [SpringApplication.run] باستخدام فئة التكوين [Application] كمعلمة. لقد صادفنا هذه العملية من قبل ونعلم أن خادم Tomcat المضمن في تبعيات Maven للمشروع سيتم تشغيله ونشر المشروع عليه. رأينا أن أربعة عناوين URL تمت إدارتها [/, /home, /login, /hello] وأن بعضها محمي بحقوق الوصول.
16.3.6. اختبار التطبيق
لنبدأ بطلب عنوان URL [/]، وهو أحد عناوين URL الأربعة المقبولة. وهو مرتبط بالعرض [/templates/home.html]:
![]() |
عنوان URL المطلوب [/] متاح للجميع. ولهذا السبب تمكنا من استرداده. الرابط [هنا] هو كما يلي:
سيتم طلب عنوان URL [/hello] عند النقر على الرابط. هذا العنوان محمي:
القاعدة | رمز | |
الوصول بدون مصادقة | | |
الوصول المصادق عليه فقط |
يجب أن تكون مصادقًا عليه للوصول إليه. سيقوم Spring Security بعد ذلك بإعادة توجيه متصفح العميل إلى صفحة المصادقة. استنادًا إلى التكوين الموضح، هذه هي الصفحة الموجودة على عنوان URL [/login]. هذه الصفحة متاحة للجميع:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
لذا نحصل على [1]:
![]() |
فيما يلي شفرة المصدر للصفحة التي تم الحصول عليها:
- السطر 7، يظهر حقل مخفي غير موجود في الصفحة الأصلية [login.html]. أضافه Thymeleaf. تم تصميم هذا الرمز، المعروف باسم CSRF (تزوير الطلبات عبر المواقع)، للقضاء على ثغرة أمنية. يجب إرسال هذا الرمز إلى Spring Security مع المصادقة حتى يتم قبوله؛
نتذكر أن Spring Security لا يتعرف إلا على زوج المستخدم/كلمة المرور. إذا أدخلنا شيئًا آخر في [2]، فسنحصل على نفس الصفحة مع رسالة خطأ في [3]. قام Spring Security بإعادة توجيه المتصفح إلى عنوان URL [http://localhost:8080/login?error]. أدى وجود المعلمة [error] إلى عرض العلامة:
<div th:if="${param.error}">Invalid username and password.</div>
الآن، دعونا ندخل قيم اسم المستخدم وكلمة المرور المتوقعة [4]:
![]() |
- في [4]، نقوم بتسجيل الدخول؛
- في [5]، يعيدنا Spring Security إلى عنوان URL [/hello] لأن هذا هو عنوان URL الذي طلبناه عندما تمت إعادة توجيهنا إلى صفحة تسجيل الدخول. تم عرض هوية المستخدم من خلال السطر التالي من [hello.html]:
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
تعرض الصفحة [5] النموذج التالي:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
عند النقر على زر [تسجيل الخروج]، يتم إرسال طلب POST إلى عنوان URL [/logout]. ومثل عنوان URL [/login]، فإن هذا العنوان متاح للجميع:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
في تعيين عناوين URL/العرض لدينا، لم نحدد أي شيء لعنوان URL [/logout]. ماذا سيحدث؟ لنجرب:
![]() |
- في [6]، نضغط على زر [Sign Out]؛
- في [7]، نرى أنه تمت إعادة توجيهنا إلى عنوان URL [http://localhost:8080/login?logout]. طلب Spring Security إعادة التوجيه هذه. تسبب وجود المعلمة [logout] في عنوان URL في عرض السطر التالي في العرض:
<div th:if="${param.logout}">You have been logged out.</div>
16.3.7. الخلاصة
في المثال السابق، كان بإمكاننا كتابة تطبيق الويب أولاً ثم تأمينه لاحقًا. Spring Security غير تدخلي. يمكنك تنفيذ الأمان لتطبيق ويب تمت كتابته بالفعل. علاوة على ذلك، اكتشفنا النقاط التالية:
- من الممكن تعريف صفحة مصادقة؛
- يجب أن تكون المصادقة مصحوبة برمز CSRF الصادر عن Spring Security؛
- في حالة فشل المصادقة، يتم إعادة توجيهك إلى صفحة المصادقة مع معلمة خطأ إضافية في عنوان URL؛
- إذا نجحت المصادقة، يتم إعادة توجيهك إلى الصفحة المطلوبة وقت المصادقة. إذا طلبت صفحة المصادقة مباشرةً دون المرور بصفحة وسيطة، فإن Spring Security يعيد توجيهك إلى عنوان URL [/] (لم يتم توضيح هذه الحالة)؛
- يمكنك تسجيل الخروج عن طريق طلب عنوان URL [/logout] باستخدام طلب POST. ثم يقوم Spring Security بإعادة توجيهك إلى صفحة المصادقة مع معلمة "logout" في عنوان URL؛
تستند جميع هذه الاستنتاجات إلى السلوك الافتراضي لـ Spring Security. يمكن تغيير هذا السلوك من خلال التكوين عن طريق تجاوز طرق معينة لفئة [WebSecurityConfigurerAdapter].
لن يكون الدرس السابق مفيدًا لنا كثيرًا في المستقبل. سنستخدم، في الواقع، ما يلي:
- قاعدة بيانات لتخزين المستخدمين وكلمات مرورهم وأدوارهم؛
- المصادقة المستندة إلى رأس HTTP؛
هناك عدد قليل جدًا من الدروس المتاحة لما نريد القيام به هنا. الحل الذي سنقترحه هو مزيج من مقتطفات الكود الموجودة هنا وهناك.
16.4. تنفيذ الأمان على خدمة الويب الخاصة بالمنتج / JSON
16.4.1. قاعدة البيانات
يتم تحديث قاعدة البيانات [dbintrospringdata] لتشمل المستخدمين وكلمات مرورهم وأدوارهم. يتم إضافة ثلاثة جداول جديدة:

الجدول [USERS]: users
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار الصف؛
- IDENTITY: معرف وصفية للمستخدم؛
- LOGIN: تسجيل دخول المستخدم؛
- PASSWORD: كلمة المرور الخاصة به؛
في جدول USERS، لا يتم تخزين كلمات المرور بنص عادي:
![]() |
الخوارزمية المستخدمة لتشفير كلمات المرور هي خوارزمية BCRYPT.
جدول [ROLES]: الأدوار
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار الصف؛
- NAME: اسم الدور. بشكل افتراضي، يتوقع Spring Security أسماءً بالصيغة ROLE_XX، مثل ROLE_ADMIN أو ROLE_GUEST؛
![]() |
الجدول [USERS_ROLES]: جدول ربط USERS/ROLES
يمكن أن يكون للمستخدم أدوار متعددة، ويمكن أن يشمل الدور مستخدمين متعددين. هذه علاقة متعددة إلى متعددة يمثلها جدول [USERS_ROLES].
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار الصف؛
- USER_ID: معرف المستخدم؛
- ROLE_ID: معرف الدور؛
![]() |
16.4.2. مشروع Eclipse
نقوم بإنشاء مشروع Eclipse التالي:
1 ![]() |
- في [1]: المشروع الجديد مع الحزم التالية:
- [spring.security.entities]: يحتوي على كيانات JPA المطابقة للجداول الثلاثة الجديدة في قاعدة البيانات؛
- [spring.security.repositories]: يحتوي على مستودعات Spring Data المرتبطة بالجداول الثلاثة الجديدة؛
- [spring.security.dao]: يحتوي على خدمة تستند إلى [repositories]؛
- [spring.security.config]: يحتوي على تكوين المشروع، بما في ذلك التكوين الخاص بالوصول الآمن إلى خدمة الويب؛
- [spring.security.boot]: يحتوي على فئة التشغيل لخدمة الويب الآمنة؛
16.4.3. تكوين Maven
المشروع الجديد هو مشروع Maven تم تكوينه بواسطة ملف [pom.xml] التالي:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring.security</groupId>
<artifactId>intro-spring-security-server-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>intro-spring-security-server-01</name>
<description>démo spring security</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>istia.st.webjson</groupId>
<artifactId>intro-server-webjson-01</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- Spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring logs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- plugins -->
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.18.1</version>
</plugin>
</plugins>
</build>
</project>
- الأسطر 23–27: نعيد استخدام الكود الموجود مع أرشيف خدمة الويب/JSON الذي درسناه؛
- الأسطر 29–32: التبعية التي تجلب فئات Spring Security؛
- الأسطر 34–37: مكتبة التسجيل؛
- الأسطر 39–42: المكتبة التي تتيح استخدام تعليقات Spring Boot؛
- الأسطر 44–48: المكتبة المطلوبة للاختبار؛
16.4.4. الكيانات [JPA] الجديدة
![]() |
تحدد طبقة JPA ثلاث كيانات جديدة:
![]() |
تمثل فئة [User] الجدول [USERS]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
// properties
@Column(name = "NAME")
private String name;
@Column(name = "LOGIN")
private String login;
@Column(name = "PASSWORD")
private String password;
// manufacturer
public User() {
}
public User(String name, String login, String password) {
this.name = name;
this.login = login;
this.password = password;
}
// getters and setters
...
}
- السطر 11: تمتد الفئة إلى فئة [AbstractEntity] المستخدمة بالفعل للكيانات الأخرى؛
تمثل فئة [Role] الجدول [ROLES]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
// properties
@Column(name="NAME")
private String name;
// manufacturers
public Role() {
}
public Role(String name) {
this.name = name;
}
// getters and setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
تمثل فئة [UserRole] الجدول [USERS_ROLES]:
package spring.security.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import spring.data.entities.AbstractEntity;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
// foreign keys
@Column(name = "USER_ID", insertable = false, updatable = false)
private Long userId;
@Column(name = "ROLE_ID", insertable = false, updatable = false)
private Long roleId;
// a UserRole refers to a User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// a UserRole refers to a Role
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// manufacturers
public UserRole() {
}
public UserRole(User user, Role role) {
this.user = user;
this.role = role;
}
// getters and setters
...
}
- الأسطر 22–24: تعريف المفتاح الخارجي من الجدول [USERS_ROLES] إلى الجدول [USERS]؛
- الأسطر 27-29: تعريف المفتاح الخارجي من الجدول [USERS_ROLES] إلى الجدول [ROLES]؛
16.4.5. [repositories]
![]() |
يتم إدارة كل كيان من كيانات JPA السابقة بواسطة [مستودع] Spring Data:
![]() |
تدير واجهة [UserRepository] الوصول إلى كيانات [User]:
package spring.security.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
import spring.security.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// liste des rôles d'un utilisateur identifié par son id
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// liste des rôles d'un utilisateur identifié par son login unique
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// recherche d'un utilisateur via son login
User findUserByLogin(String login);
}
- السطر 9: واجهة [UserRepository] تمتد واجهة [CrudRepository] الخاصة بـ Spring Data (السطر 4)؛
- السطران 12-13: تسترد طريقة [getRoles(User user)] جميع الأدوار الخاصة بمستخدم محدد بواسطة [id] الخاص به
- السطران 16-17: كما هو مذكور أعلاه، ولكن بالنسبة لمستخدم يتم تحديده بواسطة اسم تسجيل الدخول وكلمة المرور؛
- السطر 20: للبحث عن مستخدم باستخدام اسم تسجيل الدخول الخاص به؛
تدير واجهة [RoleRepository] الوصول إلى كيانات [Role]:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.Role;
public interface RoleRepository extends CrudRepository<Role, Long> {
// search for a role by name
Role findRoleByName(String name);
}
- السطر 7: واجهة [RoleRepository] تمتد من واجهة [CrudRepository]؛
- السطر 10: يمكنك البحث عن دور حسب اسمه؛
تدير واجهة [UserRoleRepository] الوصول إلى كيانات [UserRole]:
package spring.security.repositories;
import org.springframework.data.repository.CrudRepository;
import spring.security.entities.UserRole;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- السطر 5: واجهة [UserRoleRepository] تمتد ببساطة واجهة [CrudRepository] دون إضافة أي طرق جديدة؛
16.4.6. فئات إدارة المستخدمين والأدوار
![]() |
![]() |
يتطلب Spring Security إنشاء فئة تنفذ واجهة [UsersDetail] التالية:
![]() |
يتم تنفيذ هذه الواجهة هنا بواسطة فئة [AppUserDetails]:
package spring.security.dao;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// properties
private User user;
private UserRepository userRepository;
// manufacturers
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------interface
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// getters and setters
...
}
- السطر 14: تنفذ فئة [AppUserDetails] واجهة [UserDetails]؛
- السطران 19-20: تغلف الفئة مستخدمًا (السطر 19) والمستودع الذي يوفر تفاصيل عن هذا المستخدم (السطر 20)؛
- الأسطر 26-29: المنشئ الذي ينشئ مثيلًا للفئة باستخدام مستخدم ومستودعه؛
- الأسطر 32–36: تنفيذ طريقة [getAuthorities] لواجهة [UserDetails]. يجب أن تنشئ مجموعة من العناصر من النوع [GrantedAuthority] أو نوع مشتق. هنا، نستخدم النوع المشتق [SimpleGrantedAuthority] (السطر 36)، الذي يغلف اسم أحد أدوار المستخدم من السطر 19؛
- الأسطر 35-37: نكرر قائمة أدوار المستخدم من السطر 19 لإنشاء قائمة بالعناصر من النوع [SimpleGrantedAuthority]؛
- الأسطر 42-44: تنفيذ طريقة [getPassword] لواجهة [UserDetails]. نُرجع كلمة مرور المستخدم من السطر 19؛
- الأسطر 42–44: ننفذ طريقة [getUserName] لواجهة [UserDetails]. نُرجع اسم تسجيل الدخول للمستخدم من السطر 19؛
- الأسطر 51–54: لا تنتهي صلاحية حساب المستخدم أبدًا؛
- الأسطر 56–59: لا يتم قفل حساب المستخدم أبدًا؛
- الأسطر 61–64: بيانات اعتماد المستخدم لا تنتهي صلاحيتها أبدًا؛
- الأسطر 66–69: حساب المستخدم نشط دائمًا؛
يتطلب Spring Security أيضًا وجود فئة تنفذ واجهة [AppUserDetailsService]:
![]() |
يتم تنفيذ هذه الواجهة بواسطة فئة [AppUserDetailsService] التالية:
package spring.security.dao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// search for user via login
User user = userRepository.findUserByLogin(login);
// found?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// render user details
return new AppUserDetails(user, userRepository);
}
}
- السطر 12: ستكون الفئة مكونًا في Spring، لذا ستكون متاحة في سياقها؛
- السطران 15-16: سيتم إدخال مكون [UserRepository] هنا؛
- الأسطر 19-28: تنفيذ طريقة [loadUserByUsername] لواجهة [UserDetailsService] (السطر 10). المعلمة هي اسم تسجيل دخول المستخدم؛
- السطر 21: يتم البحث عن المستخدم باستخدام اسم تسجيل الدخول الخاص به؛
- الأسطر 23-25: إذا لم يتم العثور على المستخدم، يتم إصدار استثناء؛
- السطر 27: يتم إنشاء كائن [AppUserDetails] وإرجاعه. وهو بالفعل من النوع [UserDetails] (السطر 19)؛
16.4.7. تكوين المشروع
![]() |
يتم تكوين المشروع بواسطة فئتين:
![]() |
تقوم فئة [DaoConfig] بتكوين طبقة [DAO] التي أدخلها المشروع الجديد:
package spring.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@EnableJpaRepositories(basePackages = { "spring.security.repositories" })
@ComponentScan(basePackages = { "spring.security.dao" })
@Import({ spring.data.config.DaoConfig.class })
public class DaoConfig {
// constants
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities", "spring.security.entities" };
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
}
- السطر 10: نقوم باستيراد فئة التكوين [spring.data.config.DaoConfig] من مشروع [intro-spring-data-01]، الذي ينفذ طبقة [DAO] للمنتجات والفئات؛
- السطر 8: نحدد المجلدات في المشروع الحالي التي تحتوي على [مستودعات] Spring Data؛
- السطر 9: نحدد المجلدات في المشروع الحالي التي تحتوي على مكونات Spring المتعلقة بطبقة [DAO]؛
- السطر 14: يحدد هذا الدلائل التي تحتوي على كيانات JPA. وتشمل هذه الكيانات تلك الموجودة في مشروع [intro-spring-data-01] وتلك الموجودة في مشروع الخادم الآمن. يتم تعريف هذه المعلومات في الفول في الأسطر 16-19. يتجاوز هذا الفول الفول الذي يحمل نفس الاسم في مشروع [intro-spring-data-01]:
final static private String[] ENTITIES_PACKAGES = { "spring.data.entities" };
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(packagesToScan());
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
@Bean
public String[] packagesToScan() {
return ENTITIES_PACKAGES;
}
في طبقة [DAO]، يقوم السطر 8 بمسح الدلائل المحددة في السطر 1. ونظرًا لإعادة تعريف الكائن في الأسطر 14–17 في المشروع الآمن (الأسطر 16–19)، سيقوم السطر 8 أعلاه الآن بمسح الدلائل ["spring.data.entities"، "spring.security.entities"]. لاحظ أن الفئة المستوردة في السطر 10 من فئة [spring.security.config.DaoConfig] يجب أن تتضمن التعليق التوضيحي [@Configuration]؛ وإلا، فلن يعمل السلوك الموصوف أعلاه.
تقوم فئة [SecurityConfig] بتكوين الجانب الأمني للمشروع. لقد صادفنا بالفعل فئة تكوين Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
سنتبع نفس الإجراء:
- السطر 11: تعريف فئة تمتد من فئة [WebSecurityConfigurerAdapter]؛
- السطر 13: تعريف طريقة [configure(HttpSecurity http)] التي تحدد حقوق الوصول إلى عناوين URL المختلفة لخدمة الويب؛
- السطر 19: تعريف طريقة [configure(AuthenticationManagerBuilder auth)] التي تحدد المستخدمين وأدوارهم؛
ستكون فئة [SecurityConfig] كما يلي:
package spring.security.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import spring.security.dao.AppUserDetailsService;
@EnableWebSecurity
@ComponentScan(basePackages = { "spring.security.service" })
@Import({ spring.webjson.config.AppConfig.class, DaoConfig.class })
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
// security
private boolean activateSecurity = true;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// authentication is performed by bean [appUserDetailsService]
// the password is encrypted using the BCrypt hash algorithm
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// secure application?
if (activateSecurity) {
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// the HTTP OPTIONS method must be authorized for all
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- السطر 16: لتمكين مكونات Spring Security؛
- السطر 17: نضيف مكونات Spring من حزمة [spring.security.service]؛
- السطر 18: استيراد الفاصوليا من طبقة [DAO] التي قدمناها للتو، بالإضافة إلى تلك الموجودة في خادم الويب غير الآمن/JSON؛
- السطران 21-22: يتم إدخال فئة [AppUserDetails]، التي توفر الوصول إلى مستخدمي التطبيق؛
- السطر 25: قيمة منطقية تؤمن (true) أو لا تؤمن (false) تطبيق الويب؛
- الأسطر 27–32: تحدد طريقة [configure(HttpSecurity http)] المستخدمين وأدوارهم. وهي تأخذ نوع [AuthenticationManagerBuilder] كمعلمة. يتم إثراء هذه المعلمة بمعلومتين (السطر 38):
- إشارة إلى [appUserDetailsService] من السطر 22، والتي توفر الوصول إلى المستخدمين المسجلين. لاحظ هنا أن حقيقة تخزينهم في قاعدة بيانات لم يتم ذكرها صراحةً. لذلك، يمكن أن يكونوا في ذاكرة التخزين المؤقت، أو يتم توفيرهم بواسطة خدمة ويب، إلخ.
- نوع التشفير المستخدم لكلمة المرور. تذكر أننا استخدمنا خوارزمية BCrypt؛
- الأسطر 34-52: تحدد طريقة [configure(HttpSecurity http)] حقوق الوصول إلى عناوين URL لخدمة الويب؛
- السطر 37: رأينا في المشروع التمهيدي أن Spring Security تدير افتراضيًا رمز CSRF (تزوير الطلبات عبر المواقع) الذي يجب على المستخدم الذي يحاول المصادقة إرساله مرة أخرى إلى الخادم. هنا، يتم تعطيل هذه الآلية. بالاقتران مع القيمة المنطقية (isSecured=false)، يسمح هذا باستخدام تطبيق الويب بدون أمان؛
- السطر 41: نقوم بتمكين المصادقة عبر رؤوس HTTP. يجب على العميل إرسال رأس HTTP التالي:
حيث code هو ترميز Base64 لسلسلة login:password. على سبيل المثال، ترميز Base64 لسلسلة admin:admin هو YWRtaW46YWRtaW4=. لذلك، سيقوم المستخدم الذي يستخدم اسم المستخدم [admin] وكلمة المرور [admin] بإرسال رأس HTTP التالي للمصادقة:
- الأسطر 46–48: تحدد أن جميع عناوين URL لخدمة الويب متاحة للمستخدمين الذين لديهم دور [ROLE_ADMIN]. وهذا يعني أن المستخدم الذي لا يمتلك هذا الدور لا يمكنه الوصول إلى خدمة الويب؛
- السطر 50: في وضع [session]، لا يحتاج المستخدم الذي قام بالمصادقة مرة واحدة إلى القيام بذلك في عمليات الوصول اللاحقة. هنا، نقوم بتعطيل هذا الوضع، بحيث يتعين على المستخدم المصادقة في كل مرة يصل فيها إلى الخدمة؛
16.4.8. اختبار طبقة [DAO]
![]() |
![]() |
أولاً، نقوم بإنشاء فئة قابلة للتنفيذ [CreateUser] قادرة على إنشاء مستخدم له دور:
package sprin.security.tests;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import spring.security.config.DaoConfig;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.entities.UserRole;
import spring.security.repositories.RoleRepository;
import spring.security.repositories.UserRepository;
import spring.security.repositories.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// syntax: login password roleName
// three parameters are required
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// parameters are retrieved
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// spring context
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// does the role already exist?
Role role = roleRepository.findRoleByName(roleName);
// if it doesn't exist, we create it
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// does the user already exist?
User user = userRepository.findUserByLogin(login);
// if it doesn't exist, we create it
if (user == null) {
// hash the password with bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// save user
user = userRepository.save(new User(login, login, crypt));
// we create the relationship with the role
userRoleRepository.save(new UserRole(user, role));
} else {
// the user already exists - does he/she have the required role?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// if not found, we create the relationship with the role
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// closing Spring context
context.close();
// end
System.out.println("Travail terminé...");
}
}
- السطر 17: تتوقع الفئة ثلاثة معلمات تحدد هوية المستخدم: اسم المستخدم وكلمة المرور والدور؛
- الأسطر 25–27: يتم استرداد المعلمات الثلاثة؛
- السطر 29: يتم إنشاء سياق Spring من فئة التكوين [AppConfig]؛
- الأسطر 30–32: يتم استرداد المراجع إلى الكائنات الثلاثة [Repository] التي قد تكون مفيدة لإنشاء المستخدم؛
- السطر 34: نتحقق مما إذا كان الدور موجودًا بالفعل؛
- الأسطر 36-38: إذا لم يكن موجودًا، نقوم بإنشائه في قاعدة البيانات. سيكون اسمه على شكل [ROLE_XX]؛
- السطر 40: نتحقق مما إذا كان اسم المستخدم موجودًا بالفعل؛
- الأسطر 42-49: إذا لم يكن اسم المستخدم موجودًا، نقوم بإنشائه في قاعدة البيانات؛
- السطر 44: نقوم بتشفير كلمة المرور. هنا، نستخدم فئة [BCrypt] من Spring Security (السطر 4). لذلك نحتاج إلى أرشيفات هذا الإطار. يتضمن ملف [pom.xml] هذه التبعية:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- السطر 46: يتم حفظ المستخدم في قاعدة البيانات؛
- السطر 48: وكذلك العلاقة التي تربطه بدوره؛
- الأسطر 51-57: إذا كان تسجيل الدخول موجودًا بالفعل، نتحقق مما إذا كان الدور الذي نريد تعيينه له موجودًا بالفعل ضمن أدواره؛
- الأسطر 59–61: إذا لم يتم العثور على الدور الذي يتم البحث عنه، يتم إنشاء صف في جدول [USERS_ROLES] لربط المستخدم بدوره؛
- لم نقم بالحماية من الاستثناءات المحتملة. هذه فئة مساعدة لإنشاء مستخدم بدور بسرعة.
عند تنفيذ الفئة مع الحجج [x x guest]، يتم الحصول على النتائج التالية في قاعدة البيانات:
الجدول [USERS]
![]() |
جدول [الأدوار]
![]() |
الجدول [USERS_ROLES]
![]() |
الآن دعونا ننظر إلى الفئة الثانية [UsersTest]، وهي اختبار JUnit:
![]() |
package spring.security.tests;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import spring.security.config.DaoConfig;
import spring.security.dao.AppUserDetails;
import spring.security.dao.AppUserDetailsService;
import spring.security.entities.Role;
import spring.security.entities.User;
import spring.security.repositories.UserRepository;
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
// mapper jSON
private ObjectMapper mapper = new ObjectMapper();
@Test
public void findAllUsersWithTheirRoles() throws JsonProcessingException {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(String.format("\n----------Utilisateur [%s]",mapper.writeValueAsString(user)));
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// user [admin] is retrieved
User user = userRepository.findUserByLogin("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// check admin / admin role
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// user [admin] is retrieved
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// check admin / admin role
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// utility method - displays items in a collection
private void display(String message, Iterable<?> elements) throws JsonProcessingException {
System.out.println(message);
for (Object element : elements) {
System.out.println(mapper.writeValueAsString(element));
}
}
}
- الأسطر 37–44: اختبار مرئي. نعرض جميع المستخدمين مع أدوارهم؛
- الأسطر 46–56: نتحقق من أن المستخدم [admin] لديه كلمة المرور [admin] والدور [ROLE_ADMIN] باستخدام [UserRepository]؛
- السطر 51: [admin] هي كلمة المرور النصية العادية. في قاعدة البيانات، يتم تشفيرها باستخدام خوارزمية BCrypt. تتحقق طريقة [BCrypt.checkpw] من أن كلمة المرور النصية العادية المشفرة تتطابق مع تلك الموجودة في قاعدة البيانات؛
- الأسطر 58–69: نتحقق من أن المستخدم [admin] لديه كلمة المرور [admin] والدور [ROLE_ADMIN] باستخدام [appUserDetailsService]؛
تم تشغيل الاختبارات بنجاح مع السجلات التالية:
16.4.9. اختبار خدمة الويب
سنقوم باختبار خدمة الويب باستخدام عميل Chrome [Advanced Rest Client]. سنحتاج إلى تحديد رأس مصادقة HTTP:
حيث [code] هي السلسلة المشفرة بـ Base64 [login:password]. لإنشاء هذا الرمز، يمكنك استخدام البرنامج التالي:
![]() |
package spring.security.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// we expect two arguments: login password
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// we retrieve the two arguments
String chaîne = String.format("%s:%s", args[0], args[1]);
// encode the string
byte[] data = Base64.encode(chaîne.getBytes());
// displays its Base64 encoding
System.out.println(new String(data));
}
}
إذا قمنا بتشغيل هذا البرنامج مع الحجتين [admin admin]:
![]() |
نحصل على النتيجة التالية:
الآن بعد أن عرفنا كيفية إنشاء رأس مصادقة HTTP، نقوم بتشغيل خدمة الويب الآمنة، ثم باستخدام عميل Chrome [Advanced Rest Client]، نطلب قائمة بجميع المنتجات:
![]() |
- في [1]، نطلب عنوان URL للفئات؛
- في [2]، باستخدام طريقة GET؛
- في [3]، نقدم رأس مصادقة HTTP. الرمز [YWRtaW46YWRtaW4=] هو ترميز Base64 للسلسلة [admin:admin]؛
- في [4]، نرسل طلب HTTP؛
استجابة الخادم هي كما يلي:
![]() |
- في [1]، رأس مصادقة HTTP؛
- في [2]، يقوم الخادم بإرجاع استجابة JSON؛
نجحنا في الحصول على قائمة الفئات:
![]() |
الآن دعونا نجرب طلب HTTP برأس مصادقة غير صحيح. تكون الاستجابة عندئذ كما يلي:
![]() |
- في [1]: رأس المصادقة HTTP؛
نتلقى الرد التالي:
![]() |
- في [2]: رد خدمة الويب؛
الآن، دعونا نجرب المستخدم / user. إنه موجود ولكنه لا يملك حق الوصول إلى خدمة الويب. إذا قمنا بتشغيل برنامج الترميز Base64 مع الحجتين [user user]:
![]() |
نحصل على النتيجة التالية:
![]() |
- في [1]: رأس مصادقة HTTP غير الصحيح؛
![]() |
- في [2]: استجابة خدمة الويب. وهي تختلف عن الاستجابة السابقة، التي كانت [401 غير مصرح به]. هذه المرة، تمت مصادقة المستخدم بشكل صحيح ولكنه لا يمتلك أذونات كافية للوصول إلى عنوان URL؛
خدمة الويب الآمنة لدينا تعمل الآن.
16.4.10. عنوان URL للمصادقة
![]() |
سننشئ عنوان URL يسمح لنا بتحديد ما إذا كان المستخدم مخولًا للوصول إلى خدمة الويب. للقيام بذلك، نقوم بإنشاء وحدة تحكم MVC جديدة [AuthenticateController] كما يلي:
package spring.security.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import spring.webjson.models.Response;
@Controller
public class AuthenticateController {
// spring dependencies
@Autowired
private ApplicationContext context;
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {
// answer jSON
ObjectMapper mapperResponse = context.getBean(ObjectMapper.class);
return mapperResponse.writeValueAsString(new Response<Void>(0, null, null));
}
}
- السطر 15: فئة [AuthenticateController] هي وحدة تحكم Spring. وبصفتها كذلك، فإنها تعرض عناوين URL؛
- السطر 22: تعرض عنوان URL [/authenticate]؛
- السطر 23: سيتم إرسال نتيجة الطريقة مباشرة إلى العميل؛
- السطران 26 و27: تُرجع الدالة ببساطة كائن [Response] فارغًا، لكن مع قيمة [status] تساوي 0، مما يشير إلى عدم حدوث أي خطأ؛
ما الغرض من عنوان URL هذا؟ عندما نريد ببساطة مصادقة مستخدم، سنطلبه. وقد رأينا أنه إذا لم تقبل طبقة الأمان هذا المستخدم، فإنها ترمي استثناءً. وإليك مثال على ذلك؛
مع المستخدم [admin:admin]:
![]() | ![]() |
نحصل على استجابة فارغة ولكن دون حدوث استثناء.
مع المستخدم [user:user]:
![]() | ![]() |
حدثت استثناء.
16.4.11. الخلاصة
تمت إضافة الفئات الضرورية لـ Spring Security دون تعديل مشروع web/JSON الأصلي. ينبع هذا السيناريو المواتي للغاية من حقيقة أن الجداول الثلاثة المضافة إلى قاعدة البيانات مستقلة عن الجداول الموجودة. كان بإمكاننا حتى وضعها في قاعدة بيانات منفصلة. في حالات أخرى، قد تكون للجداول المضافة علاقات مع الجداول الموجودة. في هذه الحالة، يجب تعديل كيانات JPA، مما يؤثر عمومًا على جميع طبقات المشروع.
16.5. عميل مبرمج لخدمة web/JSON الآمنة
لقد قمنا بالفعل بكتابة عميل لخدمة الويب / JSON غير الآمنة:
![]() |
سنقوم الآن بإنشاء عميل مبرمج لخدمة الويب الآمنة:
![]() |
نقوم بنسخ المشروع الحالي [intro-webjson-client] إلى مشروع جديد [intro-spring-security-client-01]:
![]() |
16.5.1. فئة [AbstractDao]
تتولى فئة [AbstractDao] إدارة الاتصال عبر HTTP مع خادم الويب/JSON الآمن. وكما رأينا للتو، في هذا الاتصال عبر HTTP، يجب على العميل الآن إرسال رأس مصادقة، على سبيل المثال:
ويتم ذلك على النحو التالي:
package spring.security.client.dao;
import java.net.URI;
...
public abstract class AbstractDao {
// data
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
// generic request
protected String getResponse(User user, String url, String jsonPost) {
// url : URL to contact
- السطر 15: الطريقة العامة [getResponse]، المسؤولة عن الاتصال HTTP مع خدمة الويب الآمنة، تقبل الآن المستخدم الذي يطلب عنوان URL كمعلمة أولى لها. فئة [User] هي كما يلي:
هذه الفئة هي كما يلي:
![]() |
package spring.security.client.entities;
public class User {
// properties
private String login;
private String password;
// manufacturer
public User() {
}
public User(String login, String password) {
this.login = login;
this.password = password;
}
// getters and setters
...
}
تصبح طريقة [getResponse] كما يلي:
package spring.security.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.web.client.RestTemplate;
import spring.security.client.entities.User;
public abstract class AbstractDao {
// data
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected String urlServiceWebJson;
private String getBase64(User user) {
// encodes user and password in base 64 - requires java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPassword());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// generic request
protected String getResponse(User user, String url, String jsonPost) {
// url : URL to contact
// jsonPost: the jSON value to be posted
try {
// request execution
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url)))
.accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// execute the query
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e1) {
throw new DaoException(20, e1);
} catch (RuntimeException e2) {
throw new DaoException(21, e2);
}
}
}
- الأسطر 42–44، 49–51: إذا لم يكن المستخدم فارغًا، يتم إضافة رأس المصادقة. يتم التعامل مع ترميز Base64 للمستخدم وكلمة المرور الخاصة به بواسطة طريقة [getBase64] في الأسطر 25–29. لاحظ أن هذه الطريقة تستخدم فئة [Base64] تابعة لـ JDK 1.8.
- بصرف النظر عن الأسطر السابقة، يظل الكود دون تغيير؛
16.5.2. واجهة [IDao]
تستقبل جميع طرق واجهة [IDao] معلمة إضافية [User user]:
![]() |
package spring.security.client.dao;
import java.util.List;
import spring.security.client.entities.Categorie;
import spring.security.client.entities.Produit;
import spring.security.client.entities.User;
public interface IDaoClient {
// authentication
public void authenticate(User user);
// insert product list
public List<Produit> addProduits(User user, List<Produit> produits);
// removal of all products
public void deleteAllProduits(User user);
// product list update
public List<Produit> updateProduits(User user, List<Produit> produits);
// all products obtained
public List<Produit> getAllProduits(User user);
// inserting a list of categories
public List<Categorie> addCategories(User user, List<Categorie> categories);
// delete all categories
public void deleteAllCategories(User user);
// updating a list of categories
public List<Categorie> updateCategories(User user, List<Categorie> categories);
// obtaining all categories
public List<Categorie> getAllCategories(User user);
// a special product
public Produit getProduitByIdWithCategorie(User user, Long idProduit);
public Produit getProduitByIdWithoutCategorie(User user, Long idProduit);
public Produit getProduitByNameWithCategorie(User user, String nom);
public Produit getProduitByNameWithoutCategorie(User user, String nom);
// a special category
public Categorie getCategorieByIdWithProduits(User user, Long idCategorie);
public Categorie getCategorieByIdWithoutProduits(User user, Long idCategorie);
public Categorie getCategorieByNameWithProduits(User user, String nom);
public Categorie getCategorieByNameWithoutProduits(User user, String nom);
}
- السطر 12: أضفنا طريقة [authenticate(User user)] لمصادقة المستخدم. ترمي هذه الطريقة استثناءً إذا لم يكن لدى المستخدم إذن للوصول إلى عنوان URL [/authenticate] لخدمة الويب؛
16.5.3. فئة [Dao]
تستقبل جميع الطرق في فئة [Dao] معلمة إضافية [User user] تقوم بتمريرها إلى الطريقة العامة [getResponse] لفئة [AbstractDao]. فيما يلي مثالان:
// authentication
@Override
public void authenticate(User user) {
getResponse(user, "/authenticate", null);
}
@Override
public List<Produit> addProduits(User user, List<Produit> produits) {
// ----------- add products (without category)
try {
// mappers jSON
ObjectMapper mapperPost = context.getBean(ObjectMapper.class);
mapperPost.setFilters(jsonFilterProduitWithoutCategorie);
ObjectMapper mapperResponse = mapperPost;
// request
Response<List<Produit>> response = mapperResponse.readValue(
getResponse(user, "/addProduits", mapperPost.writeValueAsString(produits)),
new TypeReference<Response<List<Produit>>>() {
});
// mistake?
if (response.getStatus() != 0) {
// 1 exception is thrown
throw new DaoException(response.getStatus(), response.getMessages());
} else {
// render the core of the server response
return response.getBody();
}
} catch (DaoException e1) {
throw e1;
} catch (IOException | RuntimeException e2) {
throw new DaoException(100, e2);
}
}
16.5.4. اختبارات الوحدة لفئة [Dao]
تم تعديل فئة [Test01] الخاصة باختبار الوحدة لفئة [Dao] على النحو التالي:
![]() |
package client.tests.junit;
...
@SpringApplicationConfiguration(classes = DaoConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Test01 {
// spring context
@Autowired
private ApplicationContext context;
// layer [DAO]
@Autowired
private IDaoClient dao;
// users
static private User admin;
static private User user;
static private User unknown;
@BeforeClass
public static void init() {
admin = new User("admin", "admin");
user = new User("user", "user");
unknown = new User("x", "y");
}
@Before
public void cleanAndFill() {
// the base is cleaned before each test
log("Vidage de la base de données", 1);
// table [CATEGORIES] is emptied - by cascade, table [PRODUITS] will be emptied
dao.deleteAllCategories(admin);
// --------------------------------------------------------------------------------------
log("Remplissage de la base", 1);
// fill the tables
List<Categorie> categories = new ArrayList<Categorie>();
for (int i = 0; i < 2; i++) {
Categorie categorie = new Categorie(String.format("categorie%d", i));
for (int j = 0; j < 5; j++) {
categorie.addProduit(new Produit(String.format("produit%d%d", i, j), 100 * (1 + (double) (i * 10 + j) / 100),
String.format("desc%d%d", i, j)));
}
categories.add(categorie);
}
// add the category - the products will be cascaded in as well
dao.addCategories(admin, categories);
}
@Test
public void showDataBase() throws BeansException, JsonProcessingException {
// list of categories
log("Liste des catégories", 2);
List<Categorie> categories = dao.getAllCategories(admin);
affiche(categories, context.getBean("jsonMapperCategorieWithoutProduits", ObjectMapper.class));
// product list
log("Liste des produits", 2);
List<Produit> produits = dao.getAllProduits(admin);
affiche(produits, context.getBean("jsonMapperProduitWithoutCategorie", ObjectMapper.class));
// a few checks
Assert.assertEquals(2, categories.size());
Assert.assertEquals(10, produits.size());
Categorie categorie = findCategorieByName("categorie0", categories);
Assert.assertNotNull(categorie);
Produit produit = findProduitByName("produit03", produits);
Assert.assertNotNull(produit);
Long idCategorie = produit.getIdCategorie();
Assert.assertEquals(categorie.getId(), idCategorie);
}
...
@Test()
public void checkUserUser() {
ServiceException se = null;
try {
dao.authenticate(user);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("403 Forbidden", se.getMessages().get(0));
}
@Test()
public void checkUserUnknown() {
ServiceException se = null;
try {
dao.authenticate(unknown);
} catch (ServiceException e) {
se = e;
}
Assert.assertNotNull(se);
Assert.assertEquals("401 Unauthorized", se.getMessages().get(0));
}
@Test()
public void checkUserAdmin() {
ServiceException se = null;
try {
dao.authenticate(admin);
} catch (ServiceException e) {
se = e;
}
Assert.assertNull(se);
}
...
}
- أثناء تهيئة فئة الاختبار، في الأسطر 21–26، يتم إنشاء ثلاثة مستخدمين:
- المستخدم [admin] لديه حق الوصول إلى عناوين URL لخدمة الويب، اختبار الأسطر 96-104؛
- المستخدم [user] موجود ولكنه غير مخول باستخدام عناوين URL لخدمة الويب، الأسطر 71-81 من الاختبار؛
- المستخدم [unknown] غير موجود، الأسطر 83-93 من الاختبار؛
- طرق الاختبار هي نفسها التي سبق رؤيتها لخدمة الويب غير الآمنة، باستثناء أن طرق واجهة [IDaoClient] يتم استدعاؤها باستخدام المستخدم [admin] —الذي لديه إذن لاستخدام عناوين URL— كمعلمة أولى؛
اجتاز الاختبار، لكن يمكننا ملاحظة أنه أبطأ من خدمة الويب غير الآمنة. يؤدي تأمين التطبيق إلى زيادة أوقات الاستجابة بشكل كبير. هناك عامل مهم واحد يؤثر على أداء خدمة الويب الآمنة: في فئة [AppConfig] التي تقوم بتكوينها، كتبنا:
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// secure application?
if (activateSecurity) {
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// the HTTP OPTIONS method must be authorized for all
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
السطر 17 له ثمنه. فهو يجبر المستخدم على المصادقة عند كل وصول. إذا قمنا بتعليقه، تنخفض مدة اختبار JUnit السابق من 10.57 ثانية إلى 4.21 ثانية، لأن المستخدم [admin] يقوم بالمصادقة فقط للاختبار الأول وليس للاختبارات اللاحقة (على الرغم من إرسال رأس مصادقة HTTP من قبل العميل، لا يقوم الخادم بإعادة التحقق من كلمة مرور المستخدم). مع خدمة ويب غير آمنة، تنخفض مدة اختبار JUnit إلى 2.33 ثانية.
























































