diff --git a/daemon/src/policy_apply.rs b/daemon/src/policy_apply.rs index cc903fe..14bc295 100644 --- a/daemon/src/policy_apply.rs +++ b/daemon/src/policy_apply.rs @@ -93,27 +93,72 @@ pub(crate) fn render_profile(p: &Policy) -> String { .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 recovery_line = - " [success=done default=ignore] pam_authforge_pending.so mode=recovery"; + let mut lines: Vec = Vec::new(); + lines.push( + " [success=done default=ignore] pam_authforge_pending.so mode=recovery" + .to_string(), + ); - let main_lines = if any_required_fido2 { - // Per design doc § pam-auth-update profile. - " [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings\n [success=ok default=die] pam_authforge_pending.so".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. - " [success=ok default=die] pam_authforge_pending.so".to_string() + 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" + } }; - let auth_lines = format!("{recovery_line}\n{main_lines}"); - - let default = if any_required_fido2 { "yes" } else { "no" }; - format!( "Name: Dangerous Things authforge MFA\nDefault: {default}\nPriority: 192\nAuth-Type: Additional\nAuth:\n{auth_lines}\n" ) @@ -195,6 +240,51 @@ mod tests { 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();