Développement Web Performant avec Rust : Backend et WebAssembly
Développement Web Performant avec Rust : Backend et WebAssembly

Sécurité des Applications Web Rust : Authentification et Autorisation

Contexte du cours : Développement Web Performant avec Rust : Backend et WebAssembly

Introduction à la Sécurité des Applications Web en Rust

Dans le monde du développement web, la sécurité n'est pas une option, mais une nécessité absolue. Les applications web sont des cibles privilégiées pour les cyberattaques, et les conséquences d'une faille de sécurité peuvent être désastreuses : vol de données, perte de confiance des utilisateurs, amendes réglementaires, etc.

Rust, avec sa sécurité mémoire garantie par le compilateur et son système de types robuste, offre une base solide pour construire des applications web sécurisées. Il aide à prévenir de nombreuses vulnérabilités courantes (comme les buffer overflows) dès la compilation, réduisant ainsi la surface d'attaque. Cependant, la sécurité logique (authentification, autorisation, validation des entrées) reste de la responsabilité du développeur.

Cette leçon se concentre sur deux piliers fondamentaux de la sécurité des applications web : l'authentification et l'autorisation. Bien que souvent utilisées de manière interchangeable par erreur, elles désignent des processus distincts et complémentaires.

  • Authentification : Qui êtes-vous ? C'est le processus de vérification de l'identité d'un utilisateur, d'un service ou d'un processus.
  • Autorisation : Qu'êtes-vous autorisé à faire ? C'est le processus de détermination des permissions d'accès et des actions qu'une entité authentifiée peut effectuer.

I. Authentification : Vérifier l'Identité

L'authentification est la première étape pour sécuriser l'accès à vos ressources. Elle établit une preuve qu'un utilisateur est bien celui qu'il prétend être.

Méthodes d'Authentification Courantes

Il existe plusieurs stratégies d'authentification pour les applications web, chacune avec ses avantages et inconvénients :

1. Authentification Basée sur les Sessions (Cookies)

  • Principe : Lorsqu'un utilisateur se connecte, le serveur crée une session unique et stocke des informations sur cet utilisateur. Un identifiant de session est ensuite envoyé au client, généralement sous forme de cookie sécurisé. À chaque requête ultérieure, le client renvoie ce cookie, permettant au serveur d'identifier l'utilisateur et de retrouver sa session.
  • Avantages :
    • Simplicité de mise en œuvre pour des applications monolithiques.
    • Facilite la gestion de l'état utilisateur côté serveur.
    • Peut être sécurisé contre les attaques CSRF (Cross-Site Request Forgery) avec des jetons synchronisés (CSRF tokens).
  • Inconvénients :
    • Moins adaptée aux architectures distribuées (microservices, API RESTful sans état) car l'état de session doit être partagé ou stocké centralement.
    • Vulnérable aux attaques XSS (Cross-Site Scripting) si le cookie n'est pas marqué HttpOnly.
    • Nécessite une gestion rigoureuse de l'expiration et de la révocation des sessions.

2. Authentification Basée sur les Tokens (JWT - JSON Web Tokens)

  • Principe : Après authentification, le serveur génère un jeton (token) cryptographiquement signé (JWT) qui contient des informations sur l'utilisateur (ses claims). Ce jeton est renvoyé au client, qui le stocke (par exemple, dans le localStorage ou un cookie HttpOnly). Pour les requêtes suivantes, le client inclut ce jeton dans l'en-tête Authorization (généralement Bearer <token>). Le serveur valide la signature du jeton et peut extraire les informations sans avoir besoin de consulter une base de données de sessions.
  • Structure d'un JWT : Un JWT est composé de trois parties séparées par des points (.):
    1. Header : Type de jeton et algorithme de signature (ex: HS256).
    2. Payload : Les claims (informations) sur l'utilisateur (ex: ID utilisateur, rôles, date d'expiration).
    3. Signature : Créée en encodant le header, le payload et une clé secrète avec l'algorithme spécifié.
  • Avantages :
    • Sans état (Stateless) : Le serveur n'a pas besoin de maintenir l'état de la session, ce qui simplifie la mise à l'échelle et la distribution.
    • Polyvalent : Idéal pour les API RESTful, les architectures microservices et les applications mobiles.
    • Facilité d'intégration : Standard largement adopté.
  • Inconvénients :
    • Révocation : La révocation d'un JWT avant son expiration est plus complexe (nécessite une liste noire ou une période d'expiration courte).
    • Taille : Les informations sont encodées dans le token, ce qui peut augmenter la taille des en-têtes de requête si trop de données sont stockées.
    • Sécurité de la clé : La clé secrète utilisée pour signer les jetons doit être extrêmement bien protégée.

