From 334fa5e20b6be3c6e3e6c9653856077c329db1e2 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 12:12:02 -0700 Subject: [PATCH] feat(cli): authforgectl totp enroll/status/revoke --- cli/src/bus.rs | 16 +++++++++++++- cli/src/commands/mod.rs | 46 +++++++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 46 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/cli/src/bus.rs b/cli/src/bus.rs index b831579..7eafbdb 100644 --- a/cli/src/bus.rs +++ b/cli/src/bus.rs @@ -4,7 +4,9 @@ use anyhow::{Context, Result}; 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 OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; @@ -64,4 +66,16 @@ impl Daemon { pub async fn revoke_recovery_code(&self, user: &str) -> Result { Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?) } + + pub async fn enroll_totp(&self, user: &str) -> Result { + Ok(self.proxy.call("EnrollTotp", &(user,)).await?) + } + + pub async fn is_totp_enrolled(&self, user: &str) -> Result { + Ok(self.proxy.call("IsTotpEnrolled", &(user,)).await?) + } + + pub async fn revoke_totp(&self, user: &str) -> Result { + Ok(self.proxy.call("RevokeTotp", &(user,)).await?) + } } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 492f1bf..0eeeeaa 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -241,6 +241,52 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> { 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(()) } diff --git a/cli/src/main.rs b/cli/src/main.rs index 5c31a1a..1850e6c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -56,6 +56,11 @@ enum Cmd { #[command(subcommand)] cmd: RecoveryCmd, }, + /// Manage TOTP enrollments. + Totp { + #[command(subcommand)] + cmd: TotpCmd, + }, } #[derive(Subcommand)] @@ -99,6 +104,16 @@ enum RecoveryCmd { 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] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -197,4 +212,35 @@ mod tests { _ => 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 { .. } + } + )); + } }