Chapitre 920 minIntermédiaire
Notifications push et authentification biométrique
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- programmer une notification locale ;
- décrire le circuit d'une notification push et l'envoyer depuis une API ;
- déverrouiller une application par biométrie et journaliser les tentatives ;
- expliquer ce que la biométrie protège, ses limites et son cadre RGPD.
4 exercices corrigés1 schéma
Une notification est un message que le système affiche hors de l'application : locale quand l'application la programme elle-même sur le téléphone, push quand un serveur l'envoie à travers les services de Google et d'Apple. L'authentification biométrique demande au système de vérifier l'empreinte ou le visage de l'utilisateur, sans que l'application voie jamais la donnée biométrique. Ce chapitre ajoute à AppliFrais un rappel de fin de mois, une notification quand une fiche est validée, et un déverrouillage par empreinte, avec la journalisation des tentatives que demandait le sujet de cybersécurité SLAM de 2024.
Notification locale ou notification push
| Locale | Push | |
|---|---|---|
| Qui la déclenche | L'application, sur le téléphone | Un serveur, ton API |
| Réseau nécessaire | Non | Oui, à l'envoi et à la réception |
| Exemple dans AppliFrais | « Dernier jour du mois : vérifie tes frais » | « Ta fiche d'octobre est validée : 389,20 € » |
| Ce qu'il faut | expo-notifications et la permission | En plus : un jeton d'appareil, un serveur qui l'enregistre et qui envoie, un development build sur Android |
| Fonctionne dans Expo Go | Oui | Non sur Android (depuis le SDK 53) |
npx expo install expo-notifications expo-device expo-constants
Programmer un rappel local
Le fichier src/lib/notifications.ts regroupe tout ce qui touche aux notifications :
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { dernierJourDuMois } from './format';
// Que faire d'une notification reçue quand l'application est ouverte.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export async function autoriserNotifications(): Promise<boolean> {
if (Platform.OS === 'android') {
// Android 8+ : toute notification appartient à un canal, que l'utilisateur peut couper séparément.
await Notifications.setNotificationChannelAsync('rappels', {
name: 'Rappels de saisie',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const actuelles = await Notifications.getPermissionsAsync();
if (actuelles.granted) return true;
const demande = await Notifications.requestPermissionsAsync();
return demande.granted;
}
export async function programmerRappelCloture(maintenant = new Date()) {
await Notifications.cancelAllScheduledNotificationsAsync();
const date = dernierJourDuMois(maintenant);
if (date <= maintenant) return;
await Notifications.scheduleNotificationAsync({
content: { title: 'Fiche de frais', body: 'Dernier jour du mois : vérifie tes frais avant la clôture.' },
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date, channelId: 'rappels' },
});
}
-
setNotificationHandlerdécide de l'affichage d'une notification reçue pendant que l'application est ouverte : bandeau, liste du centre de notifications, son, pastille. Hors de l'application, c'est le système qui affiche. -
Les canaux Android regroupent les notifications par thème ; l'utilisateur peut couper « Rappels de saisie » sans couper le reste. Depuis Android 13, la fenêtre de permission ne s'affiche qu'une fois au moins un canal créé : d'où l'ordre des instructions.
-
dernierJourDuMoisest rangée avec les autres fonctions de dates danssrc/lib/format.ts, et non dans ce fichier, qui agit dès son import (l'appel àsetNotificationHandler) : un module à effets de bord se teste mal (chapitre 11). Elle utilise une propriété deDate: le jour 0 d'un mois est le dernier jour du mois précédent.new Date(2026, 11, 0, 18)(mois 11, c'est-à-dire décembre, jour 0) donne le 30 novembre 2026 à 18 h.// src/lib/format.ts — le dernier jour du mois à 18 h, heure du téléphone. export function dernierJourDuMois(maintenant: Date, heure = 18): Date { return new Date(maintenant.getFullYear(), maintenant.getMonth() + 1, 0, heure, 0, 0); } -
cancelAllScheduledNotificationsAsyncévite d'empiler les rappels si Lucas réactive l'option plusieurs fois. Le rappel se reprogramme à chaque ouverture de l'application, pour le mois en cours.
Les déclencheurs (trigger) possibles : une date précise (DATE), un délai en secondes (TIME_INTERVAL, avec repeats pour répéter), ou un rythme (DAILY, WEEKLY, MONTHLY sur certains systèmes). L'écran Profil active le rappel avec un interrupteur :
async function basculerRappels(actif: boolean) {
if (actif && !(await autoriserNotifications())) return;
if (actif) await programmerRappelCloture();
setRappels(actif);
}
Si Lucas refuse la permission, l'interrupteur reste sur « désactivé » : l'interface ne ment pas sur ce qui va se passer.
Les notifications push : le circuit
Une notification push ne va pas directement de ton serveur au téléphone. Elle traverse les services de notification des systèmes : FCM (Firebase Cloud Messaging) de Google pour Android, APNs (Apple Push Notification service) pour iOS. Expo ajoute un intermédiaire qui te dispense de parler à chacun : le service de notifications push d'Expo.
Les étapes, côté application et côté serveur :
- l'application obtient un jeton d'appareil (un identifiant de ce téléphone pour cette application) et l'envoie à l'API ;
- l'API l'enregistre, associé au visiteur connecté ;
- quand un événement survient (la validation d'une fiche), l'API envoie le message au service d'Expo, avec le jeton ;
- le service le transmet à FCM ou à APNs, qui le délivre au téléphone.
Les notifications push demandent un development build sur Android, et un projet Expo : npx eas-cli@latest init relie ton dossier à un projet de ton compte Expo et ajoute son identifiant (projectId) dans app.json. Pour iOS, il faut en plus un compte développeur Apple, qui fournit la clé APNs.
Enregistrer l'appareil
// Le jeton qui permet au serveur d'envoyer une notification push à CET appareil.
export async function obtenirJetonPush(): Promise<string | null> {
if (!Device.isDevice) return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
if (!projectId) return null;
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // de la forme ExponentPushToken[…]
}
Après la connexion et l'accord de la permission, l'application envoie le jeton à l'API :
const jeton = await obtenirJetonPush();
if (jeton) await api.enregistrerAppareil(jeton, Platform.OS);
Device.isDevice vaut false sur un émulateur ou un simulateur, où les notifications push ne sont pas fiables : on ne s'y enregistre pas. La route PUT /v1/appareils (son code au chapitre 15) est authentifiée : l'API associe le jeton au visiteur du jeton d'accès, jamais à un identifiant envoyé dans le corps. Sinon, n'importe qui pourrait recevoir les notifications d'un autre. Le jeton d'appareil peut changer (réinstallation, restauration du téléphone) : l'application le renvoie à chaque démarrage, et l'API met à jour sa table.
Envoyer une notification depuis l'API
Quand le comptable valide une fiche, l'API Node.js envoie la notification aux appareils du visiteur avec un simple fetch vers le service d'Expo :
// Côté API : prévenir le visiteur que sa fiche est validée.
async function notifierValidation(jetonsPush, fiche) {
const messages = jetonsPush.map((to) => ({
to,
title: 'Fiche validée',
body: `Ta fiche de ${fiche.moisLisible} est validée.`,
data: { idFiche: fiche.id },
}));
const reponse = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(messages), // 100 messages au plus par requête
});
const { data: tickets } = await reponse.json();
return tickets; // un ticket par message, dans le même ordre : { status: 'ok', id } ou { status: 'error', … }
}
Le service répond par un ticket par message : status: 'ok' veut dire que le message a été reçu par Expo, pas qu'il est arrivé sur le téléphone. Le serveur peut consulter plus tard l'accusé de livraison (push receipt) de chaque ticket. Si un accusé ou un ticket contient l'erreur DeviceNotRegistered, l'application a été désinstallée ou les notifications coupées : l'API supprime ce jeton de sa table et cesse d'y envoyer. La bibliothèque officielle expo-server-sdk pour Node.js gère pour toi le découpage par 100 et les accusés.
Réagir au toucher d'une notification
Quand Lucas touche la notification, l'application doit s'ouvrir sur la fiche concernée. Un écouteur reçoit la réponse de l'utilisateur et lit les données du message :
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
export function useOuvertureNotification() {
useEffect(() => {
const abonnement = Notifications.addNotificationResponseReceivedListener((reponse) => {
const idFiche = reponse.notification.request.content.data?.idFiche;
if (typeof idFiche === 'number') {
router.push({ pathname: '/fiche/[id]', params: { id: idFiche } });
}
});
return () => abonnement.remove();
}, []);
}
Le hook s'appelle dans le layout racine, sous la session. Le contenu d'une notification est une entrée comme une autre : on vérifie le type de idFiche avant de s'en servir, et l'API contrôlera de toute façon que la fiche appartient bien à Lucas.
La biométrie : ce que l'application voit et ne voit pas
L'authentification biométrique n'envoie jamais l'empreinte à l'application. L'application demande au système d'authentifier l'utilisateur ; le système affiche sa propre fenêtre, compare l'empreinte ou le visage au modèle stocké dans une puce sécurisée du téléphone, et répond seulement « succès » ou « échec ». Ni l'application ni le serveur ne reçoivent de donnée biométrique. Le principe, le lien avec le Keystore et le cadre juridique sont détaillés dans le cours Sécurité des applications.
npx expo install expo-local-authentication
Les fonctions d'expo-local-authentication :
| Fonction | Renvoie |
|---|---|
hasHardwareAsync() | true si le téléphone a un capteur d'empreinte ou de visage |
isEnrolledAsync() | true si l'utilisateur a enregistré au moins une empreinte ou un visage |
supportedAuthenticationTypesAsync() | Les types disponibles : empreinte, visage, iris |
authenticateAsync(options) | { success: true }, ou { success: false, error } avec un code d'erreur |
Déverrouiller l'application avec expo-local-authentication
AppliFrais propose, dans le profil, de verrouiller l'application : à chaque ouverture, une empreinte ou un visage est demandé avant d'afficher les fiches. Le fichier src/lib/biometrie.ts :
import * as LocalAuthentication from 'expo-local-authentication';
import { journaliser } from './db';
export async function biometrieDisponible(): Promise<boolean> {
const materiel = await LocalAuthentication.hasHardwareAsync();
const enrolee = await LocalAuthentication.isEnrolledAsync();
return materiel && enrolee;
}
// Verrou de l'application : le système affiche sa fenêtre et ne renvoie qu'un succès ou un échec.
export async function deverrouiller(): Promise<boolean> {
const resultat = await LocalAuthentication.authenticateAsync({
promptMessage: 'Déverrouiller AppliFrais',
cancelLabel: 'Annuler',
biometricsSecurityLevel: 'strong', // Android : empreinte ou visage 3D, pas la reconnaissance par caméra
});
if (resultat.success) {
await journaliser('LoginBiometrie', 'Succes', 'OK');
return true;
}
await journaliser('LoginBiometrie', 'Echec', resultat.error);
return false;
}
biometrieDisponiblese vérifie avant de proposer l'option : un téléphone sans capteur, ou sans empreinte enregistrée, ne doit pas afficher un interrupteur inutilisable.biometricsSecurityLevel: 'strong'n'accepte, sur Android, que la biométrie de classe 3 (empreinte, visage en 3D), et refuse la reconnaissance faciale par la caméra frontale, de classe 2, qu'une photo peut parfois tromper.- Après plusieurs échecs, le système propose le code du téléphone (sauf avec l'option
disableDeviceFallback). C'est l'alternative que le RGPD et la CNIL demandent : personne ne doit être obligé d'utiliser sa biométrie. - Chaque tentative est journalisée dans la table
journalde la base SQLite locale, avec le type, le résultat et le code d'erreur (user_cancel,lockout,not_enrolled…). C'est l'équivalent React Native de ce que demandait le sujet de cybersécurité SLAM de 2024 (alors épreuve E6, aujourd'hui E7) en Java Android (corrigé du sujet 2024). Un journal conforme aux recommandations de la CNIL identifie aussi l'auteur : sur une tablette partagée, il faudrait ajouter l'identifiant de l'utilisateur.
Lier la biométrie à un secret
Le verrou ci-dessus a une limite : authenticateAsync renvoie un booléen, et l'application décide ensuite d'afficher ou non les fiches. Sur un téléphone rooté, un outil d'instrumentation peut forcer ce booléen à « succès ». Contre quelqu'un qui ramasse un téléphone déverrouillé, le verrou suffit ; contre un attaquant qui modifie l'application, non.
La parade, décrite dans l'OWASP MASTG, consiste à faire débloquer un secret par la biométrie : la clé qui chiffre le jeton est créée dans le Keystore ou le trousseau avec l'exigence d'une authentification biométrique, et le système refuse de s'en servir sans empreinte valide. expo-secure-store le propose avec une option :
await SecureStore.setItemAsync('applifrais.rafraichissement', jeton, {
requireAuthentication: true, // lecture impossible sans authentification biométrique
authenticationPrompt: 'Déverrouiller AppliFrais',
keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
});
Sans authentification réussie, getItemAsync échoue : même une application modifiée ne peut pas lire le jeton. Deux contraintes à connaître avant de l'adopter : sur Android, chaque lecture et chaque écriture redemandent l'empreinte, y compris l'écriture du nouveau jeton à chaque rotation ; et les valeurs deviennent illisibles si l'utilisateur ajoute ou supprime une empreinte. C'est le bon choix pour une application sensible (banque, santé) ; pour AppliFrais, le verrou par authenticateAsync et un jeton de rafraîchissement révocable côté serveur forment un compromis raisonnable, à justifier dans ta documentation.
Biométrie et RGPD
Une donnée biométrique traitée pour identifier une personne de façon unique est une catégorie particulière de données personnelles (article 9 du RGPD), dont le traitement est interdit par principe, sauf exceptions encadrées. Avec expo-local-authentication, le modèle de l'empreinte reste dans la puce du téléphone, sous le seul contrôle de son propriétaire, et l'employeur ne le reçoit jamais : le risque est bien moindre qu'avec une base centrale d'empreintes. Dans un cadre professionnel, l'employeur doit tout de même proposer une alternative (le mot de passe ou le code), informer les salariés, et documenter son choix ; la CNIL a publié un règlement type sur la biométrie au travail (les obligations du RGPD).
Exercice 1 sur 4 Locale ou push ? Réussi À revoir
Pour chaque besoin, dis s'il faut une notification locale ou push, et pourquoi.
- Rappeler à Lucas, chaque vendredi à 17 h, de saisir ses frais de la semaine.
- Prévenir Lucas que le comptable a refusé un justificatif illisible.
- Prévenir tous les visiteurs que l'application sera indisponible dimanche pour maintenance.
- Signaler à Lucas, dans le train, que ses 3 frais en attente ont bien été envoyés au retour du réseau.
Voir la réponseMasquer la réponse de l'exercice 1
- Locale : l'événement est prévisible, l'application le programme elle-même (déclencheur hebdomadaire), sans serveur ni réseau.
- Push : l'événement a lieu sur le serveur, l'application ne peut pas le deviner.
- Push, envoyée par l'API à tous les jetons enregistrés.
- Locale (ou un simple message dans l'application) : c'est l'application elle-même qui a fait l'envoi et qui en connaît le résultat.
Tu avais trouvé ?
Exercice 2 sur 4 Le dernier jour du mois Réussi À revoir
Que renvoient dernierJourDuMois(new Date(2026, 1, 10)), dernierJourDuMois(new Date(2028, 1, 10)) et dernierJourDuMois(new Date(2026, 11, 31, 19)) ? Pour le dernier cas, programmerRappelCloture programme-t-elle un rappel ?
Voir la réponseMasquer la réponse de l'exercice 2
En JavaScript, les mois commencent à 0 : 1 est février, 11 est décembre.
new Date(2026, 1, 10): 10 février 2026. Le jour 0 de mars 2026 est le 28 février 2026, à 18 h.new Date(2028, 1, 10): 2028 est bissextile, le résultat est le 29 février 2028 à 18 h.new Date(2026, 11, 31, 19): 31 décembre 2026 à 19 h. Le jour 0 de janvier 2027 (mois 12 de 2026, queDateconvertit) est le 31 décembre 2026 à 18 h. Cette date est antérieure à maintenant (19 h) :programmerRappelClotures'arrête sans rien programmer. Le rappel de janvier sera programmé à la prochaine ouverture de l'application.
Tu avais trouvé ?
Exercice 3 sur 4 Le jeton d'appareil Réussi À revoir
Un développeur écrit la route PUT /v1/appareils ainsi : elle reçoit { "idVisiteur": 7, "jetonPush": "ExponentPushToken[…]" } sans authentification, et enregistre le couple. Quel est le risque ? Corrige la conception.
Voir la réponseMasquer la réponse de l'exercice 3
N'importe qui peut associer son propre jeton d'appareil à l'identifiant d'un autre visiteur, et recevoir ses notifications (validation de fiches, messages du comptable) : c'est une faille de contrôle d'accès. La route doit être authentifiée par le jeton d'accès, et l'identifiant du visiteur doit venir de ce jeton (req.utilisateur.id), jamais du corps de la requête. Le corps ne contient que le jeton d'appareil et la plateforme.
Tu avais trouvé ?
Exercice 4 sur 4 Analyser le journal biométrique Réussi À revoir
Lucas signale qu'il « n'arrive plus à entrer » dans AppliFrais. Le journal local contient, dans l'ordre : Echec authentication_failed, Echec authentication_failed, Echec authentication_failed, Echec lockout. Que s'est-il passé, et que doit proposer l'application ?
Voir la réponseMasquer la réponse de l'exercice 4
Trois empreintes ont été refusées (doigt humide, capteur sale, ou une autre personne), puis le système a bloqué la biométrie (lockout) après trop d'échecs : c'est une protection contre les essais répétés. L'application doit proposer l'alternative : le code du téléphone (repli du système), ou la reconnexion avec identifiant et mot de passe. Le journal permet au support de comprendre la situation sans deviner ; un pic de lockout sur un téléphone peut aussi signaler une tentative d'accès par un tiers.
Tu avais trouvé ?