Skip to main content

mailsis_utils/
auth.rs

1//! Credential verification for SMTP and IMAP sessions.
2//!
3//! Both the SMTP `AUTH LOGIN` flow and the IMAP `LOGIN` command delegate
4//! to an [`AuthEngine`] implementation. The crate ships with
5//! [`MemoryAuthEngine`], which can be loaded from a plaintext credentials
6//! file or constructed in-memory for tests.
7
8use std::{collections::HashMap, fmt::Display, fs::read_to_string, io, sync::Arc};
9
10/// Result type for authentication operations.
11pub type AuthResult<T> = Result<T, AuthError>;
12
13/// Errors that can occur during authentication.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum AuthError {
16    /// The provided credentials are invalid.
17    InvalidCredentials,
18    /// The user was not found.
19    UserNotFound,
20    /// The authentication engine encountered an internal error.
21    EngineError(String),
22}
23
24impl Display for AuthError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            AuthError::InvalidCredentials => write!(f, "Invalid credentials"),
28            AuthError::UserNotFound => write!(f, "User not found"),
29            AuthError::EngineError(msg) => write!(f, "Engine error: {msg}"),
30        }
31    }
32}
33
34impl std::error::Error for AuthError {}
35
36/// Trait for authentication engines.
37///
38/// Implementations of this trait provide different authentication backends,
39/// such as in-memory storage, databases, LDAP, etc.
40pub trait AuthEngine: Send + Sync + Default {
41    /// Authenticates a user with the given username and password.
42    ///
43    /// Returns `Ok(())` if authentication succeeds, or an error if authentication
44    /// fails (`AuthError::InvalidCredentials` for wrong password,
45    /// `AuthError::UserNotFound` for non-existent user).
46    fn authenticate(&self, username: &str, password: &str) -> AuthResult<()>;
47
48    /// Checks if a user exists in the authentication store.
49    fn user_exists(&self, username: &str) -> AuthResult<bool>;
50}
51
52/// In-memory authentication engine using a HashMap.
53///
54/// This is a simple authentication engine that stores credentials in memory.
55/// Useful for testing and simple deployments.
56#[derive(Debug, Clone)]
57pub struct MemoryAuthEngine {
58    credentials: Arc<HashMap<String, String>>,
59}
60
61impl MemoryAuthEngine {
62    /// Creates a new empty MemoryAuthEngine.
63    pub fn new() -> Self {
64        Self {
65            credentials: Arc::new(HashMap::new()),
66        }
67    }
68
69    /// Creates a MemoryAuthEngine from an existing HashMap.
70    pub fn from_map(credentials: HashMap<String, String>) -> Self {
71        Self {
72            credentials: Arc::new(credentials),
73        }
74    }
75
76    /// Creates a MemoryAuthEngine from an Arc<HashMap>.
77    pub fn from_arc(credentials: Arc<HashMap<String, String>>) -> Self {
78        Self { credentials }
79    }
80
81    /// Loads credentials from a file.
82    ///
83    /// The file should be formatted as:
84    /// ```text
85    /// username:password
86    /// username2:password2
87    /// ```
88    ///
89    /// Returns an error if the file cannot be read.
90    pub fn from_file(path: &str) -> io::Result<Self> {
91        let content = read_to_string(path)?;
92        let mut creds = HashMap::new();
93        for line in content.lines() {
94            if let Some((user, pass)) = line.split_once(':') {
95                creds.insert(user.trim().to_string(), pass.trim().to_string());
96            }
97        }
98        Ok(Self::from_map(creds))
99    }
100
101    /// Adds a user to the credential store.
102    ///
103    /// Note: This requires mutable access and will clone the internal HashMap.
104    pub fn add_user(&mut self, username: String, password: String) {
105        let mut creds = (*self.credentials).clone();
106        creds.insert(username, password);
107        self.credentials = Arc::new(creds);
108    }
109
110    /// Returns the number of users in the store.
111    pub fn len(&self) -> usize {
112        self.credentials.len()
113    }
114
115    /// Returns true if the store is empty.
116    pub fn is_empty(&self) -> bool {
117        self.credentials.is_empty()
118    }
119}
120
121impl Default for MemoryAuthEngine {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl AuthEngine for MemoryAuthEngine {
128    fn authenticate(&self, username: &str, password: &str) -> AuthResult<()> {
129        match self.credentials.get(username) {
130            Some(stored_password) if stored_password == password => Ok(()),
131            Some(_) => Err(AuthError::InvalidCredentials),
132            None => Err(AuthError::UserNotFound),
133        }
134    }
135
136    fn user_exists(&self, username: &str) -> AuthResult<bool> {
137        Ok(self.credentials.contains_key(username))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_memory_engine_new() {
147        let engine = MemoryAuthEngine::new();
148        assert!(engine.is_empty());
149    }
150
151    #[test]
152    fn test_memory_engine_from_map() {
153        let mut map = HashMap::new();
154        map.insert("user1".to_string(), "pass1".to_string());
155        map.insert("user2".to_string(), "pass2".to_string());
156
157        let engine = MemoryAuthEngine::from_map(map);
158        assert_eq!(engine.len(), 2);
159    }
160
161    #[test]
162    fn test_memory_engine_authenticate_success() {
163        let mut map = HashMap::new();
164        map.insert("testuser".to_string(), "testpass".to_string());
165
166        let engine = MemoryAuthEngine::from_map(map);
167        assert!(engine.authenticate("testuser", "testpass").is_ok());
168    }
169
170    #[test]
171    fn test_memory_engine_authenticate_wrong_password() {
172        let mut map = HashMap::new();
173        map.insert("testuser".to_string(), "testpass".to_string());
174
175        let engine = MemoryAuthEngine::from_map(map);
176        assert_eq!(
177            engine.authenticate("testuser", "wrongpass"),
178            Err(AuthError::InvalidCredentials)
179        );
180    }
181
182    #[test]
183    fn test_memory_engine_authenticate_user_not_found() {
184        let engine = MemoryAuthEngine::new();
185        assert_eq!(
186            engine.authenticate("nonexistent", "pass"),
187            Err(AuthError::UserNotFound)
188        );
189    }
190
191    #[test]
192    fn test_memory_engine_user_exists() {
193        let mut map = HashMap::new();
194        map.insert("testuser".to_string(), "testpass".to_string());
195
196        let engine = MemoryAuthEngine::from_map(map);
197        assert!(engine.user_exists("testuser").unwrap());
198        assert!(!engine.user_exists("nonexistent").unwrap());
199    }
200
201    #[test]
202    fn test_memory_engine_add_user() {
203        let mut engine = MemoryAuthEngine::new();
204        engine.add_user("newuser".to_string(), "newpass".to_string());
205
206        assert!(engine.authenticate("newuser", "newpass").is_ok());
207        assert_eq!(engine.len(), 1);
208    }
209
210    #[test]
211    fn test_auth_error_display() {
212        assert_eq!(
213            AuthError::InvalidCredentials.to_string(),
214            "Invalid credentials"
215        );
216        assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
217        assert_eq!(
218            AuthError::EngineError("test error".to_string()).to_string(),
219            "Engine error: test error"
220        );
221    }
222
223    #[test]
224    fn test_memory_engine_from_arc() {
225        let mut map = HashMap::new();
226        map.insert("user".to_string(), "pass".to_string());
227        let arc = Arc::new(map);
228
229        let engine = MemoryAuthEngine::from_arc(arc.clone());
230        assert_eq!(engine.len(), 1);
231        assert!(engine.authenticate("user", "pass").is_ok());
232    }
233
234    #[test]
235    fn test_memory_engine_from_file() {
236        let temp_dir = tempfile::TempDir::new().unwrap();
237        let file_path = temp_dir.path().join("credentials.txt");
238        std::fs::write(&file_path, "alice:secret\nbob:password123\n").unwrap();
239
240        let engine = MemoryAuthEngine::from_file(file_path.to_str().unwrap()).unwrap();
241        assert_eq!(engine.len(), 2);
242        assert!(engine.authenticate("alice", "secret").is_ok());
243        assert!(engine.authenticate("bob", "password123").is_ok());
244    }
245
246    #[test]
247    fn test_memory_engine_from_file_not_found() {
248        let result = MemoryAuthEngine::from_file("/nonexistent/credentials.txt");
249        assert!(result.is_err());
250    }
251
252    #[test]
253    fn test_memory_engine_from_file_with_whitespace() {
254        let temp_dir = tempfile::TempDir::new().unwrap();
255        let file_path = temp_dir.path().join("creds.txt");
256        std::fs::write(&file_path, " alice : secret \n bob : pass \n").unwrap();
257
258        let engine = MemoryAuthEngine::from_file(file_path.to_str().unwrap()).unwrap();
259        assert!(engine.authenticate("alice", "secret").is_ok());
260        assert!(engine.authenticate("bob", "pass").is_ok());
261    }
262
263    #[test]
264    fn test_memory_engine_from_file_empty() {
265        let temp_dir = tempfile::TempDir::new().unwrap();
266        let file_path = temp_dir.path().join("empty.txt");
267        std::fs::write(&file_path, "").unwrap();
268
269        let engine = MemoryAuthEngine::from_file(file_path.to_str().unwrap()).unwrap();
270        assert!(engine.is_empty());
271    }
272
273    #[test]
274    fn test_memory_engine_default() {
275        let engine = MemoryAuthEngine::default();
276        assert!(engine.is_empty());
277        assert_eq!(engine.len(), 0);
278    }
279
280    #[test]
281    fn test_memory_engine_len_and_is_empty() {
282        let mut engine = MemoryAuthEngine::new();
283        assert!(engine.is_empty());
284        assert_eq!(engine.len(), 0);
285
286        engine.add_user("user".to_string(), "pass".to_string());
287        assert!(!engine.is_empty());
288        assert_eq!(engine.len(), 1);
289    }
290}