Skip to main content

varpulis_cli/
users.rs

1//! Local user store with password authentication and session management.
2//!
3//! Provides username/password authentication with argon2 password hashing,
4//! in-memory session tracking, and JSON file persistence for user records.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12use tokio::sync::RwLock;
13
14// ---------------------------------------------------------------------------
15// Data structures
16// ---------------------------------------------------------------------------
17
18/// Stored user record (serialized to JSON file).
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StoredUser {
21    pub id: String,
22    pub username: String,
23    pub password_hash: String,
24    pub display_name: String,
25    pub email: String,
26    pub role: String,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29    pub disabled: bool,
30}
31
32/// Session metadata for tracking parallel sessions.
33#[derive(Debug, Clone)]
34pub struct SessionRecord {
35    pub session_id: String,
36    pub user_id: String,
37    pub username: String,
38    pub role: String,
39    pub created_at: Instant,
40    pub last_activity: Instant,
41    pub absolute_expiry: Instant,
42}
43
44/// Session management configuration.
45#[derive(Debug, Clone)]
46pub struct SessionConfig {
47    pub idle_timeout: Duration,
48    pub absolute_timeout: Duration,
49    pub max_parallel_sessions: usize,
50    pub renewal_window: Duration,
51}
52
53impl Default for SessionConfig {
54    fn default() -> Self {
55        Self {
56            idle_timeout: Duration::from_secs(30 * 60), // 30 minutes
57            absolute_timeout: Duration::from_secs(24 * 3600), // 24 hours
58            max_parallel_sessions: 5,
59            renewal_window: Duration::from_secs(5 * 60), // 5 minutes before expiry
60        }
61    }
62}
63
64/// In-memory user + session store backed by a JSON file.
65#[derive(Debug)]
66pub struct UserStore {
67    users: HashMap<String, StoredUser>,
68    sessions: HashMap<String, SessionRecord>,
69    file_path: PathBuf,
70    config: SessionConfig,
71}
72
73pub type SharedUserStore = Arc<RwLock<UserStore>>;
74
75// ---------------------------------------------------------------------------
76// Persistence format
77// ---------------------------------------------------------------------------
78
79#[derive(Debug, Serialize, Deserialize)]
80struct UsersFile {
81    users: Vec<StoredUser>,
82}
83
84// ---------------------------------------------------------------------------
85// Implementation
86// ---------------------------------------------------------------------------
87
88impl UserStore {
89    /// Load from JSON file or create empty store.
90    pub fn new(file_path: PathBuf, config: SessionConfig) -> Self {
91        let users = if file_path.exists() {
92            match std::fs::read_to_string(&file_path) {
93                Ok(contents) => match serde_json::from_str::<UsersFile>(&contents) {
94                    Ok(data) => {
95                        let mut map = HashMap::new();
96                        for user in data.users {
97                            map.insert(user.username.clone(), user);
98                        }
99                        tracing::info!("Loaded {} users from {}", map.len(), file_path.display());
100                        map
101                    }
102                    Err(e) => {
103                        tracing::error!("Failed to parse users file: {}", e);
104                        HashMap::new()
105                    }
106                },
107                Err(e) => {
108                    tracing::error!("Failed to read users file: {}", e);
109                    HashMap::new()
110                }
111            }
112        } else {
113            HashMap::new()
114        };
115
116        Self {
117            users,
118            sessions: HashMap::new(),
119            file_path,
120            config,
121        }
122    }
123
124    /// Save users to JSON file (atomic write via temp file + rename).
125    pub fn save(&self) -> Result<(), String> {
126        let data = UsersFile {
127            users: self.users.values().cloned().collect(),
128        };
129        let json =
130            serde_json::to_string_pretty(&data).map_err(|e| format!("Serialize error: {e}"))?;
131
132        // Ensure parent directory exists
133        if let Some(parent) = self.file_path.parent() {
134            let _ = std::fs::create_dir_all(parent);
135        }
136
137        // Atomic write: write to temp file, then rename
138        let tmp_path = self.file_path.with_extension("tmp");
139        std::fs::write(&tmp_path, &json).map_err(|e| format!("Write error: {e}"))?;
140        std::fs::rename(&tmp_path, &self.file_path).map_err(|e| format!("Rename error: {e}"))?;
141
142        Ok(())
143    }
144
145    /// Check if the store has any users.
146    pub fn is_empty(&self) -> bool {
147        self.users.is_empty()
148    }
149
150    /// Create a new user with argon2 password hashing.
151    pub fn create_user(
152        &mut self,
153        username: &str,
154        password: &str,
155        display_name: &str,
156        email: &str,
157        role: &str,
158    ) -> Result<StoredUser, String> {
159        if self.users.contains_key(username) {
160            return Err(format!("User '{username}' already exists"));
161        }
162
163        if username.is_empty() || username.len() > 64 {
164            return Err("Username must be 1-64 characters".to_string());
165        }
166
167        if password.len() < 8 {
168            return Err("Password must be at least 8 characters".to_string());
169        }
170
171        let password_hash = hash_password(password)?;
172
173        let now = Utc::now();
174        let user = StoredUser {
175            id: uuid::Uuid::new_v4().to_string(),
176            username: username.to_string(),
177            password_hash,
178            display_name: display_name.to_string(),
179            email: email.to_string(),
180            role: role.to_string(),
181            created_at: now,
182            updated_at: now,
183            disabled: false,
184        };
185
186        self.users.insert(username.to_string(), user.clone());
187        self.save()
188            .map_err(|e| format!("Failed to persist user: {e}"))?;
189
190        Ok(user)
191    }
192
193    /// Verify a username/password combination. Returns the user on success.
194    pub fn verify_password(&self, username: &str, password: &str) -> Result<StoredUser, String> {
195        let user = self
196            .users
197            .get(username)
198            .ok_or_else(|| "Invalid username or password".to_string())?;
199
200        if user.disabled {
201            return Err("Account is disabled".to_string());
202        }
203
204        if !verify_password(password, &user.password_hash)? {
205            return Err("Invalid username or password".to_string());
206        }
207
208        Ok(user.clone())
209    }
210
211    /// Create a new session for a user. Evicts oldest session if max exceeded.
212    pub fn create_session(&mut self, user: &StoredUser) -> SessionRecord {
213        let now = Instant::now();
214
215        // Count existing sessions for this user
216        let user_sessions: Vec<String> = self
217            .sessions
218            .iter()
219            .filter(|(_, s)| s.user_id == user.id)
220            .map(|(id, _)| id.clone())
221            .collect();
222
223        // Evict oldest sessions if at max
224        if user_sessions.len() >= self.config.max_parallel_sessions {
225            let mut sessions_with_time: Vec<_> = user_sessions
226                .iter()
227                .filter_map(|id| self.sessions.get(id).map(|s| (id.clone(), s.created_at)))
228                .collect();
229            sessions_with_time.sort_by_key(|(_, t)| *t);
230
231            // Remove oldest sessions to make room
232            let to_remove = user_sessions.len() - self.config.max_parallel_sessions + 1;
233            for (id, _) in sessions_with_time.iter().take(to_remove) {
234                self.sessions.remove(id);
235            }
236        }
237
238        let session = SessionRecord {
239            session_id: uuid::Uuid::new_v4().to_string(),
240            user_id: user.id.clone(),
241            username: user.username.clone(),
242            role: user.role.clone(),
243            created_at: now,
244            last_activity: now,
245            absolute_expiry: now + self.config.absolute_timeout,
246        };
247
248        self.sessions
249            .insert(session.session_id.clone(), session.clone());
250        session
251    }
252
253    /// Validate a session: check idle + absolute timeout, update last_activity.
254    pub fn validate_session(&mut self, session_id: &str) -> Option<&SessionRecord> {
255        let now = Instant::now();
256        let config = self.config.clone();
257
258        let session = self.sessions.get_mut(session_id)?;
259
260        // Check absolute expiry
261        if now >= session.absolute_expiry {
262            self.sessions.remove(session_id);
263            return None;
264        }
265
266        // Check idle timeout
267        if now.duration_since(session.last_activity) > config.idle_timeout {
268            self.sessions.remove(session_id);
269            return None;
270        }
271
272        // Update last_activity (re-borrow after checks pass)
273        let session = self.sessions.get_mut(session_id)?;
274        session.last_activity = now;
275        Some(session)
276    }
277
278    /// Check if a session is within the renewal window (close to expiry).
279    pub fn needs_renewal(&self, session_id: &str) -> bool {
280        if let Some(session) = self.sessions.get(session_id) {
281            let now = Instant::now();
282            let time_remaining = session
283                .absolute_expiry
284                .checked_duration_since(now)
285                .unwrap_or(Duration::ZERO);
286            time_remaining <= self.config.renewal_window
287        } else {
288            false
289        }
290    }
291
292    /// Revoke a single session.
293    pub fn revoke_session(&mut self, session_id: &str) -> bool {
294        self.sessions.remove(session_id).is_some()
295    }
296
297    /// Revoke all sessions for a user.
298    pub fn revoke_all_user_sessions(&mut self, user_id: &str) -> usize {
299        let to_remove: Vec<String> = self
300            .sessions
301            .iter()
302            .filter(|(_, s)| s.user_id == user_id)
303            .map(|(id, _)| id.clone())
304            .collect();
305        let count = to_remove.len();
306        for id in to_remove {
307            self.sessions.remove(&id);
308        }
309        count
310    }
311
312    /// Remove expired sessions (called periodically).
313    pub fn cleanup_expired(&mut self) -> usize {
314        let now = Instant::now();
315        let config = self.config.clone();
316        let before = self.sessions.len();
317        self.sessions.retain(|_, s| {
318            now < s.absolute_expiry && now.duration_since(s.last_activity) <= config.idle_timeout
319        });
320        before - self.sessions.len()
321    }
322
323    /// List all users (without password hashes).
324    pub fn list_users(&self) -> Vec<UserSummary> {
325        self.users
326            .values()
327            .map(|u| UserSummary {
328                id: u.id.clone(),
329                username: u.username.clone(),
330                display_name: u.display_name.clone(),
331                email: u.email.clone(),
332                role: u.role.clone(),
333                disabled: u.disabled,
334                created_at: u.created_at,
335            })
336            .collect()
337    }
338
339    /// Get a user by username.
340    pub fn get_user(&self, username: &str) -> Option<&StoredUser> {
341        self.users.get(username)
342    }
343
344    /// Get a user by ID.
345    pub fn get_user_by_id(&self, user_id: &str) -> Option<&StoredUser> {
346        self.users.values().find(|u| u.id == user_id)
347    }
348
349    /// Update a user's details (admin operation).
350    pub fn update_user(
351        &mut self,
352        user_id: &str,
353        display_name: Option<&str>,
354        email: Option<&str>,
355        role: Option<&str>,
356        disabled: Option<bool>,
357    ) -> Result<StoredUser, String> {
358        let user = self
359            .users
360            .values_mut()
361            .find(|u| u.id == user_id)
362            .ok_or_else(|| "User not found".to_string())?;
363
364        if let Some(name) = display_name {
365            user.display_name = name.to_string();
366        }
367        if let Some(email) = email {
368            user.email = email.to_string();
369        }
370        if let Some(role) = role {
371            user.role = role.to_string();
372        }
373        if let Some(disabled) = disabled {
374            user.disabled = disabled;
375        }
376        user.updated_at = Utc::now();
377
378        let updated = user.clone();
379        self.save().map_err(|e| format!("Failed to persist: {e}"))?;
380
381        Ok(updated)
382    }
383
384    /// Change a user's password.
385    pub fn change_password(&mut self, user_id: &str, new_password: &str) -> Result<(), String> {
386        if new_password.len() < 8 {
387            return Err("Password must be at least 8 characters".to_string());
388        }
389
390        let user = self
391            .users
392            .values_mut()
393            .find(|u| u.id == user_id)
394            .ok_or_else(|| "User not found".to_string())?;
395
396        user.password_hash = hash_password(new_password)?;
397        user.updated_at = Utc::now();
398
399        self.save().map_err(|e| format!("Failed to persist: {e}"))?;
400        Ok(())
401    }
402
403    /// Delete a user by ID.
404    pub fn delete_user(&mut self, user_id: &str) -> Result<(), String> {
405        let username = self
406            .users
407            .values()
408            .find(|u| u.id == user_id)
409            .map(|u| u.username.clone())
410            .ok_or_else(|| "User not found".to_string())?;
411
412        self.users.remove(&username);
413        self.revoke_all_user_sessions(user_id);
414        self.save().map_err(|e| format!("Failed to persist: {e}"))?;
415
416        Ok(())
417    }
418
419    /// Get session config (for JWT TTL).
420    pub const fn session_config(&self) -> &SessionConfig {
421        &self.config
422    }
423}
424
425// ---------------------------------------------------------------------------
426// User summary (safe to return via API, no password hash)
427// ---------------------------------------------------------------------------
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct UserSummary {
431    pub id: String,
432    pub username: String,
433    pub display_name: String,
434    pub email: String,
435    pub role: String,
436    pub disabled: bool,
437    pub created_at: DateTime<Utc>,
438}
439
440// ---------------------------------------------------------------------------
441// Password hashing (argon2)
442// ---------------------------------------------------------------------------
443
444fn hash_password(password: &str) -> Result<String, String> {
445    use argon2::password_hash::{rand_core::OsRng, SaltString};
446    use argon2::{Argon2, PasswordHasher};
447
448    let salt = SaltString::generate(&mut OsRng);
449    let argon2 = Argon2::default(); // Argon2id with safe defaults
450
451    argon2
452        .hash_password(password.as_bytes(), &salt)
453        .map(|h| h.to_string())
454        .map_err(|e| format!("Password hashing failed: {e}"))
455}
456
457fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
458    use argon2::password_hash::PasswordHash;
459    use argon2::{Argon2, PasswordVerifier};
460
461    let parsed_hash = PasswordHash::new(hash).map_err(|e| format!("Invalid password hash: {e}"))?;
462
463    Ok(Argon2::default()
464        .verify_password(password.as_bytes(), &parsed_hash)
465        .is_ok())
466}
467
468// ---------------------------------------------------------------------------
469// Tests
470// ---------------------------------------------------------------------------
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn temp_store() -> UserStore {
477        let dir = tempfile::tempdir().unwrap();
478        let path = dir.path().join("users.json");
479        let _ = dir.keep();
480        UserStore::new(path, SessionConfig::default())
481    }
482
483    #[test]
484    fn test_create_and_verify_user() {
485        let mut store = temp_store();
486        let user = store
487            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
488            .unwrap();
489        assert_eq!(user.username, "alice");
490        assert_eq!(user.role, "admin");
491
492        // Verify correct password
493        let verified = store.verify_password("alice", "password123").unwrap();
494        assert_eq!(verified.id, user.id);
495
496        // Verify wrong password
497        assert!(store.verify_password("alice", "wrong").is_err());
498    }
499
500    #[test]
501    fn test_duplicate_username() {
502        let mut store = temp_store();
503        store
504            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
505            .unwrap();
506        assert!(store
507            .create_user("alice", "other123456", "Alice2", "a2@test.com", "viewer")
508            .is_err());
509    }
510
511    #[test]
512    fn test_short_password_rejected() {
513        let mut store = temp_store();
514        assert!(store
515            .create_user("alice", "short", "Alice", "alice@test.com", "admin")
516            .is_err());
517    }
518
519    #[test]
520    fn test_session_lifecycle() {
521        let mut store = temp_store();
522        let user = store
523            .create_user("bob", "password123", "Bob", "bob@test.com", "operator")
524            .unwrap();
525
526        let session = store.create_session(&user);
527        assert!(!session.session_id.is_empty());
528
529        // Validate session
530        assert!(store.validate_session(&session.session_id).is_some());
531
532        // Revoke session
533        assert!(store.revoke_session(&session.session_id));
534        assert!(store.validate_session(&session.session_id).is_none());
535    }
536
537    #[test]
538    fn test_max_parallel_sessions() {
539        let config = SessionConfig {
540            max_parallel_sessions: 2,
541            ..Default::default()
542        };
543        let dir = tempfile::tempdir().unwrap();
544        let path = dir.path().join("users.json");
545        let _ = dir.keep();
546        let mut store = UserStore::new(path, config);
547
548        let user = store
549            .create_user("carol", "password123", "Carol", "carol@test.com", "viewer")
550            .unwrap();
551
552        let s1 = store.create_session(&user);
553        let s2 = store.create_session(&user);
554        let s3 = store.create_session(&user); // should evict s1
555
556        // s1 should be evicted (oldest)
557        assert!(store.validate_session(&s1.session_id).is_none());
558        assert!(store.validate_session(&s2.session_id).is_some());
559        assert!(store.validate_session(&s3.session_id).is_some());
560    }
561
562    #[test]
563    fn test_list_users() {
564        let mut store = temp_store();
565        store
566            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
567            .unwrap();
568        store
569            .create_user("bob", "password456", "Bob", "bob@test.com", "viewer")
570            .unwrap();
571
572        let list = store.list_users();
573        assert_eq!(list.len(), 2);
574    }
575
576    #[test]
577    fn test_persistence_roundtrip() {
578        let dir = tempfile::tempdir().unwrap();
579        let path = dir.path().join("users.json");
580
581        // Create store with users
582        {
583            let mut store = UserStore::new(path.clone(), SessionConfig::default());
584            store
585                .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
586                .unwrap();
587        }
588
589        // Load from same file
590        let store = UserStore::new(path, SessionConfig::default());
591        assert_eq!(store.list_users().len(), 1);
592        assert!(store.verify_password("alice", "password123").is_ok());
593    }
594
595    #[test]
596    fn test_update_user() {
597        let mut store = temp_store();
598        let user = store
599            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
600            .unwrap();
601
602        let updated = store
603            .update_user(&user.id, Some("Alice Updated"), None, Some("viewer"), None)
604            .unwrap();
605
606        assert_eq!(updated.display_name, "Alice Updated");
607        assert_eq!(updated.role, "viewer");
608    }
609
610    #[test]
611    fn test_delete_user() {
612        let mut store = temp_store();
613        let user = store
614            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
615            .unwrap();
616
617        store.delete_user(&user.id).unwrap();
618        assert!(store.list_users().is_empty());
619    }
620
621    #[test]
622    fn test_disabled_user_cannot_login() {
623        let mut store = temp_store();
624        let user = store
625            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
626            .unwrap();
627        store
628            .update_user(&user.id, None, None, None, Some(true))
629            .unwrap();
630
631        assert!(store.verify_password("alice", "password123").is_err());
632    }
633
634    #[test]
635    fn test_change_password() {
636        let mut store = temp_store();
637        let user = store
638            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
639            .unwrap();
640
641        store.change_password(&user.id, "newpassword456").unwrap();
642        assert!(store.verify_password("alice", "password123").is_err());
643        assert!(store.verify_password("alice", "newpassword456").is_ok());
644    }
645
646    #[test]
647    fn test_revoke_all_user_sessions() {
648        let mut store = temp_store();
649        let user = store
650            .create_user("alice", "password123", "Alice", "alice@test.com", "admin")
651            .unwrap();
652
653        store.create_session(&user);
654        store.create_session(&user);
655        store.create_session(&user);
656
657        let revoked = store.revoke_all_user_sessions(&user.id);
658        assert_eq!(revoked, 3);
659    }
660}