مصادقة OAuth2 وJWT
JWT هو جواز مرور الخدمات المصغرة—عديم الحالة، مكتفٍ ذاتياً، ويمكّن المصادقة عبر الخدمات—مما ينهي تحديات مشاركة الجلسات.
1. ما ستتعلمه
- مقارنة بين تدفق رمز التفويض OAuth 2.0 ومنح كلمة المرور
- بنية رمز JWT: Header / Payload / Signature
- تهيئة
spring-boot-starter-oauth2-resource-serverوفك تشفير JWT - تعيين Claims المخصصة لـ JWT إلى أدوار المستخدم
- Alice تنفذ إصدار JWT ومصادقة الرموز لواجهة API في OrderFlow
2. قصة حقيقية لمهندس بنية
(1) نقطة الألم: الجلسات تنتهي في الخدمات المصغرة
بعد أن قام Alice بتوسيع OrderFlow من مثيل واحد إلى ثلاثة مثيلات، ظهرت مشكلة جلسات: عندما يسجل المستخدم الدخول على المثيل A وتتم إعادة توجيه الطلب عبر موازن الحمل إلى المثيل B، لا تكون الجلسة موجودة، ويتم تسجيل خروج المستخدم. حاول Bob استخدام Redis لمشاركة الجلسات، لكن هذا أدخل تبعيات وتعقيداً جديدين. طلب Charlie دعم تسجيل الدخول الخارجي (Google/GitHub)، وهو ما لم تستطع حل الجلسات التقليدية توفيره على الإطلاق.
(2) حل JWT
رموز JWT تحتوي على جميع معلومات المصادقة، لذا لا يحتاج الخادم إلى تخزين الجلسات:
// إصدار JWT عند تسجيل الدخول
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
// التحقق من JWT في كل طلب (عديم الحالة، بدون حاجة لجلسة)
// Spring Security يستخرج الأدوار تلقائياً من JWT claims
(3) العوائد
بعد أن استبدلت Alice الجلسات بـ JWTs: المثيلات الثلاثة لم تعد بحاجة لمشاركة جلسة، مما يتيح التوسع الأفقي بدون تكلفة؛ الرموز تُخزن على جانب العميل، مما يجعل الخادم عديم الحالة؛ ودعم تسجيل الدخول عبر OAuth 2.0 خارجي أصبح ممكناً.
3. مفاهيم OAuth 2.0 وJWT
(1) تدفق تفويض OAuth 2.0
sequenceDiagram
participant U as وكيل المستخدم<br/>(المتصفح)
participant C as العميل<br/>(OrderFlow SPA)
participant AS as خادم<br/>التفويض
participant RS as خادم<br/>الموارد (API)
U->>C: انقر "تسجيل الدخول عبر GitHub"
C->>AS: إعادة توجيه إلى /authorize
AS->>U: تسجيل الدخول + صفحة الموافقة
U->>AS: موافقة
AS->>C: إعادة توجيه مع رمز التفويض
C->>AS: POST /token (رمز التفويض + client_secret)
AS->>C: Access Token (JWT)
C->>RS: GET /api/orders (Bearer token)
RS->>RS: التحقق من توقيع JWT
RS->>C: 200 OK + البيانات
| نموذج التفويض | حالات الاستخدام | أنواع العميل |
|---|---|---|
| تدفق رمز التفويض | تطبيقات الويب / SPAs | خدمات خلفية + واجهة أمامية |
| بيانات اعتماد العميل | استدعاءات بين الخدمات | آلة إلى آلة |
| بيانات اعتماد مالك المورد (ROPC) | مهمل، للاختبار فقط | تطبيقات الطرف الأول |
| الوضع الضمني | مهمل | SPA قديم |
(2) بنية رمز JWT
يتكون JWT من ثلاثة أجزاء، مفصولة بـ .:
Header.Payload.Signature
eyJhbGciOiJS256.eyJzdWIiOiJib2IiLCJyb2xlIjoiQ1VTVU9NRVIifQ.signature
| القسم | المحتوى | مثال |
|---|---|---|
| Header | الخوارزمية + نوع الرمز | {"alg":"RS256","typ":"JWT"} |
| Payload | Claims | {"sub":"bob","role":"CUSTOMER","exp":1705312000} |
| Signature | توقيع Header+Payload | تحقق سلامة توقيع RS256 |
| Claim معياري | المعنى |
|---|---|
sub |
الموضوع (مُعرّف المستخدم) |
iss |
المُصدر |
aud |
الجمهور |
exp |
انتهاء الصلاحية |
iat |
صدر في (تاريخ الإصدار) |
scope |
نطاق الصلاحيات |
4. تهيئة OAuth 2.0 Resource Server
(1) التبعيات والتهيئة
(1) ▶ مثال: تبعيات pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
الناتج:
// التنفيذ ناجح
(2) ▶ مثال: تهيئة SecurityFilterChain لـ JWT
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login").permitAll()
.requestMatchers("/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/orders/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
String role = jwt.getClaimAsString("role");
if (role == null) return List.of();
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
});
return converter;
}
}
الناتج:
// التنفيذ ناجح
5. إصدار JWT والتحقق منه
(1) تهيئة زوج مفاتيح RSA
(1) ▶ مثال: توليد زوج مفاتيح RSA
@Configuration
public class JwtConfig {
@Bean
public KeyPair keyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
@Bean
public JwtEncoder jwtEncoder(KeyPair keyPair) {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSource<SecurityContext> jwkSource =
new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder(KeyPair keyPair) {
return JwtDecoders.withPublicKey((RSAPublicKey) keyPair.getPublic());
}
}
الناتج:
// التنفيذ ناجح
(2) ▶ مثال: إصدار JWT عند تسجيل الدخول
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final JwtEncoder jwtEncoder;
private final AuthenticationManager authenticationManager;
public AuthController(JwtEncoder jwtEncoder,
AuthenticationManager authenticationManager) {
this.jwtEncoder = jwtEncoder;
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
Instant now = Instant.now();
Instant expiry = now.plus(1, ChronoUnit.HOURS);
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("orderflow-service")
.subject(auth.getName())
.issuedAt(now)
.expiresAt(expiry)
.claim("role", auth.getAuthorities().stream()
.filter(a -> a.getAuthority().startsWith("ROLE_"))
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("CUSTOMER"))
.build();
String token = jwtEncoder
.encode(JwtEncoderParameters.from(claims))
.getTokenValue();
return Map.of("accessToken", token);
}
public record LoginRequest(String username, String password) {}
}
الناتج:
// التنفيذ ناجح
6. Claims المخصصة لـ JWT وتعيين الأدوار
(1) استراتيجية تعيين الأدوار
| حقل Claim | القيمة | التعيين إلى صلاحية |
|---|---|---|
role: "ADMIN" |
سلسلة دور واحدة | ROLE_ADMIN |
roles: ["ADMIN","CUSTOMER"] |
مصفوفة أدوار | ROLE_ADMIN، ROLE_CUSTOMER |
scope: "read write" |
نطاق OAuth2 | SCOPE_read، SCOPE_write |
(1) ▶ مثال: JwtAuthenticationConverter مخصص
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// دعم كل من claims "role" (مفرد) و"roles" (مصفوفة)
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
String role = jwt.getClaimAsString("role");
if (role != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(
new SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
});
return converter;
}
الناتج:
// التنفيذ ناجح
(2) ▶ مثال: استخدام معلومات JWT في المتحكم
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping("/my")
public List<OrderResponse> getMyOrders(
@AuthenticationPrincipal Jwt jwt) {
String username = jwt.getClaimAsString("sub");
String role = jwt.getClaimAsString("role");
return orderService.getOrdersByUsername(username);
}
}
الناتج:
// التنفيذ ناجح
| الطريقة | الحصول على معلومات المستخدم | السيناريوهات المناسبة |
|---|---|---|
@AuthenticationPrincipal Jwt jwt |
JWT Claims الكاملة | حاجة إلى Claim مخصص |
@AuthenticationPrincipal UserDetails user |
UserDetails | حاجة لمعلومات مستخدم معيارية |
Principal principal |
getName() | اسم المستخدم فقط |
7. مثال شامل: التنفيذ الكامل لمصادقة JWT في OrderFlow
// SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/orders/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt ->
Optional.ofNullable(jwt.getClaimAsString("role"))
.map(role -> List.of(new SimpleGrantedAuthority("ROLE_" + role)))
.orElse(List.of()));
return converter;
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
// AuthController.java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final JwtEncoder jwtEncoder;
private final AuthenticationManager authManager;
public AuthController(JwtEncoder jwtEncoder, AuthenticationManager authManager) {
this.jwtEncoder = jwtEncoder;
this.authManager = authManager;
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("orderflow").subject(auth.getName())
.issuedAt(now).expiresAt(now.plus(1, ChronoUnit.HOURS))
.claim("role", auth.getAuthorities().stream()
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("CUSTOMER"))
.build();
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return Map.of("accessToken", token);
}
public record LoginRequest(String username, String password) {}
}
# 1. تسجيل الدخول والحصول على JWT
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"pass123"}' | jq -r '.accessToken')
# 2. الوصول إلى واجهة API محمية باستخدام JWT
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/orders/my
# 3. نقطة نهاية الإدارة باستخدام JWT مدير
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"admin123"}' | jq -r '.accessToken')
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:8080/api/v1/admin/dashboard
❓ أسئلة شائعة
📖 ملخص
- الأجزاء الثلاثة لـ JWT: Header (الخوارزمية)، Payload (الclaims)، Signature (التوقيع)
- خادم موارد OAuth2 يتحقق من توقيع JWT ويستخرج الأدوار من claims لمنح التفويض
- تشفير RSA غير المتماثل: إصدار بالمفتاح الخاص، تحقق بالمفتاح العام، وإدارة مفاتيح أكثر أماناً
JwtAuthenticationConverterمنطق تعيين مخصص من Claims إلى Authority- واجهة API لتسجيل الدخول تصدر JWT؛ واجهات API الأخرى تستخدم مصادقة Bearer Token
- JWT عديم الحالة ومناسب جداً للخدمات المصغرة؛ عيبه أنه لا يمكن إبطاله بشكل نشط
📝 تمارين
-
تمرين أساسي (الصعوبة ⭐): نفّذ تسجيل دخول JWT ومصادقة API لـ OrderFlow، مستبدلاً httpBasic. استخدم curl لاختبار عملية تسجيل الدخول والحصول على رمز → استخدم الرمز للوصول إلى واجهة API المحمية.
-
تمرين متقدم (الصعوبة: ⭐⭐): نفّذ آلية Refresh Token—Access Token صالح لمدة 15 دقيقة، Refresh Token صالح لمدة 7 أيام، ويُستخدم Refresh Token للحصول على Access Token جديد.
-
تحدٍ (الصعوبة: ⭐⭐⭐): ادمج Keycloak كخادم تفويض خارجي، حيث يعمل OrderFlow فقط كخادم موارد للتحقق من JWTs التي أصدرها Keycloak. فكّر في كيفية إنشاء علاقات ثقة بين الخدمات.