Implémentation en Rust

Pour l'authentification en Rust, nous nous appuyons sur des caisses (crates) tierces.

1. Hachage des Mots de Passe avec argon2

Il est impératif de ne jamais stocker les mots de passe en clair dans votre base de données. Utilisez toujours une fonction de hachage cryptographique robuste et lente, comme Argon2 (recommandé), Bcrypt ou Scrypt. Ces fonctions ajoutent un sel (salt) aléatoire pour chaque mot de passe et sont conçues pour être coûteuses en calcul, rendant les attaques par brute-force ou par dictionnaire beaucoup plus difficiles.

La caisse argon2 offre une implémentation simple et sécurisée d'Argon2.

use argon2::{
    password_hash::{
        rand_core::OsRng,
        PasswordHasher, SaltString
    },
    Argon2
};

/// Hashe un mot de passe en utilisant Argon2
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
    // Générer un sel aléatoire pour chaque hachage
    let salt = SaltString::generate(&mut OsRng);

    // Configurer Argon2 (vous pouvez ajuster les paramètres selon vos besoins)
    let argon2 = Argon2::default(); // Utilise les paramètres par défaut recommandés

    // Hacher le mot de passe
    argon2.hash_password(password.as_bytes(), &salt)
        .map(|hash| hash.to_string())
}

/// Vérifie si un mot de passe correspond à un hachage donné
pub fn verify_password(password: &str, hashed_password: &str) -> Result<bool, argon2::password_hash::Error> {
    use argon2::password_hash::PasswordVerifier;

    // Parser le hachage stocké
    let parsed_hash = argon2::password_hash::PasswordHash::new(hashed_password)?;

    // Vérifier le mot de passe
    Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_password_hashing_and_verification() {
        let password = "MotDePasseSuperSecret123!";
        let hashed_pw_result = hash_password(password);

        assert!(hashed_pw_result.is_ok());
        let hashed_pw = hashed_pw_result.unwrap();
        println!("Mot de passe haché: {}", hashed_pw);

        // Vérifier le mot de passe correct
        assert!(verify_password(password, &hashed_pw).unwrap());

        // Vérifier un mot de passe incorrect
        assert!(!verify_password("MauvaisMotDePasse", &hashed_pw).unwrap());
    }
}

Explication du code :

  • Le code utilise la caisse argon2 pour générer et vérifier les hachages de mots de passe.
  • hash_password : Prend un mot de passe en chaîne de caractères, génère un sel unique et aléatoire pour ce mot de passe, puis utilise Argon2::default() (avec des paramètres recommandés) pour hacher le mot de passe. Le résultat est une chaîne de caractères contenant le hachage et le sel, prête à être stockée.
  • verify_password : Prend le mot de passe soumis par l'utilisateur et le hachage stocké. Il parse le hachage pour extraire le sel et les paramètres, puis utilise Argon2::default().verify_password pour comparer le mot de passe soumis avec le hachage. Il retourne true si les mots de passe correspondent, false sinon.
  • L'utilisation d'un sel aléatoire par mot de passe est cruciale pour empêcher les attaques par table arc-en-ciel et garantir que deux utilisateurs avec le même mot de passe ont des hachages différents.

2. Implémentation de JWT avec jsonwebtoken

La caisse jsonwebtoken est l'outil standard en Rust pour créer et valider des JWT.

use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use chrono::Utc; // Pour gérer les dates d'expiration

/// Structure des "claims" (informations) que nous voulons stocker dans le JWT
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String, // Sujet (généralement l'ID de l'utilisateur)
    pub exp: i64,    // Date d'expiration du token (timestamp Unix)
    pub iat: i64,    // Date d'émission du token (timestamp Unix)
    pub roles: Vec<String>, // Exemple: rôles de l'utilisateur pour l'autorisation
}

const JWT_SECRET: &[u8] = b"votre_cle_secrete_ultra_securisee_et_longue"; // DEVRAIT ÊTRE UNE VARIABLE D'ENVIRONNEMENT !

