feat: advance login workflow and integration gates

This commit is contained in:
2026-09-26 01:18:57 +05:30
parent 71de5bc6cd
commit 9492dbd7c9
16 changed files with 1471 additions and 151 deletions

View File

@@ -6,7 +6,7 @@ use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use crate::{
@@ -29,6 +29,87 @@ pub struct Identity {
pub shell: PathBuf,
}
/// Effective account state determined from the login shell and shadow aging data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
/// Account can be offered for login.
Active,
/// Password is disabled by the shadow lock marker.
Locked,
/// Password must be changed before login can continue.
PasswordExpired,
/// Account expiration date has elapsed.
AccountExpired,
/// Password inactivity period has elapsed.
AccountInactive,
/// Login shell explicitly disallows interactive sessions.
NoLoginShell,
/// Shadow data was not available or could not be parsed.
Unknown,
}
/// Non-sensitive password and account aging information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatus {
/// Effective state used by the chooser and diagnostics.
pub state: AccountState,
/// Day the password was last changed, if present.
pub password_changed_day: Option<i64>,
/// Day the password expires, if the policy specifies one.
pub password_expiry_day: Option<i64>,
/// Day the account expires, if specified.
pub account_expiry_day: Option<i64>,
/// Day after which an expired password disables the account.
pub inactive_after_day: Option<i64>,
}
impl AccountStatus {
/// Returns whether this account may be presented as an active login choice.
pub const fn is_login_allowed(self) -> bool {
matches!(self.state, AccountState::Active)
}
}
/// An identity paired with its non-sensitive account policy state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
/// Discovered NSS identity.
pub identity: Identity,
/// Current local account status.
pub status: AccountStatus,
}
/// A currently logged-in process obtained from UTMPX.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSession {
/// Login name associated with the session.
pub username: String,
/// UTMPX line, such as `tty2` or `pts/0`.
pub terminal: String,
/// Session leader PID when reported by UTMPX.
pub pid: libc::pid_t,
/// Remote host, if supplied by the session producer.
pub host: Option<String>,
}
/// Runtime session-management backend detected on this host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend {
/// systemd-logind runtime markers are available.
SystemdLogind,
/// UTMPX is available as a conservative fallback.
Utmpx,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShadowEntry {
hash: String,
last_change: Option<i64>,
max_days: Option<i64>,
inactive_days: Option<i64>,
account_expiry: Option<i64>,
}
/// A non-authoritative account lockout summary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
@@ -81,6 +162,96 @@ pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
accounts
}
/// Discovers identities and attaches account-aging state when the shadow
/// database is readable. A missing or inaccessible shadow file yields
/// [`AccountState::Unknown`] rather than hiding the account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
discover(config)
.into_iter()
.map(|identity| {
let status = inspect_account(&identity, config).unwrap_or(AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
});
AccountRecord { identity, status }
})
.collect()
}
/// Inspects password and account aging fields without returning password
/// hashes. The configured shadow path must be root-owned and non-writable.
pub fn inspect_account(
identity: &Identity,
config: &AccountsConfig,
) -> Result<AccountStatus, Error> {
if !trusted_shadow(&config.shadow_path) {
return Err(Error::Config("shadow_path is not a trusted file".into()));
}
let content = std::fs::read_to_string(&config.shadow_path)
.io_context(format!("read {}", config.shadow_path.display()))?;
let entry = content.lines().find_map(|line| {
let (username, _) = line.split_once(':')?;
(username == identity.username)
.then(|| parse_shadow_entry(line))
.flatten()
});
let day = unix_day();
Ok(account_status_from_shadow(identity, entry, day))
}
/// Returns all active UTMPX user-process records in deterministic order.
pub fn active_sessions() -> Vec<ActiveSession> {
let mut sessions = Vec::new();
unsafe {
libc::setutxent();
loop {
let entry = libc::getutxent();
if entry.is_null() {
break;
}
let record = &*entry;
if record.ut_type != libc::USER_PROCESS {
continue;
}
let Some(username) = utmp_field(&record.ut_user) else {
continue;
};
let Some(terminal) = utmp_field(&record.ut_line) else {
continue;
};
sessions.push(ActiveSession {
username,
terminal,
pid: record.ut_pid,
host: utmp_field(&record.ut_host),
});
}
libc::endutxent();
}
sessions.sort_by(|left, right| {
left.username
.cmp(&right.username)
.then_with(|| left.terminal.cmp(&right.terminal))
.then_with(|| left.pid.cmp(&right.pid))
});
sessions
}
/// Detects whether systemd-logind has initialized its runtime seat state.
pub fn session_backend() -> SessionBackend {
if logind_markers_present(
Path::new("/run/systemd/system").is_dir(),
Path::new("/run/systemd/seats").is_dir(),
) {
SessionBackend::SystemdLogind
} else {
SessionBackend::Utmpx
}
}
/// Runs the configured helper and parses its stable numeric output.
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path;
@@ -116,16 +287,26 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
if !output.status.success() {
return Ok(FaillockStatus::default());
}
let failures = parse_failure_count(&output.stdout);
let max_tries = read_deny(&config.faillock_config);
Ok(faillock_status_from_output(&output.stdout, max_tries))
}
/// Converts stable helper output into an advisory lockout status.
///
/// This parser accepts both the legacy `count timestamp` format and modern
/// rows containing a username prefix or timestamp columns. It intentionally
/// treats malformed output as zero failures instead of making authentication
/// fail open or fail closed.
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
let failures = parse_failure_count(output);
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
Ok(FaillockStatus {
FaillockStatus {
available: true,
failures,
max_tries,
remaining,
locked_out: remaining.is_some_and(|value| value == 0),
})
}
}
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
@@ -165,11 +346,49 @@ fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
}
fn parse_failure_count(bytes: &[u8]) -> u32 {
String::from_utf8_lossy(bytes)
.lines()
.filter_map(|line| line.split_whitespace().next())
.find_map(|value| value.parse::<u32>().ok())
.unwrap_or_default()
let mut rows = 0u32;
for line in String::from_utf8_lossy(bytes).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((_, count)) = line.split_once(':')
&& let Some(value) = count.split_whitespace().next()
&& let Ok(parsed) = value.parse::<u32>()
{
return parsed;
}
let mut fields = line.split_whitespace();
let Some(first) = fields.next() else {
continue;
};
if let Ok(parsed) = first.parse::<u32>() {
return parsed;
}
if is_date(first) && fields.next().is_some_and(is_time) {
rows = rows.saturating_add(1);
}
}
rows
}
fn is_date(value: &str) -> bool {
let mut fields = value.split('-');
fields
.next()
.is_some_and(|year| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|month| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|day| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()))
&& fields.next().is_none()
}
fn is_time(value: &str) -> bool {
let mut fields = value.split(':');
fields.all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit()))
}
fn read_deny(path: &Path) -> Option<u32> {
@@ -185,6 +404,118 @@ fn read_deny(path: &Path) -> Option<u32> {
})
}
fn trusted_shadow(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0
}
fn parse_shadow_entry(line: &str) -> Option<ShadowEntry> {
let mut fields = line.split(':');
let username = fields.next()?;
if username.is_empty() {
return None;
}
let hash = fields.next()?.to_owned();
let last_change = parse_shadow_day(fields.next()?);
let _minimum_days = parse_shadow_day(fields.next()?);
let max_days = parse_shadow_day(fields.next()?);
let _warning_days = parse_shadow_day(fields.next()?);
let inactive_days = parse_shadow_day(fields.next()?);
let account_expiry = parse_shadow_day(fields.next()?);
let _reserved = fields.next()?;
Some(ShadowEntry {
hash,
last_change,
max_days,
inactive_days,
account_expiry,
})
}
fn parse_shadow_day(value: &str) -> Option<i64> {
(!value.is_empty() && value != "-1")
.then(|| value.parse::<i64>().ok())
.flatten()
.filter(|day| *day >= 0)
}
fn shadow_hash_is_locked(hash: &str) -> bool {
hash.is_empty() || hash.starts_with('!') || hash.starts_with('*')
}
fn account_status_from_shadow(
identity: &Identity,
entry: Option<ShadowEntry>,
today: i64,
) -> AccountStatus {
let Some(entry) = entry else {
return AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
};
};
let password_expiry_day = entry
.last_change
.zip(entry.max_days)
.and_then(|(changed, max)| changed.checked_add(max));
let inactive_after_day = password_expiry_day
.zip(entry.inactive_days)
.and_then(|(expiry, inactive)| expiry.checked_add(inactive));
let state = if !identity.shell.is_absolute()
|| identity.shell == Path::new("/bin/false")
|| identity.shell == Path::new("/usr/sbin/nologin")
|| identity.shell == Path::new("/sbin/nologin")
{
AccountState::NoLoginShell
} else if entry.account_expiry.is_some_and(|expiry| expiry <= today) {
AccountState::AccountExpired
} else if shadow_hash_is_locked(&entry.hash) {
AccountState::Locked
} else if entry.last_change == Some(0)
|| password_expiry_day.is_some_and(|expiry| expiry <= today)
{
AccountState::PasswordExpired
} else if inactive_after_day.is_some_and(|expiry| expiry <= today) {
AccountState::AccountInactive
} else {
AccountState::Active
};
AccountStatus {
state,
password_changed_day: entry.last_change,
password_expiry_day,
account_expiry_day: entry.account_expiry,
inactive_after_day,
}
}
fn unix_day() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| (duration.as_secs() / 86_400) as i64)
.unwrap_or_default()
}
fn utmp_field<const N: usize>(field: &[libc::c_char; N]) -> Option<String> {
let length = field.iter().position(|byte| *byte == 0).unwrap_or(N);
if length == 0 {
return None;
}
let bytes: Vec<u8> = field[..length].iter().map(|byte| *byte as u8).collect();
String::from_utf8(bytes)
.ok()
.filter(|value| !value.is_empty())
}
fn logind_markers_present(system_dir: bool, seats_dir: bool) -> bool {
system_dir && seats_dir
}
fn trusted_helper(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
@@ -199,6 +530,56 @@ fn trusted_helper(path: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn parses_faillock_rows_with_username_and_timestamp_prefixes() {
assert_eq!(parse_failure_count(b"alice: 4\n"), 4);
assert_eq!(
parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"),
2
);
}
#[test]
fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() {
let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry");
assert_eq!(entry.last_change, Some(0));
assert_eq!(entry.max_days, Some(30));
assert_eq!(entry.inactive_days, Some(14));
assert_eq!(entry.account_expiry, Some(20_000));
assert!(shadow_hash_is_locked("!$6$hash"));
assert!(!shadow_hash_is_locked("$6$hash"));
}
#[test]
fn account_state_prioritises_non_login_shell_and_expiry() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/usr/sbin/nologin".into(),
};
let status = account_status_from_shadow(
&identity,
Some(ShadowEntry {
hash: "*".into(),
last_change: Some(1),
max_days: Some(1),
inactive_days: Some(1),
account_expiry: Some(1),
}),
20_000,
);
assert_eq!(status.state, AccountState::NoLoginShell);
}
#[test]
fn detects_systemd_logind_only_when_runtime_markers_exist() {
assert!(logind_markers_present(true, true));
assert!(!logind_markers_present(true, false));
assert!(!logind_markers_present(false, true));
}
#[test]
fn parses_legacy_failure_count() {
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);