Biometría en Flutter: ejercicio resuelto con local_auth y huella dactilar / Face ID

Biometría en Flutter: ejercicio resuelto con local_auth y Face ID / huella dactilar

local_auth permite autenticar al usuario con huella dactilar, Face ID o el PIN/patrón del dispositivo, sin enviar credenciales a ningún servidor. Es ideal para proteger pantallas sensibles dentro de una app ya autenticada.

Enunciado

Implementa una pantalla de autenticación biométrica que:

  • Verifica si el dispositivo soporta biometría y qué tipos están disponibles.
  • Solicita autenticación biométrica con fallback a PIN/patrón.
  • Muestra el resultado (éxito, fallo, error) con feedback claro.
  • Combina la biometría con flutter_secure_storage para leer un token protegido.

Dependencias

1
2
3
dependencies:
  local_auth: ^2.3.0
  flutter_secure_storage: ^9.2.2

Configuración Android

En android/app/src/main/AndroidManifest.xml:

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

La activity principal debe usar el tema FlutterFragmentActivity:

1
2
3
4
// android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()

Configuración iOS

En ios/Runner/Info.plist:

1
2
<key>NSFaceIDUsageDescription</key>
<string>Usamos Face ID para proteger tu cuenta</string>

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;

// ── Servicio de biometría ──────────────────────────────────────────────────────
class BiometricService {
  final _auth = LocalAuthentication();
  final _storage = const FlutterSecureStorage();

  static const _tokenKey = 'auth_token';

  /// Verifica si el dispositivo puede usar biometría
  Future<BiometricAvailability> checkAvailability() async {
    final canCheck = await _auth.canCheckBiometrics;
    final isDeviceSupported = await _auth.isDeviceSupported();

    if (!isDeviceSupported) {
      return BiometricAvailability.notSupported;
    }
    if (!canCheck) {
      return BiometricAvailability.notEnrolled;
    }

    return BiometricAvailability.available;
  }

  /// Devuelve los tipos de biometría disponibles en el dispositivo
  Future<List<BiometricType>> getAvailableBiometrics() =>
      _auth.getAvailableBiometrics();

  /// Autentica al usuario. Devuelve [AuthResult] con el resultado.
  Future<AuthResult> authenticate({
    required String reason,
    bool biometricOnly = false,
  }) async {
    try {
      final success = await _auth.authenticate(
        localizedReason: reason,
        options: AuthenticationOptions(
          biometricOnly: biometricOnly, // false = permite fallback a PIN
          stickyAuth: true, // mantiene el diálogo si la app va al background
          useErrorDialogs: true, // diálogo de error nativo
        ),
      );
      return success ? AuthResult.success : AuthResult.failed;
    } on PlatformException catch (e) {
      return switch (e.code) {
        auth_error.notAvailable => AuthResult.notAvailable,
        auth_error.notEnrolled => AuthResult.notEnrolled,
        auth_error.lockedOut => AuthResult.lockedOut,
        auth_error.permanentlyLockedOut => AuthResult.permanentlyLockedOut,
        _ => AuthResult.error,
      };
    }
  }

  // ── Integración con flutter_secure_storage ─────────────────────────────────

  Future<void> saveToken(String token) =>
      _storage.write(key: _tokenKey, value: token);

  /// Lee el token solo si la biometría es exitosa
  Future<String?> readTokenWithBiometrics() async {
    final result = await authenticate(
      reason: 'Autentícate para ver tu token de sesión',
    );
    if (result != AuthResult.success) return null;
    return _storage.read(key: _tokenKey);
  }

  Future<void> deleteToken() => _storage.delete(key: _tokenKey);
}

enum BiometricAvailability { available, notSupported, notEnrolled }

enum AuthResult {
  success,
  failed,
  notAvailable,
  notEnrolled,
  lockedOut,
  permanentlyLockedOut,
  error,
}

// ── Main ───────────────────────────────────────────────────────────────────────
void main() => runApp(const MaterialApp(home: BiometricDemo()));

// ── Pantalla de biometría ──────────────────────────────────────────────────────
class BiometricDemo extends StatefulWidget {
  const BiometricDemo({super.key});
  @override
  State<BiometricDemo> createState() => _BiometricDemoState();
}

