use crate::http::security::User;
use actix_web::cookie::{Cookie, SameSite};
use base64::prelude::*;
use rand::Rng;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct RememberMeConfig {
key: String,
token_validity: Duration,
cookie_name: String,
cookie_path: String,
cookie_domain: Option<String>,
cookie_secure: bool,
cookie_http_only: bool,
cookie_same_site: SameSite,
parameter_name: String,
always_remember: bool,
}
impl RememberMeConfig {
pub fn new(key: &str) -> Self {
Self {
key: key.to_string(),
token_validity: Duration::from_secs(14 * 24 * 60 * 60), cookie_name: "remember-me".to_string(),
cookie_path: "/".to_string(),
cookie_domain: None,
cookie_secure: true,
cookie_http_only: true,
cookie_same_site: SameSite::Lax,
parameter_name: "remember-me".to_string(),
always_remember: false,
}
}
pub fn token_validity_days(mut self, days: u64) -> Self {
self.token_validity = Duration::from_secs(days * 24 * 60 * 60);
self
}
pub fn token_validity_seconds(mut self, seconds: u64) -> Self {
self.token_validity = Duration::from_secs(seconds);
self
}
pub fn cookie_name(mut self, name: &str) -> Self {
self.cookie_name = name.to_string();
self
}
pub fn cookie_path(mut self, path: &str) -> Self {
self.cookie_path = path.to_string();
self
}
pub fn cookie_domain(mut self, domain: &str) -> Self {
self.cookie_domain = Some(domain.to_string());
self
}
pub fn cookie_secure(mut self, secure: bool) -> Self {
self.cookie_secure = secure;
self
}
pub fn cookie_http_only(mut self, http_only: bool) -> Self {
self.cookie_http_only = http_only;
self
}
pub fn cookie_same_site(mut self, same_site: SameSite) -> Self {
self.cookie_same_site = same_site;
self
}
pub fn parameter_name(mut self, name: &str) -> Self {
self.parameter_name = name.to_string();
self
}
pub fn always_remember(mut self, always: bool) -> Self {
self.always_remember = always;
self
}
pub fn get_key(&self) -> &str {
&self.key
}
pub fn get_token_validity(&self) -> Duration {
self.token_validity
}
pub fn get_cookie_name(&self) -> &str {
&self.cookie_name
}
pub fn get_parameter_name(&self) -> &str {
&self.parameter_name
}
pub fn is_always_remember(&self) -> bool {
self.always_remember
}
}
#[derive(Debug, Clone)]
pub struct RememberMeToken {
pub username: String,
pub expiry: u64,
pub signature: String,
}
impl RememberMeToken {
pub fn new(username: &str, validity: Duration, key: &str) -> Self {
let expiry = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ validity.as_secs();
let signature = Self::compute_signature(username, expiry, key);
Self {
username: username.to_string(),
expiry,
signature,
}
}
fn compute_signature(username: &str, expiry: u64, key: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
format!("{}:{}:{}", username, expiry, key).hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn encode(&self) -> String {
let data = format!("{}:{}:{}", self.username, self.expiry, self.signature);
BASE64_STANDARD.encode(data.as_bytes())
}
pub fn decode(encoded: &str) -> Option<Self> {
let decoded = BASE64_STANDARD.decode(encoded).ok()?;
let data = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = data.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
Some(Self {
username: parts[0].to_string(),
expiry: parts[1].parse().ok()?,
signature: parts[2].to_string(),
})
}
pub fn validate(&self, key: &str) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now > self.expiry {
return false;
}
let expected_signature = Self::compute_signature(&self.username, self.expiry, key);
self.signature == expected_signature
}
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
now > self.expiry
}
}
#[derive(Clone)]
pub struct RememberMeServices {
config: RememberMeConfig,
}
impl RememberMeServices {
pub fn new(config: RememberMeConfig) -> Self {
Self { config }
}
pub fn login_success(&self, user: &User) -> Cookie<'static> {
let token = RememberMeToken::new(
user.get_username(),
self.config.token_validity,
&self.config.key,
);
self.create_cookie(token.encode())
}
pub fn auto_login(&self, cookie_value: &str) -> Option<String> {
let token = RememberMeToken::decode(cookie_value)?;
if token.validate(&self.config.key) {
Some(token.username)
} else {
None
}
}
pub fn logout(&self) -> Cookie<'static> {
let mut cookie = Cookie::build(self.config.cookie_name.clone(), "")
.path(self.config.cookie_path.clone())
.max_age(actix_web::cookie::time::Duration::ZERO)
.http_only(self.config.cookie_http_only)
.same_site(self.config.cookie_same_site);
if let Some(domain) = &self.config.cookie_domain {
cookie = cookie.domain(domain.clone());
}
if self.config.cookie_secure {
cookie = cookie.secure(true);
}
cookie.finish()
}
fn create_cookie(&self, value: String) -> Cookie<'static> {
let max_age =
actix_web::cookie::time::Duration::seconds(self.config.token_validity.as_secs() as i64);
let mut cookie = Cookie::build(self.config.cookie_name.clone(), value)
.path(self.config.cookie_path.clone())
.max_age(max_age)
.http_only(self.config.cookie_http_only)
.same_site(self.config.cookie_same_site);
if let Some(domain) = &self.config.cookie_domain {
cookie = cookie.domain(domain.clone());
}
if self.config.cookie_secure {
cookie = cookie.secure(true);
}
cookie.finish()
}
pub fn cookie_name(&self) -> &str {
&self.config.cookie_name
}
pub fn parameter_name(&self) -> &str {
&self.config.parameter_name
}
pub fn is_always_remember(&self) -> bool {
self.config.always_remember
}
pub fn config(&self) -> &RememberMeConfig {
&self.config
}
#[allow(dead_code)]
fn generate_random_token() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 32] = rng.gen();
BASE64_STANDARD.encode(bytes)
}
}
#[derive(Debug)]
pub enum RememberMeError {
InvalidToken,
TokenExpired,
InvalidSignature,
UserNotFound,
}
impl std::fmt::Display for RememberMeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RememberMeError::InvalidToken => write!(f, "Invalid remember-me token"),
RememberMeError::TokenExpired => write!(f, "Remember-me token expired"),
RememberMeError::InvalidSignature => write!(f, "Invalid token signature"),
RememberMeError::UserNotFound => write!(f, "User not found"),
}
}
}
impl std::error::Error for RememberMeError {}
#[cfg(test)]
mod tests {
use super::*;
fn test_user() -> User {
User::new("testuser".to_string(), "password".to_string()).roles(&["USER".into()])
}
#[test]
fn test_remember_me_config() {
let config = RememberMeConfig::new("secret")
.token_validity_days(7)
.cookie_name("my-remember-me")
.cookie_secure(false)
.parameter_name("rememberMe");
assert_eq!(config.get_key(), "secret");
assert_eq!(
config.get_token_validity(),
Duration::from_secs(7 * 24 * 60 * 60)
);
assert_eq!(config.get_cookie_name(), "my-remember-me");
assert_eq!(config.get_parameter_name(), "rememberMe");
}
#[test]
fn test_token_encode_decode() {
let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
let encoded = token.encode();
let decoded = RememberMeToken::decode(&encoded).unwrap();
assert_eq!(decoded.username, "testuser");
assert_eq!(decoded.expiry, token.expiry);
assert_eq!(decoded.signature, token.signature);
}
#[test]
fn test_token_validation() {
let token = RememberMeToken::new("testuser", Duration::from_secs(3600), "secret");
assert!(token.validate("secret"));
assert!(!token.validate("wrong-secret"));
}
#[test]
fn test_token_expiry() {
let token = RememberMeToken {
username: "testuser".to_string(),
expiry: 1, signature: "invalid".to_string(),
};
assert!(token.is_expired());
assert!(!token.validate("secret"));
}
#[test]
fn test_remember_me_services() {
let config = RememberMeConfig::new("secret")
.token_validity_days(14)
.cookie_secure(false);
let services = RememberMeServices::new(config);
let user = test_user();
let cookie = services.login_success(&user);
assert_eq!(cookie.name(), "remember-me");
let username = services.auto_login(cookie.value());
assert_eq!(username, Some("testuser".to_string()));
}
#[test]
fn test_remember_me_logout() {
let config = RememberMeConfig::new("secret");
let services = RememberMeServices::new(config);
let cookie = services.logout();
assert_eq!(cookie.name(), "remember-me");
assert_eq!(cookie.value(), "");
}
#[test]
fn test_invalid_token() {
let config = RememberMeConfig::new("secret");
let services = RememberMeServices::new(config);
assert!(services.auto_login("not-valid-base64!!!").is_none());
let invalid = BASE64_STANDARD.encode("invalid");
assert!(services.auto_login(&invalid).is_none());
}
}