/// Crée un nouveau JWT pour un utilisateur donné
pub fn create_jwt(user_id: &str, user_roles: Vec<String>) -> Result<String, jsonwebtoken::errors::Error> {
    let now = Utc::now();
    let expiration_time = now + chrono::Duration::hours(24); // Token valide pour 24 heures

    let claims = Claims {
        sub: user_id.to_owned(),
        exp: expiration_time.timestamp(),
        iat: now.timestamp(),
        roles: user_roles,
    };

    let header = Header::new(Algorithm::HS256);
    encode(&header, &claims, &EncodingKey::from_secret(JWT_SECRET))
}

/// Valide un JWT et retourne les claims si valide
pub fn validate_jwt(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    let mut validation = Validation::new(Algorithm::HS256);
    validation.validate_exp = true; // Valider l'expiration
    validation.leeway = 60; // Autorise une marge de 60 secondes pour les décalages d'horloge

    decode::<Claims>(token, &DecodingKey::from_secret(JWT_SECRET), &validation)
        .map(|data| data.claims)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_jwt_creation_and_validation() {
        let user_id = "user123";
        let user_roles = vec!["user".to_string(), "premium".to_string()];

        let token_result = create_jwt(user_id, user_roles.clone());
        assert!(token_result.is_ok());
        let token = token_result.unwrap();
        println!("JWT créé: {}", token);

        let claims_result = validate_jwt(&token);
        assert!(claims_result.is_ok());
        let claims = claims_result.unwrap();

        assert_eq!(claims.sub, user_id);
        assert!(claims.roles.contains(&"user".to_string()));
        assert!(claims.roles.contains(&"premium".to_string()));
        assert!(claims.exp > Utc::now().timestamp());

        // Test de token expiré (simulé)
        let expired_claims = Claims {
            sub: user_id.to_owned(),
            exp: Utc::now().timestamp() - 3600, // Une heure dans le passé
            iat: Utc::now().timestamp() - 7200,
            roles: user_roles,
        };
        let expired_token_result = encode(&Header::new(Algorithm::HS256), &expired_claims, &EncodingKey::from_secret(JWT_SECRET));
        assert!(expired_token_result.is_ok());
        let expired_token = expired_token_result.unwrap();

        let validation_result = validate_jwt(&expired_token);
        assert!(validation_result.is_err());
        println!("Erreur de validation pour token expiré: {:?}", validation_result.unwrap_err());
    }
}

Explication du code :

  • Claims struct : C'est la structure qui définit les données que nous voulons stocker dans le payload du JWT. Elle implémente Serialize et Deserialize pour permettre l'encodage/décodage JSON. sub (subject), exp (expiration time), et iat (issued at) sont des claims standards. Nous ajoutons un champ roles pour l'autorisation.
  • JWT_SECRET : Une clé secrète est utilisée pour signer et vérifier les jetons. Dans un environnement de production, cette clé NE DOIT JAMAIS être codée en dur. Elle devrait être chargée depuis une variable d'environnement ou un service de gestion de secrets.
  • create_jwt : Prend un ID utilisateur et ses rôles, construit un objet Claims avec une date d'expiration (ici, 24 heures). Il utilise jsonwebtoken::encode pour générer le JWT signé.
  • validate_jwt : Prend un JWT en chaîne de caractères. Il configure un objet Validation pour vérifier l'expiration du token. jsonwebtoken::decode est utilisé pour décoder et vérifier la signature du token. Si tout est valide, il renvoie l'objet Claims décodé, sinon une erreur.

II. Autorisation : Déterminer les Permissions

Une fois qu'un utilisateur est authentifié (nous savons qui il est), l'étape suivante est l'autorisation : déterminer ce qu'il est autorisé à faire ou quelles ressources il peut accéder.

Modèles d'Autorisation Courants

1. Contrôle d'Accès Basé sur les Rôles (RBAC - Role-Based Access Control)

  • Principe : C'est le modèle le plus courant. Les permissions ne sont pas attribuées directement aux utilisateurs, mais à des rôles. Les utilisateurs se voient attribuer un ou plusieurs rôles. Par exemple, un rôle "Administrateur" peut avoir des permissions pour gérer les utilisateurs, tandis qu'un rôle "Éditeur" peut avoir des permissions pour créer et modifier des articles.
  • Avantages :
    • Simplicité de gestion, surtout pour un grand nombre d'utilisateurs.
    • Facile à comprendre et à implémenter.
  • Inconvénients :
    • Peut devenir rigide si les permissions deviennent très granulaires ou basées sur des contextes spécifiques.

