Dans l'article précédent, on a vu comment normaliser le timing du login pour empêcher l'enumeration par oracle. Mais même avec un timing constant, un attaquant peut encore brute-forcer les mots de passe. Il lui faut juste du temps.
Le lockout est la réponse standard. Sauf que la plupart des implémentations que j'ai vues ont au moins un de ces trois problèmes : elles leakent l'existence du compte, elles ne se réinitialisent pas correctement, ou elles utilisent un délai fixe qui est soit trop court (inutile) soit trop long (déni de service sur les vrais utilisateurs).
Le pattern : exponential backoff cappé
Les N premiers échecs sont silencieux — pas de lockout, juste un compteur incrémenté.
Au-delà de N, le compte est locké pour 1 << (failures - N) secondes,
avec un cap à 15 minutes.
| Echecs | Délai (N=5) |
|---|---|
| 1-5 | 0 (silencieux) |
| 6 | 2s |
| 7 | 4s |
| 8 | 8s |
| 9 | 16s |
| 10 | 32s |
| 11 | 64s |
| 12 | 128s |
| 13 | 256s |
| 14 | 512s |
| 15+ | 900s (cap 15 min) |
L'exponentiel est le bon compromis : un typo sur le 6ème essai coûte 2 secondes. Un brute force au 15ème essai coûte 15 minutes par tentative. Le ratio effort attaquant / gêne utilisateur est maximal.
func lockoutDuration(failures, threshold int) time.Duration {
if failures <= threshold {
return 0
}
shift := failures - threshold
seconds := 1 << shift // 2, 4, 8, 16, 32...
if seconds > 900 {
seconds = 900 // cap a 15 min
}
return time.Duration(seconds) * time.Second
}
Le piège du status code
J'ai vu des implémentations qui retournent 423 Locked quand le compte est
verrouillé et 401 Unauthorized quand le mot de passe est faux. L'intention
est bonne — informer l'utilisateur. Le résultat est catastrophique : l'attaquant sait
que le compte existe.
La règle : le status code ne doit jamais distinguer "locked" de "wrong credentials".
Toujours 401, toujours le même message. L'attaquant ne sait pas si le compte
existe, s'il est locké, ou si le mot de passe est faux.
func (h *LoginHandler) Handle(email, password string) error {
user, err := h.repo.FindByEmail(email)
if err != nil {
argon2.CompareHashAndPassword(h.dummyHash, []byte(password))
return ErrInvalidCredentials // meme erreur
}
if h.isLocked(user) {
argon2.CompareHashAndPassword(h.dummyHash, []byte(password))
return ErrInvalidCredentials // meme erreur, meme timing
}
if !argon2.CompareHashAndPassword(user.PasswordHash, []byte(password)) {
h.incrementFailures(user)
return ErrInvalidCredentials // meme erreur
}
h.resetFailures(user)
return nil
}
Note le dummy hash sur le cas "locked" aussi. Même timing que les autres branches. On combine le pattern du dummy hash Argon2 avec le lockout.
Le reset : trois cas, un seul oubli suffit
Le compteur d'échecs doit être remis à zéro dans exactement trois situations :
- Login réussi — l'utilisateur a prouvé qu'il connaît le mot de passe
- Changement de mot de passe — par l'utilisateur lui-même
- Reset admin — un admin déverrouille manuellement le compte
J'ai vu une codebase où le reset se faisait uniquement sur login réussi. Résultat : un utilisateur qui change son mot de passe (via un flow "forgot password") garde son ancien compteur. Au prochain typo, il se retrouve immédiatement locké au niveau où il en était avant le reset. Pas le comportement attendu.
// Dans le command handler de changement de mot de passe
func (h *ChangePasswordHandler) Handle(cmd ChangePasswordCmd) error {
// ... validation, hash du nouveau mot de passe ...
user.PasswordHash = newHash
user.FailedAttempts = 0 // reset obligatoire
user.LockedUntil = time.Time{} // deverrouillage
return h.repo.Save(user)
}
Lockout sur unknown users : ne rien persister
Un piège plus subtil : si tu persistes les échecs de login pour des utilisateurs qui n'existent pas, un brute-forcer peut polluer ta table. 10 millions de tentatives sur des emails random = 10 millions de rows dans ta table de lockout.
Pour les unknown users : log en slog, jamais de row en base.
Le rate limiter par IP (en amont) est là pour limiter le volume.
Le lockout est par compte, pas par tentative random.
Audit log : ce qu'on trace, ce qu'on ne trace pas
Les login failures sur des comptes existants sont des événements métier — ils alimentent
le compteur et méritent une entrée dans l'audit log. Les login failures sur des comptes
inexistants sont du bruit — slog.Warn et c'est tout.
Si tu traces les deux dans la même table, tu donnes à un attaquant un vecteur de pollution de ta table d'audit. Et tu rends l'analyse des vrais incidents plus difficile.
Conclusion
Le lockout exponential backoff est un pattern simple en apparence. Les subtilités sont dans les détails : même status code partout, même timing partout, reset sur les trois cas, pas de persistence pour les unknown users.
Combiné avec le dummy hash, tu as un login endpoint qui ne leak ni l'existence des comptes, ni leur état de verrouillage, et qui résiste au brute force avec un coût progressif pour l'attaquant.
Le login est maintenant solide. Mais les requêtes authentifiées qui suivent ont leur propre surface d'attaque. Le CSRF, par exemple : le double-submit cookie que tout le monde utilise est insuffisant pour certains contextes. C'est le sujet du prochain article.