Skip to main content

rusmes_auth/
file.rs

1//! File-based authentication backend (htpasswd-style with bcrypt password hashing)
2//!
3//! File format: one user per line
4//! ```text
5//! username:$2b$12$... (bcrypt hash)
6//! ```
7
8use crate::AuthBackend;
9use anyhow::{anyhow, Context, Result};
10use async_trait::async_trait;
11use rusmes_proto::Username;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use tokio::fs;
16use tokio::io::{AsyncReadExt, AsyncWriteExt};
17use tokio::sync::RwLock;
18
19/// File-based authentication backend using bcrypt for password hashing
20pub struct FileAuthBackend {
21    file_path: PathBuf,
22    users: Arc<RwLock<HashMap<String, String>>>,
23}
24
25impl FileAuthBackend {
26    /// Create a new file-based authentication backend
27    ///
28    /// # Arguments
29    /// * `file_path` - Path to the password file
30    pub async fn new(file_path: impl AsRef<Path>) -> Result<Self> {
31        let file_path = file_path.as_ref().to_path_buf();
32        let users = Self::load_users(&file_path).await?;
33
34        Ok(Self {
35            file_path,
36            users: Arc::new(RwLock::new(users)),
37        })
38    }
39
40    /// Load users from the password file
41    async fn load_users(file_path: &Path) -> Result<HashMap<String, String>> {
42        // Create the file if it doesn't exist
43        if !file_path.exists() {
44            if let Some(parent) = file_path.parent() {
45                fs::create_dir_all(parent)
46                    .await
47                    .context("Failed to create parent directory")?;
48            }
49            fs::File::create(file_path)
50                .await
51                .context("Failed to create password file")?;
52            return Ok(HashMap::new());
53        }
54
55        let mut file = fs::File::open(file_path)
56            .await
57            .context("Failed to open password file")?;
58        let mut contents = String::new();
59        file.read_to_string(&mut contents)
60            .await
61            .context("Failed to read password file")?;
62
63        let mut users = HashMap::new();
64        for (line_num, line) in contents.lines().enumerate() {
65            let line = line.trim();
66            if line.is_empty() || line.starts_with('#') {
67                continue;
68            }
69
70            let parts: Vec<&str> = line.splitn(2, ':').collect();
71            if parts.len() != 2 {
72                return Err(anyhow!(
73                    "Invalid format on line {}: expected 'username:hash'",
74                    line_num + 1
75                ));
76            }
77
78            let username = parts[0].to_string();
79            let hash = parts[1].to_string();
80
81            if username.is_empty() {
82                return Err(anyhow!("Empty username on line {}", line_num + 1));
83            }
84
85            if !hash.starts_with("$2b$") && !hash.starts_with("$2a$") && !hash.starts_with("$2y$") {
86                return Err(anyhow!(
87                    "Invalid bcrypt hash on line {}: hash must start with $2a$, $2b$, or $2y$",
88                    line_num + 1
89                ));
90            }
91
92            users.insert(username, hash);
93        }
94
95        Ok(users)
96    }
97
98    /// Save users to the password file
99    async fn save_users(&self, users: &HashMap<String, String>) -> Result<()> {
100        let mut contents = String::new();
101        let mut usernames: Vec<&String> = users.keys().collect();
102        usernames.sort();
103
104        for username in usernames {
105            let hash = &users[username];
106            contents.push_str(&format!("{}:{}\n", username, hash));
107        }
108
109        // Write to a temporary file first, then rename atomically
110        let temp_path = self.file_path.with_extension("tmp");
111        let mut file = fs::File::create(&temp_path)
112            .await
113            .context("Failed to create temporary file")?;
114        file.write_all(contents.as_bytes())
115            .await
116            .context("Failed to write to temporary file")?;
117        file.sync_all()
118            .await
119            .context("Failed to sync temporary file")?;
120        drop(file);
121
122        fs::rename(&temp_path, &self.file_path)
123            .await
124            .context("Failed to rename temporary file")?;
125
126        Ok(())
127    }
128
129    /// Hash a password using bcrypt
130    fn hash_password(password: &str) -> Result<String> {
131        bcrypt::hash(password, bcrypt::DEFAULT_COST).context("Failed to hash password")
132    }
133
134    /// Verify a password against a bcrypt hash
135    fn verify_password(password: &str, hash: &str) -> Result<bool> {
136        bcrypt::verify(password, hash).context("Failed to verify password")
137    }
138}
139
140#[async_trait]
141impl AuthBackend for FileAuthBackend {
142    async fn authenticate(&self, username: &Username, password: &str) -> Result<bool> {
143        let users = self.users.read().await;
144
145        if let Some(hash) = users.get(username.as_str()) {
146            Self::verify_password(password, hash)
147        } else {
148            // User not found - still run bcrypt to prevent timing attacks
149            let _ = bcrypt::verify(
150                password,
151                "$2b$12$dummy_hash_to_prevent_timing_attack_00000000000000000000000000000",
152            );
153            Ok(false)
154        }
155    }
156
157    async fn verify_identity(&self, username: &Username) -> Result<bool> {
158        let users = self.users.read().await;
159        Ok(users.contains_key(username.as_str()))
160    }
161
162    async fn list_users(&self) -> Result<Vec<Username>> {
163        let users = self.users.read().await;
164        let mut usernames = Vec::new();
165
166        for username_str in users.keys() {
167            let username = Username::new(username_str.clone()).context(format!(
168                "Invalid username in password file: {}",
169                username_str
170            ))?;
171            usernames.push(username);
172        }
173
174        usernames.sort_by(|a, b| a.as_str().cmp(b.as_str()));
175        Ok(usernames)
176    }
177
178    async fn create_user(&self, username: &Username, password: &str) -> Result<()> {
179        let mut users = self.users.write().await;
180
181        if users.contains_key(username.as_str()) {
182            return Err(anyhow!("User '{}' already exists", username.as_str()));
183        }
184
185        let hash = Self::hash_password(password)?;
186        users.insert(username.as_str().to_string(), hash);
187
188        self.save_users(&users).await?;
189
190        Ok(())
191    }
192
193    async fn delete_user(&self, username: &Username) -> Result<()> {
194        let mut users = self.users.write().await;
195
196        if !users.contains_key(username.as_str()) {
197            return Err(anyhow!("User '{}' does not exist", username.as_str()));
198        }
199
200        users.remove(username.as_str());
201        self.save_users(&users).await?;
202
203        Ok(())
204    }
205
206    async fn change_password(&self, username: &Username, new_password: &str) -> Result<()> {
207        let mut users = self.users.write().await;
208
209        if !users.contains_key(username.as_str()) {
210            return Err(anyhow!("User '{}' does not exist", username.as_str()));
211        }
212
213        let hash = Self::hash_password(new_password)?;
214        users.insert(username.as_str().to_string(), hash);
215
216        self.save_users(&users).await?;
217
218        Ok(())
219    }
220}