Skip to main content

actix_security_core/http/security/
remember_me.rs

1//! Remember-Me Authentication.
2//!
3//! # Spring Security Equivalent
4//! Similar to Spring Security's Remember-Me authentication with `RememberMeServices`.
5//!
6//! # Features
7//! - Persistent login via cookie
8//! - Token-based remember-me (secure)
9//! - Configurable token validity
10//! - Automatic token refresh
11//!
12//! # Example
13//! ```rust,ignore
14//! use actix_security_core::http::security::remember_me::{RememberMeServices, RememberMeConfig};
15//!
16//! let remember_me = RememberMeServices::new(
17//!     RememberMeConfig::new("my-secret-key")
18//!         .token_validity_days(14)
19//!         .cookie_name("remember-me")
20//! );
21//!
22//! // In login handler
23//! async fn login(
24//!     session: Session,
25//!     form: Form<LoginForm>,
26//!     remember_me: Data<RememberMeServices>,
27//! ) -> impl Responder {
28//!     // Validate credentials...
29//!     let user = validate_user(&form.username, &form.password)?;
30//!
31//!     // Create remember-me cookie if checkbox is checked
32//!     if form.remember_me {
33//!         let cookie = remember_me.login_success(&user);
34//!         return HttpResponse::Ok()
35//!             .cookie(cookie)
36//!             .body("Logged in with remember-me");
37//!     }
38//!
39//!     HttpResponse::Ok().body("Logged in")
40//! }
41//! ```
42
43use crate::http::security::User;
44use actix_web::cookie::{Cookie, SameSite};
45use base64::prelude::*;
46use rand::Rng;
47use std::time::{Duration, SystemTime, UNIX_EPOCH};
48
49// =============================================================================
50// Remember-Me Configuration
51// =============================================================================
52
53/// Remember-Me configuration.
54///
55/// # Spring Security Equivalent
56/// Similar to `RememberMeConfigurer` in Spring Security.
57#[derive(Clone)]
58pub struct RememberMeConfig {
59    /// Secret key for token signing
60    key: String,
61    /// Token validity duration
62    token_validity: Duration,
63    /// Cookie name
64    cookie_name: String,
65    /// Cookie path
66    cookie_path: String,
67    /// Cookie domain (None = current domain)
68    cookie_domain: Option<String>,
69    /// Cookie secure flag (HTTPS only)
70    cookie_secure: bool,
71    /// Cookie HTTP only flag
72    cookie_http_only: bool,
73    /// Cookie SameSite attribute
74    cookie_same_site: SameSite,
75    /// Parameter name in form for remember-me checkbox
76    parameter_name: String,
77    /// Whether to always remember (ignore checkbox)
78    always_remember: bool,
79}
80
81impl RememberMeConfig {
82    /// Create a new remember-me configuration with the given secret key.
83    ///
84    /// The key is used to sign tokens and should be kept secret.
85    pub fn new(key: &str) -> Self {
86        Self {
87            key: key.to_string(),
88            token_validity: Duration::from_secs(14 * 24 * 60 * 60), // 14 days
89            cookie_name: "remember-me".to_string(),
90            cookie_path: "/".to_string(),
91            cookie_domain: None,
92            cookie_secure: true,
93            cookie_http_only: true,
94            cookie_same_site: SameSite::Lax,
95            parameter_name: "remember-me".to_string(),
96            always_remember: false,
97        }
98    }
99
100    /// Set token validity in days.
101    pub fn token_validity_days(mut self, days: u64) -> Self {
102        self.token_validity = Duration::from_secs(days * 24 * 60 * 60);
103        self
104    }
105
106    /// Set token validity in seconds.
107    pub fn token_validity_seconds(mut self, seconds: u64) -> Self {
108        self.token_validity = Duration::from_secs(seconds);
109        self
110    }
111
112    /// Set the cookie name.
113    pub fn cookie_name(mut self, name: &str) -> Self {
114        self.cookie_name = name.to_string();
115        self
116    }
117
118    /// Set the cookie path.
119    pub fn cookie_path(mut self, path: &str) -> Self {
120        self.cookie_path = path.to_string();
121        self
122    }
123
124    /// Set the cookie domain.
125    pub fn cookie_domain(mut self, domain: &str) -> Self {
126        self.cookie_domain = Some(domain.to_string());
127        self
128    }
129
130    /// Set whether the cookie requires HTTPS.
131    pub fn cookie_secure(mut self, secure: bool) -> Self {
132        self.cookie_secure = secure;
133        self
134    }
135
136    /// Set whether the cookie is HTTP only.
137    pub fn cookie_http_only(mut self, http_only: bool) -> Self {
138        self.cookie_http_only = http_only;
139        self
140    }
141
142    /// Set the cookie SameSite attribute.
143    pub fn cookie_same_site(mut self, same_site: SameSite) -> Self {
144        self.cookie_same_site = same_site;
145        self
146    }
147
148    /// Set the form parameter name for remember-me checkbox.
149    pub fn parameter_name(mut self, name: &str) -> Self {
150        self.parameter_name = name.to_string();
151        self
152    }
153
154    /// Set whether to always remember (ignore checkbox).
155    pub fn always_remember(mut self, always: bool) -> Self {
156        self.always_remember = always;
157        self
158    }
159
160    /// Get the secret key.
161    pub fn get_key(&self) -> &str {
162        &self.key
163    }
164
165    /// Get token validity duration.
166    pub fn get_token_validity(&self) -> Duration {
167        self.token_validity
168    }
169
170    /// Get the cookie name.
171    pub fn get_cookie_name(&self) -> &str {
172        &self.cookie_name
173    }
174
175    /// Get the parameter name.
176    pub fn get_parameter_name(&self) -> &str {
177        &self.parameter_name
178    }
179
180    /// Check if always remember is enabled.
181    pub fn is_always_remember(&self) -> bool {
182        self.always_remember
183    }
184}
185
186// =============================================================================
187// Remember-Me Token
188// =============================================================================
189
190/// Remember-Me token structure.
191///
192/// Token format: base64(username:expiry_timestamp:signature)
193/// Where signature = hmac(key, username:expiry_timestamp)
194#[derive(Debug, Clone)]
195pub struct RememberMeToken {
196    /// Username
197    pub username: String,
198    /// Expiry timestamp (seconds since UNIX epoch)
199    pub expiry: u64,
200    /// Token signature
201    pub signature: String,
202}
203
204impl RememberMeToken {
205    /// Create a new token for the given username.
206    pub fn new(username: &str, validity: Duration, key: &str) -> Self {
207        let expiry = SystemTime::now()
208            .duration_since(UNIX_EPOCH)
209            .unwrap()
210            .as_secs()
211            + validity.as_secs();
212
213        let signature = Self::compute_signature(username, expiry, key);
214
215        Self {
216            username: username.to_string(),
217            expiry,
218            signature,
219        }
220    }
221
222    /// Compute token signature.
223    fn compute_signature(username: &str, expiry: u64, key: &str) -> String {
224        use std::collections::hash_map::DefaultHasher;
225        use std::hash::{Hash, Hasher};
226
227        // Simple signature using hash (in production, use HMAC-SHA256)
228        let mut hasher = DefaultHasher::new();
229        format!("{}:{}:{}", username, expiry, key).hash(&mut hasher);
230        format!("{:016x}", hasher.finish())
231    }
232
233    /// Encode token to string.
234    pub fn encode(&self) -> String {
235        let data = format!("{}:{}:{}", self.username, self.expiry, self.signature);
236        BASE64_STANDARD.encode(data.as_bytes())
237    }
238
239    /// Decode token from string.
240    pub fn decode(encoded: &str) -> Option<Self> {
241        let decoded = BASE64_STANDARD.decode(encoded).ok()?;
242        let data = String::from_utf8(decoded).ok()?;
243
244        let parts: Vec<&str> = data.splitn(3, ':').collect();
245        if parts.len() != 3 {
246            return None;
247        }
248
249        Some(Self {
250            username: parts[0].to_string(),
251            expiry: parts[1].parse().ok()?,
252            signature: parts[2].to_string(),
253        })
254    }
255
256    /// Validate token.
257    pub fn validate(&self, key: &str) -> bool {
258        // Check expiry
259        let now = SystemTime::now()
260            .duration_since(UNIX_EPOCH)
261            .unwrap()
262            .as_secs();
263
264        if now > self.expiry {
265            return false;
266        }
267
268        // Check signature
269        let expected_signature = Self::compute_signature(&self.username, self.expiry, key);
270        self.signature == expected_signature
271    }
272
273    /// Check if token is expired.
274    pub fn is_expired(&self) -> bool {
275        let now = SystemTime::now()
276            .duration_since(UNIX_EPOCH)
277            .unwrap()
278            .as_secs();
279        now > self.expiry
280    }
281}
282
283// =============================================================================
284// Remember-Me Services
285// =============================================================================
286
287/// Remember-Me authentication services.
288///
289/// # Spring Security Equivalent
290/// Similar to `TokenBasedRememberMeServices` in Spring Security.
291///
292/// Provides methods for:
293/// - Creating remember-me cookies on login
294/// - Validating remember-me cookies
295/// - Clearing remember-me cookies on logout
296#[derive(Clone)]
297pub struct RememberMeServices {
298    config: RememberMeConfig,
299}
300
301impl RememberMeServices {
302    /// Create new remember-me services with the given configuration.
303    pub fn new(config: RememberMeConfig) -> Self {
304        Self { config }
305    }
306
307    /// Create a remember-me cookie for successful login.
308    ///
309    /// # Spring Equivalent
310    /// `RememberMeServices.loginSuccess()`
311    pub fn login_success(&self, user: &User) -> Cookie<'static> {
312        let token = RememberMeToken::new(
313            user.get_username(),
314            self.config.token_validity,
315            &self.config.key,
316        );
317
318        self.create_cookie(token.encode())
319    }
320
321    /// Validate remember-me cookie and return username if valid.
322    ///
323    /// # Spring Equivalent
324    /// `RememberMeServices.autoLogin()`
325    pub fn auto_login(&self, cookie_value: &str) -> Option<String> {
326        let token = RememberMeToken::decode(cookie_value)?;
327
328        if token.validate(&self.config.key) {
329            Some(token.username)
330        } else {
331            None
332        }
333    }
334
335    /// Create a cookie that clears the remember-me token (for logout).
336    ///
337    /// # Spring Equivalent
338    /// `RememberMeServices.logout()`
339    pub fn logout(&self) -> Cookie<'static> {
340        let mut cookie = Cookie::build(self.config.cookie_name.clone(), "")
341            .path(self.config.cookie_path.clone())
342            .max_age(actix_web::cookie::time::Duration::ZERO)
343            .http_only(self.config.cookie_http_only)
344            .same_site(self.config.cookie_same_site);
345
346        if let Some(domain) = &self.config.cookie_domain {
347            cookie = cookie.domain(domain.clone());
348        }
349
350        if self.config.cookie_secure {
351            cookie = cookie.secure(true);
352        }
353
354        cookie.finish()
355    }
356
357    /// Create a remember-me cookie with the given value.
358    fn create_cookie(&self, value: String) -> Cookie<'static> {
359        let max_age = actix_web::cookie::time::Duration::seconds(
360            self.config.token_validity.as_secs() as i64,
361        );
362
363        let mut cookie = Cookie::build(self.config.cookie_name.clone(), value)
364            .path(self.config.cookie_path.clone())
365            .max_age(max_age)
366            .http_only(self.config.cookie_http_only)
367            .same_site(self.config.cookie_same_site);
368
369        if let Some(domain) = &self.config.cookie_domain {
370            cookie = cookie.domain(domain.clone());
371        }
372
373        if self.config.cookie_secure {
374            cookie = cookie.secure(true);
375        }
376
377        cookie.finish()
378    }
379
380    /// Get the cookie name for reading from request.
381    pub fn cookie_name(&self) -> &str {
382        &self.config.cookie_name
383    }
384
385    /// Get the form parameter name.
386    pub fn parameter_name(&self) -> &str {
387        &self.config.parameter_name
388    }
389
390    /// Check if always remember is enabled.
391    pub fn is_always_remember(&self) -> bool {
392        self.config.always_remember
393    }
394
395    /// Get the configuration.
396    pub fn config(&self) -> &RememberMeConfig {
397        &self.config
398    }
399
400    /// Generate a random token (for persistent token variant).
401    #[allow(dead_code)]
402    fn generate_random_token() -> String {
403        let mut rng = rand::thread_rng();
404        let bytes: [u8; 32] = rng.gen();
405        BASE64_STANDARD.encode(bytes)
406    }
407}
408
409// =============================================================================
410// Remember-Me Error
411// =============================================================================
412
413/// Remember-Me related errors.
414#[derive(Debug)]
415pub enum RememberMeError {
416    /// Invalid token format
417    InvalidToken,
418    /// Token expired
419    TokenExpired,
420    /// Invalid signature
421    InvalidSignature,
422    /// User not found
423    UserNotFound,
424}
425
426impl std::fmt::Display for RememberMeError {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        match self {
429            RememberMeError::InvalidToken => write!(f, "Invalid remember-me token"),
430            RememberMeError::TokenExpired => write!(f, "Remember-me token expired"),
431            RememberMeError::InvalidSignature => write!(f, "Invalid token signature"),
432            RememberMeError::UserNotFound => write!(f, "User not found"),
433        }
434    }
435}
436
437impl std::error::Error for RememberMeError {}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn test_user() -> User {
444        User::new("testuser".to_string(), "password".to_string())
445            .roles(&["USER".into()])
446    }
447
448    #[test]
449    fn test_remember_me_config() {
450        let config = RememberMeConfig::new("secret")
451            .token_validity_days(7)
452            .cookie_name("my-remember-me")
453            .cookie_secure(false)
454            .parameter_name("rememberMe");
455
456        assert_eq!(config.get_key(), "secret");
457        assert_eq!(config.get_token_validity(), Duration::from_secs(7 * 24 * 60 * 60));
458        assert_eq!(config.get_cookie_name(), "my-remember-me");
459        assert_eq!(config.get_parameter_name(), "rememberMe");
460    }
461
462    #[test]
463    fn test_token_encode_decode() {
464        let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
465        let encoded = token.encode();
466
467        let decoded = RememberMeToken::decode(&encoded).unwrap();
468        assert_eq!(decoded.username, "testuser");
469        assert_eq!(decoded.expiry, token.expiry);
470        assert_eq!(decoded.signature, token.signature);
471    }
472
473    #[test]
474    fn test_token_validation() {
475        let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
476
477        // Valid token
478        assert!(token.validate("secret"));
479
480        // Invalid key
481        assert!(!token.validate("wrong-secret"));
482    }
483
484    #[test]
485    fn test_token_expiry() {
486        // Create a token with expiry in the past
487        let token = RememberMeToken {
488            username: "testuser".to_string(),
489            expiry: 1, // Way in the past (1970)
490            signature: "invalid".to_string(),
491        };
492
493        assert!(token.is_expired());
494        assert!(!token.validate("secret"));
495    }
496
497    #[test]
498    fn test_remember_me_services() {
499        let config = RememberMeConfig::new("secret")
500            .token_validity_days(14)
501            .cookie_secure(false);
502
503        let services = RememberMeServices::new(config);
504        let user = test_user();
505
506        // Create login cookie
507        let cookie = services.login_success(&user);
508        assert_eq!(cookie.name(), "remember-me");
509
510        // Validate cookie
511        let username = services.auto_login(cookie.value());
512        assert_eq!(username, Some("testuser".to_string()));
513    }
514
515    #[test]
516    fn test_remember_me_logout() {
517        let config = RememberMeConfig::new("secret");
518        let services = RememberMeServices::new(config);
519
520        let cookie = services.logout();
521        assert_eq!(cookie.name(), "remember-me");
522        assert_eq!(cookie.value(), "");
523    }
524
525    #[test]
526    fn test_invalid_token() {
527        let config = RememberMeConfig::new("secret");
528        let services = RememberMeServices::new(config);
529
530        // Invalid base64
531        assert!(services.auto_login("not-valid-base64!!!").is_none());
532
533        // Invalid format (valid base64 but wrong structure)
534        let invalid = BASE64_STANDARD.encode("invalid");
535        assert!(services.auto_login(&invalid).is_none());
536    }
537}