extractors.rs 342 lignes · 10938 octets
//! Axum extractors for authenticated user information
//!
//! This module provides the AuthUser extractor that automatically extracts
//! and validates JWT tokens, making authenticated user information available
//! to request handlers.

use axum::{
    extract::FromRequestParts,
    http::{StatusCode, request::Parts},
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use uuid::Uuid;

use crate::auth::jwt::JwtService;
use crate::config::auth::AuthConfig;
use crate::error::AppError;
use crate::services::jwt_blacklist_service::JwtBlacklistService;

/// Authenticated user extractor
///
/// This extractor automatically validates JWT tokens from the Authorization header
/// and makes user information available to request handlers.
///
/// # Usage
///
/// Add `AuthUser` as a parameter to any handler that requires authentication:
///
/// ```rust,no_run
/// use axum::Json;
/// use rustwarden_core::auth::extractors::AuthUser;
///
/// async fn protected_handler(user: AuthUser) -> Json<String> {
///     Json(format!("Hello, {}!", user.username))
/// }
/// ```
///
/// # Automatic Validation
///
/// The extractor performs the following checks:
/// 1. Extracts Authorization header
/// 2. Validates Bearer token format
/// 3. Verifies JWT signature
/// 4. Checks token expiration
/// 5. Validates issuer
///
/// If any check fails, the extractor returns a 401 Unauthorized response
/// automatically, preventing the handler from executing.
///
/// # Examples
///
/// ```rust,no_run
/// use axum::{routing::get, Router, Json};
/// use rustwarden_core::auth::extractors::AuthUser;
///
/// async fn get_profile(user: AuthUser) -> Json<serde_json::Value> {
///     Json(serde_json::json!({
///         "user_id": user.user_id,
///         "username": user.username,
///         "roles": user.roles
///     }))
/// }
///
/// let app: Router = Router::new().route("/profile", get(get_profile));
/// ```
///
/// # Security
///
/// - Token validation happens on every request
/// - Invalid or expired tokens are rejected immediately
/// - User information is extracted from verified claims only
/// - No database queries are performed (stateless authentication)
#[derive(Debug, Clone)]
pub struct AuthUser {
    /// User unique identifier (from JWT sub claim)
    pub user_id: Uuid,

    /// Username (from JWT username claim)
    pub username: String,

    /// User roles (from JWT roles claim)
    pub roles: Vec<String>,

    /// User permissions (from JWT permissions claim)
    pub permissions: Vec<String>,
}

impl AuthUser {
    /// Check if user has a specific role
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use rustwarden_core::auth::extractors::AuthUser;
    ///
    /// fn check_admin(user: &AuthUser) -> bool {
    ///     user.has_role("admin")
    #[allow(dead_code)]
    /// }
    /// ```
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }

    /// Check if user has any of the specified roles
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use rustwarden_core::auth::extractors::AuthUser;
    ///
    /// fn check_access(user: &AuthUser) -> bool {
    ///     user.has_any_role(&["admin", "moderator"])
    /// }
    /// ```
    #[allow(dead_code)]
    pub fn has_any_role(&self, roles: &[&str]) -> bool {
        roles.iter().any(|role| self.has_role(role))
    }

    /// Check if user has all of the specified roles
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use rustwarden_core::auth::extractors::AuthUser;
    ///
    /// fn check_permissions(user: &AuthUser) -> bool {
    ///     user.has_all_roles(&["user", "verified"])
    /// }
    /// ```
    #[allow(dead_code)]
    pub fn has_all_roles(&self, roles: &[&str]) -> bool {
        roles.iter().all(|role| self.has_role(role))
    }

    /// Check if user has a specific permission.
    /// Admin role is a superuser and has all permissions implicitly.
    pub fn has_permission(&self, permission: &str) -> bool {
        if self.has_role("admin") {
            return true;
        }
        self.permissions.iter().any(|p| p == permission)
    }
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, String);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        // Extract JWT token from Authorization header or cookie
        // Priority: Authorization header > Cookie (for API clients to override)
        let token: String = if let Some(auth_header) = parts
            .headers
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|value| value.to_str().ok())
        {
            // Try Authorization header first (for API clients)
            JwtService::extract_token_from_header(auth_header)
                .map(|t| t.to_string())
                .map_err(|e| {
                    tracing::debug!("Invalid Authorization header format: {}", e);
                    (
                        StatusCode::UNAUTHORIZED,
                        "Invalid Authorization header format".to_string(),
                    )
                })?
        } else {
            // Fallback to cookie (for web browsers with HttpOnly cookies)
            use axum_extra::extract::CookieJar;
            let cookie_jar = CookieJar::from_headers(&parts.headers);
            cookie_jar
                .get("jwt_token")
                .and_then(|cookie| {
                    let value = cookie.value();
                    if value.is_empty() {
                        None
                    } else {
                        Some(value.to_string())
                    }
                })
                .ok_or_else(|| {
                    tracing::debug!("Missing Authorization header and jwt_token cookie");
                    (
                        StatusCode::UNAUTHORIZED,
                        "Missing Authorization header or jwt_token cookie".to_string(),
                    )
                })?
        };

        // SEC-M1: Read AuthConfig from Extension (loaded once at startup) with fallback
        let auth_config_ext = parts.extensions.get::<Arc<AuthConfig>>().cloned();
        let auth_config_owned;
        let auth_config: &AuthConfig = if let Some(ref arc) = auth_config_ext {
            arc.as_ref()
        } else {
            auth_config_owned = AuthConfig::from_env().map_err(|e| {
                tracing::error!("Failed to load auth config: {}", e);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Server configuration error".to_string(),
                )
            })?;
            &auth_config_owned
        };

        // SEC-C1: Check JWT blacklist if DatabaseConnection is available in extensions
        if let Some(db) = parts.extensions.get::<DatabaseConnection>() {
            if JwtBlacklistService::is_blacklisted(db, &token)
                .await
                .unwrap_or(false)
            {
                tracing::warn!("AuthUser: JWT token is blacklisted");
                return Err((
                    StatusCode::UNAUTHORIZED,
                    "Token has been revoked".to_string(),
                ));
            }
        }

        // Validate token and extract claims
        let claims = JwtService::validate_token(auth_config, &token).map_err(|e| {
            let (status, message) = match e {
                AppError::TokenExpired => {
                    tracing::debug!("Token expired");
                    (StatusCode::UNAUTHORIZED, "Token expired".to_string())
                }
                AppError::InvalidToken => {
                    tracing::debug!("Invalid token");
                    (StatusCode::UNAUTHORIZED, "Invalid token".to_string())
                }
                _ => {
                    tracing::warn!("Token validation failed: {}", e);
                    (
                        StatusCode::UNAUTHORIZED,
                        "Token validation failed".to_string(),
                    )
                }
            };
            (status, message)
        })?;

        tracing::debug!("User {} authenticated via JWT", claims.username);

        Ok(AuthUser {
            user_id: claims.sub,
            username: claims.username,
            roles: claims.roles,
            permissions: claims.permissions,
        })
    }
}

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

    #[test]
    fn test_auth_user_creation() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "test_user".to_string(),
            roles: vec!["user".to_string(), "admin".to_string()],
            permissions: vec![],
        };

        assert_eq!(user.username, "test_user");
        assert_eq!(user.roles.len(), 2);
    }

    #[test]
    fn test_has_role() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "test_user".to_string(),
            roles: vec!["user".to_string(), "admin".to_string()],
            permissions: vec![],
        };

        assert!(user.has_role("user"));
        assert!(user.has_role("admin"));
        assert!(!user.has_role("moderator"));
    }

    #[test]
    fn test_has_any_role() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "test_user".to_string(),
            roles: vec!["user".to_string()],
            permissions: vec![],
        };

        assert!(user.has_any_role(&["admin", "user"]));
        assert!(user.has_any_role(&["user"]));
        assert!(!user.has_any_role(&["admin", "moderator"]));
    }

    #[test]
    fn test_has_all_roles() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "test_user".to_string(),
            roles: vec!["user".to_string(), "admin".to_string()],
            permissions: vec![],
        };

        assert!(user.has_all_roles(&["user", "admin"]));
        assert!(user.has_all_roles(&["user"]));
        assert!(!user.has_all_roles(&["user", "admin", "moderator"]));
    }

    #[test]
    fn test_has_permission() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "test_user".to_string(),
            roles: vec!["user".to_string()],
            permissions: vec!["users.list".to_string(), "settings.view".to_string()],
        };

        assert!(user.has_permission("users.list"));
        assert!(user.has_permission("settings.view"));
        assert!(!user.has_permission("users.delete"));
    }

    #[test]
    fn test_admin_has_all_permissions() {
        let user = AuthUser {
            user_id: Uuid::new_v4(),
            username: "admin_user".to_string(),
            roles: vec!["admin".to_string()],
            permissions: vec![],
        };

        assert!(user.has_permission("users.delete"));
        assert!(user.has_permission("any.permission"));
    }
}