class _BiometricDemoState extends State<BiometricDemo> {
  final _service = BiometricService();

  BiometricAvailability? _availability;
  List<BiometricType> _biometrics = [];
  AuthResult? _lastResult;
  String? _token;
  bool _loading = false;

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

  Future<void> _init() async {
    setState(() => _loading = true);
    // Guardar un token de demo en secure storage
    await _service.saveToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo');
    final availability = await _service.checkAvailability();
    final biometrics = await _service.getAvailableBiometrics();
    setState(() {
      _availability = availability;
      _biometrics = biometrics;
      _loading = false;
    });
  }

  Future<void> _authenticate() async {
    setState(() => _loading = true);
    final result = await _service.authenticate(
      reason: 'Confirma tu identidad para acceder a la app',
    );
    setState(() {
      _lastResult = result;
      _loading = false;
    });
  }

  Future<void> _readToken() async {
    setState(() => _loading = true);
    final token = await _service.readTokenWithBiometrics();
    setState(() {
      _token = token;
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Biometría con local_auth')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // ── Estado del dispositivo ───────────────────────────────────
            _DeviceInfo(
              availability: _availability,
              biometrics: _biometrics,
              loading: _loading,
            ),

            const Divider(height: 32),

            // ── Acciones ─────────────────────────────────────────────────
            FilledButton.icon(
              onPressed:
                  (_availability == BiometricAvailability.available && !_loading)
                      ? _authenticate
                      : null,
              icon: const Icon(Icons.fingerprint),
              label: const Text('Autenticar con biometría'),
            ),

            const SizedBox(height: 8),

            FilledButton.icon(
              onPressed:
                  (_availability == BiometricAvailability.available && !_loading)
                      ? _readToken
                      : null,
              icon: const Icon(Icons.lock_open),
              label: const Text('Leer token protegido'),
              style: FilledButton.styleFrom(backgroundColor: Colors.teal),
            ),

            const SizedBox(height: 24),

            // ── Resultado ─────────────────────────────────────────────────
            if (_lastResult != null) ...[
              _ResultCard(result: _lastResult!),
              const SizedBox(height: 16),
            ],

            // ── Token recuperado ──────────────────────────────────────────
            if (_token != null) ...[
              const Text('Token recuperado de Secure Storage:',
                  style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 4),
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.green.shade50,
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(color: Colors.green.shade200),
                ),
                child: SelectableText(
                  _token!,
                  style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
                ),
              ),
            ],

            const Spacer(),

            // ── Leyenda de biometría ────────────────────────────────────
            _BiometricLegend(biometrics: _biometrics),
          ],
        ),
      ),
    );
  }
}

// ── Widget: info del dispositivo ───────────────────────────────────────────────
class _DeviceInfo extends StatelessWidget {
  final BiometricAvailability? availability;
  final List<BiometricType> biometrics;
  final bool loading;

  const _DeviceInfo({
    required this.availability,
    required this.biometrics,
    required this.loading,
  });

  @override
  Widget build(BuildContext context) {
    if (loading && availability == null) {
      return const Center(child: CircularProgressIndicator());
    }

    final (icon, label, color) = switch (availability) {
      BiometricAvailability.available => (Icons.check_circle, 'Biometría disponible', Colors.green),
      BiometricAvailability.notEnrolled => (Icons.warning_amber, 'Sin biometría configurada en el dispositivo', Colors.orange),
      BiometricAvailability.notSupported => (Icons.error, 'Dispositivo no soporta biometría', Colors.red),
      _ => (Icons.help, 'Verificando...', Colors.grey),
    };

    return Row(
      children: [
        Icon(icon, color: color, size: 32),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold)),
              if (biometrics.isNotEmpty)
                Text(
                  'Tipos: ${biometrics.map(_biometricName).join(', ')}',
                  style: const TextStyle(fontSize: 12, color: Colors.grey),
                ),
            ],
          ),
        ),
      ],
    );
  }

  String _biometricName(BiometricType t) => switch (t) {
    BiometricType.face => 'Face ID',
    BiometricType.fingerprint => 'Huella dactilar',
    BiometricType.iris => 'Iris',
    BiometricType.strong => 'Biometría fuerte',
    BiometricType.weak => 'Biometría débil',
    _ => t.toString(),
  };
}