2. Contrôle d'Accès Basé sur les Attributs (ABAC - Attribute-Based Access Control)

  • Principe : L'accès est déterminé dynamiquement en fonction d'un ensemble d'attributs liés à l'utilisateur (rôle, département, localisation), à la ressource (type, propriétaire, sensibilité), à l'environnement (heure de la journée, adresse IP), et à l'action demandée. Par exemple : "Seuls les utilisateurs du département 'Finance' peuvent accéder aux documents 'confidentiels' entre 9h et 17h depuis le réseau interne."
  • Avantages :
    • Extrêmement flexible et granulaire.
    • Permet des politiques d'accès complexes et dynamiques.
  • Inconvénients :
    • Beaucoup plus complexe à concevoir, implémenter et maintenir.

Implémentation en Rust avec actix-web-grants (Exemple Conceptuel)

Pour l'autorisation basée sur les rôles dans un framework comme actix-web, la caisse actix-web-grants est une excellente option. Elle permet de vérifier les permissions directement sur les gestionnaires de routes (handlers) ou via des guards (middleware).

L'idée est d'extraire les rôles ou permissions de l'utilisateur (par exemple, des Claims de son JWT validé) et de les rendre disponibles pour actix-web-grants.

  1. Ajoutez la dépendance :

    [dependencies]
    actix-web = "4"
    actix-web-grants = "3"
    serde = { version = "1.0", features = ["derive"] }
    jsonwebtoken = "8"
    chrono = { version = "0.4", features = ["serde"] }
    # ... autres dépendances pour l'authentification (argon2 etc.)
    
  2. Intégrez la validation JWT et l'extraction des rôles dans un Extractor Actix-web ou un middleware : Ceci est un exemple simplifié pour montrer comment les rôles pourraient être mis à disposition. Dans une application réelle, vous auriez un middleware qui décode et valide le JWT, puis extrait les rôles.

    use actix_web::{web, App, HttpResponse, HttpServer, Responder};
    use actix_web_grants::proc_macro::{has_any_role, has_roles};
    use actix_web_grants::GrantsMiddleware;
    use actix_web::dev::ServiceRequest;
    use futures::future::{ok, Ready};
    use crate::auth::{validate_jwt, Claims}; // Supposons que auth.rs contient vos fonctions JWT
    
    // Un exemple de fonction pour extraire les rôles du JWT
    async fn extract_roles_from_jwt(req: &ServiceRequest) -> Result<Vec<String>, actix_web::Error> {
        let auth_header = req.headers().get("Authorization");
        if let Some(header_value) = auth_header {
            let token = header_value.to_str().unwrap_or("").replace("Bearer ", "");
            if let Ok(claims) = validate_jwt(&token) {
                return Ok(claims.roles);
            }
        }
        // Si aucune autorisation ou JWT invalide, retourner une liste vide (pas de rôles)
        Ok(vec![])
    }
    
    // Un handler accessible uniquement aux utilisateurs avec le rôle "ADMIN"
    #[has_roles("ADMIN")]
    async fn admin_dashboard() -> impl Responder {
        HttpResponse::Ok().body("Bienvenue, Admin ! Vous avez accès au tableau de bord.")
    }
    
    // Un handler accessible aux utilisateurs avec le rôle "USER" OU "ADMIN"
    #[has_any_role("USER", "ADMIN")]
    async fn user_profile() -> impl Responder {
        HttpResponse::Ok().body("Bienvenue, utilisateur ! Voici votre profil.")
    }
    
    // Un handler public
    async fn public_access() -> impl Responder {
        HttpResponse::Ok().body("Ceci est une page publique.")
    }
    
    // Point d'entrée de l'application Actix-web
    #[actix_web::main]
    async fn main() -> std::io::Result<()> {
        // ... (partie d'authentification et de création de JWT irait ici pour simuler une connexion)
    
        HttpServer::new(|| {
            App::new()
                // Le middleware GrantsMiddleware doit être ajouté AVANT les routes qui l'utilisent.
                // Il utilise notre fonction `extract_roles_from_jwt` pour obtenir les rôles.
                .wrap(GrantsMiddleware::with_extractor(extract_roles_from_jwt))
                .service(web::resource("/admin").get(admin_dashboard))
                .service(web::resource("/profile").get(user_profile))
                .service(web::resource("/public").get(public_access))
        })
        .bind("127.0.0.1:8080")?
        .run()
        .await
    }
    

