Authentification
L'authentification répond à une seule question : qui envoie cette requête ? (Ce qu'on a le droit de faire est une autre question, traitée dans Droits par rôle.)
L'API a deux clients, qui ne vivent pas dans le même monde :
| Site web | Application mobile | |
|---|---|---|
| Où il tourne | dans un navigateur | une application native |
| Où il garde ses jetons | des cookies HttpOnly, que son JavaScript ne peut pas lire | le coffre sécurisé du téléphone (Keychain, Keystore) |
| Comment il prouve qui il est | le cookie, et l'en-tête x-csrf-token | l'en-tête Authorization: Bearer <jeton> |
| Connexion | POST /api/auth/login | la même, avec "client": "mobile" |
Deux jetons par session
Une session repose sur deux jetons, aux rôles complémentaires :
| Jeton d'accès | Jeton de rafraîchissement | |
|---|---|---|
| Format | un JWT signé (HS256) | une chaîne aléatoire de 32 octets |
| Durée | 1 heure | 30 jours, renouvelés à chaque usage |
| Envoyé | à chaque requête | seulement pour obtenir de nouveaux jetons |
| Vérifié | par sa signature, sans la base | dans la table refresh_tokens |
| Révocable | non (il expire vite) | oui, à tout moment |
Pourquoi deux ? Un jeton vérifié sans la base est rapide, mais impossible à révoquer : on le fait donc durer peu. Le jeton de rafraîchissement, lui, est gardé dans la base et peut être révoqué (déconnexion, changement de mot de passe), mais il ne sert qu'une fois par heure.
Ce que contient le JWT
sub (l'id du compte), role, iat (émis à), exp (expire à) et csrf. Il est signé, pas chiffré : n'importe qui peut le lire, personne ne peut le modifier sans la clé secrète. On n'y met donc rien de secret.
La base ne garde jamais le jeton de rafraîchissement lui-même, seulement son empreinte SHA-256. Une fuite de la table ne donnerait aucun jeton utilisable.
Se connecter
Quelques détails qui comptent (Accounts.authenticate/2) :
- Les mots de passe sont hachés avec Bcrypt, un algorithme volontairement lent : deviner un mot de passe par essais successifs coûte très cher.
- Une erreur unique : e-mail inconnu ou mauvais mot de passe donnent le même 401. On ne révèle pas quels e-mails ont un compte.
- Le même temps de réponse : pour un e-mail inconnu, l'API fait quand même un calcul Bcrypt factice (
Bcrypt.no_user_verify/0). Sinon, une réponse plus rapide trahirait qu'il n'y a pas de compte. - Seul un compte actif peut se connecter : un compte invité n'a pas encore de mot de passe.
Le site web : cookies et CSRF
À la connexion, l'API pose deux cookies (SessionCookies) :
| Cookie | Contenu | Envoyé vers |
|---|---|---|
timemanager_jwt | le jeton d'accès | toute l'API |
timemanager_refresh | le jeton de rafraîchissement | /api/auth seulement |
Les deux sont HttpOnly (le JavaScript de la page ne peut pas les lire, donc une faille XSS ne peut pas les voler), SameSite=Lax, et Secure en production (HTTPS seulement).
Pourquoi un jeton CSRF en plus ?
Un navigateur envoie les cookies tout seul, même quand la requête part d'un autre site. Un site piégé pourrait donc faire pointer Renee à son insu : c'est l'attaque CSRF (Cross-Site Request Forgery).
La parade : à la connexion, l'API renvoie aussi un csrf_token dans la réponse JSON, et ce même jeton est caché dans le JWT. À chaque requête, le site web le renvoie dans l'en-tête x-csrf-token, et l'API vérifie que les deux correspondent. Un autre site peut faire envoyer le cookie, mais il ne peut pas lire le csrf_token : il ne peut donc pas remplir l'en-tête.
CORS
Le site web est servi depuis une autre adresse que l'API. Le navigateur n'autorise ses appels que si l'API le permet : Plugs.CORS répond oui uniquement aux origines listées dans CORS_ORIGINS. Le joker * n'est pas possible, car les navigateurs le refusent pour les requêtes qui portent des cookies.
L'application mobile : Bearer
Avec "client": "mobile", l'API ne pose aucun cookie : elle renvoie les jetons dans la réponse. Le téléphone envoie ensuite Authorization: Bearer <access_token> à chaque requête.
Pas de CSRF ici, et ce n'est pas un oubli : un navigateur n'ajoute jamais l'en-tête Authorization de lui-même, donc un autre site ne peut pas le forger. Pour la même raison, CORS ne concerne pas l'application : ce n'est pas un navigateur.
Le plug Plugs.Authenticate choisit le chemin :
« CSRF égal » : l'en-tête x-csrf-token est-il le csrf caché dans le JWT ? « Jeton récent » : a-t-il été émis après le dernier changement de mot de passe ?
Un Bearer invalide est refusé, même si un cookie valide est aussi présent : les deux chemins ne se mélangent jamais.
Rafraîchir : la rotation des jetons
Quand le jeton d'accès expire, le client appelle POST /api/auth/refresh (le web avec son cookie et l'en-tête CSRF, le mobile avec {"refresh_token": "…"} dans le corps). L'API :
- révoque le jeton de rafraîchissement utilisé ;
- en donne un nouveau, de la même « famille » (une famille = une connexion sur un appareil) ;
- donne un nouveau jeton d'accès.
C'est la rotation. Son intérêt : un jeton de rafraîchissement volé ne sert qu'une fois. Si un jeton déjà utilisé revient, quelqu'un l'a copié : l'API révoque toute la famille, et le voleur comme la victime doivent se reconnecter.
| Cas | Web | Mobile |
|---|---|---|
| Deux onglets rafraîchissent en même temps | délai de grâce de 10 s : le second est refusé, la session continue | (pas d'onglets) |
| La réponse s'est perdue (réseau coupé) | — | l'ancien jeton revient alors que son successeur n'a jamais servi : l'API remplace ce successeur, la session continue |
| Un jeton déjà utilisé revient, son successeur a servi | toute la session est révoquée | toute la session est révoquée, sans délai de grâce |
Une session web ne se rafraîchit jamais par le corps de la requête, et une session mobile jamais par un cookie (colonne client de refresh_tokens).
Le compromis à connaître
Tolérer une réponse perdue retarde un peu la détection d'un vol : elle n'a lieu qu'au moment où le successeur sert. C'est le prix d'une application qui ne déconnecte pas l'employé à chaque coupure de réseau. Ce compromis ne concerne que les sessions mobiles.
Changer de mot de passe
PUT /api/users/:id/password, avec le mot de passe actuel et le nouveau. L'API :
- vérifie les règles : 12 caractères au moins, une majuscule, un caractère spécial (
User.password_changeset/2, le seul passage de tout nouveau mot de passe) ; - révoque toutes les sessions de la personne, sur tous ses appareils ;
- note l'heure du changement (
password_changed_at) : tout jeton d'accès émis avant est refusé, même s'il n'a pas encore expiré ; - ouvre une nouvelle session pour l'appareil qui a fait la demande, et prévient la personne par e-mail.
Les liens envoyés par e-mail
Trois actions passent par un lien à usage unique (password_tokens) :
| Lien | Durée | Pour |
|---|---|---|
| Invitation | 7 jours | une nouvelle personne choisit son nom et son mot de passe |
| Réinitialisation | 1 heure | « mot de passe oublié » |
| Changement d'e-mail | 1 heure | le General Manager confirme sa nouvelle adresse |
Comme pour les sessions, la base ne garde que l'empreinte du jeton. Un lien ne sert qu'une fois, et un nouveau lien remplace le précédent.
« Mot de passe oublié » répond toujours 204, que l'e-mail ait un compte ou non : la réponse ne révèle rien. Pour qu'un script ne puisse pas remplir la boîte mail de quelqu'un, aucun nouveau lien ne part si le dernier a moins de 30 secondes.
Les fichiers
| Fichier | Rôle |
|---|---|
lib/timemanager/auth/token.ex | crée et vérifie le JWT |
lib/timemanager/auth/sessions.ex | sessions, rotation, réponses perdues, révocation |
lib/timemanager/auth/refresh_token.ex | le schéma des jetons de rafraîchissement |
lib/timemanager_web/plugs/authenticate.ex | qui appelle ? (Bearer, ou cookie + CSRF) |
lib/timemanager_web/session_cookies.ex | pose et efface les cookies du web |
lib/timemanager_web/plugs/cors.ex | les origines autorisées pour le navigateur |
lib/timemanager_web/controllers/auth_controller.ex | connexion, rafraîchissement, déconnexion |
lib/timemanager/accounts.ex | mots de passe, liens envoyés par e-mail |