مشروع: واجهة REST API مؤمَّنة بـ JWT
يجمع هذا الدرس الأخير كل مفهوم من مفاهيم البرنامج التعليمي في مشروع واحد ذي شكل إنتاجي. ستبني واجهة API صغيرة لإدارة المهام تُوضّح التسجيل وتسجيل الدخول وإصدار الرموز المميزة والتحكم في الوصول القائم على الأدوار وتدوير رموز التحديث ومعالجة الأخطاء — كل ذلك مربوطًا معًا باستخدام Spring Boot 3 وSpring Security 6. الهدف ليس بناء أكبر نظام، بل إظهار سبب وضع كل قطعة في مكانها وما الذي ينكسر إذا أهملتها.
نظرة عامة على المشروع والتبعيات
تعرض الواجهة ثلاث مجموعات من الموارد: نقاط نهاية المصادقة العامة (/api/auth/**)، ونقاط نهاية المهام المخصصة للمستخدم (/api/tasks/**)، ونقطة نهاية تقارير المشرف (/api/admin/**). يحتاج pom.xml إلى أربعة starters إضافةً إلى مكتبة JWT:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
لماذا H2 في هذا المشروع؟ تعني قاعدة بيانات H2 المضمّنة صفر إعداد خارجي — يمكن تشغيل التطبيق بالكامل بـ ./mvnw spring-boot:run. استبدل رابط JDBC والمشغّل بـ PostgreSQL أو MySQL في الإنتاج دون تغيير سطر واحد من كود التطبيق.
الإعداد والتهيئة
تعيش جميع القيم الحساسة أمنيًا في application.yml وتُحقن عبر @Value. لا تُضمّن الأسرار مطلقًا في ملفات Java المصدرية.
spring:
datasource:
url: jdbc:h2:mem:taskdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
app:
jwt:
secret: "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
access-token-expiry-ms: 900000 # 15 دقيقة
refresh-token-expiry-ms: 604800000 # 7 أيام
نموذج النطاق
تخزّن كيان AppUser بيانات الاعتماد والأدوار. تُخزَّن الأدوار كمجموعة enum بسيطة — لا حاجة لجدول وصل في مشروع بهذا الحجم.
@Entity
@Table(name = "app_users")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String passwordHash;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
private Set<Role> roles = new HashSet<>();
// حذفت getters وsetters للإيجاز
}
public enum Role { ROLE_USER, ROLE_ADMIN }
تُحفظ رموز التحديث في قاعدة البيانات حتى يتمكن الخادم من إلغائها بشكل فردي — فتخزينها في الذاكرة يفقد حالة الإلغاء عند إعادة التشغيل.
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token; // سلسلة عشوائية غير شفافة (UUID)
@ManyToOne(fetch = FetchType.LAZY)
private AppUser user;
private Instant expiresAt;
private boolean revoked;
// حذفت getters وsetters للإيجاز
}
خدمة JWT
تمركز JwtService كل منطق الرمز المميز — التوليد واستخراج المطالبات والتحقق. إبقاؤها في فئة واحدة يعني أن كل جزء من النظام يشارك نفس قواعد التحليل، ولديك مكان واحد بالضبط للتحديث عند تدوير خيارات الخوارزميات.
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.access-token-expiry-ms}")
private long accessExpiryMs;
private SecretKey signingKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateAccessToken(UserDetails user) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.claims(claims)
.subject(user.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessExpiryMs))
.signWith(signingKey())
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return parseClaims(token).getExpiration().before(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
فلتر المصادقة
يعترض JwtAuthenticationFilter كل طلب، ويستخرج رمز Bearer من ترويسة Authorization، ويتحقق منه، ويملأ SecurityContext. بمجرد ملء السياق، تجد فلاتر Spring Security التالية مبدأً مصادقًا وتسمح بالوصول إلى المسارات المحمية.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
إعداد الأمان
تربط فئة SecurityConfig كل شيء معًا. لاحظ سياسة الجلسة عديمة الحالة — بما أن الخادم لا يخزّن حالة الجلسة أبدًا، فإن كل طلب يحمل بيانات اعتماده الخاصة في JWT.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // واجهة API عديمة الحالة؛ CSRF لا ينطبق
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler((req, res, e) ->
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
لا تُعطّل CSRF في التطبيقات القائمة على الجلسة. تعطيل CSRF آمن فقط عندما تكون الواجهة عديمة الحالة حقًا (لا ملفات تعريف الارتباط تحمل بيانات الاعتماد). إذا أضفت لاحقًا مصادقة قائمة على الكوكيز جانبًا إلى JWT، فيجب إعادة تفعيل حماية CSRF أو تقديم استراتيجية كوكيز CSRF منفصلة.
وحدة التحكم في المصادقة — التسجيل وتسجيل الدخول والتحديث
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<MessageResponse> register(@Valid @RequestBody RegisterRequest req) {
authService.register(req);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new MessageResponse("User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest req) {
return ResponseEntity.ok(authService.login(req));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
return ResponseEntity.ok(authService.refreshTokens(req.refreshToken()));
}
@PostMapping("/logout")
public ResponseEntity<MessageResponse> logout(@RequestBody RefreshRequest req) {
authService.revokeRefreshToken(req.refreshToken());
return ResponseEntity.ok(new MessageResponse("Logged out"));
}
}
تُصادق طريقة AuthService.login عبر AuthenticationManager (الذي يستخدم DaoAuthenticationProvider المُعدَّ أعلاه)، ثم تُصدر كلا الرمزين في استجابة واحدة:
public AuthResponse login(LoginRequest req) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password())
);
AppUser user = userRepository.findByUsername(req.username())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDetails userDetails = toUserDetails(user);
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = refreshTokenService.createRefreshToken(user);
return new AuthResponse(accessToken, refreshToken);
}
نقاط نهاية المهام — الأمان على مستوى الأسلوب
استخدام @PreAuthorize يُبقي منطق التفويض قريبًا من كود العمل ويجعله قابلًا للتدقيق في ملف واحد:
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
@GetMapping
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public List<TaskDto> getMyTasks(Authentication auth) {
return taskService.getTasksForUser(auth.getName());
}
@PostMapping
@PreAuthorize("hasRole('USER')")
public ResponseEntity<TaskDto> create(@Valid @RequestBody CreateTaskRequest req,
Authentication auth) {
TaskDto created = taskService.createTask(req, auth.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @taskService.isOwner(#id, authentication.name)")
public ResponseEntity<Void> delete(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
}
مراجع bean في SpEL ضمن @PreAuthorize: يستدعي التعبير @taskService.isOwner(#id, authentication.name) طريقة Spring bean وقت التفويض. هذه هي الطريقة الاصطلاحية للتعبير عن فحوصات الملكية على مستوى البيانات دون ترميز استدعاءات المستودع في وحدة التحكم. تقوم طريقة bean ببساطة بتحميل المهمة ومقارنة أسماء مستخدمي المالك.
اختبار نقاط النهاية المؤمَّنة
يستخدم اختبار التكامل الكامل @SpringBootTest مع MockMvc. يسجّل الاختبار مستخدمًا، ويسجّل الدخول للحصول على JWT حقيقي، ثم يؤكد أن نقطة النهاية المؤمَّنة تُعيد 200 بالرمز و401 بدونه:
@SpringBootTest
@AutoConfigureMockMvc
class TaskApiIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
private String accessToken;
@BeforeEach
void authenticate() throws Exception {
mvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(
new RegisterRequest("alice", "P@ssw0rd!", Set.of(Role.ROLE_USER)))));
String body = mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new LoginRequest("alice", "P@ssw0rd!"))))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
accessToken = mapper.readTree(body).get("accessToken").asText();
}
@Test
void getTasksRequiresJwt() throws Exception {
mvc.perform(get("/api/tasks")).andExpect(status().isUnauthorized());
}
@Test
void getTasksWithValidJwt() throws Exception {
mvc.perform(get("/api/tasks")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken))
.andExpect(status().isOk());
}
}
مقايضات الأنظمة الموزعة
قبل الختام، تأمّل القرارات المعمارية المدمجة في هذا التصميم:
- رموز وصول قصيرة العمر (15 دقيقة): إذا سُرق رمز، فإن نافذة الهجوم ضيقة. الثمن هو أن العملاء يجب أن يجددوا بشكل متكرر — استخدم معترض تجديد في عميل HTTP الخاص بواجهتك الأمامية لفعل ذلك بشكل صامت.
- رموز التحديث المحفوظة: يُتيح صف قاعدة بيانات لكل جلسة نشطة إلغاءً موجّهًا (تسجيل خروج من جهاز واحد). المقايضة هي جولة إضافية واحدة لقاعدة البيانات لكل طلب تجديد. للواجهات عالية الحركة، ادعم هذا الجدول بـ Redis مع TTL انتهاء صلاحية.
- الأدوار في حمولة JWT: بعد تغيير الأدوار، تعكس الرموز الحالية الأدوار القديمة حتى تنتهي صلاحيتها. إما اقبل هذا التأخير (مناسب لمعظم التطبيقات)، أو اقصّر مدة صلاحية رمز الوصول أكثر، أو قدّم قائمة حظر رموز لإلغاء فوري.
- لا سر مشترك بين الخدمات: إذا أضفت خدمة مصغّرة ثانية، فكلتاهما تحتاج نفس مفتاح التوقيع — أو التحوّل إلى RS256 غير المتماثل بحيث يحتفظ خادم المصادقة بالمفتاح الخاص وتتحقق الخدمات الأخرى بالمفتاح العام فقط.
الخلاصة
لقد جمعت واجهة REST API مؤمَّنة بـ JWT كاملة وقابلة للتشغيل وذات شكل إنتاجي: إعداد التبعيات والتهيئة المدفوعة بـ YAML ونموذج نطاق محفوظ وخدمة JwtService مركّزة وسلسلة فلاتر SecurityFilterChain عديمة الحالة وفلتر OncePerRequestFilter يصادق كل طلب وحراسة نقاط النهاية القائمة على الأدوار على مستوى المسار والأسلوب وتدوير رموز التحديث مع الإلغاء وجناح اختبار تكامل كامل. كل قطعة تتوافق مباشرةً مع درس سابق في هذا البرنامج التعليمي — استخدم هذا المشروع كمرجع حي عند بناء خدماتك الخاصة.