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

المصادقة البيومترية باستخدام local_auth

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

المصادقة البيومترية باستخدام local_auth

تتيح حزمة local_auth لتطبيق Flutter الاستفادة من أجهزة الأمان المدمجة في الجهاز — مستشعرات بصمة الإصبع وFace ID ورمز PIN/نمط الجهاز — لمصادقة المستخدم دون إرسال أي بيانات اعتماد عبر الشبكة. تتم المصادقة بالكامل على الجهاز، مما يجعلها سريعة وصديقة للخصوصية. يتناول هذا الدرس الإعداد وفحوصات التوفر وتشغيل المصادقة ودمج البيومترية مع رمز مخزن بأمان لتدفق "فتح قفل التطبيق".

إضافة الاعتمادية

أضف local_auth إلى ملف pubspec.yaml الخاص بك:

dependencies:
  local_auth: ^2.3.0
  flutter_secure_storage: ^9.0.0  # لتخزين رمز الجلسة

تحتاج أيضاً إلى إعداد خاص بكل منصة:

  • Android: في ملف android/app/src/main/AndroidManifest.xml، أضف <uses-permission android:name="android.permission.USE_BIOMETRIC" /> واستبدل FlutterActivity بـ FlutterFragmentActivity في ملف MainActivity.kt.
  • iOS: في ملف ios/Runner/Info.plist، أضف المفتاح NSFaceIDUsageDescription مع نص يوضح سبب الحاجة إلى الوصول لـ Face ID.
ملاحظة: على Android، تتطلب local_auth استخدام FlutterFragmentActivity بدلاً من FlutterActivity الافتراضية لأن مربع حوار البيومترية يستخدم Fragment داخلياً. نسيان هذا التغيير هو أكثر أخطاء الإعداد شيوعاً.

فحص توفر البيومترية

قبل مطالبة المستخدم، تحقق دائماً من ثلاثة أشياء: (1) يدعم الجهاز البيومترية على مستوى الأجهزة، (2) سجّل المستخدم بيومترية واحدة على الأقل، (3) يمكن للتطبيق استخدام المصادقة البيومترية. استخدم LocalAuthentication للاستعلام عن الثلاثة:

import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  /// تُرجع true إذا كان الجهاز يدعم البيومترية AND
  /// سجّل المستخدم بيومترية واحدة على الأقل.
  Future<bool> isBiometricAvailable() async {
    final bool canCheck = await _auth.canCheckBiometrics;
    final bool isDeviceSupported = await _auth.isDeviceSupported();
    if (!canCheck || !isDeviceSupported) return false;

    final List<BiometricType> enrolled =
        await _auth.getAvailableBiometrics();
    return enrolled.isNotEmpty;
  }

  /// تُرجع قائمة أنواع البيومترية المسجّلة.
  Future<List<BiometricType>> getEnrolledBiometrics() async {
    return _auth.getAvailableBiometrics();
  }
}

يمكن أن يكون BiometricType من النوع BiometricType.fingerprint أو BiometricType.face أو BiometricType.iris أو BiometricType.weak أو BiometricType.strong. فحص هذه القائمة يتيح لك تخصيص نص المطالبة (مثل "استخدم Face ID" مقابل "استخدم بصمة الإصبع").

نصيحة: يُرجع canCheckBiometrics القيمة true حتى عندما لا تكون هناك بيومترية مسجّلة — فهو يختبر وجود الأجهزة فقط. استدع دائماً getAvailableBiometrics() أيضاً وتأكد من أن القائمة غير فارغة قبل عرض مطالبة البيومترية.

مصادقة المستخدم

استدع authenticate() مع كائن AuthenticationOptions للتحكم في سلوك الاحتياط. تُرجع الدالة true عند النجاح وfalse إذا ألغى المستخدم أو فشل عدة مرات.

