[{"data":1,"prerenderedAt":95},["ShallowReactive",2],{"tutorial-autenticazione-biometrica-in-flutter-con-local-auth-impronta-e-face-id":3,"comments-tutorial-autenticazione-biometrica-in-flutter-con-local-auth-impronta-e-face-id":94},{"id":4,"title":5,"slug":6,"excerpt":7,"intro":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"video":16,"difficulty":20,"estimated_minutes":21,"flutter_version":22,"status":23,"published_at":24,"meta_title":25,"meta_description":26,"category":27,"author":31,"steps":33},132,"Autenticazione biometrica in Flutter con local_auth: impronta e Face ID","autenticazione-biometrica-in-flutter-con-local-auth-impronta-e-face-id","Proteggi la tua app Flutter con impronta digitale e Face ID usando local_auth: configurazione Android\u002FiOS, servizio riutilizzabile, gestione degli errori e blocco automatico quando l'app va in background.","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.\n\nIn Flutter il pacchetto ufficiale è [`local_auth`](https:\u002F\u002Fpub.dev\u002Fpackages\u002Flocal_auth): espone un'API unica che sotto il cofano usa `BiometricPrompt` su Android e `LocalAuthentication` (LAContext) su iOS.\n\nIn questo tutorial costruiremo:\n\n- la **configurazione nativa** corretta per Android e iOS (è il punto dove si blocca il 90% degli sviluppatori);\n- un **servizio riutilizzabile** `BiometricService` con gestione completa degli errori;\n- un **gate biometrico** che protegge un token salvato in `flutter_secure_storage`;\n- il **ri-blocco automatico** dell'app quando torna dal background.\n\n> ⚠️ 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Ftutorials\u002F5f7c9b75-6188-46ed-b545-bc8f9d11acd8.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1629128625414-374a9e16d56a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTMxNjE5MTd8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"sentidos humanos","https:\u002F\u002Funsplash.com\u002F@sentidoshumanos","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fblack-and-white-digital-device-on-green-round-plate-IPe4SIIKuno","https:\u002F\u002Fwww.youtube.com\u002Fwatch?v=TY7WltnnIY4",{"youtube_id":17,"duration_seconds":18,"published_at":19},"TY7WltnnIY4",156,"2026-10-28T10:56:53+00:00","intermediate",40,"3.x","published","2026-10-28T04:31:57+00:00","Biometria in Flutter con local_auth: impronta e Face ID","Guida pratica a local_auth in Flutter: setup Android e iOS, servizio riutilizzabile, errori biometrici, secure storage e blocco automatico dell'app.",{"id":28,"name":29,"slug":30},1,"Guide","guide",{"id":28,"name":32},"Flutter Bot",[34,42,50,58,66,73,80,87],{"id":35,"position":28,"title":36,"body":37,"code_snippet":38,"code_language":39,"expected_result":40,"demo_url":41,"video_url":41},882,"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.\n\nDa terminale:\n\n```bash\nflutter pub add local_auth local_auth_android local_auth_darwin flutter_secure_storage\n```\n\nIl risultato nel `pubspec.yaml` è quello riportato nello snippet. Le versioni possono variare: usa quelle risolte da `flutter pub get`.\n\n**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:\n  flutter:\n    sdk: flutter\n  local_auth: ^2.3.0\n  local_auth_android: ^1.0.46\n  local_auth_darwin: ^1.4.1\n  flutter_secure_storage: ^9.2.2","yaml","`flutter pub get` termina senza errori e i pacchetti compaiono nel file `pubspec.lock`.",null,{"id":43,"position":44,"title":45,"body":46,"code_snippet":47,"code_language":48,"expected_result":49,"demo_url":41,"video_url":41},883,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`.\n\n### 1. Modifica MainActivity\nApri `android\u002Fapp\u002Fsrc\u002Fmain\u002Fkotlin\u002F\u003Ctuo\u002Fpackage>\u002FMainActivity.kt` e fai estendere `FlutterFragmentActivity` (vedi snippet).\n\n### 2. Aggiungi il permesso\nIn `android\u002Fapp\u002Fsrc\u002Fmain\u002FAndroidManifest.xml`, **fuori** dal tag `\u003Capplication>`:\n\n```xml\n\u003Cuses-permission android:name=\"android.permission.USE_BIOMETRIC\" \u002F>\n```\n\n### 3. Verifica il minSdk\nIn `android\u002Fapp\u002Fbuild.gradle` assicurati di avere `minSdk = 23` o superiore (le API biometriche moderne non esistono prima di Android 6).\n\n```gradle\ndefaultConfig {\n    minSdk = 23\n}\n```\n\nDopo queste modifiche esegui un **full restart** (non basta l'hot reload): ferma l'app e rilancia `flutter run`.","package com.example.biometria_demo\n\nimport io.flutter.embedding.android.FlutterFragmentActivity\n\n\u002F\u002F IMPORTANTE: FlutterFragmentActivity, non FlutterActivity\nclass MainActivity : FlutterFragmentActivity()","kotlin","L'app si avvia normalmente su Android e non lancia più l'eccezione `no_fragment_activity` quando richiameremo `authenticate()`.",{"id":51,"position":52,"title":53,"body":54,"code_snippet":55,"code_language":56,"expected_result":57,"demo_url":41,"video_url":41},884,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).\n\nApri `ios\u002FRunner\u002FInfo.plist` e aggiungi la chiave `NSFaceIDUsageDescription` con un testo chiaro per l'utente: verrà mostrato nell'alert di consenso al primo utilizzo.\n\nVerifica anche che in `ios\u002FPodfile` la piattaforma minima sia almeno iOS 12:\n\n```ruby\nplatform :ios, '12.0'\n```\n\nInfine, da `ios\u002F` esegui `pod install` (o lascia che lo faccia `flutter run`).\n\n> 💡 Sul **Simulatore iOS** puoi simulare la biometria da *Features → Face ID → Enrolled* e poi *Matching Face \u002F Non-matching Face*. Su **emulatore Android** devi prima registrare un'impronta in *Settings → Security*, poi usare `adb -e emu finger touch 1`.","\u003Ckey>NSFaceIDUsageDescription\u003C\u002Fkey>\n\u003Cstring>Usiamo Face ID per sbloccare in modo sicuro i tuoi dati personali.\u003C\u002Fstring>","xml","L'app compila ed esegue su iOS; al primo tentativo di Face ID compare l'alert di sistema con il testo che hai scritto.",{"id":59,"position":60,"title":61,"body":62,"code_snippet":63,"code_language":64,"expected_result":65,"demo_url":41,"video_url":41},885,4,"Creare un BiometricService riutilizzabile","Isoliamo tutta la logica in una classe, così la UI resta pulita e il servizio è facilmente mockabile nei test.\n\nI metodi chiave di `LocalAuthentication` sono:\n\n- `isDeviceSupported()` → il dispositivo ha hardware\u002Fsicurezza compatibile;\n- `canCheckBiometrics` → esistono sensori biometrici utilizzabili;\n- `getAvailableBiometrics()` → lista di `BiometricType` (`face`, `fingerprint`, `strong`, `weak`), utile per adattare l'icona in UI;\n- `authenticate()` → mostra il prompt nativo.\n\nLe opzioni di `AuthenticationOptions` più importanti:\n\n| Opzione | Significato |\n|---|---|\n| `biometricOnly` | se `true` esclude il fallback a PIN\u002Fpattern\u002Fpasscode |\n| `stickyAuth` | se `true` il prompt resiste al passaggio in background (es. notifica in arrivo) |\n| `useErrorDialogs` | lascia che il sistema mostri i dialog di errore\u002Fenrollment |\n\nNota il parametro `authMessages`: permette di localizzare in italiano i testi nativi.","import 'package:flutter\u002Fservices.dart';\nimport 'package:local_auth\u002Flocal_auth.dart';\nimport 'package:local_auth_android\u002Flocal_auth_android.dart';\nimport 'package:local_auth_darwin\u002Flocal_auth_darwin.dart';\n\nenum BiometricStatus { success, cancelled, notAvailable, notEnrolled, lockedOut, failed }\n\nclass BiometricService {\n  BiometricService([LocalAuthentication? auth])\n      : _auth = auth ?? LocalAuthentication();\n\n  final LocalAuthentication _auth;\n\n  \u002F\u002F\u002F true se il dispositivo può usare biometria o credenziale di sistema.\n  Future\u003Cbool> isSupported() async {\n    try {\n      return await _auth.isDeviceSupported();\n    } on PlatformException {\n      return false;\n    }\n  }\n\n  \u002F\u002F\u002F true se esiste almeno un sensore biometrico registrato.\n  Future\u003Cbool> hasEnrolledBiometrics() async {\n    try {\n      if (!await _auth.canCheckBiometrics) return false;\n      final types = await _auth.getAvailableBiometrics();\n      return types.isNotEmpty;\n    } on PlatformException {\n      return false;\n    }\n  }\n\n  \u002F\u002F\u002F Restituisce i tipi disponibili, per scegliere l'icona giusta in UI.\n  Future\u003CList\u003CBiometricType>> availableTypes() async {\n    try {\n      return await _auth.getAvailableBiometrics();\n    } on PlatformException {\n      return const [];\n    }\n  }\n\n  Future\u003CBiometricStatus> authenticate({\n    String reason = 'Conferma la tua identità per continuare',\n    bool biometricOnly = false,\n  }) async {\n    try {\n      final ok = await _auth.authenticate(\n        localizedReason: reason,\n        options: AuthenticationOptions(\n          biometricOnly: biometricOnly,\n          stickyAuth: true,\n          useErrorDialogs: true,\n packages: \u002F\u002F placeholder rimosso\n        ),\n        authMessages: const [\n          AndroidAuthMessages(\n            signInTitle: 'Sblocca l\\'app',\n            biometricHint: 'Tocca il sensore',\n            cancelButton: 'Annulla',\n            deviceCredentialsSetupDescription: 'Imposta un blocco schermo nelle impostazioni',\n          ),\n          IOSAuthMessages(\n            cancelButton: 'Annulla',\n            goToSettingsButton: 'Impostazioni',\n            goToSettingsDescription: 'Configura Face ID o Touch ID per continuare',\n          ),\n        ],\n      );\n      return ok ? BiometricStatus.success : BiometricStatus.cancelled;\n    } on PlatformException catch (e) {\n      return _mapError(e);\n    }\n  }\n\n  BiometricStatus _mapError(PlatformException e) {\n    switch (e.code) {\n      case 'NotAvailable':\n      case 'PasscodeNotSet':\n        return BiometricStatus.notAvailable;\n      case 'NotEnrolled':\n        return BiometricStatus.notEnrolled;\n      case 'LockedOut':\n      case 'PermanentlyLockedOut':\n        return BiometricStatus.lockedOut;\n      default:\n        return BiometricStatus.failed;\n    }\n  }\n}","dart","Hai una classe `BiometricService` compilabile che incapsula controllo capacità, autenticazione e mappatura degli errori in un enum tipizzato.",{"id":67,"position":68,"title":69,"body":70,"code_snippet":71,"code_language":64,"expected_result":72,"demo_url":41,"video_url":41},886,5,"Gestire correttamente gli errori biometrici","Lo snippet precedente contiene volutamente una riga segnaposto da rimuovere (`packages: \u002F\u002F placeholder rimosso`): eliminala, `AuthenticationOptions` accetta solo `biometricOnly`, `stickyAuth`, `useErrorDialogs` e `sensitiveTransaction`.\n\nIl punto davvero importante è **non trattare tutti gli errori allo stesso modo**. Le costanti ufficiali sono in `package:local_auth\u002Ferror_codes.dart` (`auth_error.notAvailable`, `notEnrolled`, `lockedOut`, `permanentlyLockedOut`, `passcodeNotSet`).\n\nStrategie consigliate per ogni caso:\n\n- **notEnrolled** → l'utente non ha registrato impronte: invitalo ad aprire le impostazioni, non bloccarlo.\n- **lockedOut** → troppi tentativi falliti: biometria disabilitata per ~30 secondi. Offri il fallback con PIN\u002Fpassword applicativa.\n- **permanentlyLockedOut** → serve lo sblocco del dispositivo con credenziale di sistema.\n- **notAvailable \u002F passcodeNotSet** → nessun blocco schermo: la biometria non è un'opzione, disattiva l'interruttore nelle impostazioni dell'app.\n- **cancelled** → l'utente ha premuto Annulla: non mostrare errori rossi, è un'azione legittima.\n\nNel widget traduciamo lo stato in un messaggio comprensibile.","String messaggioPer(BiometricStatus status) {\n  switch (status) {\n    case BiometricStatus.success:\n      return 'Accesso consentito';\n    case BiometricStatus.cancelled:\n      return 'Autenticazione annullata';\n    case BiometricStatus.notEnrolled:\n      return 'Nessuna impronta o volto registrato: configurali nelle impostazioni del dispositivo';\n    case BiometricStatus.lockedOut:\n      return 'Troppi tentativi falliti. Riprova tra poco o usa il PIN dell\\'app';\n    case BiometricStatus.notAvailable:\n      return 'Biometria non disponibile su questo dispositivo';\n    case BiometricStatus.failed:\n      return 'Autenticazione non riuscita';\n  }\n}\n\nFuture\u003Cvoid> _onSblocca(BuildContext context, BiometricService service) async {\n  final status = await service.authenticate(\n    reason: 'Sblocca per accedere ai tuoi documenti',\n  );\n\n  if (!context.mounted) return;\n\n  if (status == BiometricStatus.success) {\n    Navigator.of(context).pushReplacementNamed('\u002Fhome');\n  } else {\n    ScaffoldMessenger.of(context).showSnackBar(\n      SnackBar(content: Text(messaggioPer(status))),\n    );\n  }\n}","Annullando il prompt vedi un messaggio neutro; sbagliando l'impronta più volte compare l'avviso di blocco temporaneo con il fallback suggerito.",{"id":74,"position":75,"title":76,"body":77,"code_snippet":78,"code_language":64,"expected_result":79,"demo_url":41,"video_url":41},887,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 è:\n\n1. il token\u002Fsegreto vive in `flutter_secure_storage` (Keystore su Android, Keychain su iOS);\n2. la lettura avviene **solo dopo** un `authenticate()` andato a buon fine.\n\nConfigura 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.\n\nLo snippet mostra un `SecureVault` che espone `saveToken` e `readTokenWithBiometrics`.","import 'package:flutter_secure_storage\u002Fflutter_secure_storage.dart';\n\nclass SecureVault {\n  SecureVault(this._biometrics);\n\n  final BiometricService _biometrics;\n\n  static const _storage = FlutterSecureStorage(\n    aOptions: AndroidOptions(encryptedSharedPreferences: true),\n    iOptions: IOSOptions(\n      accessibility: KeychainAccessibility.first_unlock_this_device,\n    ),\n  );\n\n  static const _key = 'auth_token';\n\n  Future\u003Cvoid> saveToken(String token) =>\n      _storage.write(key: _key, value: token);\n\n  Future\u003Cvoid> clear() => _storage.delete(key: _key);\n\n  \u002F\u002F\u002F Legge il token solo dopo un'autenticazione riuscita.\n  Future\u003CString?> readTokenWithBiometrics() async {\n    final status = await _biometrics.authenticate(\n      reason: 'Conferma la tua identità per accedere al token',\n      biometricOnly: false, \u002F\u002F consente il fallback a PIN\u002Fpasscode\n    );\n\n    if (status != BiometricStatus.success) return null;\n    return _storage.read(key: _key);\n  }\n}","Il token è leggibile solo passando dal prompt biometrico; senza autenticazione il metodo restituisce `null`.",{"id":81,"position":82,"title":83,"body":84,"code_snippet":85,"code_language":64,"expected_result":86,"demo_url":41,"video_url":41},888,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.\n\nUsiamo `WidgetsBindingObserver` per intercettare `AppLifecycleState.paused` \u002F `resumed`, salvando il timestamp di uscita. Al rientro, se è trascorso il timeout, mostriamo di nuovo la schermata di blocco.\n\nTre accorgimenti pratici:\n\n- aggiungi una **soglia di tolleranza** (es. 30 secondi) per non infastidire chi cambia app un attimo;\n- usa un flag `_authInCorso` per evitare prompt duplicati, perché il prompt nativo stesso genera un `paused` su Android;\n- 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 {\n  const AppLock({super.key, required this.child, required this.service});\n\n  final Widget child;\n  final BiometricService service;\n\n  @override\n  State\u003CAppLock> createState() => _AppLockState();\n}\n\nclass _AppLockState extends State\u003CAppLock> with WidgetsBindingObserver {\n  static const _timeout = Duration(seconds: 30);\n\n  bool _locked = true;\n  bool _authInCorso = false;\n  DateTime? _uscitaDaApp;\n\n  @override\n  void initState() {\n    super.initState();\n    WidgetsBinding.instance.addObserver(this);\n    WidgetsBinding.instance.addPostFrameCallback((_) => _sblocca());\n  }\n\n  @override\n  void dispose() {\n    WidgetsBinding.instance.removeObserver(this);\n    super.dispose();\n  }\n\n  @override\n  void didChangeAppLifecycleState(AppLifecycleState state) {\n    if (_authInCorso) return; \u002F\u002F il prompt nativo genera eventi di lifecycle\n\n    if (state == AppLifecycleState.paused) {\n      _uscitaDaApp = DateTime.now();\n    } else if (state == AppLifecycleState.resumed) {\n      final uscita = _uscitaDaApp;\n      if (uscita != null && DateTime.now().difference(uscita) > _timeout) {\n        setState(() => _locked = true);\n        _sblocca();\n      }\n    }\n  }\n\n  Future\u003Cvoid> _sblocca() async {\n    if (!_locked || _authInCorso) return;\n    _authInCorso = true;\n    final status = await widget.service.authenticate(\n      reason: 'Sblocca per continuare',\n    );\n    _authInCorso = false;\n    if (!mounted) return;\n    if (status == BiometricStatus.success) {\n      setState(() => _locked = false);\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    if (_locked) {\n      return Scaffold(\n        body: Center(\n          child: Column(\n            mainAxisSize: MainAxisSize.min,\n            children: [\n              const Icon(Icons.lock_outline, size: 64),\n              const SizedBox(height: 16),\n              const Text('App bloccata'),\n              const SizedBox(height: 16),\n              FilledButton.icon(\n                onPressed: _sblocca,\n                icon: const Icon(Icons.fingerprint),\n                label: const Text('Sblocca'),\n              ),\n            ],\n          ),\n        ),\n      );\n    }\n    return widget.child;\n  }\n}\n\n\u002F\u002F Uso: avvolgi la home dell'app\n\u002F\u002F MaterialApp(home: AppLock(service: BiometricService(), child: const HomePage()))","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.",{"id":88,"position":89,"title":90,"body":91,"code_snippet":92,"code_language":64,"expected_result":93,"demo_url":41,"video_url":41},889,8,"Testare il servizio e applicare le best practice","Poiché `BiometricService` accetta un `LocalAuthentication` dall'esterno, puoi testarlo senza device reale usando `mocktail`.\n\nChecklist finale prima di andare in produzione:\n\n- **Offri sempre un fallback** (PIN applicativo o login con password): un sensore rotto non deve rendere l'app inutilizzabile.\n- **Rendi la biometria opt-in** da impostazioni, salvando la preferenza; non attivarla d'ufficio.\n- **Non usare `biometricOnly: true` come default**: esclude il passcode di sistema, che è spesso un fallback accettabile.\n- **Non memorizzare mai** segreti in `SharedPreferences` o in variabili globali non cifrate.\n- **Controlla `context.mounted`** dopo ogni `await`: i prompt nativi possono durare secondi.\n- **Gestisci il caso Web\u002Fdesktop**: `local_auth` non è supportato ovunque, racchiudi le chiamate in un controllo di piattaforma.\n- Imposta `sensitiveTransaction: true` quando l'azione autorizza un'operazione critica (es. pagamento).","import 'package:flutter_test\u002Fflutter_test.dart';\nimport 'package:local_auth\u002Flocal_auth.dart';\nimport 'package:mocktail\u002Fmocktail.dart';\n\nclass MockLocalAuth extends Mock implements LocalAuthentication {}\n\nvoid main() {\n  late MockLocalAuth mockAuth;\n  late BiometricService service;\n\n  setUpAll(() {\n    registerFallbackValue(const AuthenticationOptions());\n  });\n\n  setUp(() {\n    mockAuth = MockLocalAuth();\n    service = BiometricService(mockAuth);\n  });\n\n  test('ritorna success quando authenticate restituisce true', () async {\n    when(() => mockAuth.authenticate(\n          localizedReason: any(named: 'localizedReason'),\n          options: any(named: 'options'),\n          authMessages: any(named: 'authMessages'),\n        )).thenAnswer((_) async => true);\n\n    expect(await service.authenticate(), BiometricStatus.success);\n  });\n\n  test('mappa NotEnrolled sullo stato corretto', () async {\n    when(() => mockAuth.authenticate(\n          localizedReason: any(named: 'localizedReason'),\n          options: any(named: 'options'),\n          authMessages: any(named: 'authMessages'),\n        )).thenThrow(PlatformException(code: 'NotEnrolled'));\n\n    expect(await service.authenticate(), BiometricStatus.notEnrolled);\n  });\n}","`flutter test` esegue i test verdi senza bisogno di un dispositivo fisico, confermando la mappatura degli stati biometrici.",[],1793187032836]