Skip to main content

actix_security_core/http/security/
session.rs

1//! Session-based Authentication.
2//!
3//! # Spring Security Equivalent
4//! Similar to Spring Security's session-based authentication with `HttpSession`.
5//!
6//! # Features
7//! - Store user in session after login
8//! - Session fixation protection (migrate, new session, or none)
9//! - Configurable session timeout
10//! - Maximum sessions per user support
11//! - Integration with actix-session
12//!
13//! # Example
14//! ```rust,ignore
15//! use actix_security_core::http::security::session::{
16//!     SessionAuthenticator, SessionConfig, SessionFixationStrategy
17//! };
18//! use actix_session::SessionMiddleware;
19//! use actix_session::storage::CookieSessionStore;
20//!
21//! // Configure session middleware (required)
22//! let session_middleware = SessionMiddleware::new(
23//!     CookieSessionStore::default(),
24//!     cookie_key.clone()
25//! );
26//!
27//! // Configure session authenticator with fixation protection
28//! let config = SessionConfig::new()
29//!     .fixation_strategy(SessionFixationStrategy::MigrateSession);
30//!
31//! let authenticator = SessionAuthenticator::new(config);
32//!
33//! App::new()
34//!     .wrap(session_middleware)
35//!     .wrap(SecurityTransform::new()
36//!         .config_authenticator(move || authenticator.clone())
37//!         .config_authorizer(|| /* ... */))
38//! ```
39
40use crate::http::security::config::Authenticator;
41use crate::http::security::User;
42use actix_session::SessionExt;
43use actix_web::dev::ServiceRequest;
44use serde::{Deserialize, Serialize};
45use std::time::Duration;
46
47// =============================================================================
48// Session Fixation Strategy
49// =============================================================================
50
51/// Strategy for session fixation protection.
52///
53/// # Spring Security Equivalent
54/// Similar to `SessionFixationProtectionStrategy` in Spring Security.
55///
56/// Session fixation attacks occur when an attacker sets a user's session ID
57/// before they authenticate. After authentication, the attacker can hijack
58/// the session using the known session ID.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum SessionFixationStrategy {
61    /// Create a new session and migrate all attributes from the old session.
62    /// This is the safest option that preserves user data.
63    ///
64    /// # Spring Equivalent
65    /// `SessionFixationProtectionStrategy.MIGRATE_SESSION`
66    #[default]
67    MigrateSession,
68
69    /// Create a new session without migrating attributes.
70    /// Use this when you want a completely fresh session after login.
71    ///
72    /// # Spring Equivalent
73    /// `SessionFixationProtectionStrategy.NEW_SESSION`
74    NewSession,
75
76    /// No session fixation protection.
77    /// **WARNING**: This is insecure and should only be used for testing.
78    ///
79    /// # Spring Equivalent
80    /// `SessionFixationProtectionStrategy.NONE`
81    None,
82}
83
84// =============================================================================
85// Session User Data
86// =============================================================================
87
88/// Serializable user data stored in session.
89///
90/// This is the data structure stored in the session.
91/// It's separate from `User` to ensure clean serialization.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SessionUser {
94    /// Username
95    pub username: String,
96    /// User roles
97    pub roles: Vec<String>,
98    /// User authorities/permissions
99    pub authorities: Vec<String>,
100}
101
102impl SessionUser {
103    /// Create from a User.
104    pub fn from_user(user: &User) -> Self {
105        Self {
106            username: user.get_username().to_string(),
107            roles: user.get_roles().to_vec(),
108            authorities: user.get_authorities().to_vec(),
109        }
110    }
111
112    /// Convert to User.
113    pub fn to_user(&self) -> User {
114        User::new(self.username.clone(), String::new())
115            .roles(&self.roles)
116            .authorities(&self.authorities)
117    }
118}
119
120impl From<&User> for SessionUser {
121    fn from(user: &User) -> Self {
122        Self::from_user(user)
123    }
124}
125
126impl From<SessionUser> for User {
127    fn from(session_user: SessionUser) -> Self {
128        session_user.to_user()
129    }
130}
131
132// =============================================================================
133// Session Configuration
134// =============================================================================
135
136/// Session authentication configuration.
137///
138/// # Spring Security Equivalent
139/// Combines `SessionManagementConfigurer` and `SessionFixationConfigurer`.
140///
141/// # Example
142/// ```rust,ignore
143/// let config = SessionConfig::new()
144///     .user_key("user")
145///     .fixation_strategy(SessionFixationStrategy::MigrateSession)
146///     .maximum_sessions(1);  // Only one session per user
147/// ```
148#[derive(Clone)]
149pub struct SessionConfig {
150    /// Session key for storing user data
151    user_key: String,
152    /// Session key for authentication flag
153    authenticated_key: String,
154    /// Session key for storing the original request URL (for redirect after login)
155    saved_request_key: String,
156    /// Session fixation protection strategy
157    fixation_strategy: SessionFixationStrategy,
158    /// Maximum number of concurrent sessions per user (None = unlimited)
159    maximum_sessions: Option<usize>,
160    /// Session timeout duration (used for reference, actual timeout configured in SessionMiddleware)
161    timeout: Option<Duration>,
162    /// Whether to expire the oldest session when max sessions exceeded
163    expire_oldest_session: bool,
164}
165
166impl Default for SessionConfig {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl SessionConfig {
173    /// Create a new session configuration with default keys.
174    pub fn new() -> Self {
175        Self {
176            user_key: "security_user".to_string(),
177            authenticated_key: "security_authenticated".to_string(),
178            saved_request_key: "security_saved_request".to_string(),
179            fixation_strategy: SessionFixationStrategy::MigrateSession,
180            maximum_sessions: None,
181            timeout: None,
182            expire_oldest_session: false,
183        }
184    }
185
186    /// Set the session key for user data.
187    pub fn user_key(mut self, key: &str) -> Self {
188        self.user_key = key.to_string();
189        self
190    }
191
192    /// Set the session key for authentication flag.
193    pub fn authenticated_key(mut self, key: &str) -> Self {
194        self.authenticated_key = key.to_string();
195        self
196    }
197
198    /// Set the session key for saved request URL.
199    pub fn saved_request_key(mut self, key: &str) -> Self {
200        self.saved_request_key = key.to_string();
201        self
202    }
203
204    /// Set the session fixation protection strategy.
205    ///
206    /// # Spring Equivalent
207    /// `sessionManagement().sessionFixation().migrateSession()`
208    pub fn fixation_strategy(mut self, strategy: SessionFixationStrategy) -> Self {
209        self.fixation_strategy = strategy;
210        self
211    }
212
213    /// Set the maximum number of concurrent sessions per user.
214    ///
215    /// # Spring Equivalent
216    /// `sessionManagement().maximumSessions(1)`
217    pub fn maximum_sessions(mut self, max: usize) -> Self {
218        self.maximum_sessions = Some(max);
219        self
220    }
221
222    /// Set the session timeout duration.
223    /// Note: This is informational; actual timeout is configured in SessionMiddleware.
224    pub fn timeout(mut self, duration: Duration) -> Self {
225        self.timeout = Some(duration);
226        self
227    }
228
229    /// Whether to expire the oldest session when maximum sessions exceeded.
230    ///
231    /// # Spring Equivalent
232    /// `sessionManagement().maximumSessions(1).expiredSessionStrategy(...)`
233    pub fn expire_oldest_session(mut self, expire: bool) -> Self {
234        self.expire_oldest_session = expire;
235        self
236    }
237
238    /// Get the user key.
239    pub fn get_user_key(&self) -> &str {
240        &self.user_key
241    }
242
243    /// Get the authenticated key.
244    pub fn get_authenticated_key(&self) -> &str {
245        &self.authenticated_key
246    }
247
248    /// Get the saved request key.
249    pub fn get_saved_request_key(&self) -> &str {
250        &self.saved_request_key
251    }
252
253    /// Get the fixation strategy.
254    pub fn get_fixation_strategy(&self) -> SessionFixationStrategy {
255        self.fixation_strategy
256    }
257
258    /// Get maximum sessions.
259    pub fn get_maximum_sessions(&self) -> Option<usize> {
260        self.maximum_sessions
261    }
262
263    /// Get timeout.
264    pub fn get_timeout(&self) -> Option<Duration> {
265        self.timeout
266    }
267
268    /// Check if oldest session should be expired.
269    pub fn should_expire_oldest(&self) -> bool {
270        self.expire_oldest_session
271    }
272}
273
274// =============================================================================
275// Session Authenticator
276// =============================================================================
277
278/// Session-based authenticator.
279///
280/// Reads user information from the actix-session.
281///
282/// # Spring Security Equivalent
283/// Similar to Spring's session-based authentication where `SecurityContext`
284/// is stored in the `HttpSession`.
285///
286/// # Requirements
287/// - `SessionMiddleware` must be configured in your application
288/// - User must be logged in via `SessionAuthenticator::login()`
289///
290/// # Example
291/// ```rust,ignore
292/// use actix_security_core::http::security::session::{SessionAuthenticator, SessionConfig};
293///
294/// let config = SessionConfig::new()
295///     .fixation_strategy(SessionFixationStrategy::MigrateSession);
296/// let authenticator = SessionAuthenticator::new(config);
297///
298/// // In login handler
299/// async fn login(session: Session, form: Form<LoginForm>) -> impl Responder {
300///     // Validate credentials...
301///     let user = validate_user(&form.username, &form.password)?;
302///
303///     // Store user in session (with fixation protection)
304///     SessionAuthenticator::login(&session, &user, &config)?;
305///
306///     HttpResponse::Ok().body("Logged in")
307/// }
308/// ```
309#[derive(Clone)]
310pub struct SessionAuthenticator {
311    config: SessionConfig,
312}
313
314impl SessionAuthenticator {
315    /// Create a new session authenticator.
316    pub fn new(config: SessionConfig) -> Self {
317        Self { config }
318    }
319
320    /// Create with default configuration.
321    pub fn default_config() -> Self {
322        Self::new(SessionConfig::default())
323    }
324
325    /// Store user in session (login) with session fixation protection.
326    ///
327    /// This method:
328    /// 1. Applies session fixation protection based on configuration
329    /// 2. Stores user data in the session
330    /// 3. Sets the authenticated flag
331    ///
332    /// # Spring Equivalent
333    /// Similar to `SecurityContextHolder.getContext().setAuthentication(...)`
334    /// combined with session fixation protection.
335    ///
336    /// # Example
337    /// ```rust,ignore
338    /// async fn login_handler(
339    ///     session: Session,
340    ///     form: Form<LoginForm>,
341    ///     config: Data<SessionConfig>,
342    /// ) -> impl Responder {
343    ///     // Validate credentials
344    ///     let user = validate_user(&form.username, &form.password)?;
345    ///
346    ///     // Login with session fixation protection
347    ///     SessionAuthenticator::login(&session, &user, &config)?;
348    ///
349    ///     HttpResponse::Ok().body("Logged in")
350    /// }
351    /// ```
352    pub fn login(
353        session: &actix_session::Session,
354        user: &User,
355        config: &SessionConfig,
356    ) -> Result<(), SessionError> {
357        // Apply session fixation protection
358        Self::apply_fixation_protection(session, config)?;
359
360        // Store user in session
361        let session_user = SessionUser::from_user(user);
362
363        session
364            .insert(&config.user_key, &session_user)
365            .map_err(|e| SessionError::InsertError(e.to_string()))?;
366
367        session
368            .insert(&config.authenticated_key, true)
369            .map_err(|e| SessionError::InsertError(e.to_string()))?;
370
371        Ok(())
372    }
373
374    /// Apply session fixation protection based on configuration.
375    fn apply_fixation_protection(
376        session: &actix_session::Session,
377        config: &SessionConfig,
378    ) -> Result<(), SessionError> {
379        match config.fixation_strategy {
380            SessionFixationStrategy::MigrateSession => {
381                // Regenerate session ID but keep data
382                // Note: actix-session's renew() regenerates the session ID
383                session.renew();
384            }
385            SessionFixationStrategy::NewSession => {
386                // Clear all session data and regenerate
387                session.purge();
388            }
389            SessionFixationStrategy::None => {
390                // No protection - do nothing
391            }
392        }
393        Ok(())
394    }
395
396    /// Remove user from session (logout).
397    ///
398    /// # Example
399    /// ```rust,ignore
400    /// async fn logout_handler(session: Session, config: Data<SessionConfig>) -> impl Responder {
401    ///     SessionAuthenticator::logout(&session, &config);
402    ///     HttpResponse::Ok().body("Logged out")
403    /// }
404    /// ```
405    pub fn logout(session: &actix_session::Session, config: &SessionConfig) {
406        session.remove(&config.user_key);
407        session.remove(&config.authenticated_key);
408        session.remove(&config.saved_request_key);
409    }
410
411    /// Clear entire session (logout + clear all data).
412    pub fn clear_session(session: &actix_session::Session) {
413        session.purge();
414    }
415
416    /// Check if session is authenticated.
417    pub fn is_authenticated(session: &actix_session::Session, config: &SessionConfig) -> bool {
418        session
419            .get::<bool>(&config.authenticated_key)
420            .ok()
421            .flatten()
422            .unwrap_or(false)
423    }
424
425    /// Get user from session.
426    pub fn get_session_user(
427        session: &actix_session::Session,
428        config: &SessionConfig,
429    ) -> Option<User> {
430        session
431            .get::<SessionUser>(&config.user_key)
432            .ok()
433            .flatten()
434            .map(|su| su.to_user())
435    }
436
437    /// Save the current request URL for redirect after login.
438    ///
439    /// # Spring Equivalent
440    /// Similar to `SavedRequest` in Spring Security.
441    pub fn save_request(
442        session: &actix_session::Session,
443        url: &str,
444        config: &SessionConfig,
445    ) -> Result<(), SessionError> {
446        session
447            .insert(&config.saved_request_key, url)
448            .map_err(|e| SessionError::InsertError(e.to_string()))
449    }
450
451    /// Get the saved request URL and remove it from session.
452    ///
453    /// Returns the saved URL or the default URL if none was saved.
454    pub fn get_saved_request(
455        session: &actix_session::Session,
456        config: &SessionConfig,
457        default_url: &str,
458    ) -> String {
459        let saved = session
460            .get::<String>(&config.saved_request_key)
461            .ok()
462            .flatten();
463
464        if saved.is_some() {
465            session.remove(&config.saved_request_key);
466        }
467
468        saved.unwrap_or_else(|| default_url.to_string())
469    }
470
471    /// Get the configuration.
472    pub fn config(&self) -> &SessionConfig {
473        &self.config
474    }
475}
476
477impl Authenticator for SessionAuthenticator {
478    fn get_user(&self, req: &ServiceRequest) -> Option<User> {
479        let session = req.get_session();
480
481        // Check if authenticated
482        if !Self::is_authenticated(&session, &self.config) {
483            return None;
484        }
485
486        // Get user from session
487        Self::get_session_user(&session, &self.config)
488    }
489}
490
491// =============================================================================
492// Session Error
493// =============================================================================
494
495/// Session-related errors.
496#[derive(Debug)]
497pub enum SessionError {
498    /// Error inserting data into session
499    InsertError(String),
500    /// Error reading data from session
501    ReadError(String),
502    /// Session not found
503    NotFound,
504    /// Maximum sessions exceeded
505    MaxSessionsExceeded,
506    /// Session expired
507    Expired,
508}
509
510impl std::fmt::Display for SessionError {
511    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512        match self {
513            SessionError::InsertError(e) => write!(f, "Session insert error: {}", e),
514            SessionError::ReadError(e) => write!(f, "Session read error: {}", e),
515            SessionError::NotFound => write!(f, "Session not found"),
516            SessionError::MaxSessionsExceeded => write!(f, "Maximum sessions exceeded"),
517            SessionError::Expired => write!(f, "Session expired"),
518        }
519    }
520}
521
522impl std::error::Error for SessionError {}
523
524// =============================================================================
525// Session Login Service
526// =============================================================================
527
528/// Service for handling login/logout with sessions.
529///
530/// Combines authentication validation with session management.
531///
532/// # Example
533/// ```rust,ignore
534/// let login_service = SessionLoginService::new(
535///     memory_authenticator,  // For validating credentials
536///     session_config,
537/// );
538///
539/// // In login handler
540/// async fn login(
541///     session: Session,
542///     form: Form<LoginForm>,
543///     login_service: Data<SessionLoginService<MemoryAuthenticator>>,
544/// ) -> impl Responder {
545///     match login_service.login(&session, &form.username, &form.password) {
546///         Ok(user) => HttpResponse::Ok().body(format!("Welcome, {}!", user.get_username())),
547///         Err(_) => HttpResponse::Unauthorized().body("Invalid credentials"),
548///     }
549/// }
550/// ```
551#[derive(Clone)]
552pub struct SessionLoginService<A>
553where
554    A: Authenticator,
555{
556    /// Authenticator for validating credentials
557    #[allow(dead_code)]
558    credential_authenticator: A,
559    /// Session configuration
560    config: SessionConfig,
561}
562
563impl<A> SessionLoginService<A>
564where
565    A: Authenticator,
566{
567    /// Create a new login service.
568    pub fn new(credential_authenticator: A, config: SessionConfig) -> Self {
569        Self {
570            credential_authenticator,
571            config,
572        }
573    }
574
575    /// Login with a validated user.
576    ///
577    /// This method:
578    /// 1. Applies session fixation protection
579    /// 2. Stores user in session
580    pub fn login_with_user(
581        &self,
582        session: &actix_session::Session,
583        user: &User,
584    ) -> Result<(), SessionError> {
585        SessionAuthenticator::login(session, user, &self.config)
586    }
587
588    /// Logout - remove user from session.
589    pub fn logout(&self, session: &actix_session::Session) {
590        SessionAuthenticator::logout(session, &self.config);
591    }
592
593    /// Get the session configuration.
594    pub fn config(&self) -> &SessionConfig {
595        &self.config
596    }
597
598    /// Save the current request URL for redirect after login.
599    pub fn save_request(
600        &self,
601        session: &actix_session::Session,
602        url: &str,
603    ) -> Result<(), SessionError> {
604        SessionAuthenticator::save_request(session, url, &self.config)
605    }
606
607    /// Get the saved request URL.
608    pub fn get_saved_request(&self, session: &actix_session::Session, default_url: &str) -> String {
609        SessionAuthenticator::get_saved_request(session, &self.config, default_url)
610    }
611}
612
613// =============================================================================
614// Credential Authenticator Trait
615// =============================================================================
616
617/// Trait for authenticators that can validate username/password credentials.
618///
619/// # Spring Equivalent
620/// Similar to `AuthenticationProvider` that handles username/password authentication.
621///
622/// This trait is separate from `Authenticator` because it validates credentials
623/// directly rather than extracting them from a request.
624pub trait CredentialAuthenticator: Send + Sync {
625    /// Validate username and password, returning the user if valid.
626    fn authenticate(&self, username: &str, password: &str) -> Option<User>;
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    fn test_user() -> User {
634        User::new("testuser".to_string(), "password".to_string())
635            .roles(&["USER".into()])
636            .authorities(&["read".into()])
637    }
638
639    #[test]
640    fn test_session_user_conversion() {
641        let user = test_user();
642        let session_user = SessionUser::from_user(&user);
643
644        assert_eq!(session_user.username, "testuser");
645        assert!(session_user.roles.contains(&"USER".to_string()));
646        assert!(session_user.authorities.contains(&"read".to_string()));
647
648        let converted = session_user.to_user();
649        assert_eq!(converted.get_username(), "testuser");
650        assert!(converted.has_role("USER"));
651    }
652
653    #[test]
654    fn test_session_config() {
655        let config = SessionConfig::new()
656            .user_key("my_user")
657            .authenticated_key("my_auth")
658            .fixation_strategy(SessionFixationStrategy::NewSession)
659            .maximum_sessions(2);
660
661        assert_eq!(config.get_user_key(), "my_user");
662        assert_eq!(config.get_authenticated_key(), "my_auth");
663        assert_eq!(
664            config.get_fixation_strategy(),
665            SessionFixationStrategy::NewSession
666        );
667        assert_eq!(config.get_maximum_sessions(), Some(2));
668    }
669
670    #[test]
671    fn test_session_fixation_strategy_default() {
672        let strategy = SessionFixationStrategy::default();
673        assert_eq!(strategy, SessionFixationStrategy::MigrateSession);
674    }
675
676    #[test]
677    fn test_session_user_serialization() {
678        let user = test_user();
679        let session_user = SessionUser::from_user(&user);
680
681        // Serialize to JSON
682        let json = serde_json::to_string(&session_user).unwrap();
683        assert!(json.contains("testuser"));
684
685        // Deserialize back
686        let deserialized: SessionUser = serde_json::from_str(&json).unwrap();
687        assert_eq!(deserialized.username, "testuser");
688    }
689
690    #[test]
691    fn test_session_config_builder() {
692        use std::time::Duration;
693
694        let config = SessionConfig::new()
695            .user_key("user")
696            .authenticated_key("auth")
697            .saved_request_key("saved")
698            .fixation_strategy(SessionFixationStrategy::MigrateSession)
699            .maximum_sessions(1)
700            .timeout(Duration::from_secs(3600))
701            .expire_oldest_session(true);
702
703        assert_eq!(config.get_user_key(), "user");
704        assert_eq!(config.get_authenticated_key(), "auth");
705        assert_eq!(config.get_saved_request_key(), "saved");
706        assert_eq!(
707            config.get_fixation_strategy(),
708            SessionFixationStrategy::MigrateSession
709        );
710        assert_eq!(config.get_maximum_sessions(), Some(1));
711        assert_eq!(config.get_timeout(), Some(Duration::from_secs(3600)));
712        assert!(config.should_expire_oldest());
713    }
714}