Lockout exponential backoff : bloquer le brute force sans leaker l'existence du compte

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.

EchecsDélai (N=5)
1-50 (silencieux)
62s
74s
88s
916s
1032s
1164s
12128s
13256s
14512s
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 :

  1. Login réussi — l'utilisateur a prouvé qu'il connaît le mot de passe
  2. Changement de mot de passe — par l'utilisateur lui-même
  3. 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.

Commentaires (0)