Skip to content

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 webApplication mobile
Où il tournedans un navigateurune application native
Où il garde ses jetonsdes cookies HttpOnly, que son JavaScript ne peut pas lirele coffre sécurisé du téléphone (Keychain, Keystore)
Comment il prouve qui il estle cookie, et l'en-tête x-csrf-tokenl'en-tête Authorization: Bearer <jeton>
ConnexionPOST /api/auth/loginla même, avec "client": "mobile"

Deux jetons par session ​

Une session repose sur deux jetons, aux rôles complémentaires :

Jeton d'accèsJeton de rafraîchissement
Formatun JWT signé (HS256)une chaîne aléatoire de 32 octets
Durée1 heure30 jours, renouvelés à chaque usage
Envoyéà chaque requêteseulement pour obtenir de nouveaux jetons
Vérifiépar sa signature, sans la basedans la table refresh_tokens
Révocablenon (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) :

CookieContenuEnvoyé vers
timemanager_jwtle jeton d'accèstoute l'API
timemanager_refreshle 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 :

  1. révoque le jeton de rafraîchissement utilisé ;
  2. en donne un nouveau, de la même « famille » (une famille = une connexion sur un appareil) ;
  3. 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.

CasWebMobile
Deux onglets rafraîchissent en même tempsdé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 servitoute la session est révoquéetoute 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) :

LienDuréePour
Invitation7 joursune nouvelle personne choisit son nom et son mot de passe
Réinitialisation1 heure« mot de passe oublié »
Changement d'e-mail1 heurele 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 ​

FichierRôle
lib/timemanager/auth/token.excrée et vérifie le JWT
lib/timemanager/auth/sessions.exsessions, rotation, réponses perdues, révocation
lib/timemanager/auth/refresh_token.exle schéma des jetons de rafraîchissement
lib/timemanager_web/plugs/authenticate.exqui appelle ? (Bearer, ou cookie + CSRF)
lib/timemanager_web/session_cookies.expose et efface les cookies du web
lib/timemanager_web/plugs/cors.exles origines autorisées pour le navigateur
lib/timemanager_web/controllers/auth_controller.exconnexion, rafraîchissement, déconnexion
lib/timemanager/accounts.exmots de passe, liens envoyés par e-mail