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 =
360            actix_web::cookie::time::Duration::seconds(self.config.token_validity.as_secs() as i64);
361
362        let mut cookie = Cookie::build(self.config.cookie_name.clone(), value)
363            .path(self.config.cookie_path.clone())
364            .max_age(max_age)
365            .http_only(self.config.cookie_http_only)
366            .same_site(self.config.cookie_same_site);
367
368        if let Some(domain) = &self.config.cookie_domain {
369            cookie = cookie.domain(domain.clone());
370        }
371
372        if self.config.cookie_secure {
373            cookie = cookie.secure(true);
374        }
375
376        cookie.finish()
377    }
378
379    /// Get the cookie name for reading from request.
380    pub fn cookie_name(&self) -> &str {
381        &self.config.cookie_name
382    }
383
384    /// Get the form parameter name.
385    pub fn parameter_name(&self) -> &str {
386        &self.config.parameter_name
387    }
388
389    /// Check if always remember is enabled.
390    pub fn is_always_remember(&self) -> bool {
391        self.config.always_remember
392    }
393
394    /// Get the configuration.
395    pub fn config(&self) -> &RememberMeConfig {
396        &self.config
397    }
398
399    /// Generate a random token (for persistent token variant).
400    #[allow(dead_code)]
401    fn generate_random_token() -> String {
402        let mut rng = rand::thread_rng();
403        let bytes: [u8; 32] = rng.gen();
404        BASE64_STANDARD.encode(bytes)
405    }
406}
407
408// =============================================================================
409// Remember-Me Error
410// =============================================================================
411
412/// Remember-Me related errors.
413#[derive(Debug)]
414pub enum RememberMeError {
415    /// Invalid token format
416    InvalidToken,
417    /// Token expired
418    TokenExpired,
419    /// Invalid signature
420    InvalidSignature,
421    /// User not found
422    UserNotFound,
423}
424
425impl std::fmt::Display for RememberMeError {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        match self {
428            RememberMeError::InvalidToken => write!(f, "Invalid remember-me token"),
429            RememberMeError::TokenExpired => write!(f, "Remember-me token expired"),
430            RememberMeError::InvalidSignature => write!(f, "Invalid token signature"),
431            RememberMeError::UserNotFound => write!(f, "User not found"),
432        }
433    }
434}
435
436impl std::error::Error for RememberMeError {}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn test_user() -> User {
443        User::new("testuser".to_string(), "password".to_string()).roles(&["USER".into()])
444    }
445
446    #[test]
447    fn test_remember_me_config() {
448        let config = RememberMeConfig::new("secret")
449            .token_validity_days(7)
450            .cookie_name("my-remember-me")
451            .cookie_secure(false)
452            .parameter_name("rememberMe");
453
454        assert_eq!(config.get_key(), "secret");
455        assert_eq!(
456            config.get_token_validity(),
457            Duration::from_secs(7 * 24 * 60 * 60)
458        );
459        assert_eq!(config.get_cookie_name(), "my-remember-me");
460        assert_eq!(config.get_parameter_name(), "rememberMe");
461    }
462
463    #[test]
464    fn test_token_encode_decode() {
465        let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
466        let encoded = token.encode();
467
468        let decoded = RememberMeToken::decode(&encoded).unwrap();
469        assert_eq!(decoded.username, "testuser");
470        assert_eq!(decoded.expiry, token.expiry);
471        assert_eq!(decoded.signature, token.signature);
472    }
473
474    #[test]
475    fn test_token_validation() {
476        let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
477
478        // Valid token
479        assert!(token.validate("secret"));
480
481        // Invalid key
482        assert!(!token.validate("wrong-secret"));
483    }
484
485    #[test]
486    fn test_token_expiry() {
487        // Create a token with expiry in the past
488        let token = RememberMeToken {
489            username: "testuser".to_string(),
490            expiry: 1, // Way in the past (1970)
491            signature: "invalid".to_string(),
492        };
493
494        assert!(token.is_expired());
495        assert!(!token.validate("secret"));
496    }
497
498    #[test]
499    fn test_remember_me_services() {
500        let config = RememberMeConfig::new("secret")
501            .token_validity_days(14)
502            .cookie_secure(false);
503
504        let services = RememberMeServices::new(config);
505        let user = test_user();
506
507        // Create login cookie
508        let cookie = services.login_success(&user);
509        assert_eq!(cookie.name(), "remember-me");
510
511        // Validate cookie
512        let username = services.auto_login(cookie.value());
513        assert_eq!(username, Some("testuser".to_string()));
514    }
515
516    #[test]
517    fn test_remember_me_logout() {
518        let config = RememberMeConfig::new("secret");
519        let services = RememberMeServices::new(config);
520
521        let cookie = services.logout();
522        assert_eq!(cookie.name(), "remember-me");
523        assert_eq!(cookie.value(), "");
524    }
525
526    #[test]
527    fn test_invalid_token() {
528        let config = RememberMeConfig::new("secret");
529        let services = RememberMeServices::new(config);
530
531        // Invalid base64
532        assert!(services.auto_login("not-valid-base64!!!").is_none());
533
534        // Invalid format (valid base64 but wrong structure)
535        let invalid = BASE64_STANDARD.encode("invalid");
536        assert!(services.auto_login(&invalid).is_none());
537    }
538}