312 lines
11 KiB
Rust
312 lines
11 KiB
Rust
//! Render the AuthForge pam-configs profile and run `pam-auth-update --package`.
|
|
//!
|
|
//! Approach: write `/usr/share/pam-configs/authforge` then invoke the
|
|
//! pam-auth-update tool. On failure, restore any prior profile and re-run.
|
|
//! Both the profile path and the tool path are configurable via env vars
|
|
//! so unit tests can exercise the renderer without root.
|
|
|
|
use authforge_common::policy::Policy;
|
|
use authforge_common::types::Mode;
|
|
use std::path::{Path, PathBuf};
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub(crate) enum ApplyError {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("pam-auth-update failed (exit {code:?}): {stderr}")]
|
|
PamAuthUpdate { code: Option<i32>, stderr: String },
|
|
}
|
|
|
|
pub(crate) struct PolicyApplier {
|
|
profile_path: PathBuf,
|
|
pam_auth_update: PathBuf,
|
|
}
|
|
|
|
impl PolicyApplier {
|
|
pub fn new(profile_path: PathBuf, pam_auth_update: PathBuf) -> Self {
|
|
Self {
|
|
profile_path,
|
|
pam_auth_update,
|
|
}
|
|
}
|
|
|
|
/// Render + write profile, then call `pam-auth-update --package`. On
|
|
/// failure, restore the prior contents (if any) and re-run; the second
|
|
/// run's failure becomes the user-visible error.
|
|
pub fn apply(&self, p: &Policy) -> Result<(), ApplyError> {
|
|
let stash = read_to_option(&self.profile_path)?;
|
|
let body = render_profile(p);
|
|
write_profile(&self.profile_path, &body)?;
|
|
match run_pam_auth_update(&self.pam_auth_update) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
match stash {
|
|
Some(prev) => write_profile(&self.profile_path, &prev)?,
|
|
None => {
|
|
let _ = std::fs::remove_file(&self.profile_path);
|
|
}
|
|
}
|
|
let _ = run_pam_auth_update(&self.pam_auth_update);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_to_option(p: &Path) -> Result<Option<String>, std::io::Error> {
|
|
match std::fs::read_to_string(p) {
|
|
Ok(s) => Ok(Some(s)),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn write_profile(p: &Path, body: &str) -> Result<(), std::io::Error> {
|
|
if let Some(parent) = p.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(p, body)
|
|
}
|
|
|
|
fn run_pam_auth_update(bin: &Path) -> Result<(), ApplyError> {
|
|
let out = std::process::Command::new(bin).arg("--package").output()?;
|
|
if out.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(ApplyError::PamAuthUpdate {
|
|
code: out.status.code(),
|
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Build the `/usr/share/pam-configs/authforge` body. The profile pulls in
|
|
/// pam_u2f for stacks the policy marks Required + fido2; pam_authforge_pending
|
|
/// is always present as a backstop for the first-login flag (its default
|
|
/// behavior is PAM_IGNORE so it costs nothing when no flag exists).
|
|
pub(crate) fn render_profile(p: &Policy) -> String {
|
|
let any_required_fido2 = p.stacks.values().any(|s| {
|
|
s.mode == Mode::Required
|
|
&& s.methods
|
|
.iter()
|
|
.any(|m| matches!(m, authforge_common::types::Method::Fido2))
|
|
});
|
|
|
|
#[cfg(feature = "totp")]
|
|
let any_required_totp = p.stacks.values().any(|s| {
|
|
s.mode == Mode::Required
|
|
&& s.methods
|
|
.iter()
|
|
.any(|m| matches!(m, authforge_common::types::Method::Totp))
|
|
});
|
|
|
|
// Recovery line runs first on every login attempt: PAM_IGNORE when
|
|
// there's no recovery file (cheap stat), PAM_SUCCESS via [success=done]
|
|
// when the user typed a valid recovery code (short-circuits the rest of
|
|
// the auth stack), PAM_IGNORE otherwise. `default=ignore` keeps a
|
|
// missing recovery file from blocking normal auth.
|
|
let mut lines: Vec<String> = Vec::new();
|
|
lines.push(
|
|
" [success=done default=ignore] pam_authforge_pending.so mode=recovery"
|
|
.to_string(),
|
|
);
|
|
|
|
// TOTP slot (when present): runs between recovery and FIDO2. die-on-fail
|
|
// so a wrong code is fatal without falling through to other methods.
|
|
#[cfg(feature = "totp")]
|
|
if any_required_totp {
|
|
lines.push(
|
|
" [success=ok default=die] pam_google_authenticator.so secret=/etc/google-authenticator/${USER}"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
if any_required_fido2 {
|
|
lines.push(
|
|
" [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings"
|
|
.to_string(),
|
|
);
|
|
lines.push(
|
|
" [success=ok default=die] pam_authforge_pending.so".to_string(),
|
|
);
|
|
} else {
|
|
// Policy doesn't require fido2 anywhere — only the pending-flag
|
|
// backstop is active. pam_u2f is left to the admin's own stack edits.
|
|
lines.push(
|
|
" [success=ok default=die] pam_authforge_pending.so".to_string(),
|
|
);
|
|
}
|
|
|
|
let auth_lines = lines.join("\n");
|
|
|
|
// `Default: yes` whenever any factor is required; auto-enable the profile
|
|
// so admins don't have to run pam-auth-update manually.
|
|
let default = if any_required_fido2 {
|
|
"yes"
|
|
} else {
|
|
#[cfg(feature = "totp")]
|
|
{
|
|
if any_required_totp {
|
|
"yes"
|
|
} else {
|
|
"no"
|
|
}
|
|
}
|
|
#[cfg(not(feature = "totp"))]
|
|
{
|
|
"no"
|
|
}
|
|
};
|
|
|
|
format!(
|
|
"Name: Dangerous Things authforge MFA\nDefault: {default}\nPriority: 192\nAuth-Type: Additional\nAuth:\n{auth_lines}\n"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use authforge_common::policy::StackPolicy;
|
|
use authforge_common::types::Method;
|
|
use std::collections::BTreeMap;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn render_no_required_fido2_default_no() {
|
|
let p = Policy::default();
|
|
let body = render_profile(&p);
|
|
assert!(body.contains("Default: no"));
|
|
assert!(body.contains("pam_authforge_pending.so"));
|
|
assert!(!body.contains("pam_u2f.so"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_always_includes_recovery_line_before_other_modules() {
|
|
let body = render_profile(&Policy::default());
|
|
let recovery_idx = body.find("mode=recovery").expect("recovery line present");
|
|
let backstop_idx = body
|
|
.rfind("pam_authforge_pending.so")
|
|
.expect("default backstop present");
|
|
// Recovery line lives BEFORE the default-mode backstop in the rendered profile.
|
|
assert!(
|
|
recovery_idx < backstop_idx,
|
|
"recovery line should precede the default backstop"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_required_fido2_includes_pam_u2f_default_yes() {
|
|
let mut stacks = BTreeMap::new();
|
|
stacks.insert(
|
|
"sudo".to_string(),
|
|
StackPolicy {
|
|
mode: Mode::Required,
|
|
methods: vec![Method::Fido2],
|
|
},
|
|
);
|
|
let p = Policy {
|
|
stacks,
|
|
..Default::default()
|
|
};
|
|
let body = render_profile(&p);
|
|
assert!(body.contains("Default: yes"));
|
|
assert!(body.contains("pam_u2f.so"));
|
|
assert!(body.contains("pam_authforge_pending.so"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_writes_profile_and_runs_tool() {
|
|
let d = tempdir().unwrap();
|
|
let profile = d.path().join("authforge");
|
|
// Fake pam-auth-update: a sh script that always succeeds.
|
|
let fake = d.path().join("fake-pau.sh");
|
|
std::fs::write(&fake, "#!/bin/sh\nexit 0\n").unwrap();
|
|
std::os::unix::fs::PermissionsExt::set_mode(
|
|
&mut std::fs::metadata(&fake).unwrap().permissions(),
|
|
0o755,
|
|
);
|
|
// The above doesn't actually persist permissions — set them via fs::set_permissions.
|
|
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
|
|
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
|
std::fs::set_permissions(&fake, perms).unwrap();
|
|
|
|
let applier = PolicyApplier {
|
|
profile_path: profile.clone(),
|
|
pam_auth_update: fake,
|
|
};
|
|
applier.apply(&Policy::default()).unwrap();
|
|
let body = std::fs::read_to_string(&profile).unwrap();
|
|
assert!(body.starts_with("Name: Dangerous Things authforge MFA"));
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
#[test]
|
|
fn render_required_totp_includes_pam_google_authenticator() {
|
|
let mut stacks = BTreeMap::new();
|
|
stacks.insert(
|
|
"sudo".to_string(),
|
|
StackPolicy {
|
|
mode: Mode::Required,
|
|
methods: vec![Method::Totp],
|
|
},
|
|
);
|
|
let p = Policy {
|
|
stacks,
|
|
..Default::default()
|
|
};
|
|
let body = render_profile(&p);
|
|
assert!(body.contains("pam_google_authenticator.so"));
|
|
assert!(body.contains("secret=/etc/google-authenticator/${USER}"));
|
|
assert!(body.contains("Default: yes"));
|
|
// TOTP line lives after recovery, before fido2 backstop.
|
|
let recovery_idx = body.find("mode=recovery").unwrap();
|
|
let totp_idx = body.find("pam_google_authenticator.so").unwrap();
|
|
assert!(recovery_idx < totp_idx);
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
#[test]
|
|
fn render_no_totp_when_only_fido2_required() {
|
|
let mut stacks = BTreeMap::new();
|
|
stacks.insert(
|
|
"sudo".to_string(),
|
|
StackPolicy {
|
|
mode: Mode::Required,
|
|
methods: vec![Method::Fido2],
|
|
},
|
|
);
|
|
let p = Policy {
|
|
stacks,
|
|
..Default::default()
|
|
};
|
|
let body = render_profile(&p);
|
|
assert!(!body.contains("pam_google_authenticator.so"));
|
|
assert!(body.contains("pam_u2f.so"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_rolls_back_on_failure() {
|
|
let d = tempdir().unwrap();
|
|
let profile = d.path().join("authforge");
|
|
std::fs::write(&profile, "PRIOR CONTENT\n").unwrap();
|
|
|
|
// Failing pam-auth-update.
|
|
let fake = d.path().join("fail-pau.sh");
|
|
std::fs::write(&fake, "#!/bin/sh\nexit 1\n").unwrap();
|
|
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
|
|
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
|
std::fs::set_permissions(&fake, perms).unwrap();
|
|
|
|
let applier = PolicyApplier {
|
|
profile_path: profile.clone(),
|
|
pam_auth_update: fake,
|
|
};
|
|
let r = applier.apply(&Policy::default());
|
|
assert!(r.is_err());
|
|
// Profile rolled back to prior content.
|
|
let body = std::fs::read_to_string(&profile).unwrap();
|
|
assert_eq!(body, "PRIOR CONTENT\n");
|
|
}
|
|
}
|