1use std::collections::BTreeMap;
9use std::fs;
10use std::io;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14use zeroize::Zeroizing;
15
16use crate::error::AuthError;
17use crate::hash::{hash_password, verify_password};
18use crate::role::Role;
19
20const AUTH_FILE: &str = "auth.json";
21
22#[derive(Serialize, Deserialize, Clone, Debug)]
24pub struct User {
25 pub name: String,
27 pub password_hash: String,
29 pub role: String,
31}
32
33#[derive(Serialize, Deserialize, Clone, Debug, Default)]
35pub struct UserStore {
36 users: BTreeMap<String, User>,
37}
38
39impl UserStore {
40 pub fn new() -> Self {
42 UserStore {
43 users: BTreeMap::new(),
44 }
45 }
46
47 pub fn create_user(&mut self, name: &str, password: &str, role: &str) -> Result<(), AuthError> {
52 if self.users.contains_key(name) {
53 return Err(AuthError::UserExists(name.to_string()));
54 }
55 if Role::builtin(role).is_none() {
56 return Err(AuthError::UnknownRole(role.to_string()));
57 }
58 let secret = Zeroizing::new(password.to_string());
61 let password_hash = hash_password(&secret)?;
62 self.users.insert(
63 name.to_string(),
64 User {
65 name: name.to_string(),
66 password_hash,
67 role: role.to_string(),
68 },
69 );
70 Ok(())
71 }
72
73 pub fn authenticate(&self, name: &str, candidate: &str) -> Option<&User> {
80 let Some(user) = self.users.get(name) else {
81 verify_password(dummy_password_hash(), candidate);
82 return None;
83 };
84 if verify_password(&user.password_hash, candidate) {
85 Some(user)
86 } else {
87 None
88 }
89 }
90
91 pub fn set_role(&mut self, name: &str, role: &str) -> Result<(), AuthError> {
95 if Role::builtin(role).is_none() {
96 return Err(AuthError::UnknownRole(role.to_string()));
97 }
98 let user = self
99 .users
100 .get_mut(name)
101 .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
102 user.role = role.to_string();
103 Ok(())
104 }
105
106 pub fn set_password(&mut self, name: &str, new_password: &str) -> Result<(), AuthError> {
111 let secret = Zeroizing::new(new_password.to_string());
112 let password_hash = hash_password(&secret)?;
113 let user = self
114 .users
115 .get_mut(name)
116 .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
117 user.password_hash = password_hash;
118 Ok(())
119 }
120
121 pub fn delete_user(&mut self, name: &str) -> Result<(), AuthError> {
123 self.users
124 .remove(name)
125 .map(|_| ())
126 .ok_or_else(|| AuthError::UnknownUser(name.to_string()))
127 }
128
129 pub fn len(&self) -> usize {
131 self.users.len()
132 }
133
134 pub fn is_empty(&self) -> bool {
137 self.users.is_empty()
138 }
139
140 pub fn list_users(&self) -> Vec<(String, String)> {
142 self.users
143 .values()
144 .map(|u| (u.name.clone(), u.role.clone()))
145 .collect()
146 }
147
148 pub fn save(&self, dir: &Path) -> io::Result<()> {
152 let json = serde_json::to_string_pretty(self)
153 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
154 let path = dir.join(AUTH_FILE);
155 fs::write(&path, json)?;
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 let perms = fs::Permissions::from_mode(0o600);
160 fs::set_permissions(&path, perms)?;
161 }
162 Ok(())
163 }
164
165 pub fn load(dir: &Path) -> io::Result<Self> {
169 let path = dir.join(AUTH_FILE);
170 match fs::read_to_string(&path) {
171 Ok(json) => serde_json::from_str(&json)
172 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
173 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(UserStore::new()),
174 Err(e) => Err(e),
175 }
176 }
177}
178
179fn dummy_password_hash() -> &'static str {
184 use std::sync::OnceLock;
185 static DUMMY: OnceLock<String> = OnceLock::new();
186 DUMMY.get_or_init(|| {
187 let throwaway = Zeroizing::new(format!(
188 "powdb-timing-equalizer-{}-{:p}",
189 std::process::id(),
190 &DUMMY
191 ));
192 hash_password(&throwaway).unwrap_or_default()
197 })
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn create_then_authenticate() {
206 let mut s = UserStore::new();
207 s.create_user("alice", "pw", "admin").unwrap();
208 assert!(s.authenticate("alice", "pw").is_some());
209 assert!(s.authenticate("alice", "bad").is_none());
210 assert!(s.authenticate("nobody", "pw").is_none());
211 }
212
213 #[test]
214 fn duplicate_create_rejected() {
215 let mut s = UserStore::new();
216 s.create_user("a", "pw", "readonly").unwrap();
217 assert!(matches!(
218 s.create_user("a", "pw2", "readonly"),
219 Err(AuthError::UserExists(_))
220 ));
221 }
222
223 #[test]
224 fn empty_and_len_track_users() {
225 let mut s = UserStore::new();
226 assert!(s.is_empty());
227 assert_eq!(s.len(), 0);
228 s.create_user("alice", "pw", "readwrite").unwrap();
229 assert!(!s.is_empty());
230 assert_eq!(s.len(), 1);
231 s.create_user("bob", "pw", "readonly").unwrap();
232 assert_eq!(s.len(), 2);
233 s.delete_user("alice").unwrap();
234 assert_eq!(s.len(), 1);
235 assert!(!s.is_empty());
236 s.delete_user("bob").unwrap();
237 assert!(s.is_empty());
238 }
239
240 #[test]
241 fn set_password_changes_credential() {
242 let mut s = UserStore::new();
243 s.create_user("alice", "old", "readwrite").unwrap();
244 assert!(s.authenticate("alice", "old").is_some());
245 s.set_password("alice", "new").unwrap();
246 assert!(s.authenticate("alice", "old").is_none());
248 assert!(s.authenticate("alice", "new").is_some());
249 assert_eq!(s.authenticate("alice", "new").unwrap().role, "readwrite");
250 }
251
252 #[test]
253 fn set_password_unknown_user_rejected() {
254 let mut s = UserStore::new();
255 assert!(matches!(
256 s.set_password("ghost", "pw"),
257 Err(AuthError::UnknownUser(_))
258 ));
259 }
260
261 #[test]
262 fn create_with_unknown_role_rejected() {
263 let mut s = UserStore::new();
264 assert!(matches!(
265 s.create_user("a", "pw", "wizard"),
266 Err(AuthError::UnknownRole(_))
267 ));
268 }
269
270 #[test]
277 fn unknown_user_costs_the_same_argon2_work_as_a_wrong_password() {
278 let mut s = UserStore::new();
279 s.create_user("alice", "correct horse", "admin").unwrap();
280
281 let median = |f: &dyn Fn()| {
282 let mut runs: Vec<std::time::Duration> = (0..3)
283 .map(|_| {
284 let t = std::time::Instant::now();
285 f();
286 t.elapsed()
287 })
288 .collect();
289 runs.sort();
290 runs[1]
291 };
292
293 let wrong = median(&|| {
294 assert!(s.authenticate("alice", "wrong password").is_none());
295 });
296 let unknown = median(&|| {
297 assert!(s.authenticate("mallory", "wrong password").is_none());
298 });
299
300 assert!(
301 unknown * 4 > wrong,
302 "unknown-user auth ({unknown:?}) must not be cheaper than \
303 wrong-password auth ({wrong:?}) — that timing gap enumerates usernames"
304 );
305 }
306}