session.rs 359 lignes · 12715 octets
//! Session management with tower-sessions
//!
//! This module provides session management functionality using tower-sessions.
//! Sessions are used for server-side state management, enabling features like:
//! - Logout (session invalidation)
//! - "Remember me" functionality
//! - Session-based authorization
//! - Audit logging
//!
//! ## Backend Options
//!
//! Due to version incompatibility between `tower-sessions-seaorm-store 0.1.1` (depends on sea-orm 1.x)
//! and our project's `sea-orm 2.0.0-rc.20`, we use alternative stores:
//!
//! - **Development/Testing**: MemoryStore (in-process, fast, non-persistent)
//! - **Production**: RedisStore (distributed, persistent, scalable)
//!
//! ## Security
//!
//! Sessions are configured with:
//! - HttpOnly cookies (prevent XSS attacks)
//! - Secure flag (HTTPS only in production)
//! - SameSite=Strict (CSRF protection)
//! - Configurable expiration (default 30 minutes inactivity)

use crate::config::auth::{AuthConfig, SessionBackend};
use crate::error::AppError;
use time::Duration;
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
use uuid::Uuid;

/// Session keys for storing user data
pub mod keys {
    /// Key for storing user ID in session
    pub const USER_ID: &str = "user_id";

    /// Key for storing username in session
    pub const USERNAME: &str = "username";

    /// Key for storing user roles in session
    pub const ROLES: &str = "roles";
}

/// Session data structure
///
/// Represents the data stored in a user's session.
#[allow(dead_code)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionData {
    /// User's unique identifier
    pub user_id: Uuid,

    /// Username
    pub username: String,

    /// User roles
    pub roles: Vec<String>,
}

/// Session configuration helper
pub struct SessionConfig;

impl SessionConfig {
    /// Create a session manager layer with MemoryStore
    ///
    /// MemoryStore is suitable for:
    /// - Development and testing
    /// - Single-server deployments
    /// - Non-critical sessions (lost on restart)
    ///
    /// **Limitations**:
    /// - Sessions are lost on server restart
    /// - Not suitable for horizontally scaled deployments
    /// - Uses server RAM (consider memory limits)
    ///
    /// # Arguments
    ///
    /// * `config` - Authentication configuration (contains session timeout)
    ///
    /// # Returns
    ///
    /// A configured `SessionManagerLayer` ready to use as Axum middleware
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rustwarden_core::auth::session::SessionConfig;
    /// use rustwarden_core::config::auth::AuthConfig;
    ///
    /// let auth_config = AuthConfig::from_env().expect("Failed to load auth config");
    /// let session_layer = SessionConfig::create_memory_layer(&auth_config);
    ///
    /// // Use session_layer in your Axum app
    /// ```
    pub fn create_memory_layer(config: &AuthConfig) -> SessionManagerLayer<MemoryStore> {
        let session_store = MemoryStore::default();

        let timeout_seconds = config.session_timeout_minutes * 60;

        tracing::info!(
            "Session store initialized (MemoryStore) with {} minute timeout",
            config.session_timeout_minutes
        );

        // Configure SameSite policy for session cookie
        // Use Lax instead of Strict to allow cookies to be sent with PUT/POST requests from JavaScript
        // Strict would block cookies on programmatic requests (fetch/XMLHttpRequest)
        // Lax allows cookies on top-level navigation and same-site requests, which is sufficient for CSRF protection
        // Note: The default is now "Lax" (configured in AuthConfig::from_env)
        use tower_sessions::cookie::SameSite;
        let same_site = match config.cookie_same_site.to_lowercase().as_str() {
            "strict" => SameSite::Strict,
            "lax" => SameSite::Lax,
            "none" => SameSite::None,
            _ => {
                tracing::warn!(
                    "Invalid COOKIE_SAME_SITE value: '{}', defaulting to Lax for session cookies",
                    config.cookie_same_site
                );
                SameSite::Lax
            }
        };

        // SEC-L6: Custom session cookie name (hides technology stack)
        // __Host- prefix requires Secure flag (HTTPS); fall back to plain name in dev
        let session_cookie_name = std::env::var("SESSION_COOKIE_NAME").unwrap_or_else(|_| {
            if config.cookie_secure {
                "__Host-sid".to_string()
            } else {
                "sid".to_string()
            }
        });

        let session_layer = SessionManagerLayer::new(session_store)
            .with_name(session_cookie_name.clone())
            .with_expiry(Expiry::OnInactivity(Duration::seconds(timeout_seconds)))
            .with_same_site(same_site)
            .with_secure(config.cookie_secure)
            .with_http_only(true);

        tracing::info!(
            "Session cookie configured: name={}, SameSite={:?}, Secure={}, HttpOnly=true, Expiry={} seconds",
            session_cookie_name,
            same_site,
            config.cookie_secure,
            timeout_seconds
        );

        session_layer
    }

