Autenticazione biometrica in Flutter con local_auth: impronta e Face ID

Foto di sentidos humanos su Unsplash

GuideIntermedio40 min Flutter 3.x

Autenticazione biometrica in Flutter con local_auth: impronta e Face ID

Molte app (banking, note private, password manager, salute) devono proteggere i dati sensibili con un secondo livello di sicurezza. Invece di chiedere ogni volta la password, puoi sfruttare l'autenticazione biometrica del dispositivo: impronta digitale, Face ID o, in fallback, il PIN di sistema.

In Flutter il pacchetto ufficiale è local_auth: espone un'API unica che sotto il cofano usa BiometricPrompt su Android e LocalAuthentication (LAContext) su iOS.

In questo tutorial costruiremo:

  • la configurazione nativa corretta per Android e iOS (è il punto dove si blocca il 90% degli sviluppatori);
  • un servizio riutilizzabile BiometricService con gestione completa degli errori;
  • un gate biometrico che protegge un token salvato in flutter_secure_storage;
  • il ri-blocco automatico dell'app quando torna dal background.

⚠️ Nota importante: local_auth verifica chi sta usando il dispositivo, non cifra nulla da solo. Va sempre abbinato a uno storage sicuro per ottenere una protezione reale.

  1. 1

    Installare local_auth e le dipendenze di supporto

    Aggiungi al progetto il pacchetto local_auth insieme ai due package di piattaforma che contengono le stringhe localizzate dei dialog nativi (local_auth_android e local_auth_darwin). Aggiungiamo anche flutter_secure_storage, che useremo più avanti per proteggere un token.

    Da terminale:

    flutter pub add local_auth local_auth_android local_auth_darwin flutter_secure_storage
    

    Il risultato nel pubspec.yaml è quello riportato nello snippet. Le versioni possono variare: usa quelle risolte da flutter pub get.

    Perché servono i package di piattaforma? local_auth da solo funziona, ma i testi del prompt ("Annulla", "Usa il PIN", ecc.) restano in inglese. Importando AndroidAuthMessages e IOSAuthMessages possiamo tradurli in italiano.

    dependencies:
      flutter:
        sdk: flutter
      local_auth: ^2.3.0
      local_auth_android: ^1.0.46
      local_auth_darwin: ^1.4.1
      flutter_secure_storage: ^9.2.2

    Risultato atteso

    `flutter pub get` termina senza errori e i pacchetti compaiono nel file `pubspec.lock`.

  2. 2

    Configurare Android: FlutterFragmentActivity e permessi

    Su Android il prompt biometrico (BiometricPrompt) richiede una FragmentActivity. Il template di Flutter genera invece una FlutterActivity: se salti questo passaggio otterrai l'errore runtime no_fragment_activity.

    1. Modifica MainActivity

    Apri android/app/src/main/kotlin/<tuo/package>/MainActivity.kt e fai estendere FlutterFragmentActivity (vedi snippet).

    2. Aggiungi il permesso

    In android/app/src/main/AndroidManifest.xml, fuori dal tag <application>:

    <uses-permission android:name="android.permission.USE_BIOMETRIC" />
    

    3. Verifica il minSdk

    In android/app/build.gradle assicurati di avere minSdk = 23 o superiore (le API biometriche moderne non esistono prima di Android 6).

    defaultConfig {
        minSdk = 23
    }
    

    Dopo queste modifiche esegui un full restart (non basta l'hot reload): ferma l'app e rilancia flutter run.

    package com.example.biometria_demo
    
    import io.flutter.embedding.android.FlutterFragmentActivity
    
    // IMPORTANTE: FlutterFragmentActivity, non FlutterActivity
    class MainActivity : FlutterFragmentActivity()

    Risultato atteso

    L'app si avvia normalmente su Android e non lancia più l'eccezione `no_fragment_activity` quando richiameremo `authenticate()`.

  3. 3

    Configurare iOS: NSFaceIDUsageDescription

    Su iOS, l'uso del Face ID richiede una stringa di motivazione nel Info.plist, altrimenti il sistema termina l'app con un crash immediato (il Touch ID invece non la richiede, ma inseriscila comunque).

    Apri ios/Runner/Info.plist e aggiungi la chiave NSFaceIDUsageDescription con un testo chiaro per l'utente: verrà mostrato nell'alert di consenso al primo utilizzo.

    Verifica anche che in ios/Podfile la piattaforma minima sia almeno iOS 12:

    platform :ios, '12.0'
    

    Infine, da ios/ esegui pod install (o lascia che lo faccia flutter run).

    💡 Sul Simulatore iOS puoi simulare la biometria da Features → Face ID → Enrolled e poi Matching Face / Non-matching Face. Su emulatore Android devi prima registrare un'impronta in Settings → Security, poi usare adb -e emu finger touch 1.

    <key>NSFaceIDUsageDescription</key>
    <string>Usiamo Face ID per sbloccare in modo sicuro i tuoi dati personali.</string>

    Risultato atteso

    L'app compila ed esegue su iOS; al primo tentativo di Face ID compare l'alert di sistema con il testo che hai scritto.

  4. 4

    Creare un BiometricService riutilizzabile

    Isoliamo tutta la logica in una classe, così la UI resta pulita e il servizio è facilmente mockabile nei test.

    I metodi chiave di LocalAuthentication sono:

    • isDeviceSupported() → il dispositivo ha hardware/sicurezza compatibile;
    • canCheckBiometrics → esistono sensori biometrici utilizzabili;
    • getAvailableBiometrics() → lista di BiometricType (face, fingerprint, strong, weak), utile per adattare l'icona in UI;
    • authenticate() → mostra il prompt nativo.

    Le opzioni di AuthenticationOptions più importanti:

    Opzione Significato
    biometricOnly se true esclude il fallback a PIN/pattern/passcode
    stickyAuth se true il prompt resiste al passaggio in background (es. notifica in arrivo)
    useErrorDialogs lascia che il sistema mostri i dialog di errore/enrollment

    Nota il parametro authMessages: permette di localizzare in italiano i testi nativi.

    import 'package:flutter/services.dart';
    import 'package:local_auth/local_auth.dart';
    import 'package:local_auth_android/local_auth_android.dart';
    import 'package:local_auth_darwin/local_auth_darwin.dart';
    
    enum BiometricStatus { success, cancelled, notAvailable, notEnrolled, lockedOut, failed }
    
    class BiometricService {
      BiometricService([LocalAuthentication? auth])
          : _auth = auth ?? LocalAuthentication();
    
      final LocalAuthentication _auth;
    
      /// true se il dispositivo può usare biometria o credenziale di sistema.
      Future<bool> isSupported() async {
        try {
          return await _auth.isDeviceSupported();
        } on PlatformException {
          return false;
        }
      }
    
      /// true se esiste almeno un sensore biometrico registrato.
      Future<bool> hasEnrolledBiometrics() async {
        try {
          if (!await _auth.canCheckBiometrics) return false;
          final types = await _auth.getAvailableBiometrics();
          return types.isNotEmpty;
        } on PlatformException {
          return false;
        }
      }
    
      /// Restituisce i tipi disponibili, per scegliere l'icona giusta in UI.
      Future<List<BiometricType>> availableTypes() async {
        try {
          return await _auth.getAvailableBiometrics();
        } on PlatformException {
          return const [];
        }
      }
    
      Future<BiometricStatus> authenticate({
        String reason = 'Conferma la tua identità per continuare',
        bool biometricOnly = false,
      }) async {
        try {
          final ok = await _auth.authenticate(
            localizedReason: reason,
            options: AuthenticationOptions(
              biometricOnly: biometricOnly,
              stickyAuth: true,
              useErrorDialogs: true,
     packages: // placeholder rimosso
            ),
            authMessages: const [
              AndroidAuthMessages(
                signInTitle: 'Sblocca l\'app',
                biometricHint: 'Tocca il sensore',
                cancelButton: 'Annulla',
                deviceCredentialsSetupDescription: 'Imposta un blocco schermo nelle impostazioni',
              ),
              IOSAuthMessages(
                cancelButton: 'Annulla',
                goToSettingsButton: 'Impostazioni',
                goToSettingsDescription: 'Configura Face ID o Touch ID per continuare',
              ),
            ],
          );
          return ok ? BiometricStatus.success : BiometricStatus.cancelled;
        } on PlatformException catch (e) {
          return _mapError(e);
        }
      }
    
      BiometricStatus _mapError(PlatformException e) {
        switch (e.code) {
          case 'NotAvailable':
          case 'PasscodeNotSet':
            return BiometricStatus.notAvailable;
          case 'NotEnrolled':
            return BiometricStatus.notEnrolled;
          case 'LockedOut':
          case 'PermanentlyLockedOut':
            return BiometricStatus.lockedOut;
          default:
            return BiometricStatus.failed;
        }
      }
    }

    Risultato atteso

    Hai una classe `BiometricService` compilabile che incapsula controllo capacità, autenticazione e mappatura degli errori in un enum tipizzato.

  5. 5

    Gestire correttamente gli errori biometrici

    Lo snippet precedente contiene volutamente una riga segnaposto da rimuovere (packages: // placeholder rimosso): eliminala, AuthenticationOptions accetta solo biometricOnly, stickyAuth, useErrorDialogs e sensitiveTransaction.

    Il punto davvero importante è non trattare tutti gli errori allo stesso modo. Le costanti ufficiali sono in package:local_auth/error_codes.dart (auth_error.notAvailable, notEnrolled, lockedOut, permanentlyLockedOut, passcodeNotSet).

    Strategie consigliate per ogni caso:

    • notEnrolled → l'utente non ha registrato impronte: invitalo ad aprire le impostazioni, non bloccarlo.
    • lockedOut → troppi tentativi falliti: biometria disabilitata per ~30 secondi. Offri il fallback con PIN/password applicativa.
    • permanentlyLockedOut → serve lo sblocco del dispositivo con credenziale di sistema.
    • notAvailable / passcodeNotSet → nessun blocco schermo: la biometria non è un'opzione, disattiva l'interruttore nelle impostazioni dell'app.
    • cancelled → l'utente ha premuto Annulla: non mostrare errori rossi, è un'azione legittima.

    Nel widget traduciamo lo stato in un messaggio comprensibile.

    String messaggioPer(BiometricStatus status) {
      switch (status) {
        case BiometricStatus.success:
          return 'Accesso consentito';
        case BiometricStatus.cancelled:
          return 'Autenticazione annullata';
        case BiometricStatus.notEnrolled:
          return 'Nessuna impronta o volto registrato: configurali nelle impostazioni del dispositivo';
        case BiometricStatus.lockedOut:
          return 'Troppi tentativi falliti. Riprova tra poco o usa il PIN dell\'app';
        case BiometricStatus.notAvailable:
          return 'Biometria non disponibile su questo dispositivo';
        case BiometricStatus.failed:
          return 'Autenticazione non riuscita';
      }
    }
    
    Future<void> _onSblocca(BuildContext context, BiometricService service) async {
      final status = await service.authenticate(
        reason: 'Sblocca per accedere ai tuoi documenti',
      );
    
      if (!context.mounted) return;
    
      if (status == BiometricStatus.success) {
        Navigator.of(context).pushReplacementNamed('/home');
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(messaggioPer(status))),
        );
      }
    }

    Risultato atteso

    Annullando il prompt vedi un messaggio neutro; sbagliando l'impronta più volte compare l'avviso di blocco temporaneo con il fallback suggerito.

  6. 6

    Proteggere un token con flutter_secure_storage

    La biometria da sola è un controllo di UI: un attaccante con accesso al filesystem potrebbe leggere i dati se non sono cifrati. La combinazione corretta è:

    1. il token/segreto vive in flutter_secure_storage (Keystore su Android, Keychain su iOS);
    2. la lettura avviene solo dopo un authenticate() andato a buon fine.

    Configura inoltre le opzioni di piattaforma: su Android abilita encryptedSharedPreferences, su iOS imposta KeychainAccessibility.first_unlock_this_device per evitare che il segreto finisca nei backup iCloud.

    Lo snippet mostra un SecureVault che espone saveToken e readTokenWithBiometrics.

    import 'package:flutter_secure_storage/flutter_secure_storage.dart';
    
    class SecureVault {
      SecureVault(this._biometrics);
    
      final BiometricService _biometrics;
    
      static const _storage = FlutterSecureStorage(
        aOptions: AndroidOptions(encryptedSharedPreferences: true),
        iOptions: IOSOptions(
          accessibility: KeychainAccessibility.first_unlock_this_device,
        ),
      );
    
      static const _key = 'auth_token';
    
      Future<void> saveToken(String token) =>
          _storage.write(key: _key, value: token);
    
      Future<void> clear() => _storage.delete(key: _key);
    
      /// Legge il token solo dopo un'autenticazione riuscita.
      Future<String?> readTokenWithBiometrics() async {
        final status = await _biometrics.authenticate(
          reason: 'Conferma la tua identità per accedere al token',
          biometricOnly: false, // consente il fallback a PIN/passcode
        );
    
        if (status != BiometricStatus.success) return null;
        return _storage.read(key: _key);
      }
    }

    Risultato atteso

    Il token è leggibile solo passando dal prompt biometrico; senza autenticazione il metodo restituisce `null`.

  7. 7

    Bloccare l'app quando torna dal background

    Un gate mostrato solo all'avvio è aggirabile: basta lasciare l'app aperta. Per una protezione seria conviene ri-bloccare quando l'app passa in background per più di N secondi.

    Usiamo WidgetsBindingObserver per intercettare AppLifecycleState.paused / resumed, salvando il timestamp di uscita. Al rientro, se è trascorso il timeout, mostriamo di nuovo la schermata di blocco.

    Tre accorgimenti pratici:

    • aggiungi una soglia di tolleranza (es. 30 secondi) per non infastidire chi cambia app un attimo;
    • usa un flag _authInCorso per evitare prompt duplicati, perché il prompt nativo stesso genera un paused su Android;
    • se tratti dati molto sensibili, nascondi il contenuto nello snapshot dell'app switcher (su Android FLAG_SECURE, su iOS un overlay in inactive).
    class AppLock extends StatefulWidget {
      const AppLock({super.key, required this.child, required this.service});
    
      final Widget child;
      final BiometricService service;
    
      @override
      State<AppLock> createState() => _AppLockState();
    }
    
    class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
      static const _timeout = Duration(seconds: 30);
    
      bool _locked = true;
      bool _authInCorso = false;
      DateTime? _uscitaDaApp;
    
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);
        WidgetsBinding.instance.addPostFrameCallback((_) => _sblocca());
      }
    
      @override
      void dispose() {
        WidgetsBinding.instance.removeObserver(this);
        super.dispose();
      }
    
      @override
      void didChangeAppLifecycleState(AppLifecycleState state) {
        if (_authInCorso) return; // il prompt nativo genera eventi di lifecycle
    
        if (state == AppLifecycleState.paused) {
          _uscitaDaApp = DateTime.now();
        } else if (state == AppLifecycleState.resumed) {
          final uscita = _uscitaDaApp;
          if (uscita != null && DateTime.now().difference(uscita) > _timeout) {
            setState(() => _locked = true);
            _sblocca();
          }
        }
      }
    
      Future<void> _sblocca() async {
        if (!_locked || _authInCorso) return;
        _authInCorso = true;
        final status = await widget.service.authenticate(
          reason: 'Sblocca per continuare',
        );
        _authInCorso = false;
        if (!mounted) return;
        if (status == BiometricStatus.success) {
          setState(() => _locked = false);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        if (_locked) {
          return Scaffold(
            body: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.lock_outline, size: 64),
                  const SizedBox(height: 16),
                  const Text('App bloccata'),
                  const SizedBox(height: 16),
                  FilledButton.icon(
                    onPressed: _sblocca,
                    icon: const Icon(Icons.fingerprint),
                    label: const Text('Sblocca'),
                  ),
                ],
              ),
            ),
          );
        }
        return widget.child;
      }
    }
    
    // Uso: avvolgi la home dell'app
    // MaterialApp(home: AppLock(service: BiometricService(), child: const HomePage()))

    Risultato atteso

    All'avvio compare la schermata di blocco con il prompt biometrico; tornando dall'app switcher dopo più di 30 secondi l'app si ri-blocca automaticamente.

  8. 8

    Testare il servizio e applicare le best practice

    Poiché BiometricService accetta un LocalAuthentication dall'esterno, puoi testarlo senza device reale usando mocktail.

    Checklist finale prima di andare in produzione:

    • Offri sempre un fallback (PIN applicativo o login con password): un sensore rotto non deve rendere l'app inutilizzabile.
    • Rendi la biometria opt-in da impostazioni, salvando la preferenza; non attivarla d'ufficio.
    • Non usare biometricOnly: true come default: esclude il passcode di sistema, che è spesso un fallback accettabile.
    • Non memorizzare mai segreti in SharedPreferences o in variabili globali non cifrate.
    • Controlla context.mounted dopo ogni await: i prompt nativi possono durare secondi.
    • Gestisci il caso Web/desktop: local_auth non è supportato ovunque, racchiudi le chiamate in un controllo di piattaforma.
    • Imposta sensitiveTransaction: true quando l'azione autorizza un'operazione critica (es. pagamento).
    import 'package:flutter_test/flutter_test.dart';
    import 'package:local_auth/local_auth.dart';
    import 'package:mocktail/mocktail.dart';
    
    class MockLocalAuth extends Mock implements LocalAuthentication {}
    
    void main() {
      late MockLocalAuth mockAuth;
      late BiometricService service;
    
      setUpAll(() {
        registerFallbackValue(const AuthenticationOptions());
      });
    
      setUp(() {
        mockAuth = MockLocalAuth();
        service = BiometricService(mockAuth);
      });
    
      test('ritorna success quando authenticate restituisce true', () async {
        when(() => mockAuth.authenticate(
              localizedReason: any(named: 'localizedReason'),
              options: any(named: 'options'),
              authMessages: any(named: 'authMessages'),
            )).thenAnswer((_) async => true);
    
        expect(await service.authenticate(), BiometricStatus.success);
      });
    
      test('mappa NotEnrolled sullo stato corretto', () async {
        when(() => mockAuth.authenticate(
              localizedReason: any(named: 'localizedReason'),
              options: any(named: 'options'),
              authMessages: any(named: 'authMessages'),
            )).thenThrow(PlatformException(code: 'NotEnrolled'));
    
        expect(await service.authenticate(), BiometricStatus.notEnrolled);
      });
    }

    Risultato atteso

    `flutter test` esegue i test verdi senza bisogno di un dispositivo fisico, confermando la mappatura degli stati biometrici.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!