JWT وOAuth2 وتأمين الواجهات

إنشاء رموز JWT والتحقق منها

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

إنشاء رموز JWT والتحقق منها

أوضح الدرس السابق ما هو رمز JWT نظريًا. يُريك هذا الدرس كيفية بناء رمز والتحقق منه بأمان في تطبيق Spring Boot 3. بنهايته سيكون لديك JwtService مكتفٍ بذاته يمكن لفلتر المصادقة واختباراتك الاعتماد عليه معًا.

اختيار المكتبة: JJWT

مكتبة Java JWT (JJWT) من Stormpath/Okta هي أكثر مكتبات JWT استخدامًا في بيئة Spring. توفر واجهة برمجية سلسة (fluent builder API) لكل من التوليد والتحليل، وتتولى ترميز Base64url وحساب التوقيع والتحقق من المطالبات (claims) عنك. أضف المكتبات الثلاث المطلوبة إلى ملف pom.xml:

<!-- JJWT — أضف إلى pom.xml --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency>

التقسيم إلى -api و-impl و-jackson مقصود: كودك يُصرَّف مقابل ملف الـ API الثابت فقط. التنفيذ ومُسلسِل JSON هما تبعيتا وقت التشغيل، لذا لا يستلزم ترقيتهما إعادة تصريف فئاتك.

تخزين مفتاح التوقيع بأمان

يجب توقيع كل رمز JWT تُصدره. التوقيع هو ما يمنع العميل من التلاعب في الحمولة. لخوارزمية HS256 (HMAC-SHA-256) تحتاج إلى مفتاح سري بطول لا يقل عن 256 بت (32 بايت). خزّنه في application.yml كسلسلة مُرمَّزة بـ Base64 وحقنه عبر @Value — لا تُضمّنه أبدًا مباشرةً في ملف الفئة.

# application.yml security: jwt: secret: "7Tz4k9mQwXpL2nRvYcBsJdEgAhFuOiKe3lNqCbPrMfTzUwVxDyGjHoSiZaLmNpQr" expiration-ms: 86400000 # 24 ساعة
أنشئ المفتاح السري بالطريقة الصحيحة. لا تخترع سلسلة نصية بيدك — نفّذ openssl rand -base64 32 (أو ما يعادله) وخزّن المخرج في مدير الأسرار أو متغير بيئة CI/CD، ثم حقنه في وقت التشغيل. المفتاح الضعيف أو المتوقع يُبطل مخطط التوقيع بأكمله.

بناء JwtService

غلّف كل منطق JWT في فئة @Service واحدة. هذا يُبقي باقي تطبيقك بمعزل عن المكتبة الأساسية.