    /// Create a session manager layer with RedisStore (requires redis-session feature)
    ///
    /// RedisStore is suitable for:
    /// - Production deployments
    /// - Horizontally scaled applications (multiple servers)
    /// - High availability requirements
    /// - Sessions survive server restarts
    ///
    /// **Requirements**:
    /// - Redis server running and accessible
    /// - `redis-session` feature enabled in Cargo.toml
    /// - `REDIS_URL` environment variable set
    ///
    /// # Arguments
    ///
    /// * `config` - Authentication configuration (contains redis_url and timeout)
    ///
    /// # Returns
    ///
    /// A configured `SessionManagerLayer` with Redis backend
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - `REDIS_URL` is not configured
    /// - Cannot connect to Redis server
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rustwarden_core::auth::session::SessionConfig;
    /// use rustwarden_core::config::auth::AuthConfig;
    ///
    /// let auth_config = AuthConfig::from_env().expect("Failed to load auth config");
    ///
    /// #[cfg(feature = "redis-session")]
    /// {
    ///     let session_layer = SessionConfig::create_redis_layer(&auth_config)
    ///         .await
    ///         .expect("Failed to create Redis session layer");
    /// }
    /// ```
    #[cfg(feature = "redis-session")]
    pub async fn create_redis_layer(
        config: &AuthConfig,
    ) -> Result<SessionManagerLayer<tower_sessions_redis_store::RedisStore>, AppError> {
        use tower_sessions_redis_store::RedisStore;

        let redis_url = config.redis_url.as_ref().ok_or_else(|| {
            AppError::Config("REDIS_URL is required for Redis session backend".to_string())
        })?;

        // Create Redis client
        let redis_client = redis::Client::open(redis_url.as_str())
            .map_err(|e| AppError::Config(format!("Failed to create Redis client: {}", e)))?;

        // Create Redis store
        let session_store = RedisStore::new(redis_client);

        let timeout_seconds = (config.session_timeout_minutes * 60) as i64;

        tracing::info!(
            "Session store initialized (Redis: {}) with {} minute timeout",
            redis_url,
            config.session_timeout_minutes
        );

        // Configure SameSite policy for session cookie (same as MemoryStore)
        use tower_sessions::cookie::SameSite;
        let same_site = match config.cookie_same_site.to_lowercase().as_str() {
            "strict" => SameSite::Strict,
            "lax" => SameSite::Lax,
            "none" => SameSite::None,
            _ => {
                tracing::warn!(
                    "Invalid COOKIE_SAME_SITE value: '{}', defaulting to Lax for session cookies",
                    config.cookie_same_site
                );
                SameSite::Lax
            }
        };

        // Configure session cookie security settings (same as MemoryStore)
        let session_layer = SessionManagerLayer::new(session_store)
            .with_expiry(Expiry::OnInactivity(Duration::seconds(timeout_seconds)))
            .with_same_site(same_site)
            .with_secure(config.cookie_secure) // Use config value (false for HTTP, true for HTTPS)
            .with_http_only(true); // HttpOnly prevents XSS attacks

        tracing::info!(
            "Redis session cookie configured: SameSite={:?}, Secure={}, Expiry={} seconds",
            same_site,
            config.cookie_secure,
            timeout_seconds
        );

        Ok(session_layer)
    }

    /// Create session layer based on configuration
    ///
    /// Automatically selects the appropriate session backend based on
    /// the `SESSION_BACKEND` environment variable:
    /// - "memory" → MemoryStore (default)
    /// - "redis" → RedisStore (if redis-session feature enabled)
    ///
    /// # Arguments
    ///
    /// * `config` - Authentication configuration
    ///
    /// # Returns
    ///
    /// A configured session manager layer
    ///
    /// # Errors
    ///
    /// Returns error if the selected backend cannot be initialized
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rustwarden_core::auth::session::SessionConfig;
    /// use rustwarden_core::config::auth::AuthConfig;
    ///
    /// async fn setup() {
    ///     let auth_config = AuthConfig::from_env().expect("Failed to load config");
    ///     let session_layer = SessionConfig::create_layer_from_config(&auth_config)
    ///         .await
    ///         .expect("Failed to create session layer");
    #[allow(dead_code)]
    /// }
    /// ```
    pub async fn create_layer_from_config(
        config: &AuthConfig,
    ) -> Result<SessionManagerLayer<MemoryStore>, AppError> {
        match config.session_backend {
            SessionBackend::Memory => {
                // MemoryStore is the default for development
                tracing::info!("Using MemoryStore session backend (development mode)");
                Ok(Self::create_memory_layer(config))
            }
            SessionBackend::SeaOrm => {
                // SeaORM store not yet available due to version incompatibility
                // Fall back to MemoryStore with a warning
                tracing::warn!(
                    "SeaORM session backend requested but not available (version incompatibility). \
                    Using MemoryStore as fallback. For production, use SESSION_BACKEND=redis."
                );
                Ok(Self::create_memory_layer(config))
            }
            SessionBackend::Redis => {
                #[cfg(feature = "redis-session")]
                {
                    tracing::info!("Using Redis session backend");
                    // Redis returns a different type, so we need to handle this differently
                    // For now, return an error and the caller should handle Redis separately
                    return Err(AppError::Config(
                        "Redis session backend requires using create_redis_layer() directly"
                            .to_string(),
                    ));
                }

                #[cfg(not(feature = "redis-session"))]
                {
                    tracing::warn!(
                        "Redis session backend requested but 'redis-session' feature not enabled. \
                        Falling back to MemoryStore. Enable with: cargo build --features redis-session"
                    );
                    Ok(Self::create_memory_layer(config))
                }
            }
        }
    }
}

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

    #[test]
    fn test_session_config_exists() {
        // Test that SessionConfig can be instantiated
        let _config = SessionConfig;
    }

    #[test]
    fn test_session_keys() {
        assert_eq!(keys::USER_ID, "user_id");
        assert_eq!(keys::USERNAME, "username");
        assert_eq!(keys::ROLES, "roles");
    }

    #[test]
    fn test_session_data_serialization() {
        let data = SessionData {
            user_id: Uuid::new_v4(),
            username: "testuser".to_string(),
            roles: vec!["user".to_string()],
        };

        // Test that SessionData can be serialized
        let json = serde_json::to_string(&data);
        assert!(json.is_ok());
    }

    // Note: Integration tests for actual session creation/destruction
    // are in tests/integration/ directory
}