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

رموز التحديث

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

رموز التحديث

في الدروس السابقة بنيت نظامًا يُصدر رمز وصول JWT عند تسجيل الدخول ويتحقق منه في كل طلب محمي. يعمل ذلك جيدًا، لكنه يحمل مقايضةً خفيةً: كي يكون الرمز مفيدًا دون الرجوع إلى قاعدة البيانات مع كل طلب، يجب أن يكون عديم الحالة (stateless)، ما يعني أن الخادم لا يستطيع إبطاله قبل انتهاء صلاحيته. الرموز قصيرة العمر (5–15 دقيقة) تُقلّص نافذة الإبطال، لكنها تعني أيضًا أن المستخدم يجب أن يُعيد تسجيل الدخول كل بضع دقائق — وهو أمر يُفسد تجربة المستخدم.

تحلّ رموز التحديث (Refresh Tokens) هذا التوتر. الفكرة بسيطة:

  • أصدر رمز وصول قصير العمر (5–15 دقيقة) — هو بيانات الاعتماد الحاملة لاستدعاءات API.
  • أصدر رمز تحديث طويل العمر (أيام إلى أسابيع) — يُخزَّن بأمان ولا يُستخدم إلا للحصول على رموز وصول جديدة.
  • عند انتهاء صلاحية رمز الوصول، يُقدّم العميل رمزَ التحديث إلى نقطة نهاية مخصصة /auth/refresh ويحصل على رمز وصول جديد دون مطالبة المستخدم ببيانات الاعتماد مجددًا.
لماذا رمزان؟ رمز الوصول يُرسَل في كل رأسية طلب ويُتحقق منه في الذاكرة فحسب (دون الوصول إلى قاعدة البيانات). إن سُرق، لا يستطيع المهاجم استخدامه سوى 15 دقيقة. أما رمز التحديث فيُستخدم نادرًا، ويمكن التحقق منه مقابل سجل في قاعدة البيانات، ويمكن إبطاله من جهة الخادم فورًا. يمنحك هذا الأداء والإبطال الحقيقي معًا.

خطوات تدفق التحديث

  1. يُسجّل المستخدم دخوله. يُعيد الخادم { accessToken, refreshToken }. يُحفظ رمز التحديث في قاعدة البيانات ويُرسَل إلى العميل.
  2. يُخزّن العميل رمز الوصول في الذاكرة (لا في localStorage للتطبيقات عالية الأمان) ورمز التحديث في ملف تعريف ارتباط HttpOnly أو تخزين آمن.
  3. يُرسل العميل الطلبات مع رمز الوصول في الرأسية Authorization: Bearer <token>.
  4. عند إعادة الخادم للاستجابة 401 Unauthorized (انتهت صلاحية رمز الوصول)، يستدعي العميل POST /auth/refresh مع رمز التحديث.
  5. يتحقق الخادم من رمز التحديث مقابل قاعدة البيانات (يتحقق من وجوده وعدم إبطاله وعدم انتهاء صلاحيته). إن كان صالحًا، يُصدر رمز وصول جديدًا (ويُدوّر رمز التحديث اختياريًا).
  6. يُعيد العميل إرسال الطلب الأصلي مع رمز الوصول الجديد.

تخزين رموز التحديث

على عكس رموز الوصول، تمتلك رموز التحديث حالة (stateful). يجب تخزينها في قاعدة البيانات حتى يمكن إبطالها. يبدو الكيان البسيط كالتالي:

@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 أو hex بـ 256 بت) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private Instant expiresAt; @Column(nullable = false) private boolean revoked = false; // getters وsetters والبنّاءون محذوفون للإيجاز }
استخدم قيمة عشوائية غير شفافة لرمز التحديث، وليس JWT آخر. رمز التحديث من نوع JWT لا يمكن إبطاله قبل انتهاء صلاحيته دون قائمة سوداء — وهذا يُفسد الغرض منه. الرمز غير الشفاف (مثل UUID.randomUUID().toString() أو بايتات SecureRandom مُشفَّرة بـ hex) يتيح حذفه أو وضع علامة مُبطَلة عليه في قاعدة البيانات على الفور.

خدمة RefreshTokenService

