المصادقة والأمان

التعامل مع JWT: فك التشفير والتحقق والتحديث

16 دقيقة الدرس 5 من 12

التعامل مع JWT: فك التشفير والتحقق والتحديث

تُعدّ رموز JSON Web Tokens (JWTs) العمود الفقري للمصادقة الحديثة عديمة الحالة. عندما يسجّل مستخدم دخوله عبر Firebase Authentication، يُصدر Firebase SDK رمز هوية ID token مُوقَّعاً — وهو رمز JWT مضغوط وآمن لعناوين URL يُشفّر هوية المستخدم وادعاءاته. في هذا الدرس ستتعلم كيف تُحلّل هذه الرموز وتتحقق منها على جانب العميل باستخدام حزمة dart_jsonwebtoken، وكيف تُنفّذ تحديثاً صامتاً للرمز حتى لا تنتهي الجلسات بشكل مفاجئ، وكيف تُرسل رمز Bearer إلى REST API مع كل طلب مصادَق عليه.

ملاحظة: يُستخدم تحليل JWT على جانب العميل لقراءة الادعاءات (uid, email, expiry) وتحديد ما إذا كان يجب التحديث بشكل استباقي. لا تعتمد أبداً على التحقق من JWT على جانب العميل كحدٍّ أمني — يجب على الخادم الخلفي أن يتحقق من الرمز باستقلالية باستخدام Firebase Admin SDK أو مفاتيح Google العامة.

تشريح رمز Firebase ID Token

يتكوّن JWT من ثلاثة أجزاء مُرمَّزة بـ Base64URL مفصولة بنقاط: header.payload.signature. يحتوي الحمولة (payload) على ادعاءات قياسية ستقرأها كثيراً:

  • sub — معرّف Firebase UID (ثابت وفريد لكل مستخدم)
  • email / email_verified — عنوان البريد الإلكتروني للمستخدم وحالة التحقق منه
  • iat (وقت الإصدار) وexp (وقت الانتهاء) — طوابع زمنية Unix؛ رموز Firebase تنتهي بعد ساعة واحدة
  • aud — معرّف مشروع Firebase الخاص بك (يتحقق من أن الرمز مخصص لتطبيقك)
  • firebase.sign_in_provider — مثل password أو google.com

فك تشفير رمز باستخدام dart_jsonwebtoken

أضف الحزمة إلى ملف pubspec.yaml:

اعتماد pubspec.yaml

dependencies:
  dart_jsonwebtoken: ^2.8.0
  firebase_auth: ^5.0.0
  http: ^1.2.0

تُحلّل دالة JWT.decode() الرمز دون التحقق من التوقيع — وهو أمر مفيد لاستخراج الادعاءات على جانب العميل. للتحقق من التوقيع تُمرّر SecretKey أو RSAPublicKey؛ تستخدم رموز Firebase خوارزمية RS256، لكن على جانب العميل عادةً تُفكّ الشفرة دون تحقق (إذ قد تحقق Firebase SDK بالفعل من الجلسة).

فك تشفير وقراءة ادعاءات Firebase JWT

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:firebase_auth/firebase_auth.dart';

/// يُعيد خريطة الحمولة المُفكَّكة من رمز هوية المستخدم الحالي.
/// يُلقي استثناءً إذا لم يكن هناك مستخدم مسجّل دخوله.
Future<Map<String, dynamic>> getTokenClaims() async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) throw StateError('No signed-in user');

  // force-refresh=false: استخدم الرمز المخزّن مؤقتاً إذا كان صالحاً
  final idToken = await user.getIdToken(false);

  // فك التشفير دون التحقق من التوقيع (جانب العميل فقط)
  final jwt = JWT.decode(idToken!);
  final payload = jwt.payload as Map<String, dynamic>;

  final uid      = payload['sub']   as String;
  final email    = payload['email'] as String?;
  final expiry   = DateTime.fromMillisecondsSinceEpoch(
                     (payload['exp'] as int) * 1000);
  final provider = (payload['firebase'] as Map)['sign_in_provider'];

  print('UID: $uid  |  expires: $expiry  |  provider: $provider');
  return payload;
}

/// يُعيد true عندما يكون انتهاء الرمز المخزّن مؤقتاً خلال [threshold].
bool isTokenExpiringSoon(Map<String, dynamic> payload,
    {Duration threshold = const Duration(minutes: 5)}) {
  final exp = DateTime.fromMillisecondsSinceEpoch(
      (payload['exp'] as int) * 1000);
  return DateTime.now().add(threshold).isAfter(exp);
}

التحديث الصامت للرمز

تنتهي صلاحية رموز Firebase كل 60 دقيقة. بدلاً من إجبار المستخدم على إعادة المصادقة، تُنفّذ التحديث الصامت: استدعاء getIdToken(true) للحصول على رمز جديد قبل انتهاء صلاحية الحالي. أفضل ممارسة هي التحديث الاستباقي — تحقق من انتهاء الصلاحية قبل كل طلب API وقم بالتحديث إذا بقي أقل من 5 دقائق.