// ── Widget: tarjeta de resultado ───────────────────────────────────────────────
class _ResultCard extends StatelessWidget {
  final AuthResult result;
  const _ResultCard({required this.result});

  @override
  Widget build(BuildContext context) {
    final (icon, label, color) = switch (result) {
      AuthResult.success => (Icons.check_circle, '✅ Autenticación exitosa', Colors.green),
      AuthResult.failed => (Icons.cancel, '❌ Autenticación fallida', Colors.red),
      AuthResult.lockedOut => (Icons.lock, '🔒 Demasiados intentos fallidos — espera un momento', Colors.orange),
      AuthResult.permanentlyLockedOut => (Icons.lock_person, '🚫 Biometría bloqueada — usa el PIN del dispositivo', Colors.red),
      AuthResult.notEnrolled => (Icons.fingerprint, '⚠️ No hay biometría configurada en el dispositivo', Colors.orange),
      AuthResult.notAvailable => (Icons.block, '⚠️ Biometría no disponible', Colors.grey),
      _ => (Icons.error, '❌ Error desconocido', Colors.red),
    };

    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: color.withOpacity(0.3)),
      ),
      child: Row(
        children: [
          Icon(icon, color: color),
          const SizedBox(width: 8),
          Expanded(child: Text(label, style: TextStyle(color: color))),
        ],
      ),
    );
  }
}

// ── Widget: leyenda de tipos de biometría ──────────────────────────────────────
class _BiometricLegend extends StatelessWidget {
  final List<BiometricType> biometrics;
  const _BiometricLegend({required this.biometrics});

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: [
        if (biometrics.contains(BiometricType.fingerprint))
          const Chip(
            avatar: Icon(Icons.fingerprint, size: 16),
            label: Text('Huella', style: TextStyle(fontSize: 11)),
          ),
        if (biometrics.contains(BiometricType.face))
          const Chip(
            avatar: Icon(Icons.face, size: 16),
            label: Text('Face ID', style: TextStyle(fontSize: 11)),
          ),
        if (biometrics.contains(BiometricType.iris))
          const Chip(
            avatar: Icon(Icons.remove_red_eye, size: 16),
            label: Text('Iris', style: TextStyle(fontSize: 11)),
          ),
      ],
    );
  }
}

Casos de error de local_auth

Código de errorCausaSolución
notAvailableHardware no disponibleVerificar con canCheckBiometrics
notEnrolledSin huella/Face ID configuradoIndicar al usuario que configure biometría
lockedOutDemasiados intentosEsperar timeout automático
permanentlyLockedOutBloqueado definitivoUsar PIN/patrón del dispositivo

Errores frecuentes

  • No cambiar FlutterActivity a FlutterFragmentActivity: local_auth en Android requiere FlutterFragmentActivity. Si no lo cambias, la biometría falla silenciosamente.
  • biometricOnly: true sin fallback: si el usuario no tiene biometría disponible y biometricOnly es true, la autenticación falla directamente. Usa biometricOnly: false para permitir fallback a PIN.
  • Guardar el token en SharedPreferences en lugar de SecureStorage: los tokens de sesión deben guardarse en flutter_secure_storage (usa Keychain en iOS y Keystore en Android), nunca en SharedPreferences, que es legible sin root.

Aplicación práctica

La biometría se usa en: banca móvil (confirmación de transferencias), apps de salud (acceso a historial médico), gestores de contraseñas y apps con datos sensibles donde el PIN ya fue introducido al inicio de sesión.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿La biometría funciona en el emulador de Android?

Sí. Ve a Configuración del emulador → Security → Fingerprint → Enroll. Después puedes simular el toque de la huella desde el menú extendido del emulador.

¿local_auth guarda las huellas o el Face ID en la app?

No. Nunca. La biometría se procesa completamente en el chip seguro del dispositivo (Secure Enclave en iOS, TEE en Android). La app solo recibe un booleano de éxito o fracaso.

¿Puedo usar biometría para proteger solo una sección de la app?

Sí. El patrón habitual es: el usuario se autentica con usuario/contraseña al iniciar sesión → el token se guarda en SecureStorage → para operaciones sensibles (transferencias, ver datos de tarjeta) se pide biometría como segundo factor sin necesidad de volver a introducir la contraseña.