import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:flutter/services.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  Future<bool> authenticate({required String reason}) async {
    try {
      return await _auth.authenticate(
        localizedReason: reason,
        options: const AuthenticationOptions(
          biometricOnly: false,   // السماح بـ PIN كاحتياط
          stickyAuth: true,       // إبقاء الحوار مفتوحاً إذا غادر المستخدم التطبيق
          sensitiveTransaction: true,
        ),
      );
    } on PlatformException catch (e) {
      if (e.code == auth_error.notAvailable ||
          e.code == auth_error.notEnrolled ||
          e.code == auth_error.lockedOut ||
          e.code == auth_error.permanentlyLockedOut) {
        // عرض رسالة ودية للمستخدم بدلاً من التعطل
        return false;
      }
      rethrow;
    }
  }

  Future<void> cancelAuthentication() async {
    await _auth.stopAuthentication();
  }
}
تحذير: التقط دائماً PlatformException عند استدعاء authenticate(). يتم إلقاء رمزَي الخطأ lockedOut وpermanentlyLockedOut عندما يعطّل الجهاز البيومترية بعد محاولات فاشلة متكررة — إذا تركتهما يتصاعدان دون التقاط فسيتعطل تطبيقك.

دمج البيومترية مع رمز آمن (نمط فتح قفل التطبيق)

النمط الشائع في بيئة الإنتاج هو فتح قفل التطبيق: يسجّل المستخدم دخوله مرة واحدة ببيانات اعتماد كاملة (بريد إلكتروني + كلمة مرور)، تخزّن رمز الجلسة الناتج في flutter_secure_storage (مدعوم بالأجهزة على كلتا المنصتين)، وعند الإطلاق اللاحق تضع وصول الرمز خلف فحص بيومتري. لا ترى الشبكة بيانات البيومترية أبداً.

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class AppLockService {
  static const _tokenKey = 'session_token';
  final _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );
  final _biometric = BiometricService();

  /// يُستدعى بعد تسجيل دخول ناجح بكلمة المرور.
  Future<void> storeToken(String token) async {
    await _storage.write(key: _tokenKey, value: token);
  }

  /// يُرجع الرمز المخزن فقط بعد فحص بيومتري ناجح.
  /// يُرجع null إذا فشلت البيومترية أو لم يكن هناك رمز مخزن.
  Future<String?> unlockWithBiometrics() async {
    final bool available = await _biometric.isBiometricAvailable();
    if (!available) return null;

    final bool authenticated = await _biometric.authenticate(
      reason: 'تحقق من هويتك للوصول إلى التطبيق',
    );
    if (!authenticated) return null;

    return _storage.read(key: _tokenKey);
  }

  Future<void> logout() async {
    await _storage.delete(key: _tokenKey);
  }
}

ربطه بواجهة المستخدم

في ودجت شاشة القفل، استدع unlockWithBiometrics() عند التهيئة وأضف زر إعادة المحاولة اليدوية. استخدم حراسة mounted بعد كل await لمنع تحديثات الحالة على الودجات المُتخلص منها:

class LockScreen extends StatefulWidget {
  const LockScreen({super.key});
  @override
  State<LockScreen> createState() => _LockScreenState();
}

class _LockScreenState extends State<LockScreen> {
  final _lock = AppLockService();
  bool _checking = false;

  @override
  void initState() {
    super.initState();
    _tryUnlock();
  }

  Future<void> _tryUnlock() async {
    if (!mounted) return;
    setState(() => _checking = true);
    final token = await _lock.unlockWithBiometrics();
    if (!mounted) return;
    setState(() => _checking = false);
    if (token != null) {
      Navigator.pushReplacementNamed(context, '/home', arguments: token);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _checking
            ? const CircularProgressIndicator()
            : Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.fingerprint, size: 64),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: _tryUnlock,
                    child: const Text('فتح القفل بالبيومترية'),
                  ),
                ],
              ),
      ),
    );
  }
}

الملخص

تتبع المصادقة البيومترية مع local_auth تدفقاً واضحاً من ثلاث خطوات: فحص التوفر (الأجهزة + التسجيل)، المصادقة (مع معالجة أخطاء الإغلاق بأناقة)، التصرف بناءً على النتيجة (في نمط فتح قفل التطبيق، اقرأ الرمز الآمن). النقاط الرئيسية:

  • تحقق دائماً من كلٍّ من canCheckBiometrics وgetAvailableBiometrics() قبل المطالبة.
  • التقط جميع رموز PlatformException — خاصة lockedOut وpermanentlyLockedOut.
  • لا تخزّن بيانات الاعتماد الخام على الجهاز؛ خزّن فقط الرمز الصادر من الخادم داخل flutter_secure_storage.
  • استخدم biometricOnly: false حتى يتمكن المستخدمون الذين لا يملكون بيومترية مسجّلة من استخدام PIN الجهاز كاحتياط.
  • احرص على وضع if (!mounted) return بعد كل تحديث حالة يأتي بعد await.