Skip to main content

actix_web_admin/auth/
store.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct User {
6    pub id: String,
7    pub username: String,
8    pub email: String,
9    pub name: String,
10    pub password_hash: String,
11    pub is_superuser: bool,
12    pub created_at: String,
13}
14
15#[derive(Debug)]
16pub enum AuthError {
17    NotFound,
18    DuplicateUsername,
19    DuplicateEmail,
20    InvalidPassword,
21    Storage(String),
22}
23
24impl std::fmt::Display for AuthError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            AuthError::NotFound => write!(f, "user not found"),
28            AuthError::DuplicateUsername => write!(f, "username already exists"),
29            AuthError::DuplicateEmail => write!(f, "email already exists"),
30            AuthError::InvalidPassword => write!(f, "invalid password"),
31            AuthError::Storage(msg) => write!(f, "storage error: {}", msg),
32        }
33    }
34}
35
36impl std::error::Error for AuthError {}
37
38#[async_trait]
39pub trait UserStore: Send + Sync + 'static {
40    async fn find_by_username(&self, username: &str) -> Result<Option<User>, AuthError>;
41
42    async fn find_by_email(&self, _email: &str) -> Result<Option<User>, AuthError> {
43        // Default: iterate all and filter. Implementations should override for efficiency.
44        Err(AuthError::Storage("not implemented".to_string()))
45    }
46
47    async fn create_user(
48        &self,
49        username: &str,
50        email: &str,
51        name: &str,
52        password: &str,
53        is_superuser: bool,
54    ) -> Result<User, AuthError>;
55
56    async fn delete_user(&self, username: &str) -> Result<(), AuthError>;
57}
58
59pub fn hash_password(password: &str) -> Result<String, AuthError> {
60    use argon2::password_hash::{rand_core::OsRng, SaltString};
61    use argon2::{Argon2, PasswordHasher};
62
63    let salt = SaltString::generate(&mut OsRng);
64    let hash = Argon2::default()
65        .hash_password(password.as_bytes(), &salt)
66        .map_err(|e| AuthError::Storage(e.to_string()))?
67        .to_string();
68    Ok(hash)
69}
70
71pub fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
72    use argon2::password_hash::PasswordHash;
73    use argon2::{Argon2, PasswordVerifier};
74
75    let parsed_hash =
76        PasswordHash::new(hash).map_err(|e| AuthError::Storage(e.to_string()))?;
77    Ok(Argon2::default()
78        .verify_password(password.as_bytes(), &parsed_hash)
79        .is_ok())
80}
81
82pub fn find_by_username_or_email<'a>(
83    users: &'a [User],
84    login: &str,
85) -> Option<&'a User> {
86    if login.contains('@') {
87        users.iter().find(|u| u.email == login)
88    } else {
89        users.iter().find(|u| u.username == login)
90    }
91}
92
93pub fn generate_id() -> String {
94    use std::sync::atomic::{AtomicU64, Ordering};
95    use std::time::{SystemTime, UNIX_EPOCH};
96    static COUNTER: AtomicU64 = AtomicU64::new(0);
97    let nanos = SystemTime::now()
98        .duration_since(UNIX_EPOCH)
99        .unwrap_or_default()
100        .as_nanos();
101    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
102    format!("{:x}{:04x}", nanos, counter & 0xFFFF)
103}
104
105pub fn timestamp() -> String {
106    chrono::Utc::now().to_rfc3339()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_hash_and_verify() {
115        let hash = hash_password("secret123").unwrap();
116        assert!(verify_password("secret123", &hash).unwrap());
117        assert!(!verify_password("wrong", &hash).unwrap());
118    }
119
120    #[test]
121    fn test_hash_unique_salts() {
122        let h1 = hash_password("same").unwrap();
123        let h2 = hash_password("same").unwrap();
124        assert_ne!(h1, h2, "each hash should have a unique salt");
125    }
126
127    #[test]
128    fn test_generate_id_unique() {
129        let ids: Vec<String> = (0..10).map(|_| generate_id()).collect();
130        let mut sorted = ids.clone();
131        sorted.sort();
132        sorted.dedup();
133        assert_eq!(ids.len(), sorted.len(), "all generated IDs should be unique");
134    }
135
136    #[test]
137    fn test_auth_error_display() {
138        assert_eq!(AuthError::NotFound.to_string(), "user not found");
139        assert_eq!(AuthError::DuplicateUsername.to_string(), "username already exists");
140        assert_eq!(AuthError::DuplicateEmail.to_string(), "email already exists");
141        assert_eq!(AuthError::InvalidPassword.to_string(), "invalid password");
142        assert_eq!(
143            AuthError::Storage("disk full".to_string()).to_string(),
144            "storage error: disk full"
145        );
146    }
147
148    #[test]
149    fn test_find_by_username_or_email() {
150        let users = vec![
151            User {
152                id: "1".into(),
153                username: "alice".into(),
154                email: "alice@example.com".into(),
155                name: "Alice".into(),
156                password_hash: "".into(),
157                is_superuser: false,
158                created_at: "".into(),
159            },
160            User {
161                id: "2".into(),
162                username: "bob".into(),
163                email: "bob@test.com".into(),
164                name: "Bob".into(),
165                password_hash: "".into(),
166                is_superuser: false,
167                created_at: "".into(),
168            },
169        ];
170
171        assert_eq!(find_by_username_or_email(&users, "alice").unwrap().id, "1");
172        assert_eq!(find_by_username_or_email(&users, "bob@test.com").unwrap().id, "2");
173        assert!(find_by_username_or_email(&users, "unknown").is_none());
174    }
175
176    #[test]
177    fn test_timestamp_format() {
178        let ts = timestamp();
179        assert!(ts.contains('T'), "expected ISO 8601 format, got: {}", ts);
180    }
181}