AuthService مع تحديث صامت استباقي

import 'dart:async';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:firebase_auth/firebase_auth.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  /// يُعيد رمز هوية جديداً (أو لا يزال صالحاً).
  /// يُجري تحديثاً إجبارياً تلقائياً إذا كان الانتهاء خلال 5 دقائق.
  Future<String> getFreshToken() async {
    final user = _auth.currentUser;
    if (user == null) throw StateError('Not authenticated');

    // الحصول على الرمز (قد يكون مخزّناً مؤقتاً)
    final rawToken = await user.getIdToken(false);
    final payload  = JWT.decode(rawToken!).payload as Map<String, dynamic>;

    final exp = DateTime.fromMillisecondsSinceEpoch(
        (payload['exp'] as int) * 1000);
    final needsRefresh =
        DateTime.now().add(const Duration(minutes: 5)).isAfter(exp);

    if (needsRefresh) {
      // إجبار رمز جديد من خوادم Firebase
      return await user.getIdToken(true) ?? '';
    }
    return rawToken;
  }

  /// يستمع إلى تغييرات رمز Firebase ويُصدر رموزاً جديدة.
  Stream<String?> get tokenStream =>
      _auth.idTokenChanges().asyncMap((user) async {
        if (user == null) return null;
        return await user.getIdToken(false);
      });
}
نصيحة: اشترك في FirebaseAuth.instance.idTokenChanges() داخل مُخبِر حالة المصادقة. يُحدّث Firebase الرمز تلقائياً في الخلفية ويُصدر قيمة جديدة، لذا يمتلك تطبيقك دائماً رمزاً حالياً دون أي منطق استطلاع.

إرسال رمز Bearer إلى REST API

بمجرد حصولك على رمز جديد، أرفقه بكل طلب HTTP كرأس Authorization: Bearer <token>. النمط الأنظف هو غلّاف HTTP رقيق أو http.BaseClient مخصص يُدرج الرأس تلقائياً.

AuthenticatedClient — يُدرج رمز Bearer تلقائياً

import 'dart:async';
import 'package:http/http.dart' as http;
import 'auth_service.dart'; // يحتوي على AuthService

/// http.BaseClient يُدرج رمز Firebase Bearer
/// في كل طلب ويُجري تحديثاً صامتاً عند الحاجة.
class AuthenticatedClient extends http.BaseClient {
  final AuthService _authService;
  final http.Client _inner;

  AuthenticatedClient(this._authService,
      {http.Client? inner})
      : _inner = inner ?? http.Client();

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    // الحصول على رمز جديد (يُحدَّث تلقائياً عند الحاجة)
    final token = await _authService.getFreshToken();

    // نسخ الطلب وإضافة رأس التفويض
    request.headers['Authorization'] = 'Bearer $token';
    request.headers['Content-Type']  = 'application/json';

    return _inner.send(request);
  }

  @override
  void close() => _inner.close();
}

// --- الاستخدام ---
// final client = AuthenticatedClient(AuthService());
// final response = await client.get(
//   Uri.parse('https://api.example.com/profile'),
// );
// print(response.body);
تحذير: لا تسجّل أو تخزّن رموز الهوية الخام في نص عادي (مثل SharedPreferences). هي بيانات اعتماد قصيرة الأجل. استخدم flutter_secure_storage إذا اضطررت إلى استمرار أي رمز، وأرسله دائماً عبر HTTPS.

التعامل مع أخطاء الرمز بأناقة

قد تتسبب أعطال الشبكة أو الرموز المُلغاة أو انحراف الساعة في FirebaseAuthException. اصطد رموز الخطأ المحددة وأعِد توجيه المستخدم بشكل مناسب:

  • user-token-expired — تم إلغاء رمز التحديث؛ أعِد التوجيه إلى تسجيل الدخول
  • network-request-failed — لا اتصال بالإنترنت؛ اعرض واجهة غير متصلة وأعِد المحاولة لاحقاً
  • user-disabled — الحساب معطّل؛ أخرج المستخدم فوراً

ملخص

في هذا الدرس تعلمت فك تشفير رموز Firebase JWT باستخدام dart_jsonwebtoken لقراءة الادعاءات مثل UID والبريد الإلكتروني ووقت الانتهاء. نفّذت استراتيجية تحديث صامت استباقي عبر getIdToken(true) وبنيت AuthenticatedClient قابلاً لإعادة الاستخدام يُدرج رأس Authorization: Bearer في كل طلب REST. في الدرس التالي ستُؤمّن التخزين المحلي وتتعامل مع إلغاء الرمز لتدفق مصادقة كامل وجاهز للإنتاج.