Biometric Authentication
Implémentation de l'authentification biométrique avec Face ID et Touch ID via expo-local-authentication.
Overview
L'authentification biométrique offre une expérience de connexion rapide et sécurisée. YaniPay utilise expo-local-authenticationpour supporter Face ID (iOS), Touch ID (iOS/macOS) et l'empreinte digitale (Android).
Face ID
iOS 11+
Touch ID
iOS/macOS
Fingerprint
Android 6+
Face Unlock
Android 10+
Setup & Permissions
Configuration du module expo-local-authentication.
app.jsonjson
{
"expo": {
"plugins": [
"expo-local-authentication"
],
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription": "YaniPay uses Face ID for secure authentication and transaction confirmation"
}
},
"android": {
"permissions": [
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT"
]
}
}
}src/lib/biometric.tstypescript
import * as LocalAuthentication from 'expo-local-authentication';
import { Platform } from 'react-native';
export type BiometricType = 'FaceID' | 'TouchID' | 'Fingerprint' | 'FaceUnlock' | null;
export interface BiometricCapabilities {
isSupported: boolean;
isEnrolled: boolean;
biometricType: BiometricType;
securityLevel: 'STRONG' | 'WEAK' | 'NONE';
}
// Check biometric capabilities
export async function checkBiometricCapabilities(): Promise<BiometricCapabilities> {
// Check hardware support
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) {
return {
isSupported: false,
isEnrolled: false,
biometricType: null,
securityLevel: 'NONE',
};
}
// Check if biometrics are enrolled
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
// Get supported types
const supportedTypes = await LocalAuthentication.supportedAuthenticationTypesAsync();
let biometricType: BiometricType = null;
let securityLevel: 'STRONG' | 'WEAK' = 'WEAK';
if (Platform.OS === 'ios') {
if (supportedTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
biometricType = 'FaceID';
securityLevel = 'STRONG';
} else if (supportedTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
biometricType = 'TouchID';
securityLevel = 'STRONG';
}
} else {
// Android
if (supportedTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
biometricType = 'FaceUnlock';
// Android face unlock is generally weaker than fingerprint
securityLevel = 'WEAK';
}
if (supportedTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
biometricType = 'Fingerprint';
securityLevel = 'STRONG';
}
}
return {
isSupported: hasHardware,
isEnrolled,
biometricType,
securityLevel,
};
}
// Get user-friendly biometric name
export function getBiometricDisplayName(type: BiometricType): string {
switch (type) {
case 'FaceID':
return 'Face ID';
case 'TouchID':
return 'Touch ID';
case 'Fingerprint':
return 'Empreinte digitale';
case 'FaceUnlock':
return 'Reconnaissance faciale';
default:
return 'Biométrie';
}
}Authentication Flow
Flux d'authentification biométrique avec gestion des erreurs.
src/lib/biometric.ts (authentication)typescript
export interface AuthenticateOptions {
promptMessage?: string;
cancelLabel?: string;
fallbackLabel?: string;
disableDeviceFallback?: boolean;
requireConfirmation?: boolean; // Android only
}
export interface AuthenticateResult {
success: boolean;
error?: string;
errorCode?: string;
warning?: string;
}
// Authenticate with biometric
export async function authenticateWithBiometric(
options: AuthenticateOptions = {}
): Promise<AuthenticateResult> {
const {
promptMessage = 'Confirmez votre identité',
cancelLabel = 'Annuler',
fallbackLabel = 'Utiliser le code',
disableDeviceFallback = false,
requireConfirmation = false,
} = options;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage,
cancelLabel,
fallbackLabel,
disableDeviceFallback,
requireConfirmation,
});
if (result.success) {
return { success: true };
}
// Handle specific error types
switch (result.error) {
case 'user_cancel':
return {
success: false,
error: 'Authentification annulée',
errorCode: 'USER_CANCEL',
};
case 'user_fallback':
return {
success: false,
error: 'Fallback requested',
errorCode: 'USER_FALLBACK',
};
case 'system_cancel':
return {
success: false,
error: 'Authentification interrompue par le système',
errorCode: 'SYSTEM_CANCEL',
};
case 'not_enrolled':
return {
success: false,
error: 'Aucune biométrie configurée sur cet appareil',
errorCode: 'NOT_ENROLLED',
};
case 'lockout':
return {
success: false,
error: 'Trop de tentatives. Veuillez réessayer plus tard.',
errorCode: 'LOCKOUT',
};
case 'lockout_permanent':
return {
success: false,
error: 'Biométrie désactivée. Utilisez votre code PIN.',
errorCode: 'LOCKOUT_PERMANENT',
};
default:
return {
success: false,
error: 'Échec de l\'authentification',
errorCode: 'UNKNOWN',
};
}
} catch (error) {
return {
success: false,
error: 'Erreur inattendue',
errorCode: 'EXCEPTION',
};
}
}
// Authenticate for sensitive action (higher security)
export async function authenticateForSensitiveAction(
actionDescription: string
): Promise<AuthenticateResult> {
return authenticateWithBiometric({
promptMessage: `Confirmez pour: ${actionDescription}`,
fallbackLabel: 'Utiliser le mot de passe',
disableDeviceFallback: false, // Allow PIN fallback
requireConfirmation: true, // Require explicit confirmation on Android
});
}Secure Credentials
Stockage sécurisé des credentials pour l'authentification biométrique.
src/lib/biometricCredentials.tstypescript
import * as SecureStore from 'expo-secure-store';
import { authenticateWithBiometric } from './biometric';
const BIOMETRIC_CREDENTIALS_KEY = 'yanipay_biometric_credentials';
interface StoredCredentials {
email: string;
password: string;
storedAt: string;
}
// Store credentials for biometric login
export async function storeBiometricCredentials(
email: string,
password: string
): Promise<boolean> {
// First, verify biometric to confirm user consent
const authResult = await authenticateWithBiometric({
promptMessage: 'Activez la connexion biométrique',
});
if (!authResult.success) {
return false;
}
try {
const credentials: StoredCredentials = {
email,
password,
storedAt: new Date().toISOString(),
};
await SecureStore.setItemAsync(
BIOMETRIC_CREDENTIALS_KEY,
JSON.stringify(credentials),
{
// Use strongest protection available
keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
}
);
return true;
} catch (error) {
console.error('Failed to store biometric credentials:', error);
return false;
}
}
// Retrieve credentials with biometric verification
export async function getBiometricCredentials(): Promise<{
email: string;
password: string;
} | null> {
// Authenticate with biometric first
const authResult = await authenticateWithBiometric({
promptMessage: 'Connectez-vous avec Face ID',
fallbackLabel: 'Utiliser le mot de passe',
});
if (!authResult.success) {
if (authResult.errorCode === 'USER_FALLBACK') {
// User wants to use password instead
return null;
}
throw new Error(authResult.error || 'Authentication failed');
}
try {
const stored = await SecureStore.getItemAsync(BIOMETRIC_CREDENTIALS_KEY);
if (!stored) {
return null;
}
const credentials: StoredCredentials = JSON.parse(stored);
// Check if credentials are too old (e.g., 90 days)
const storedDate = new Date(credentials.storedAt);
const daysSinceStored = (Date.now() - storedDate.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceStored > 90) {
// Credentials too old, delete and require re-authentication
await deleteBiometricCredentials();
return null;
}
return {
email: credentials.email,
password: credentials.password,
};
} catch (error) {
console.error('Failed to get biometric credentials:', error);
return null;
}
}
// Delete stored credentials
export async function deleteBiometricCredentials(): Promise<void> {
await SecureStore.deleteItemAsync(BIOMETRIC_CREDENTIALS_KEY);
}
// Check if biometric credentials exist
export async function hasBiometricCredentials(): Promise<boolean> {
const stored = await SecureStore.getItemAsync(BIOMETRIC_CREDENTIALS_KEY);
return stored !== null;
}Fallback Handling
Gestion des cas où la biométrie n'est pas disponible.
components/BiometricLogin.tsxtsx
import { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, Alert } from 'react-native';
import { Fingerprint, Key } from 'lucide-react-native';
import {
checkBiometricCapabilities,
getBiometricDisplayName,
BiometricType,
} from '@/lib/biometric';
import { getBiometricCredentials, hasBiometricCredentials } from '@/lib/biometricCredentials';
import { useAuthStore } from '@/stores/authStore';
interface BiometricLoginProps {
onFallbackPress: () => void;
}
export function BiometricLogin({ onFallbackPress }: BiometricLoginProps) {
const { login } = useAuthStore();
const [biometricType, setBiometricType] = useState<BiometricType>(null);
const [isAvailable, setIsAvailable] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkAvailability();
}, []);
const checkAvailability = async () => {
const [capabilities, hasCredentials] = await Promise.all([
checkBiometricCapabilities(),
hasBiometricCredentials(),
]);
setIsAvailable(
capabilities.isSupported &&
capabilities.isEnrolled &&
hasCredentials
);
setBiometricType(capabilities.biometricType);
};
const handleBiometricLogin = async () => {
setIsLoading(true);
try {
const credentials = await getBiometricCredentials();
if (!credentials) {
// User cancelled or fallback requested
onFallbackPress();
return;
}
// Login with retrieved credentials
await login(credentials.email, credentials.password);
} catch (error) {
Alert.alert(
'Échec de l\'authentification',
'Veuillez utiliser votre mot de passe.',
[{ text: 'OK', onPress: onFallbackPress }]
);
} finally {
setIsLoading(false);
}
};
if (!isAvailable) {
return null;
}
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.biometricButton}
onPress={handleBiometricLogin}
disabled={isLoading}
>
<Fingerprint size={48} color="#06b6d4" />
<Text style={styles.biometricText}>
{isLoading
? 'Vérification...'
: `Se connecter avec ${getBiometricDisplayName(biometricType)}`
}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.fallbackButton}
onPress={onFallbackPress}
>
<Key size={20} color="#64748b" />
<Text style={styles.fallbackText}>Utiliser le mot de passe</Text>
</TouchableOpacity>
</View>
);
}Best Practices
Bonnes pratiques pour l'implémentation de la biométrie.
Security Best Practicestypescript
// 1. Always verify biometric before accessing sensitive data
async function viewSensitiveData() {
const auth = await authenticateForSensitiveAction('Voir les détails de la carte');
if (!auth.success) {
return; // Don't show data
}
// Proceed to show sensitive data
}
// 2. Use appropriate security level for the action
const SECURITY_LEVELS = {
LOW: { disableDeviceFallback: false, requireConfirmation: false },
MEDIUM: { disableDeviceFallback: false, requireConfirmation: true },
HIGH: { disableDeviceFallback: true, requireConfirmation: true },
};
// 3. Handle lockout gracefully
async function handleBiometricWithLockout() {
const result = await authenticateWithBiometric();
if (result.errorCode === 'LOCKOUT' || result.errorCode === 'LOCKOUT_PERMANENT') {
// Show PIN/password fallback UI
showPasswordFallback();
return;
}
if (!result.success) {
showError(result.error);
return;
}
// Success
}
// 4. Implement timeout for biometric sessions
const BIOMETRIC_SESSION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
class BiometricSession {
private lastAuthTime: number | null = null;
async authenticate(): Promise<boolean> {
// Check if recent auth is still valid
if (this.lastAuthTime && Date.now() - this.lastAuthTime < BIOMETRIC_SESSION_TIMEOUT) {
return true; // Session still valid
}
const result = await authenticateWithBiometric();
if (result.success) {
this.lastAuthTime = Date.now();
}
return result.success;
}
invalidate() {
this.lastAuthTime = null;
}
}
// 5. Clear credentials on security events
function handleSecurityEvent(event: 'logout' | 'password_change' | 'device_removed') {
switch (event) {
case 'password_change':
case 'device_removed':
// Invalidate biometric credentials
deleteBiometricCredentials();
break;
case 'logout':
// Just invalidate session, keep credentials
break;
}
}
// 6. Check security level before enabling biometric
async function canEnableBiometric(): Promise<{ allowed: boolean; reason?: string }> {
const capabilities = await checkBiometricCapabilities();
if (!capabilities.isSupported) {
return { allowed: false, reason: 'Appareil non compatible' };
}
if (!capabilities.isEnrolled) {
return { allowed: false, reason: 'Configurez Face ID dans les réglages' };
}
// For financial apps, require strong biometric
if (capabilities.securityLevel === 'WEAK') {
return {
allowed: false,
reason: 'Ce type de biométrie n\'est pas assez sécurisé',
};
}
return { allowed: true };
}