Explication du code (Conceptuel) :

  • extract_roles_from_jwt : Cette fonction est cruciale. Elle est appelée par GrantsMiddleware pour chaque requête. Son rôle est de lire l'en-tête Authorization, d'extraire le JWT, de le valider en utilisant notre fonction validate_jwt, et enfin d'extraire les rôles (claims.roles) du JWT. Ces rôles sont ensuite mis à disposition pour les macros #[has_roles] et #[has_any_role].
  • GrantsMiddleware::with_extractor : C'est ainsi que actix-web-grants est configuré. Il prend notre fonction d'extraction de rôles.
  • #[has_roles("ADMIN")] et #[has_any_role("USER", "ADMIN")] : Ces macros procèdent à la vérification des rôles.
    • #[has_roles("ADMIN")] : Le gestionnaire ne sera appelé que si l'utilisateur possède exactement le rôle "ADMIN".
    • #[has_any_role("USER", "ADMIN")] : Le gestionnaire sera appelé si l'utilisateur possède au moins le rôle "USER" OU "ADMIN".
  • Si un utilisateur tente d'accéder à une route sans les rôles requis, actix-web-grants interceptera la requête et renverra automatiquement une réponse 403 Forbidden.

Ce modèle permet une séparation claire des préoccupations : l'authentification vérifie qui est l'utilisateur, et l'autorisation, via les rôles dans le JWT et le middleware actix-web-grants, gère ce qu'il peut faire.

III. Bonnes Pratiques et Pièges à Éviter en Sécurité

Construire des applications sécurisées est un effort continu. Voici quelques bonnes pratiques essentielles :

  • Toujours utiliser HTTPS : Le chiffrement des communications via SSL/TLS est non négociable pour protéger les données en transit contre l'interception (Man-in-the-Middle).
  • Valider toutes les entrées utilisateur : Ne jamais faire confiance aux données provenant du client. Validez, nettoyez et échappez toutes les entrées pour prévenir les injections SQL, XSS, Path Traversal, etc. Rust et ses systèmes de types aident, mais une validation explicite est toujours nécessaire.
  • Gérer les secrets avec soin : Les clés de signature JWT, les identifiants de base de données, les clés API externes ne doivent jamais être codés en dur ni poussés dans un dépôt de code. Utilisez des variables d'environnement, un gestionnaire de secrets (comme HashiCorp Vault) ou des outils de gestion de configuration.
  • Limiter les tentatives de connexion : Mettez en place des mécanismes de limitation de débit (rate limiting) pour prévenir les attaques par force brute sur les formulaires de connexion.
  • Gérer les sessions/tokens correctement :
    • Définir des durées d'expiration courtes pour les jetons/sessions.
    • Implémenter un mécanisme de rafraîchissement des tokens pour améliorer l'expérience utilisateur sans compromettre la sécurité (par ex., JWT court durée + Refresh Token longue durée).
    • Prévoir la révocation des tokens/sessions en cas de compromission (liste noire des JWT, invalidation des sessions côté serveur).
  • Sécuriser les cookies : Si vous utilisez des cookies, assurez-vous qu'ils sont marqués HttpOnly (non accessibles via JavaScript), Secure (envoyés uniquement via HTTPS) et SameSite=Lax ou Strict (pour la protection CSRF).
  • Gérer les erreurs de manière sécurisée : Ne jamais révéler d'informations sensibles (traces de pile, détails de base de données) dans les messages d'erreur exposés au client.
  • Mises à jour régulières : Maintenez vos dépendances (crates) à jour pour bénéficier des derniers correctifs de sécurité. Utilisez des outils comme cargo audit pour vérifier les vulnérabilités connues dans vos dépendances.
  • Tests de sécurité : Intégrez des tests de sécurité (tests d'intrusion, analyse de vulnérabilités, Fuzzing) dans votre cycle de développement.

Conclusion

L'authentification et l'autorisation sont des composants essentiels de toute application web sécurisée. Rust, avec ses garanties de sécurité mémoire et un écosystème de caisses matures comme argon2, jsonwebtoken et actix-web-grants, fournit des outils puissants pour les mettre en œuvre de manière robuste.

Comprendre la distinction entre "qui êtes-vous" (authentification) et "ce que vous pouvez faire" (autorisation) est la première étape. L'application rigoureuse des bonnes pratiques, de la gestion des mots de passe hachés à la protection des secrets, en passant par la validation des entrées et la gestion sécurisée des tokens, est la clé pour bâtir des applications web performantes et résilientes face aux menaces actuelles. La sécurité n'est pas une fonctionnalité, c'est une culture de développement.