pacsea/logic/
faillock.rs

1//! Faillock status checking and configuration parsing.
2
3use std::process::Command;
4
5/// What: Faillock status information for a user.
6///
7/// Inputs: None (constructed from faillock command output).
8///
9/// Output: Status information about failed login attempts.
10///
11/// Details:
12/// - Contains the number of failed attempts, maximum allowed attempts,
13/// - whether the account is locked, and the lockout duration in minutes.
14#[derive(Debug, Clone)]
15pub struct FaillockStatus {
16    /// Number of failed attempts currently recorded.
17    pub attempts_used: u32,
18    /// Maximum number of failed attempts before lockout.
19    pub max_attempts: u32,
20    /// Whether the account is currently locked.
21    pub is_locked: bool,
22    /// Lockout duration in minutes.
23    pub lockout_duration_minutes: u32,
24    /// Timestamp of the last failed attempt (if any).
25    pub last_failed_timestamp: Option<std::time::SystemTime>,
26}
27
28/// What: Faillock configuration values.
29///
30/// Inputs: None (parsed from `/etc/security/faillock.conf`).
31///
32/// Output: Configuration values for faillock behavior.
33///
34/// Details:
35/// - Contains the deny count (max attempts) and fail interval (lockout duration).
36#[derive(Debug, Clone)]
37pub struct FaillockConfig {
38    /// Maximum number of failed attempts before lockout (deny setting).
39    pub deny: u32,
40    /// Lockout duration in minutes (`fail_interval` setting).
41    pub fail_interval: u32,
42}
43
44/// What: Check faillock status for a user.
45///
46/// Inputs:
47/// - `username`: Username to check faillock status for.
48///
49/// Output:
50/// - `Ok(FaillockStatus)` with status information, or `Err(String)` on error.
51///
52/// # Errors
53///
54/// - Returns `Err` if the `faillock` command cannot be executed.
55///
56/// Details:
57/// - Executes `faillock --user <username>` command.
58/// - Parses output to count lines with "V" (valid attempts).
59/// - Compares with max attempts from config to determine if locked.
60/// - Returns status with attempt count, max attempts, lock status, and lockout duration.
61pub fn check_faillock_status(username: &str) -> Result<FaillockStatus, String> {
62    // Get config first to determine max attempts and lockout duration
63    let config = parse_faillock_config().unwrap_or(FaillockConfig {
64        deny: 3,
65        fail_interval: 15,
66    });
67
68    // Execute faillock command
69    let output = Command::new("faillock")
70        .args(["--user", username])
71        .output()
72        .map_err(|e| format!("Failed to execute faillock command: {e}"))?;
73
74    if !output.status.success() {
75        // If command fails, assume no lockout (might not be configured)
76        return Ok(FaillockStatus {
77            attempts_used: 0,
78            max_attempts: config.deny,
79            is_locked: false,
80            lockout_duration_minutes: config.fail_interval,
81            last_failed_timestamp: None,
82        });
83    }
84
85    let output_str = String::from_utf8_lossy(&output.stdout);
86    let lines: Vec<&str> = output_str.lines().collect();
87
88    // Count lines with "V" (valid attempts) - skip header line
89    // Also track the most recent failed attempt timestamp (the one that triggered lockout)
90    let mut attempts_used = 0u32;
91    let mut in_user_section = false;
92    let mut seen_header = false;
93    let mut most_recent_timestamp: Option<std::time::SystemTime> = None;
94
95    for line in lines {
96        let trimmed = line.trim();
97        if trimmed.is_empty() {
98            continue;
99        }
100
101        // Check if this is the username header line (format: "username:")
102        if trimmed.ends_with(':') && trimmed.trim_end_matches(':') == username {
103            in_user_section = true;
104            seen_header = false; // Reset header flag for this user section
105            continue;
106        }
107
108        // If we're in the user section, look for lines with "V"
109        if in_user_section {
110            // Skip the header line that contains "When", "Type", "Source", "Valid"
111            // This line also contains "V" but is not an attempt
112            if !seen_header
113                && trimmed.contains("When")
114                && trimmed.contains("Type")
115                && trimmed.contains("Source")
116                && trimmed.contains("Valid")
117            {
118                seen_header = true;
119                continue;
120            }
121
122            // Check if line contains "V" (valid attempt marker)
123            // Format is typically: "YYYY-MM-DD HH:MM:SS TTY /dev/pts/X V"
124            // Must be a date-like line (starts with YYYY-MM-DD format)
125            if (trimmed.contains(" V") || trimmed.ends_with('V'))
126                && trimmed.chars().take(4).all(|c| c.is_ascii_digit())
127            {
128                attempts_used += 1;
129                // Parse timestamp from the line (format: "YYYY-MM-DD HH:MM:SS")
130                // Extract first 19 characters which should be "YYYY-MM-DD HH:MM:SS"
131                if trimmed.len() >= 19 {
132                    let timestamp_str = &trimmed[0..19];
133                    if let Ok(dt) =
134                        chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S")
135                    {
136                        // Faillock timestamps are in local time, so we need to convert them properly
137                        // First, assume the timestamp is in local timezone
138                        let local_dt = dt.and_local_timezone(chrono::Local);
139                        // Get the single valid timezone conversion (or use UTC as fallback)
140                        let dt_utc = local_dt.single().map_or_else(
141                            || dt.and_utc(),
142                            |dt_local| dt_local.with_timezone(&chrono::Utc),
143                        );
144                        // Convert chrono DateTime to SystemTime
145                        let unix_timestamp = dt_utc.timestamp();
146                        if unix_timestamp >= 0
147                            && let Some(st) = std::time::SystemTime::UNIX_EPOCH.checked_add(
148                                std::time::Duration::from_secs(
149                                    u64::try_from(unix_timestamp).unwrap_or(0),
150                                ),
151                            )
152                        {
153                            // Keep the most recent timestamp (faillock shows oldest first, so last one is newest)
154                            most_recent_timestamp = Some(st);
155                        }
156                    }
157                }
158            }
159        }
160    }
161
162    // Check if user should be locked based on attempts
163    let should_be_locked = attempts_used >= config.deny;
164
165    // If locked, check if lockout has expired based on timestamp
166    let is_locked = if should_be_locked {
167        most_recent_timestamp.map_or(should_be_locked, |last_timestamp| {
168            // Check if lockout duration has passed since last failed attempt
169            let now = std::time::SystemTime::now();
170            now.duration_since(last_timestamp).map_or(true, |elapsed| {
171                let lockout_seconds = u64::from(config.fail_interval) * 60;
172                // If elapsed time is less than lockout duration, user is still locked
173                elapsed.as_secs() < lockout_seconds
174            })
175        })
176    } else {
177        false
178    };
179
180    Ok(FaillockStatus {
181        attempts_used,
182        max_attempts: config.deny,
183        is_locked,
184        lockout_duration_minutes: config.fail_interval,
185        last_failed_timestamp: most_recent_timestamp,
186    })
187}
188
189/// What: Parse faillock configuration from `/etc/security/faillock.conf`.
190///
191/// Inputs: None (reads from system config file).
192///
193/// Output:
194/// - `Ok(FaillockConfig)` with parsed values, or `Err(String)` on error.
195///
196/// # Errors
197///
198/// - Returns `Err` if the config file cannot be read (though defaults are used in practice).
199///
200/// Details:
201/// - Reads `/etc/security/faillock.conf`.
202/// - Parses `deny` setting (default 3 if commented out).
203/// - Parses `fail_interval` setting (default 15 minutes if commented out).
204/// - Handles comments (lines starting with `#`) and whitespace.
205pub fn parse_faillock_config() -> Result<FaillockConfig, String> {
206    use std::fs;
207
208    let config_path = "/etc/security/faillock.conf";
209    let Ok(contents) = fs::read_to_string(config_path) else {
210        // File doesn't exist or can't be read, use defaults
211        return Ok(FaillockConfig {
212            deny: 3,
213            fail_interval: 15,
214        });
215    };
216
217    let mut deny = 3u32; // Default
218    let mut fail_interval = 15u32; // Default in minutes
219
220    for line in contents.lines() {
221        let trimmed = line.trim();
222
223        // Skip empty lines and full-line comments
224        if trimmed.is_empty() || trimmed.starts_with('#') {
225            continue;
226        }
227
228        // Handle inline comments
229        let line_without_comment = trimmed.split('#').next().unwrap_or("").trim();
230
231        // Parse deny setting
232        if line_without_comment.starts_with("deny")
233            && let Some(value_str) = line_without_comment.split('=').nth(1)
234        {
235            let value_trimmed = value_str.trim();
236            if let Ok(value) = value_trimmed.parse::<u32>() {
237                deny = value;
238            }
239        }
240
241        // Parse fail_interval setting
242        if line_without_comment.starts_with("fail_interval")
243            && let Some(value_str) = line_without_comment.split('=').nth(1)
244        {
245            let value_trimmed = value_str.trim();
246            if let Ok(value) = value_trimmed.parse::<u32>() {
247                fail_interval = value;
248            }
249        }
250    }
251
252    Ok(FaillockConfig {
253        deny,
254        fail_interval,
255    })
256}
257
258/// What: Check if user is locked out and return lockout message if so.
259///
260/// Inputs:
261/// - `username`: Username to check.
262/// - `app`: Application state for translations.
263///
264/// Output:
265/// - `Some(message)` if user is locked out, `None` otherwise.
266///
267/// Details:
268/// - Checks faillock status and returns formatted lockout message if locked.
269/// - Returns `None` if not locked or if check fails.
270/// - Uses translations from `AppState`.
271#[must_use]
272pub fn get_lockout_message_if_locked(
273    username: &str,
274    app: &crate::state::AppState,
275) -> Option<String> {
276    if let Ok(status) = check_faillock_status(username)
277        && status.is_locked
278    {
279        return Some(crate::i18n::t_fmt(
280            app,
281            "app.modals.alert.account_locked_with_time",
282            &[
283                &username as &dyn std::fmt::Display,
284                &status.lockout_duration_minutes,
285            ],
286        ));
287    }
288    None
289}
290
291/// What: Calculate remaining lockout time in minutes based on last failed attempt timestamp.
292///
293/// Inputs:
294/// - `last_timestamp`: Timestamp of the last failed attempt.
295/// - `lockout_duration_minutes`: Total lockout duration in minutes.
296///
297/// Output:
298/// - `Some(minutes)` if still locked out, `None` if lockout has expired.
299///
300/// Details:
301/// - Calculates time elapsed since last failed attempt.
302/// - Returns remaining minutes if lockout is still active, `None` if expired.
303#[must_use]
304pub fn calculate_remaining_lockout_minutes(
305    last_timestamp: &std::time::SystemTime,
306    lockout_duration_minutes: u32,
307) -> Option<u32> {
308    let now = std::time::SystemTime::now();
309    now.duration_since(*last_timestamp)
310        .map_or(Some(lockout_duration_minutes), |elapsed| {
311            let lockout_seconds = u64::from(lockout_duration_minutes) * 60;
312            if elapsed.as_secs() < lockout_seconds {
313                let remaining_seconds = lockout_seconds - elapsed.as_secs();
314                let remaining_minutes =
315                    (remaining_seconds / 60) + u64::from(remaining_seconds % 60 > 0);
316                Some(u32::try_from(remaining_minutes.min(u64::from(u32::MAX))).unwrap_or(u32::MAX))
317            } else {
318                None // Lockout expired
319            }
320        })
321}
322
323/// What: Check faillock status and calculate lockout information for display.
324///
325/// Inputs:
326/// - `username`: Username to check.
327///
328/// Output:
329/// - Tuple of `(is_locked, lockout_until, remaining_minutes)`.
330///
331/// Details:
332/// - Checks faillock status and calculates remaining lockout time if locked.
333/// - Returns lockout information for UI display.
334#[must_use]
335pub fn get_lockout_info(username: &str) -> (bool, Option<std::time::SystemTime>, Option<u32>) {
336    if let Ok(status) = check_faillock_status(username)
337        && status.is_locked
338    {
339        if let Some(last_timestamp) = status.last_failed_timestamp {
340            let remaining = calculate_remaining_lockout_minutes(
341                &last_timestamp,
342                status.lockout_duration_minutes,
343            );
344            // Calculate lockout_until timestamp
345            let lockout_until = last_timestamp
346                + std::time::Duration::from_secs(u64::from(status.lockout_duration_minutes) * 60);
347            // Return remaining time - if None, it means lockout expired, show 0
348            // But if timestamp is in future (timezone issue), remaining should be Some
349            return (true, Some(lockout_until), remaining);
350        }
351        // Locked but no timestamp - still show as locked but no time remaining
352        return (true, None, Some(0));
353    }
354    (false, None, None)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::fs;
361    use std::io::Write;
362
363    #[test]
364    /// What: Test parsing faillock config with defaults.
365    ///
366    /// Inputs:
367    /// - Config file that doesn't exist or has commented settings.
368    ///
369    /// Output:
370    /// - Returns default values (deny=3, `fail_interval=15`).
371    ///
372    /// Details:
373    /// - Verifies default values are used when config is missing or commented.
374    fn test_parse_faillock_config_defaults() {
375        // This test may fail if the file exists, but that's okay
376        // The function should handle missing files gracefully
377        let _config = parse_faillock_config();
378        // Just verify it doesn't panic
379    }
380
381    #[test]
382    /// What: Test parsing faillock config with custom values.
383    ///
384    /// Inputs:
385    /// - Temporary config file with custom deny and `fail_interval` values.
386    ///
387    /// Output:
388    /// - Returns parsed values from config file.
389    ///
390    /// Details:
391    /// - Creates a temporary config file and verifies parsing works correctly.
392    fn test_parse_faillock_config_custom_values() {
393        use std::env::temp_dir;
394        let temp_file = temp_dir().join("test_faillock.conf");
395        let content = "deny = 5\nfail_interval = 30\n";
396        if let Ok(mut file) = fs::File::create(&temp_file) {
397            let _ = file.write_all(content.as_bytes());
398            // Note: We can't easily test this without mocking file reading
399            // Just verify the function doesn't panic
400            let _config = parse_faillock_config();
401            let _ = fs::remove_file(&temp_file);
402        }
403    }
404
405    #[test]
406    /// What: Test parsing faillock config with comments.
407    ///
408    /// Inputs:
409    /// - Config file with commented lines and inline comments.
410    ///
411    /// Output:
412    /// - Parses values correctly, ignoring comments.
413    ///
414    /// Details:
415    /// - Verifies that comments (both full-line and inline) are handled correctly.
416    fn test_parse_faillock_config_with_comments() {
417        // The function should handle comments correctly
418        // Since we can't easily mock file reading, just verify it doesn't panic
419        let _config = parse_faillock_config();
420    }
421
422    #[test]
423    /// What: Test faillock status checking handles errors gracefully.
424    ///
425    /// Inputs:
426    /// - Username that may or may not have faillock entries.
427    ///
428    /// Output:
429    /// - Returns status without panicking.
430    ///
431    /// Details:
432    /// - Verifies the function handles various error cases.
433    fn test_check_faillock_status_handles_errors() {
434        let username = std::env::var("USER").unwrap_or_else(|_| "testuser".to_string());
435        let result = check_faillock_status(&username);
436        // Should return Ok or handle errors gracefully
437        if let Ok(status) = result {
438            // Verify status has reasonable values
439            assert!(status.max_attempts > 0);
440            assert!(status.lockout_duration_minutes > 0);
441        } else {
442            // Error is acceptable (e.g., faillock not configured)
443        }
444    }
445
446    #[test]
447    /// What: Test faillock status structure.
448    ///
449    /// Inputs:
450    /// - Username.
451    ///
452    /// Output:
453    /// - Returns status with all fields populated.
454    ///
455    /// Details:
456    /// - Verifies that the status struct contains all expected fields.
457    fn test_faillock_status_structure() {
458        let username = std::env::var("USER").unwrap_or_else(|_| "testuser".to_string());
459        if let Ok(status) = check_faillock_status(&username) {
460            // Verify all fields are present
461            let _ = status.attempts_used;
462            let _ = status.max_attempts;
463            let _ = status.is_locked;
464            let _ = status.lockout_duration_minutes;
465            // Just verify the struct can be accessed
466        }
467    }
468}