feat(cli): authforgectl totp enroll/status/revoke

This commit is contained in:
michael
2026-04-27 12:12:02 -07:00
parent 8060364973
commit 334fa5e20b
3 changed files with 107 additions and 1 deletions

View File

@@ -4,7 +4,9 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use authforge_common::policy::Policy; use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary}; use authforge_common::types::{
Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary, TotpEnrollment,
};
const BUS_NAME: &str = "io.dangerousthings.AuthForge"; const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
@@ -64,4 +66,16 @@ impl Daemon {
pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> { pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?) Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
} }
pub async fn enroll_totp(&self, user: &str) -> Result<TotpEnrollment> {
Ok(self.proxy.call("EnrollTotp", &(user,)).await?)
}
pub async fn is_totp_enrolled(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("IsTotpEnrolled", &(user,)).await?)
}
pub async fn revoke_totp(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("RevokeTotp", &(user,)).await?)
}
} }

View File

@@ -241,6 +241,52 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
std::process::exit(1); std::process::exit(1);
} }
} }
super::Cmd::Totp {
cmd: super::TotpCmd::Enroll { user },
} => {
let e = d.enroll_totp(&user).await?;
if json {
println!("{}", serde_json::to_string_pretty(&e)?);
} else {
println!("secret: {}", e.secret_b32);
println!("uri: {}", e.otpauth_uri);
println!();
println!("Scan the URI as a QR or enter the secret in your authenticator app.");
}
}
super::Cmd::Totp {
cmd: super::TotpCmd::Status { user },
} => {
let enrolled = d.is_totp_enrolled(&user).await?;
if json {
println!(
"{}",
serde_json::json!({ "user": user, "enrolled": enrolled })
);
} else if enrolled {
println!("{user}: enrolled");
} else {
println!("{user}: not enrolled");
}
}
super::Cmd::Totp {
cmd: super::TotpCmd::Revoke { user },
} => {
let removed = d.revoke_totp(&user).await?;
if removed {
if !json {
println!("revoked TOTP for {user}");
}
} else {
if !json {
eprintln!("not enrolled: {user}");
}
std::process::exit(1);
}
}
} }
Ok(()) Ok(())
} }

View File

@@ -56,6 +56,11 @@ enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: RecoveryCmd, cmd: RecoveryCmd,
}, },
/// Manage TOTP enrollments.
Totp {
#[command(subcommand)]
cmd: TotpCmd,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -99,6 +104,16 @@ enum RecoveryCmd {
Revoke { user: String }, Revoke { user: String },
} }
#[derive(Subcommand)]
enum TotpCmd {
/// Generate a fresh TOTP secret for a user.
Enroll { user: String },
/// Show whether a user has a TOTP secret on file.
Status { user: String },
/// Remove a user's TOTP enrollment.
Revoke { user: String },
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -197,4 +212,35 @@ mod tests {
_ => panic!("wrong subcommand"), _ => panic!("wrong subcommand"),
} }
} }
#[test]
fn parse_totp_enroll() {
let c = Cli::try_parse_from(["authforgectl", "totp", "enroll", "alice"]).unwrap();
match c.cmd {
Cmd::Totp {
cmd: TotpCmd::Enroll { user },
} => assert_eq!(user, "alice"),
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parse_totp_status_and_revoke() {
assert!(matches!(
Cli::try_parse_from(["authforgectl", "totp", "status", "alice"])
.unwrap()
.cmd,
Cmd::Totp {
cmd: TotpCmd::Status { .. }
}
));
assert!(matches!(
Cli::try_parse_from(["authforgectl", "totp", "revoke", "alice"])
.unwrap()
.cmd,
Cmd::Totp {
cmd: TotpCmd::Revoke { .. }
}
));
}
} }