package com.example.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @Service public class JwtService { private final SecretKey signingKey; private final long expirationMs; public JwtService( @Value("${security.jwt.secret}") String base64Secret, @Value("${security.jwt.expiration-ms}") long expirationMs) { byte[] keyBytes = Base64.getDecoder().decode(base64Secret); this.signingKey = Keys.hmacShaKeyFor(keyBytes); // يتحقق من طول >= 256 بت this.expirationMs = expirationMs; } // ─── التوليد ───────────────────────────────────────────────────────────── public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) .subject(userDetails.getUsername()) // مطالبة "sub" .issuedAt(new Date()) // "iat" .expiration(new Date(System.currentTimeMillis() + expirationMs)) // "exp" .signWith(signingKey) // افتراضيًا HS256 .compact(); } // ─── التحقق ────────────────────────────────────────────────────────────── public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } // ─── استخراج المطالبات ──────────────────────────────────────────────────── public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // ─── داخلي ─────────────────────────────────────────────────────────────── private Claims extractAllClaims(String token) { // يرمي JwtException عند أي خلل (ExpiredJwtException, SignatureException, ...) return Jwts.parser() .verifyWith(signingKey) .build() .parseSignedClaims(token) .getPayload(); } private boolean isTokenExpired(String token) { return extractClaim(token, Claims::getExpiration).before(new Date()); } }
لماذا Keys.hmacShaKeyFor()؟ تتحقق هذه الدالة المصنعة من أن مصفوفة البايت كافية الطول للخوارزمية المطلوبة قبل إنشاء كائن المفتاح. إن مررت مفتاحًا قصيرًا جدًا تُرمى WeakKeyException عند بدء التشغيل — وهو بالضبط الوقت الذي تريد اكتشاف الإعداد الخاطئ فيه، لا في وقت التشغيل عند أول طلب.

ما الذي يجري داخل extractAllClaims

عند استدعاء Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token)، تنفّذ JJWT الخطوات التالية بالترتيب:

  1. تقسيم الرمز عند فاصلَي النقطة . إلى رأس (header) وحمولة (payload) وتوقيع.
  2. فك ترميز Base64url لملفي JSON للرأس والحمولة.
  3. إعادة حساب HMAC-SHA256 على base64url(header).base64url(payload) بمفتاح التوقيع.
  4. المقارنة بالنتيجة مع التوقيع في الرمز (مقارنة بزمن ثابت للمقاومة ضد هجمات التوقيت).
  5. التحقق من المطالبات المعيارية: exp (لم ينته الصلاحية)، وnbf (إن وُجد)، وiss/aud (إن هيّأتهما).
  6. إعادة خريطة Claims، أو رمي فئة فرعية من JwtException.

كل فئة استثناء فرعية تحمل معلومات دقيقة: ExpiredJwtException، وSignatureException، وMalformedJwtException، وUnsupportedJwtException. سيلتقط فلتر المصادقة (الدرس الرابع) الفئة الأم JwtException ويُعيد 401 Unauthorized.

إضافة مطالبات مخصصة

يمكنك تضمين أي قيمة قابلة للتسلسل JSON كمطالبة إضافية. حالة استخدام شائعة هي تضمين أدوار المستخدم حتى تستطيع طبقة التخويل قراءتها مباشرةً من الرمز دون استعلام قاعدة بيانات:

public String generateTokenWithRoles(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); claims.put("roles", userDetails.getAuthorities() .stream() .map(a -> a.getAuthority()) .toList()); return generateToken(claims, userDetails); } // استخراجها لاحقًا: @SuppressWarnings("unchecked") public List<String> extractRoles(String token) { return extractClaim(token, c -> (List<String>) c.get("roles")); }
أبقِ الحمولة صغيرة. كل طلب HTTP يحمل الرمز في رأس Authorization. تضمين كائنات كبيرة (ملفات تعريف مستخدم كاملة، قوائم أدوار طويلة) يُكبّر كل طلب. اقتصر على ما تحتاجه الخدمة الطرفية فعلًا — اسم المستخدم ورموز الأدوار ومعرّف المستأجر — وابحث عن أي شيء آخر من ذاكرة تخزين مؤقت أو قاعدة بيانات عند الطلب.

المفاتيح غير المتماثلة: متى تنتقل من HS256 إلى RS256

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

RS256 (RSA-SHA256) يُقسّم هذا إلى مفتاح خاص (يلمسه خادم المصادقة فقط) ومفتاح عام (يُوزَّع بحرية على خوادم الموارد). خادم الموارد القادر فقط على التحقق لا يستطيع تزوير الرموز. يتطلب التبديل في JJWT توليد زوج RSAPrivateKey/RSAPublicKey واستدعاء signWith(privateKey) عند التوليد وverifyWith(publicKey) عند التحليل. دعم خادم الموارد OAuth2 في Spring Security (الدرس التاسع) يمكنه خدمة المفتاح العام تلقائيًا عبر نقطة نهاية /.well-known/jwks.json معيارية.

اختبار JwtService

الخدمة مجرد Spring bean عادي — لا تحتاج إلى طبقة ويب. اختبار وحدة سريع يُغطي المسارات الحرجة:

@SpringBootTest class JwtServiceTest { @Autowired JwtService jwtService; private UserDetails user = User.withUsername("alice") .password("n/a").roles("USER").build(); @Test void generatedTokenIsValidForSameUser() { String token = jwtService.generateToken(user); assertTrue(jwtService.isTokenValid(token, user)); } @Test void extractedUsernameMatchesSubject() { String token = jwtService.generateToken(user); assertEquals("alice", jwtService.extractUsername(token)); } @Test void tamperedTokenFailsValidation() { String token = jwtService.generateToken(user); String tampered = token.substring(0, token.lastIndexOf('.') + 1) + "INVALIDSIG"; assertThrows(JwtException.class, () -> jwtService.extractUsername(tampered)); } }

الخلاصة

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