Skip to main content

varpulis_cli/
users.rs

1//! Session management and password hashing utilities for local authentication.
2//!
3//! Provides in-memory session tracking with idle/absolute timeouts,
4//! and argon2id password hashing for local username/password auth.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use tokio::sync::RwLock;
13
14// ---------------------------------------------------------------------------
15// Data structures
16// ---------------------------------------------------------------------------
17
18/// Session metadata for tracking parallel sessions.
19#[derive(Debug, Clone)]
20pub struct SessionRecord {
21    pub session_id: String,
22    pub user_id: String,
23    pub username: String,
24    pub role: String,
25    pub created_at: Instant,
26    pub last_activity: Instant,
27    pub absolute_expiry: Instant,
28}
29
30/// Session management configuration.
31#[derive(Debug, Clone)]
32pub struct SessionConfig {
33    pub idle_timeout: Duration,
34    pub absolute_timeout: Duration,
35    pub max_parallel_sessions: usize,
36    pub renewal_window: Duration,
37}
38
39impl Default for SessionConfig {
40    fn default() -> Self {
41        Self {
42            idle_timeout: Duration::from_secs(30 * 60), // 30 minutes
43            absolute_timeout: Duration::from_secs(24 * 3600), // 24 hours
44            max_parallel_sessions: 5,
45            renewal_window: Duration::from_secs(5 * 60), // 5 minutes before expiry
46        }
47    }
48}
49
50/// In-memory session manager (no file persistence).
51#[derive(Debug)]
52pub struct SessionManager {
53    sessions: HashMap<String, SessionRecord>,
54    config: SessionConfig,
55}
56
57pub type SharedSessionManager = Arc<RwLock<SessionManager>>;
58
59// ---------------------------------------------------------------------------
60// Implementation
61// ---------------------------------------------------------------------------
62
63impl SessionManager {
64    /// Create a new session manager with the given configuration.
65    pub fn new(config: SessionConfig) -> Self {
66        Self {
67            sessions: HashMap::new(),
68            config,
69        }
70    }
71
72    /// Create a new session for a user. Evicts oldest session if max exceeded.
73    pub fn create_session(&mut self, user_id: &str, username: &str, role: &str) -> SessionRecord {
74        let now = Instant::now();
75
76        // Count existing sessions for this user
77        let user_sessions: Vec<String> = self
78            .sessions
79            .iter()
80            .filter(|(_, s)| s.user_id == user_id)
81            .map(|(id, _)| id.clone())
82            .collect();
83
84        // Evict oldest sessions if at max
85        if user_sessions.len() >= self.config.max_parallel_sessions {
86            let mut sessions_with_time: Vec<_> = user_sessions
87                .iter()
88                .filter_map(|id| self.sessions.get(id).map(|s| (id.clone(), s.created_at)))
89                .collect();
90            sessions_with_time.sort_by_key(|(_, t)| *t);
91
92            // Remove oldest sessions to make room
93            let to_remove = user_sessions.len() - self.config.max_parallel_sessions + 1;
94            for (id, _) in sessions_with_time.iter().take(to_remove) {
95                self.sessions.remove(id);
96            }
97        }
98
99        let session = SessionRecord {
100            session_id: uuid::Uuid::new_v4().to_string(),
101            user_id: user_id.to_string(),
102            username: username.to_string(),
103            role: role.to_string(),
104            created_at: now,
105            last_activity: now,
106            absolute_expiry: now + self.config.absolute_timeout,
107        };
108
109        self.sessions
110            .insert(session.session_id.clone(), session.clone());
111        session
112    }
113
114    /// Validate a session: check idle + absolute timeout, update last_activity.
115    pub fn validate_session(&mut self, session_id: &str) -> Option<&SessionRecord> {
116        let now = Instant::now();
117        let config = self.config.clone();
118
119        let session = self.sessions.get_mut(session_id)?;
120
121        // Check absolute expiry
122        if now >= session.absolute_expiry {
123            self.sessions.remove(session_id);
124            return None;
125        }
126
127        // Check idle timeout
128        if now.duration_since(session.last_activity) > config.idle_timeout {
129            self.sessions.remove(session_id);
130            return None;
131        }
132
133        // Update last_activity (re-borrow after checks pass)
134        let session = self.sessions.get_mut(session_id)?;
135        session.last_activity = now;
136        Some(session)
137    }
138
139    /// Check if a session is within the renewal window (close to expiry).
140    pub fn needs_renewal(&self, session_id: &str) -> bool {
141        if let Some(session) = self.sessions.get(session_id) {
142            let now = Instant::now();
143            let time_remaining = session
144                .absolute_expiry
145                .checked_duration_since(now)
146                .unwrap_or(Duration::ZERO);
147            time_remaining <= self.config.renewal_window
148        } else {
149            false
150        }
151    }
152
153    /// Revoke a single session.
154    pub fn revoke_session(&mut self, session_id: &str) -> bool {
155        self.sessions.remove(session_id).is_some()
156    }
157
158    /// Revoke all sessions for a user.
159    pub fn revoke_all_user_sessions(&mut self, user_id: &str) -> usize {
160        let to_remove: Vec<String> = self
161            .sessions
162            .iter()
163            .filter(|(_, s)| s.user_id == user_id)
164            .map(|(id, _)| id.clone())
165            .collect();
166        let count = to_remove.len();
167        for id in to_remove {
168            self.sessions.remove(&id);
169        }
170        count
171    }
172
173    /// Remove expired sessions (called periodically).
174    pub fn cleanup_expired(&mut self) -> usize {
175        let now = Instant::now();
176        let config = self.config.clone();
177        let before = self.sessions.len();
178        self.sessions.retain(|_, s| {
179            now < s.absolute_expiry && now.duration_since(s.last_activity) <= config.idle_timeout
180        });
181        before - self.sessions.len()
182    }
183
184    /// Get session config (for JWT TTL).
185    pub const fn session_config(&self) -> &SessionConfig {
186        &self.config
187    }
188}
189
190// ---------------------------------------------------------------------------
191// User summary (safe to return via API, no password hash)
192// ---------------------------------------------------------------------------
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct UserSummary {
196    pub id: String,
197    pub username: String,
198    pub display_name: String,
199    pub email: String,
200    pub role: String,
201    pub disabled: bool,
202    pub created_at: DateTime<Utc>,
203}
204
205// ---------------------------------------------------------------------------
206// Password hashing (argon2)
207// ---------------------------------------------------------------------------
208
209pub fn hash_password(password: &str) -> Result<String, String> {
210    use argon2::password_hash::rand_core::OsRng;
211    use argon2::password_hash::SaltString;
212    use argon2::{Argon2, PasswordHasher};
213
214    let salt = SaltString::generate(&mut OsRng);
215    let argon2 = Argon2::default(); // Argon2id with safe defaults
216
217    argon2
218        .hash_password(password.as_bytes(), &salt)
219        .map(|h| h.to_string())
220        .map_err(|e| format!("Password hashing failed: {e}"))
221}
222
223pub fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
224    use argon2::password_hash::PasswordHash;
225    use argon2::{Argon2, PasswordVerifier};
226
227    let parsed_hash = PasswordHash::new(hash).map_err(|e| format!("Invalid password hash: {e}"))?;
228
229    Ok(Argon2::default()
230        .verify_password(password.as_bytes(), &parsed_hash)
231        .is_ok())
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_session_lifecycle() {
244        let mut mgr = SessionManager::new(SessionConfig::default());
245        let session = mgr.create_session("user-1", "bob", "operator");
246        assert!(!session.session_id.is_empty());
247
248        // Validate session
249        assert!(mgr.validate_session(&session.session_id).is_some());
250
251        // Revoke session
252        assert!(mgr.revoke_session(&session.session_id));
253        assert!(mgr.validate_session(&session.session_id).is_none());
254    }
255
256    #[test]
257    fn test_max_parallel_sessions() {
258        let config = SessionConfig {
259            max_parallel_sessions: 2,
260            ..Default::default()
261        };
262        let mut mgr = SessionManager::new(config);
263
264        let s1 = mgr.create_session("user-1", "carol", "viewer");
265        let s2 = mgr.create_session("user-1", "carol", "viewer");
266        let s3 = mgr.create_session("user-1", "carol", "viewer"); // should evict s1
267
268        // s1 should be evicted (oldest)
269        assert!(mgr.validate_session(&s1.session_id).is_none());
270        assert!(mgr.validate_session(&s2.session_id).is_some());
271        assert!(mgr.validate_session(&s3.session_id).is_some());
272    }
273
274    #[test]
275    fn test_revoke_all_user_sessions() {
276        let mut mgr = SessionManager::new(SessionConfig::default());
277        mgr.create_session("user-1", "alice", "admin");
278        mgr.create_session("user-1", "alice", "admin");
279        mgr.create_session("user-1", "alice", "admin");
280
281        let revoked = mgr.revoke_all_user_sessions("user-1");
282        assert_eq!(revoked, 3);
283    }
284
285    #[test]
286    fn test_password_hash_and_verify() {
287        let hash = hash_password("password123").unwrap();
288        assert!(verify_password("password123", &hash).unwrap());
289        assert!(!verify_password("wrong", &hash).unwrap());
290    }
291
292    #[test]
293    fn test_short_password_still_hashes() {
294        // Validation of password length is the caller's responsibility
295        let hash = hash_password("short").unwrap();
296        assert!(verify_password("short", &hash).unwrap());
297    }
298}