Authentification

LoginGuard — Protection contre le brute-force

LoginGuard suit les tentatives de connexion échouées par username, indépendamment de l'adresse IP.

Usage complet avec `effective_key`

effective_key détermine automatiquement la bonne clé selon que l'utilisateur a soumis un username ou non :

  • Username soumis → clé par compte (protection ciblée)
  • Username vide → "anonym:{ip}" (protection par IP, compteurs indépendants)
use runique::prelude::*;

static GUARD: LazyLock<LoginGuard> = LazyLock::new(|| {
    LoginGuard::new()
        .max_attempts(5)
        .lockout_secs(300)
});

pub async fn login(
    session: Session,
    State(db): State<DatabaseConnection>,
    mut request: Request,
) -> impl IntoResponse {
    let form: LoginForm = request.form();
    let username = form.username();
    let ip = /* extraire l'IP depuis les headers */;

    // username soumis → "alice"  |  username vide → "anonym:1.2.3.4"
    let key = LoginGuard::effective_key(&username, &ip);

    if GUARD.is_locked(&key) {
        let remaining = GUARD.remaining_lockout_secs(&key).unwrap_or(0);
        return (StatusCode::TOO_MANY_REQUESTS, format!("Réessayez dans {remaining}s")).into_response();
    }

    match authenticate(&username, &form.password(), &db).await {
        Some(user) => {
            GUARD.record_success(&key);
            login(&session, &user, None, false).await.unwrap();
            Redirect::to("/dashboard").into_response()
        }
        None => {
            GUARD.record_failure(&key);
            (StatusCode::UNAUTHORIZED, "Identifiants invalides").into_response()
        }
    }
}

Pourquoi dans le handler et pas en middleware ?

Un middleware s'exécute avant le handler et ne peut pas lire le body sans le consommer. request.form() extrait le formulaire une seule fois — le username n'est disponible qu'après cette extraction.

La session connaît l'état d'authentification, mais au moment du login l'utilisateur n'est pas encore connecté : request.user vaut None sur cette route, ce qui ne protège pas le compte ciblé.

SourceDisponible en middlewareFiable pour LoginGuard
Adresse IP✅✅ (utilisée par effective_key pour anonym)
Utilisateur connecté (request.user)✅❌ (None sur /login, retombe sur anonym)
Username (form body)❌ (consomme le body)✅ (via request.form() dans le handler)

Configuration

LoginGuard::new().max_attempts(5).lockout_secs(300)    // 5 échecs → blocage 5 minutes
LoginGuard::new().max_attempts(3).lockout_secs(900)    // 3 échecs → blocage 15 minutes
LoginGuard::new().max_attempts(10).lockout_secs(3600)  // 10 échecs → blocage 1 heure

API

LoginGuard::new()

Crée un LoginGuard avec les valeurs par défaut (5 tentatives / 300 s).

.max_attempts(max: u32)

Nombre d'échecs avant verrouillage du compte.

.lockout_secs(secs: u64)

Durée du verrouillage en secondes.

LoginGuard::effective_key(username, ip) -> Cow<str>

Retourne la clé à utiliser selon le contexte :

  • Username non vide → clé par username (protection compte ciblé)
  • Username vide ou absent → "anonym:{ip}" (protection anonyme par IP)

Garantit que deux IPs anonymes différentes ont des compteurs indépendants — bloquer "anonym:1.2.3.4" n'affecte pas "anonym:5.6.7.8".

Pourquoi pas une clé "anonym" globale ? Une seule tentative abusive suffirait à verrouiller tous les utilisateurs anonymes du monde entier. La clé par IP isole chaque attaquant.

record_failure(key)

Incrémente le compteur d'échecs pour cette clé. À appeler après chaque authentification échouée.

record_success(key)

Réinitialise le compteur. À appeler après une connexion réussie.

is_locked(key) -> bool

Retourne true si le nombre d'échecs dépasse max_attempts et que le délai de verrouillage n'est pas écoulé.

attempts(key) -> u32

Nombre d'échecs en cours pour cette clé.

remaining_lockout_secs(key) -> Option<u64>

Secondes restantes avant déverrouillage. None si non verrouillé.


Combinaison avec le rate limiting IP

Les deux mécanismes couvrent des vecteurs d'attaque différents et sont complémentaires :

RateLimiterLoginGuard
CléAdresse IPUsername ou anonym:{ip}
OùMiddleware (automatique)Handler (manuel)
CibleToutes les routesLogin uniquement
Protège contreVolume d'attaque par IPBrute-force par compte
Faux positifsPossible (NAT)Aucun (par compte)
static IP_LIMITER: LazyLock<RateLimiter> = LazyLock::new(|| RateLimiter::new().max_requests(10).retry_after(60));
static GUARD:      LazyLock<LoginGuard>  = LazyLock::new(|| LoginGuard::new().max_attempts(5).lockout_secs(300));

pub async fn login(/* ... */) -> impl IntoResponse {
    // 1. Volume par IP → RateLimiter (middleware ou manuel)
    if !IP_LIMITER.is_allowed(&ip) { /* 429 */ }

    // 2. Brute-force par compte ou IP anonyme → LoginGuard
    let key = LoginGuard::effective_key(&username, &ip);
    if GUARD.is_locked(&key) { /* 429 */ }

    // 3. Authentification
    match authenticate(&username, &password, &db).await {
        Some(user) => { GUARD.record_success(&key); /* ... */ }
        None       => { GUARD.record_failure(&key); /* ... */ }
    }
}

Limite connue : un compte peut être bloqué par quelqu'un d'autre

Le verrouillage se fait par nom d'utilisateur, pas par IP : c'est ce qui empêche un attaquant de répartir ses essais sur beaucoup d'adresses. Le revers : quiconque connaît un nom d'utilisateur (celui d'un admin, par exemple) peut bloquer ce compte en échouant exprès, sans connaître son mot de passe, et le maintenir bloqué en recommençant.

Pour le limiter :

  • gardez le limiteur par IP devant (section ci-dessus) : il plafonne le nombre d'essais par adresse, ce qui rend le blocage en boucle bien plus coûteux pour l'attaquant ;
  • bloquez les adresses abusives avant l'application, au niveau du proxy inverse ;
  • choisissez des noms d'utilisateur d'admin qui ne sont pas publics (ni admin, ni le nom affiché sur le site).

Les compteurs vivent dans la mémoire de chaque instance : voir Rate limiting — plusieurs instances.


← Middlewares de protection | Exemple complet →