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 }
GitRust