@Service @Transactional public class RefreshTokenService { private static final Duration REFRESH_TTL = Duration.ofDays(7); private final RefreshTokenRepository refreshTokenRepo; public RefreshTokenService(RefreshTokenRepository refreshTokenRepo) { this.refreshTokenRepo = refreshTokenRepo; } public RefreshToken createFor(User user) { // التدوير: إبطال أي رمز موجود لهذا المستخدم refreshTokenRepo.revokeAllByUser(user); RefreshToken rt = new RefreshToken(); rt.setToken(UUID.randomUUID().toString()); rt.setUser(user); rt.setExpiresAt(Instant.now().plus(REFRESH_TTL)); return refreshTokenRepo.save(rt); } public RefreshToken validate(String rawToken) { RefreshToken rt = refreshTokenRepo.findByToken(rawToken) .orElseThrow(() -> new TokenException("رمز التحديث غير موجود")); if (rt.isRevoked()) { throw new TokenException("رمز التحديث تم إبطاله"); } if (rt.getExpiresAt().isBefore(Instant.now())) { rt.setRevoked(true); refreshTokenRepo.save(rt); throw new TokenException("انتهت صلاحية رمز التحديث"); } return rt; } }

متحكم المصادقة — نقطتا الدخول والتحديث

@RestController @RequestMapping("/auth") public class AuthController { private final AuthenticationManager authManager; private final JwtService jwtService; private final RefreshTokenService refreshTokenService; // حقن المنشئ محذوف للإيجاز @PostMapping("/login") public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.email(), req.password()) ); User user = (User) auth.getPrincipal(); String accessToken = jwtService.generateAccessToken(user); RefreshToken rt = refreshTokenService.createFor(user); return ResponseEntity.ok(new AuthResponse(accessToken, rt.getToken())); } @PostMapping("/refresh") public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) { RefreshToken rt = refreshTokenService.validate(req.refreshToken()); User user = rt.getUser(); String accessToken = jwtService.generateAccessToken(user); // اختياري: تدوير رمز التحديث عند كل استخدام RefreshToken newRt = refreshTokenService.createFor(user); return ResponseEntity.ok(new AuthResponse(accessToken, newRt.getToken())); } @PostMapping("/logout") public ResponseEntity<Void> logout(@RequestBody RefreshRequest req) { refreshTokenService.revokeToken(req.refreshToken()); return ResponseEntity.noContent().build(); } }

تدوير رمز التحديث واكتشاف إعادة الاستخدام

لاحظ استدعاء createFor(user) داخل نقطة نهاية /auth/refresh. هذا هو تدوير رمز التحديث (refresh token rotation): كل تحديث ناجح يُصدر رمزَ تحديث جديدًا ويُبطل القديم. وهو أسلوب أمني بالغ الأهمية.

لماذا يهم التدوير؟ إن سرق مهاجم رمز تحديث واستخدمه، فإن التدوير يعني أن رمز المستخدم الشرعي يُبطَل في نفس اللحظة. في المرة التالية التي يحاول فيها المستخدم الشرعي التحديث، ستفشل عملية التحقق — مُنبّهةً إياك (وربما إياه) بأن الرمز قد تعرض للاختراق. بدون تدوير، يظل رمز التحديث المسروق صالحًا حتى تاريخ انتهاء صلاحيته الطبيعي.

احذر من حالات السباق (race conditions) مع تدوير الرمز. إذا أرسل عميل محمول طلبين متزامنَين يُفعّلان كلاهما التحديث، سيفشل أحدهما لأن التدوير الأول قد أبطل الرمز بالفعل. صمّم عميلك لتسلسل استدعاءات التحديث (واحد فقط في آنٍ واحد) أو نفّذ نافذة سماح قصيرة يظل فيها الرمز القديم صالحًا لبضع ثوانٍ بعد التدوير.

توصيات عمر رمز الوصول

هذه نقاط بداية عملية — اضبطها وفقًا لنموذج التهديد لديك:

  • رمز الوصول: 5–15 دقيقة. عديم الحالة، يُتحقق منه في الذاكرة. النافذة القصيرة تُقلّل تعرض الرمز المسروق للخطر.
  • رمز التحديث: 7–30 يومًا. يمتلك حالة، مدعوم بقاعدة بيانات، يُدار عند الاستخدام. عمود expiresAt في قاعدة البيانات هو الموعد النهائي الصارم.
  • حد الجلسة المطلق: حدد مدة جلسة قصوى إجمالية (مثلًا 90 يومًا). بعدها يجب على المستخدم إعادة المصادقة بغض النظر عن نشاط الرمز. يمنع هذا جلسة تسجيل دخول واحدة من الاستمرار إلى الأبد على جهاز مشترك.

تخزين الرموز بأمان على العميل

تصميم رمز الخادم لا يهم إلا إذا خزّن العميل الرموز بشكل صحيح:

  • رمز الوصول: احتفظ به في ذاكرة JavaScript (متغير على مستوى الوحدة أو مخزن الحالة). لا في localStorage أو sessionStorage — يستطيع هجوم XSS قراءتهما.
  • رمز التحديث: أرسله في ملف تعريف ارتباط بخاصيات HttpOnly; Secure; SameSite=Strict. تجعله HttpOnly غير مرئي لـ JavaScript؛ وتمنع SameSite=Strict هجمات CSRF. في هذا المتغير الأكثر صرامة، تقرأ نقطة نهاية الخادم /auth/refresh الرمز من ملف تعريف الارتباط بدلًا من جسم الطلب.

الخلاصة

رموز التحديث هي الآلية التي تتيح لك الحصول على رموز وصول قصيرة العمر يصعب إساءة استخدامها وتجربة مستخدم سلسة في آنٍ واحد. تظل رموز الوصول عديمة الحالة وسريعة؛ أما رموز التحديث فتمتلك حالة وقابلة للإبطال. اقرنها بالتدوير للكشف عن السرقة، وخزّنها في قاعدة بيانات من جهة الخادم، واجمعها مع ملفات تعريف الارتباط HttpOnly من جهة العميل لتحقيق أفضل وضع أمني. في الدرس القادم ستتعرف على OAuth2 وOpenID Connect — البروتوكولات المعيارية في الصناعة التي تُضفرم بالضبط هذا النوع من دورة حياة الرمز.