الأدوار والصلاحيات في JWT
رمز JWT الذي يُثبت هويتك فحسب ليس سوى نصف القصة. تحتاج واجهات برمجية التطبيقات في بيئات الإنتاج أيضًا إلى معرفة ما يُسمح لك بفعله. إن تضمين الأدوار والصلاحيات الدقيقة مباشرةً داخل الرمز يُتيح لكل خدمة في بنيتك التحتية اتخاذ قرارات التفويض محليًا — دون الحاجة إلى رحلة ذهاب وإياب إلى مخزن أذونات مركزي. يُرشدك هذا الدرس بالتفصيل إلى كيفية تحقيق ذلك مع Spring Security 6.
مفاهيم Spring Security: الأدوار مقابل الصلاحيات
يستخدم Spring Security مصطلحَين مرتبطَين لكنهما متمايزان:
- الصلاحية (
GrantedAuthority) — أي سلسلة نصية تمثّل إذنًا. أمثلة: READ_REPORTS، DELETE_USERS، SCOPE_openid.
- الدور — صلاحية مسمّاة يتوقّع Spring Security أن تحمل البادئة
ROLE_. عند استدعاء hasRole("ADMIN")، يتحقّق Spring داخليًا من وجود الصلاحية ROLE_ADMIN.
في عالم JWT، الأدوار والصلاحيات ليست سوى ادعاءات (claims) — أزواج مفتاح وقيمة مدمجة في حمولة الرمز. اصطلاح التسمية متروك لك؛ قاعدة البادئة تسري فقط عند استخدام تعبيرات Spring كـ hasRole() و hasAuthority().
خيار تصميمي: تخزّن كثير من الفرق قائمة مسطّحة تحت مفتاح ادعاء واحد (مثلًا "roles": ["ROLE_ADMIN","ROLE_USER"]) وتُحوّلها جميعًا إلى كائنات GrantedAuthority في Spring. وتُفرّق فرق أخرى بين الأدوار الكبيرة والأذونات الدقيقة في ادعاءَين منفصلَين. كلاهما يعمل؛ المهم أن يتّفق كود المعالجة مع تعبيرات الأمان.
تضمين الأدوار عند بناء JWT
أنت تُولّد الرمز بالفعل في JwtUtil. وسّع generateToken لتقبّل صلاحيات المستخدم:
// JwtUtil.java
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.GrantedAuthority;
import java.security.Key;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class JwtUtil {
private final Key signingKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private static final long EXPIRY_MS = 3_600_000; // ساعة واحدة
public String generateToken(String username,
Collection<? extends GrantedAuthority> authorities) {
List<String> roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.setSubject(username)
.claim("roles", roles) // <-- تضمين القائمة
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRY_MS))
.signWith(signingKey)
.compact();
}
public String extractUsername(String token) {
return Jwts.parserBuilder().setSigningKey(signingKey).build()
.parseClaimsJws(token).getBody().getSubject();
}
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return (List<String>) Jwts.parserBuilder().setSigningKey(signingKey).build()
.parseClaimsJws(token).getBody().get("roles", List.class);
}
}
أبقِ ادعاء الأدوار صغيرًا. تنتقل JWTs في كل ترويسة طلب. مستخدم يمتلك 200 صلاحية دقيقة سيُضخّم الرمز بشكل كبير. أعطِ الأولوية للأدوار الكبيرة في JWT وجلب الأذونات الدقيقة من قاعدة البيانات فقط حين يكون ذلك ضروريًا فعلًا.
استعادة الصلاحيات في فلتر المصادقة
يبني JwtAuthenticationFilter الخاص بك (من الدرس الرابع) حاليًا UsernamePasswordAuthenticationToken بدون صلاحيات. وسّعه لاستخراج ادعاء الأدوار وتحويله مجددًا إلى كائنات GrantedAuthority:
// JwtAuthenticationFilter.java (تحديث doFilterInternal)
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.List;
import java.util.stream.Collectors;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
String username = jwtUtil.extractUsername(token);
List<String> roles = jwtUtil.extractRoles(token);
// تحويل السلاسل النصية إلى GrantedAuthority
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, authorities); // <-- الوسيط الثالث = الصلاحيات
auth.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "رمز غير صالح");
return;
}
}
chain.doFilter(request, response);
}
بمجرد أن يحمل كائن Authentication الصلاحيات، يستطيع طبقة التفويض في Spring Security تقييم تعبيرات كـ hasRole و hasAuthority بشكل صحيح لهذا الطلب — كل ذلك دون الوصول إلى قاعدة البيانات.
حماية نقاط النهاية حسب الدور
في SecurityConfig الخاص بك، حدّث قواعد التفويض:
// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // ROLE_ADMIN
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAuthority("READ_REPORTS")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
hasRole("ADMIN") مقابل hasAuthority("ROLE_ADMIN"): كلا التعبيرَين يتحقّقان من نفس سلسلة الصلاحية. hasRole اختصار يُضيف البادئة ROLE_ تلقائيًا. هما متبادلان — اختر أسلوبًا واحدًا والتزم به في كل الكودبيس.
الأمان على مستوى الدوال مع @PreAuthorize
قواعد مسارات HTTP خشنة. للتحكم الدقيق، فعّل أمان الدوال وأضّف تعليقات توضيحية إلى دوال الخدمة الفردية:
// SecurityConfig.java — أضف التعليق التوضيحي
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // يُفعّل @PreAuthorize و @PostAuthorize و @Secured
public class SecurityConfig { ... }
// ReportService.java
import org.springframework.security.access.prepost.PreAuthorize;
@Service
public class ReportService {
@PreAuthorize("hasRole('ADMIN') or hasAuthority('READ_REPORTS')")
public List<Report> getAllReports() { ... }
@PreAuthorize("hasRole('ADMIN')")
public void deleteReport(Long id) { ... }
// الوصول إلى المبدأ المصادق عليه داخل التعبير
@PreAuthorize("authentication.name == #username or hasRole('ADMIN')")
public UserProfile getProfile(String username) { ... }
}
تتكامل التعليقات التوضيحية على مستوى الدوال جيدًا مع قواعد HTTP: يرفض طبقة HTTP المتصلين غير المصادق عليهم مبكرًا، بينما يوفر @PreAuthorize خطًا ثانيًا من الإنفاذ لقواعد العمل المعقدة.
الآثار الأمنية ومقايضات الأنظمة الموزّعة
تضمين الأدوار في JWT يحمل تحذيرًا مهمًا: الرمز مكتفٍ بذاته وموقَّع، وليس حيًا. إن سحبت دورًا من مستخدم في قاعدة بياناتك، لن يسري هذا التغيير حتى انتهاء صلاحية رمزه الحالي. أبرز المقايضات:
- ميزة — قابلية التوسع: تتحقق كل خدمة من الرمز محليًا. عدم الاحتياج لرحلة إلى مخزن الأذونات يعني زمن استجابة أقل وانعدام نقطة فشل واحدة للتفويض.
- عيب — أدوار قديمة: يظل الرمز الصادر قبل تغيير دور صالحًا حتى انتهاء صلاحيته. من وسائل التخفيف: فترات انتهاء صلاحية قصيرة (15–60 دقيقة) مع رموز تحديث، أو قائمة إلغاء رموز تُفحص في العمليات الحساسة.
- ميزة — إمكانية التدقيق: تُظهر لقطة الرمز ما كانت عليه أدوار المستخدم وقت تسجيل الدخول، وهو ما قد يُفيد في سجلات التدقيق.
لا تضمّن أذونات بالغة الحساسية في رموز طويلة الصلاحية. أدوار كـ ROLE_SUPER_ADMIN أو DELETE_PRODUCTION_DB يجب أن تعيش في رموز قصيرة الصلاحية (5–15 دقيقة) أو أن تُتحقق منها مقابل قاعدة البيانات في كل طلب، متحمّلًا تكلفة الأداء مقابل هامش أمان إضافي.
تحديث نقطة نهاية تسجيل الدخول
مرّر صلاحيات المستخدم عند توليد الرمز في وحدة تحكم المصادقة:
// AuthController.java
@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
UserDetails user = (UserDetails) auth.getPrincipal();
String token = jwtUtil.generateToken(user.getUsername(), user.getAuthorities());
return ResponseEntity.ok(Map.of("token", token));
}
الخلاصة
الأدوار والصلاحيات المضمّنة في JWT تمنح كل خدمة في نظامك لقطة تفويض مكتفية بذاتها: استخرج ادعاء roles، حوّله إلى كائنات GrantedAuthority في فلترك، ودع تعبيرات Spring Security من hasRole و hasAuthority تتولى الباقي — سواء في SecurityConfig لقواعد المسار أو عبر @PreAuthorize لقواعد مستوى الدوال. المقايضة هي الحداثة: افهم النافذة الزمنية بين تغيير دور وانتهاء صلاحية الرمز، وصمّم عمر الرمز وفقًا لذلك، واحتفظ بفحوصات قاعدة البيانات لكل طلب للعمليات الأكثر حساسية فقط.