1use std::io::{BufRead, IsTerminal};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::cli::CliError;
21use crate::cli::render;
22use crate::cli::window::{DEFAULT_LIMIT, Window};
23use acme_proxy_admin::admin;
24use acme_proxy_admin::admin::changes::{self, OperatorTrail};
25use acme_proxy_admin::admin::mfa;
26use acme_proxy_admin::admin::password::PasswordContext;
27use acme_proxy_admin::admin::prompt::confirm;
28use acme_proxy_admin::admin::users;
29use acme_proxy_admin::admin::users::UserDeleteOutcome;
30use acme_proxy_admin::admin::users::UserError;
31use acme_proxy_core::config::Config;
32use acme_proxy_core::palette::Palette;
33use acme_proxy_jobs::auditor::admin as audit_admin;
34use acme_proxy_jobs::auditor::admin::SessionScope;
35use acme_proxy_store::admin_session::AdminSession;
36use acme_proxy_store::admin_user::AdminRole;
37use acme_proxy_store::admin_user::AdminStatus;
38use acme_proxy_store::admin_user::AdminUser;
39use acme_proxy_store::db::Database;
40
41#[derive(Subcommand)]
42pub enum AdminCommand {
43 User {
45 #[command(subcommand)]
46 command: AdminUserCommand,
47 },
48 Session {
50 #[command(subcommand)]
51 command: AdminSessionCommand,
52 },
53}
54
55#[derive(Subcommand)]
56pub enum AdminUserCommand {
57 Create {
60 username: String,
62 #[arg(long = "password-file")]
65 password_file: Option<PathBuf>,
66 #[arg(long, default_value = "admin")]
70 role: String,
71 #[arg(long)]
76 contact: Option<String>,
77 },
78 List {
80 #[arg(long, default_value_t = DEFAULT_LIMIT)]
82 limit: i64,
83 #[arg(long, default_value_t = 0)]
85 offset: i64,
86 #[arg(long)]
88 json: bool,
89 },
90 Show {
92 username: String,
94 #[arg(long)]
96 json: bool,
97 },
98 Passwd {
100 username: String,
102 #[arg(long = "password-file")]
105 password_file: Option<PathBuf>,
106 },
107 Role {
109 username: String,
111 role: String,
113 },
114 Contact {
117 username: String,
119 #[arg(long)]
121 contact: Option<String>,
122 },
123 Delete {
125 username: String,
127 },
128 Disable {
130 username: String,
132 },
133 Enable {
135 username: String,
137 },
138 Totp {
140 #[command(subcommand)]
141 command: AdminUserTotpCommand,
142 },
143}
144
145#[derive(Subcommand)]
154pub enum AdminUserTotpCommand {
155 Status {
158 username: String,
160 #[arg(long)]
162 json: bool,
163 },
164 Reset {
170 username: String,
172 },
173 RecoveryCodes {
176 username: String,
178 },
179}
180
181#[derive(Subcommand)]
182pub enum AdminSessionCommand {
183 List {
185 #[arg(long)]
187 user: Option<String>,
188 #[arg(long, default_value_t = DEFAULT_LIMIT)]
190 limit: i64,
191 #[arg(long, default_value_t = 0)]
193 offset: i64,
194 #[arg(long)]
196 json: bool,
197 },
198 Revoke {
200 #[arg(long, conflicts_with = "all")]
202 user: Option<String>,
203 #[arg(long, conflicts_with = "user")]
205 all: bool,
206 #[arg(long, requires = "user", conflicts_with = "all")]
208 session: Option<String>,
209 },
210}
211
212pub async fn run_admin_command(
213 command: AdminCommand,
214 yes: bool,
215 palette: Palette,
216 reader: &mut impl BufRead,
217 config: &Config,
218 database: Arc<Database>,
219) -> Result<(), CliError> {
220 match command {
221 AdminCommand::User { command } => {
222 run_user_command(command, yes, palette, reader, config, database).await
223 }
224 AdminCommand::Session { command } => run_session_command(command, palette, database).await,
225 }
226}
227
228async fn run_user_command(
229 command: AdminUserCommand,
230 yes: bool,
231 palette: Palette,
232 reader: &mut impl BufRead,
233 config: &Config,
234 database: Arc<Database>,
235) -> Result<(), CliError> {
236 match command {
237 AdminUserCommand::Create {
238 username,
239 password_file,
240 role,
241 contact,
242 } => {
243 let role = super::parse_value::<AdminRole>("--role", &role)?;
244 let password = read_password(password_file.as_deref(), reader)?;
245 let context = PasswordContext::from_config(config, &username);
246 let user =
250 users::create_user(&username, &password, &context, Some(role), database.clone())
251 .await
252 .map_err(user_error)?;
253 audit_admin::record_cli_action(&database, |actor, client| {
257 audit_admin::operator_created(actor, client, &user.username, role.as_str())
258 })
259 .await;
260 if let Some(contact) = contact.as_deref() {
261 users::set_contact_email(&user.username, Some(contact), database.clone())
262 .await
263 .map_err(user_error)?
264 .ok_or_else(|| not_found(&user.username))?;
265 audit_admin::record_cli_action(&database, |actor, client| {
270 audit_admin::operator_contact_updated(actor, client, &user.username, true)
271 })
272 .await;
273 }
274 println!(
276 "Created admin user {} ({}), role {role}.",
277 user.username, user.id
278 );
279 }
280 AdminUserCommand::Contact { username, contact } => {
281 let trail = CliTrail::new(config, &database);
282 match changes::change_contact(&username, contact.as_deref(), database.clone(), &trail)
283 .await
284 .map_err(user_error)?
285 {
286 None => return Err(not_found(&username)),
287 Some((user, _changed)) => match user.contact_email {
288 Some(address) => {
289 println!("Contact address for {} set to {address}.", user.username)
290 }
291 None => println!("Contact address for {} cleared.", user.username),
292 },
293 }
294 }
295 AdminUserCommand::Role { username, role } => {
296 let role: AdminRole = role
297 .parse()
298 .map_err(|error| CliError::bad_request(format!("role: {error}")))?;
299 let trail = CliTrail::new(config, &database);
300 match changes::change_role(&username, role, database.clone(), &trail)
301 .await
302 .map_err(user_error)?
303 {
304 None => return Err(not_found(&username)),
305 Some((user, revoked)) => {
306 println!(
307 "Role of {} set to {role}. Every session they held was revoked ({revoked}).",
308 user.username
309 );
310 }
311 }
312 }
313 AdminUserCommand::List {
314 limit,
315 offset,
316 json,
317 } => {
318 let window = Window::resolve(limit, offset);
319 let (users, total) = users::list_users(window.limit, window.offset, database).await?;
320 render::print_page(
321 &users,
322 total,
323 window,
324 json,
325 admin::render_admin_user_json,
326 |user| render::render_admin_user_line(user, palette),
327 );
328 }
329 AdminUserCommand::Show { username, json } => {
330 let user = find_user(&username, database.clone()).await?;
335 let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
336
337 if json {
338 println!("{}", admin::render_admin_user_detail_json(&user, remaining));
339 } else {
340 print!(
341 "{}",
342 render::render_admin_user_detail_text(&user, remaining, palette)
343 );
344 }
345 }
346 AdminUserCommand::Passwd {
347 username,
348 password_file,
349 } => {
350 let password = read_password(password_file.as_deref(), reader)?;
351 let context = PasswordContext::from_config(config, &username);
352 match users::set_password(&username, &password, &context, database.clone())
353 .await
354 .map_err(user_error)?
355 {
356 None => return Err(not_found(&username)),
357 Some((user, revoked)) => {
358 audit_admin::record_cli_action(&database, |actor, client| {
359 audit_admin::operator_password_changed(actor, client, &user.username, false)
360 })
361 .await;
362 notify_credential_change(
363 config,
364 &database,
365 &user,
366 acme_proxy_jobs::notify::AdminCredentialChange::Password,
367 None,
368 )
369 .await;
370 if revoked > 0 {
373 revoked_sessions_row(
374 SessionScope::AllOf(user.username.clone()),
375 revoked,
376 &database,
377 )
378 .await;
379 }
380 println!(
381 "Password changed for {}. Every session they held was revoked ({revoked}).",
382 user.username
383 );
384 }
385 }
386 }
387 AdminUserCommand::Delete { username } => {
388 match users::confirm_delete_user(&username, yes, reader, database.clone()).await? {
389 UserDeleteOutcome::NotFound => return Err(not_found(&username)),
390 UserDeleteOutcome::Cancelled => println!("Cancelled."),
391 UserDeleteOutcome::Deleted(_) => {
392 audit_admin::record_cli_action(&database, |actor, client| {
393 audit_admin::operator_deleted(actor, client, &username)
394 })
395 .await;
396 println!("Deleted admin user {username}.");
397 }
398 }
399 }
400 AdminUserCommand::Disable { username } => {
401 set_status_or_not_found(&username, AdminStatus::Disabled, config, database).await?;
405 println!("Disabled {username}. Their sessions were revoked.");
406 }
407 AdminUserCommand::Enable { username } => {
408 set_status_or_not_found(&username, AdminStatus::Active, config, database).await?;
409 println!("Enabled {username}.");
410 }
411 AdminUserCommand::Totp { command } => {
412 run_totp_command(command, yes, palette, reader, config, database).await?;
413 }
414 }
415 Ok(())
416}
417
418async fn run_totp_command(
419 command: AdminUserTotpCommand,
420 yes: bool,
421 palette: Palette,
422 reader: &mut impl BufRead,
423 config: &Config,
424 database: Arc<Database>,
425) -> Result<(), CliError> {
426 match command {
427 AdminUserTotpCommand::Status { username, json } => {
428 let user = find_user(&username, database.clone()).await?;
429 let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
430
431 if json {
432 println!(
433 "{}",
434 serde_json::json!({
435 "username": user.username,
436 "totpEnabled": user.has_totp(),
437 "enrolmentPending": user.has_pending_totp(),
438 "recoveryCodesRemaining": remaining,
439 })
440 );
441 } else {
442 println!(
443 "{}",
444 render::render_admin_totp_line(&user, remaining, palette)
445 );
446 }
447 }
448 AdminUserTotpCommand::Reset { username } => {
449 let mut user = find_user(&username, database.clone()).await?;
450 if !user.has_totp() && !user.has_pending_totp() {
451 println!("{} has no second factor; nothing to reset.", user.username);
452 return Ok(());
453 }
454
455 let prompt = format!(
456 "Remove the second factor and every recovery code for {}, \
457 and revoke their sessions?",
458 user.username
459 );
460 if !confirm(&prompt, yes, reader) {
461 println!("Cancelled.");
462 return Ok(());
463 }
464
465 let trail = CliTrail::new(config, &database);
468 changes::reset_totp(&mut user, database.clone(), &trail).await?;
469 println!(
470 "Removed the second factor for {}. Their sessions were revoked; \
471 they can sign in with a password alone until they enrol again.",
472 user.username
473 );
474 }
475 AdminUserTotpCommand::RecoveryCodes { username } => {
476 let user = find_user(&username, database.clone()).await?;
477 if !user.has_totp() {
478 return Err(CliError::bad_request(format!(
479 "{} has no second factor, so recovery codes would recover nothing: \
480 enrol from the panel first",
481 user.username
482 )));
483 }
484
485 let codes = mfa::regenerate_recovery_codes(&user, database.clone()).await?;
486 audit_admin::record_cli_action(&database, |actor, client| {
487 audit_admin::operator_recovery_codes_regenerated(actor, client, &user.username)
488 })
489 .await;
490 notify_credential_change(
491 config,
492 &database,
493 &user,
494 acme_proxy_jobs::notify::AdminCredentialChange::RecoveryCodesRegenerated,
495 None,
496 )
497 .await;
498 println!(
501 "New recovery codes for {} — the previous set no longer works.\n\
502 Store these now; they are not recoverable.\n",
503 user.username
504 );
505 for code in &codes {
506 println!(" {code}");
507 }
508 }
509 }
510 Ok(())
511}
512
513async fn notify_credential_change(
525 config: &Config,
526 database: &Arc<Database>,
527 user: &AdminUser,
528 change: acme_proxy_jobs::notify::AdminCredentialChange,
529 previous_recipient: Option<String>,
530) {
531 if !config.admin.enabled {
532 return;
533 }
534 let Ok(egress) = acme_proxy_net::egress::Egress::from_config(config) else {
535 return;
536 };
537 let jobs = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
538 let Ok(dispatcher) = acme_proxy_jobs::notify::from_config(
539 acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
540 &config.admin.notify,
541 egress.outbound(),
542 &jobs,
543 ) else {
544 return;
545 };
546 dispatcher
547 .dispatch(
548 acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(
549 acme_proxy_jobs::notify::AdminCredentialChangeData::new(
550 user,
551 change,
552 false,
553 None,
554 None,
555 previous_recipient,
556 ),
557 ),
558 )
559 .await;
560}
561
562async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
563 AdminUser::find_by_username(username, &database)
564 .await?
565 .ok_or_else(|| not_found(username))
566}
567
568async fn run_session_command(
569 command: AdminSessionCommand,
570 palette: Palette,
571 database: Arc<Database>,
572) -> Result<(), CliError> {
573 match command {
574 AdminSessionCommand::List {
575 user,
576 limit,
577 offset,
578 json,
579 } => {
580 let user_id = match user.as_deref() {
584 None => None,
585 Some(name) => match AdminUser::find_by_username(name, &database).await? {
586 None => return Err(not_found(name)),
587 Some(user) => Some(user.id),
588 },
589 };
590
591 let window = Window::resolve(limit, offset);
592 let (sessions, total) =
593 AdminSession::search(user_id, window.limit, window.offset, &database).await?;
594 render::print_page(
595 &sessions,
596 total,
597 window,
598 json,
599 admin::render_admin_session_json,
600 |session| render::render_admin_session_line(session, palette),
601 );
602 }
603 AdminSessionCommand::Revoke { user, all, session } => match (user, all, session) {
604 (Some(username), _, Some(fp)) => {
605 let Some(user) = AdminUser::find_by_username(&username, &database).await? else {
606 return Err(not_found(&username));
607 };
608 match AdminSession::find_by_user_and_fingerprint(user.id, &fp, &database).await? {
609 None => {
610 return Err(CliError::bad_request(format!(
611 "no such session for {username}: {fp}"
612 )));
613 }
614 Some(target) => {
615 AdminSession::delete(&target.token_hash, &database).await?;
616 revoked_sessions_row(SessionScope::OneOf(username.clone()), 1, &database)
617 .await;
618 println!("Revoked session {fp} for {username}.");
619 }
620 }
621 }
622 (Some(username), _, None) => {
623 match users::revoke_sessions(&username, database.clone()).await? {
624 None => return Err(not_found(&username)),
625 Some(count) => {
626 revoked_sessions_row(
627 SessionScope::AllOf(username.clone()),
628 count,
629 &database,
630 )
631 .await;
632 println!("Revoked {count} session(s) for {username}.");
633 }
634 }
635 }
636 (None, true, _) => {
637 let count = AdminSession::delete_all(&database).await?;
638 revoked_sessions_row(SessionScope::Everyone, count, &database).await;
639 println!("Revoked {count} session(s).");
640 }
641 (None, false, _) => {
642 return Err(CliError::bad_request(
643 "say whose sessions to revoke: --user <username> (optionally --session <id>), \
644 or --all"
645 .to_string(),
646 ));
647 }
648 },
649 }
650 Ok(())
651}
652
653async fn revoked_sessions_row(scope: SessionScope, count: u64, database: &Database) {
662 audit_admin::record_cli_action(database, |actor, client| {
663 audit_admin::session_revoked(actor, client, scope, count)
664 })
665 .await;
666}
667
668async fn set_status_or_not_found(
669 username: &str,
670 status: AdminStatus,
671 config: &Config,
672 database: Arc<Database>,
673) -> Result<(), CliError> {
674 let trail = CliTrail::new(config, &database);
675 match changes::change_status(username, status, database.clone(), &trail).await? {
676 Some(_) => Ok(()),
677 None => Err(not_found(username)),
678 }
679}
680
681struct CliTrail<'a> {
685 config: &'a Config,
686 database: &'a Arc<Database>,
687}
688
689impl<'a> CliTrail<'a> {
690 fn new(config: &'a Config, database: &'a Arc<Database>) -> Self {
691 Self { config, database }
692 }
693}
694
695impl OperatorTrail for CliTrail<'_> {
696 async fn record(
697 &self,
698 build: impl FnOnce(
699 acme_proxy_core::audit::Actor,
700 acme_proxy_core::audit::ClientContext,
701 ) -> acme_proxy_core::audit::AuditRecord
702 + Send,
703 ) {
704 audit_admin::record_cli_action(self.database, build).await;
705 }
706
707 async fn notify(
708 &self,
709 user: &AdminUser,
710 change: acme_proxy_jobs::notify::AdminCredentialChange,
711 previous_recipient: Option<String>,
712 ) {
713 notify_credential_change(self.config, self.database, user, change, previous_recipient)
714 .await;
715 }
716}
717
718fn read_password(
724 path: Option<&std::path::Path>,
725 reader: &mut impl BufRead,
726) -> Result<String, CliError> {
727 match path {
728 Some(path) => {
729 let raw = std::fs::read_to_string(path).map_err(|error| {
730 CliError::failed(format!("cannot read {}: {error}", path.display()))
731 })?;
732 Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
733 }
734 None => {
735 if std::io::stdin().is_terminal() {
739 eprintln!(
740 "Note: the password will be echoed. Use --password-file, or pipe it in:\n \
741 printf '%s' \"$password\" | acme-proxy admin user create <username>"
742 );
743 }
744 eprintln!("Enter the password, then press Enter:");
745 let mut line = String::new();
746 match reader.read_line(&mut line) {
752 Ok(0) => return Err(CliError::bad_request("no password supplied".to_string())),
753 Ok(_) => {}
754 Err(error) => {
755 return Err(CliError::failed(format!(
756 "cannot read the password from stdin: {error}"
757 )));
758 }
759 }
760 let password = line.strip_suffix('\n').unwrap_or(&line);
763 let password = password.strip_suffix('\r').unwrap_or(password);
764 Ok(password.to_string())
765 }
766 }
767}
768
769fn user_error(error: UserError) -> CliError {
770 match error {
771 UserError::Database(error) => CliError::from(error),
772 UserError::DuplicateUsername(_) | UserError::Policy(_) | UserError::InvalidContact(_) => {
775 CliError::bad_request(error.to_string())
776 }
777 }
778}
779
780fn not_found(username: &str) -> CliError {
781 CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Operator.missing(username))
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787 use crate::cli::CliErrorKind;
788 use acme_proxy_core::testutil::TempDir;
789 use acme_proxy_store::admin_session::NewSession;
790
791 const GOOD: &str = "a-long-enough-password";
792
793 async fn db() -> Arc<Database> {
794 Arc::new(Database::connect_in_memory().await.unwrap())
795 }
796
797 async fn run(
800 command: AdminCommand,
801 input: &str,
802 database: Arc<Database>,
803 ) -> Result<(), CliError> {
804 run_with_config(command, input, &Config::default(), database).await
805 }
806
807 async fn run_with_config(
810 command: AdminCommand,
811 input: &str,
812 config: &Config,
813 database: Arc<Database>,
814 ) -> Result<(), CliError> {
815 let mut reader = input.as_bytes();
816 run_admin_command(
817 command,
818 true,
819 Palette::plain(),
820 &mut reader,
821 config,
822 database,
823 )
824 .await
825 }
826
827 fn create(username: &str) -> AdminCommand {
828 create_with_role(username, "admin")
829 }
830
831 fn create_with_role(username: &str, role: &str) -> AdminCommand {
832 create_full(username, role, None)
833 }
834
835 fn create_full(username: &str, role: &str, contact: Option<&str>) -> AdminCommand {
836 AdminCommand::User {
837 command: AdminUserCommand::Create {
838 username: username.to_string(),
839 password_file: None,
840 role: role.to_string(),
841 contact: contact.map(str::to_string),
842 },
843 }
844 }
845
846 #[tokio::test]
847 async fn create_reads_the_password_from_stdin() {
848 let db = db().await;
849 run(create("alice"), &format!("{GOOD}\n"), db.clone())
850 .await
851 .unwrap();
852
853 let user = AdminUser::find_by_username("alice", &db)
854 .await
855 .unwrap()
856 .unwrap();
857 assert!(user.is_active());
858 assert_eq!(
859 admin::password::verify_password(&user.password_hash, GOOD),
860 Ok(true)
861 );
862 }
863
864 #[tokio::test]
868 async fn the_operator_lifecycle_writes_cli_audit_rows() {
869 use acme_proxy_store::audit::AuditEntry;
870 use acme_proxy_store::audit::AuditQuery;
871
872 let db = db().await;
873 run(create("alice"), &format!("{GOOD}\n"), db.clone())
874 .await
875 .unwrap();
876 run(
877 AdminCommand::User {
878 command: AdminUserCommand::Disable {
879 username: "alice".to_string(),
880 },
881 },
882 "",
883 db.clone(),
884 )
885 .await
886 .unwrap();
887 run(
888 AdminCommand::Session {
889 command: AdminSessionCommand::Revoke {
890 user: None,
891 all: true,
892 session: None,
893 },
894 },
895 "",
896 db.clone(),
897 )
898 .await
899 .unwrap();
900
901 let (rows, _) = AuditEntry::search(
902 &AuditQuery {
903 limit: 50,
904 ..AuditQuery::default()
905 },
906 &db,
907 )
908 .await
909 .unwrap();
910 let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
911 assert!(events.contains(&"operator_created"), "{events:?}");
912 assert!(events.contains(&"operator_disabled"), "{events:?}");
913 assert!(events.contains(&"session_revoked"), "{events:?}");
914 for row in &rows {
915 assert_eq!(row.actor_kind, "cli");
916 assert_eq!(row.outcome, "success");
917 assert_eq!(
918 row.profile, "",
919 "an operator action is not scoped to a profile"
920 );
921 }
922 }
923
924 #[tokio::test]
928 async fn a_role_change_with_no_session_writes_no_session_row() {
929 use acme_proxy_store::audit::AuditEntry;
930 use acme_proxy_store::audit::AuditQuery;
931
932 let db = db().await;
933 for name in ["alice", "root"] {
934 run(create(name), &format!("{GOOD}\n"), db.clone())
935 .await
936 .unwrap();
937 }
938 run(
939 AdminCommand::User {
940 command: AdminUserCommand::Role {
941 username: "alice".to_string(),
942 role: "operator".to_string(),
943 },
944 },
945 "",
946 db.clone(),
947 )
948 .await
949 .unwrap();
950
951 let (rows, _) = AuditEntry::search(
952 &AuditQuery {
953 limit: 50,
954 ..AuditQuery::default()
955 },
956 &db,
957 )
958 .await
959 .unwrap();
960 let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
961 assert!(events.contains(&"operator_role_changed"), "{events:?}");
962 assert!(!events.contains(&"session_revoked"), "{events:?}");
963 }
964
965 #[tokio::test]
966 async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
967 let dir = TempDir::new("admin-passwd");
968 let path = dir.join("pw");
969 std::fs::write(&path, format!("{GOOD}\n")).unwrap();
970
971 let db = db().await;
972 run(
973 AdminCommand::User {
974 command: AdminUserCommand::Create {
975 username: "alice".to_string(),
976 password_file: Some(path),
977 role: "admin".to_string(),
978 contact: None,
979 },
980 },
981 "",
982 db.clone(),
983 )
984 .await
985 .unwrap();
986
987 let user = AdminUser::find_by_username("alice", &db)
988 .await
989 .unwrap()
990 .unwrap();
991 assert_eq!(
992 admin::password::verify_password(&user.password_hash, GOOD),
993 Ok(true),
994 "the trailing newline must not be part of the password"
995 );
996 }
997
998 #[tokio::test]
999 async fn create_refuses_a_missing_password_file() {
1000 let db = db().await;
1001 let error = run(
1002 AdminCommand::User {
1003 command: AdminUserCommand::Create {
1004 username: "alice".to_string(),
1005 password_file: Some(PathBuf::from("/nonexistent/pw")),
1006 role: "admin".to_string(),
1007 contact: None,
1008 },
1009 },
1010 "",
1011 db,
1012 )
1013 .await
1014 .unwrap_err();
1015 assert!(error.message.starts_with("cannot read /nonexistent/pw"));
1016 }
1017
1018 #[tokio::test]
1023 async fn create_surfaces_the_context_and_corpus_rules_in_words() {
1024 let db = db().await;
1025
1026 let error = run(create("alice"), "passwordpassword\n", db.clone())
1027 .await
1028 .unwrap_err();
1029 assert!(
1030 error.message.contains("commonly used"),
1031 "got: {}",
1032 error.message
1033 );
1034 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1035
1036 let mut config = Config::default();
1037 config.server.base_url = "https://ca.contoso.example".to_string();
1038 let error = run_with_config(
1039 create("alice"),
1040 "contoso-is-my-password\n",
1041 &config,
1042 db.clone(),
1043 )
1044 .await
1045 .unwrap_err();
1046 assert!(error.message.contains("contoso"), "got: {}", error.message);
1047 assert!(
1048 error.message.contains("names this deployment"),
1049 "got: {}",
1050 error.message
1051 );
1052
1053 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
1055 }
1056
1057 #[tokio::test]
1058 async fn create_refuses_empty_stdin() {
1059 let db = db().await;
1060 let error = run(create("alice"), "", db).await.unwrap_err();
1061 assert_eq!(
1064 error,
1065 CliError::bad_request("no password supplied".to_string())
1066 );
1067 assert_eq!(error.exit_code(), 3);
1068 }
1069
1070 fn contact(username: &str, address: Option<&str>) -> AdminCommand {
1071 AdminCommand::User {
1072 command: AdminUserCommand::Contact {
1073 username: username.to_string(),
1074 contact: address.map(str::to_string),
1075 },
1076 }
1077 }
1078
1079 #[tokio::test]
1080 async fn create_with_contact_stores_the_address() {
1081 let db = db().await;
1082 run(
1083 create_full("alice", "admin", Some("alice@example.com")),
1084 &format!("{GOOD}\n"),
1085 db.clone(),
1086 )
1087 .await
1088 .unwrap();
1089 let user = AdminUser::find_by_username("alice", &db)
1090 .await
1091 .unwrap()
1092 .unwrap();
1093 assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
1094 }
1095
1096 #[tokio::test]
1097 async fn create_with_a_bad_contact_is_a_bad_request() {
1098 let db = db().await;
1099 let error = run(
1100 create_full("alice", "admin", Some("not an address")),
1101 &format!("{GOOD}\n"),
1102 db.clone(),
1103 )
1104 .await
1105 .unwrap_err();
1106 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1107 assert!(
1109 AdminUser::find_by_username("alice", &db)
1110 .await
1111 .unwrap()
1112 .is_some()
1113 );
1114 }
1115
1116 #[tokio::test]
1117 async fn contact_sets_clears_and_refuses_an_unknown_user() {
1118 let db = db().await;
1119 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1120 .await
1121 .unwrap();
1122
1123 run(contact("alice", Some("a@example.com")), "", db.clone())
1124 .await
1125 .unwrap();
1126 assert_eq!(
1127 AdminUser::find_by_username("alice", &db)
1128 .await
1129 .unwrap()
1130 .unwrap()
1131 .contact_email
1132 .as_deref(),
1133 Some("a@example.com")
1134 );
1135
1136 run(contact("alice", None), "", db.clone()).await.unwrap();
1137 assert_eq!(
1138 AdminUser::find_by_username("alice", &db)
1139 .await
1140 .unwrap()
1141 .unwrap()
1142 .contact_email,
1143 None
1144 );
1145
1146 let error = run(contact("nobody", Some("a@example.com")), "", db.clone())
1147 .await
1148 .unwrap_err();
1149 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1150
1151 let error = run(contact("alice", Some("bad address")), "", db)
1152 .await
1153 .unwrap_err();
1154 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1155 }
1156
1157 #[tokio::test]
1158 async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
1159 let db = db().await;
1160 let error = run(create("alice"), "short\n", db.clone())
1161 .await
1162 .unwrap_err();
1163 assert!(
1164 error.message.contains("at least 12"),
1165 "got: {}",
1166 error.message
1167 );
1168 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1169
1170 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1171 .await
1172 .unwrap();
1173 let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
1174 .await
1175 .unwrap_err();
1176 assert_eq!(
1177 error,
1178 CliError::bad_request("an admin user named `alice` already exists".to_string())
1179 );
1180 }
1181
1182 #[tokio::test]
1187 async fn a_host_credential_change_queues_its_notification() {
1188 use acme_proxy_store::job::Job;
1189
1190 let db = db().await;
1191 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1192 .await
1193 .unwrap();
1194 run(contact("alice", Some("alice@example.com")), "", db.clone())
1195 .await
1196 .unwrap();
1197 let passwd = || AdminCommand::User {
1198 command: AdminUserCommand::Passwd {
1199 username: "alice".to_string(),
1200 password_file: None,
1201 },
1202 };
1203 let queued = || async {
1204 Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
1205 .await
1206 .unwrap()
1207 };
1208
1209 run(passwd(), "another-long-password\n", db.clone())
1211 .await
1212 .unwrap();
1213 assert_eq!(queued().await, 0);
1214
1215 let mut config = Config::default();
1216 config.admin.enabled = true;
1217 config.admin.notify.enabled = vec!["custom".to_string()];
1218 config.admin.notify.custom_enabled = vec!["pager".to_string()];
1219 config.admin.notify.custom.insert(
1220 "pager".to_string(),
1221 acme_proxy_core::config::CustomNotifyConfig {
1222 script_path: "/bin/true".to_string(),
1223 ..acme_proxy_core::config::CustomNotifyConfig::default()
1224 },
1225 );
1226 run_with_config(passwd(), "yet-another-long-password\n", &config, db.clone())
1227 .await
1228 .unwrap();
1229 assert_eq!(queued().await, 1);
1230 }
1231
1232 #[tokio::test]
1233 async fn passwd_changes_the_password_and_reports_an_unknown_user() {
1234 let db = db().await;
1235 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1236 .await
1237 .unwrap();
1238
1239 let passwd = |username: &str| AdminCommand::User {
1240 command: AdminUserCommand::Passwd {
1241 username: username.to_string(),
1242 password_file: None,
1243 },
1244 };
1245
1246 run(passwd("alice"), "another-long-password\n", db.clone())
1247 .await
1248 .unwrap();
1249 let user = AdminUser::find_by_username("alice", &db)
1250 .await
1251 .unwrap()
1252 .unwrap();
1253 assert_eq!(
1254 admin::password::verify_password(&user.password_hash, "another-long-password"),
1255 Ok(true)
1256 );
1257
1258 let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
1259 .await
1260 .unwrap_err();
1261 assert_eq!(
1262 error,
1263 CliError::bad_request("no such operator: nobody".to_string())
1264 );
1265 }
1266
1267 #[tokio::test]
1271 async fn show_reports_the_row_and_the_second_factor() {
1272 let db = db().await;
1273 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1274 .await
1275 .unwrap();
1276
1277 let show = |username: &str, json: bool| AdminCommand::User {
1278 command: AdminUserCommand::Show {
1279 username: username.to_string(),
1280 json,
1281 },
1282 };
1283 for json in [true, false] {
1284 run(show("alice", json), "", db.clone()).await.unwrap();
1285 }
1286 assert_eq!(
1287 run(show("nobody", false), "", db.clone())
1288 .await
1289 .unwrap_err(),
1290 CliError::bad_request("no such operator: nobody".to_string())
1291 );
1292
1293 let user = AdminUser::find_by_username("alice", &db)
1298 .await
1299 .unwrap()
1300 .unwrap();
1301 let rendered = admin::render_admin_user_detail_json(&user, 0);
1302 assert_eq!(rendered["totpEnabled"], false);
1303 assert_eq!(rendered["enrolmentPending"], false);
1304 assert_eq!(rendered["recoveryCodesRemaining"], 0);
1305 let listed = admin::render_admin_user_json(&user);
1307 assert!(listed.get("enrolmentPending").is_none());
1308 assert!(listed.get("recoveryCodesRemaining").is_none());
1309
1310 let enrolled = enrol("alice", db.clone()).await;
1311 let rendered = admin::render_admin_user_detail_json(&enrolled, 10);
1312 assert_eq!(rendered["totpEnabled"], true);
1313 assert_eq!(rendered["recoveryCodesRemaining"], 10);
1314 run(show("alice", true), "", db).await.unwrap();
1315 }
1316
1317 #[tokio::test]
1321 async fn the_user_listing_pages_oldest_first() {
1322 let db = db().await;
1323 for name in ["alice", "bob", "carol"] {
1324 run(create(name), &format!("{GOOD}\n"), db.clone())
1325 .await
1326 .unwrap();
1327 }
1328
1329 let (first, total) = users::list_users(2, 0, db.clone()).await.unwrap();
1330 let (second, also_total) = users::list_users(2, 2, db.clone()).await.unwrap();
1331 assert_eq!((total, also_total), (3, 3), "the total is the table");
1332 let walked: Vec<&str> = first
1333 .iter()
1334 .chain(second.iter())
1335 .map(|user| user.username.as_str())
1336 .collect();
1337 assert_eq!(walked, ["alice", "bob", "carol"]);
1338
1339 for (limit, offset) in [(0, 0), (-5, -5)] {
1343 run(
1344 AdminCommand::User {
1345 command: AdminUserCommand::List {
1346 limit,
1347 offset,
1348 json: true,
1349 },
1350 },
1351 "",
1352 db.clone(),
1353 )
1354 .await
1355 .unwrap();
1356 }
1357 }
1358
1359 #[tokio::test]
1360 async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
1361 let db = db().await;
1362 for json in [true, false] {
1363 run(
1364 AdminCommand::User {
1365 command: AdminUserCommand::List {
1366 limit: DEFAULT_LIMIT,
1367 offset: 0,
1368 json,
1369 },
1370 },
1371 "",
1372 db.clone(),
1373 )
1374 .await
1375 .unwrap();
1376 }
1377
1378 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1379 .await
1380 .unwrap();
1381 for json in [true, false] {
1382 run(
1383 AdminCommand::User {
1384 command: AdminUserCommand::List {
1385 limit: DEFAULT_LIMIT,
1386 offset: 0,
1387 json,
1388 },
1389 },
1390 "",
1391 db.clone(),
1392 )
1393 .await
1394 .unwrap();
1395 }
1396 }
1397
1398 #[tokio::test]
1399 async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
1400 let db = db().await;
1401 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1402 .await
1403 .unwrap();
1404
1405 let disable = |username: &str| AdminCommand::User {
1406 command: AdminUserCommand::Disable {
1407 username: username.to_string(),
1408 },
1409 };
1410 let enable = |username: &str| AdminCommand::User {
1411 command: AdminUserCommand::Enable {
1412 username: username.to_string(),
1413 },
1414 };
1415
1416 run(disable("alice"), "", db.clone()).await.unwrap();
1417 assert!(
1418 !AdminUser::find_by_username("alice", &db)
1419 .await
1420 .unwrap()
1421 .unwrap()
1422 .is_active()
1423 );
1424
1425 run(enable("alice"), "", db.clone()).await.unwrap();
1426 assert!(
1427 AdminUser::find_by_username("alice", &db)
1428 .await
1429 .unwrap()
1430 .unwrap()
1431 .is_active()
1432 );
1433
1434 for command in [disable("nobody"), enable("nobody")] {
1435 assert_eq!(
1436 run(command, "", db.clone()).await.unwrap_err(),
1437 CliError::bad_request("no such operator: nobody".to_string())
1438 );
1439 }
1440 }
1441
1442 #[tokio::test]
1443 async fn create_writes_the_requested_role_and_refuses_an_unknown_one() {
1444 let db = db().await;
1445
1446 run(
1447 create_with_role("reader", "viewer"),
1448 &format!("{GOOD}\n"),
1449 db.clone(),
1450 )
1451 .await
1452 .unwrap();
1453 let user = AdminUser::find_by_username("reader", &db)
1454 .await
1455 .unwrap()
1456 .unwrap();
1457 assert_eq!(user.role(), AdminRole::Viewer);
1458
1459 run(create("boss"), &format!("{GOOD}\n"), db.clone())
1461 .await
1462 .unwrap();
1463 assert_eq!(
1464 AdminUser::find_by_username("boss", &db)
1465 .await
1466 .unwrap()
1467 .unwrap()
1468 .role(),
1469 AdminRole::Admin
1470 );
1471
1472 let error = run(
1473 create_with_role("nope", "supervisor"),
1474 &format!("{GOOD}\n"),
1475 db.clone(),
1476 )
1477 .await
1478 .unwrap_err();
1479 assert!(error.message.contains("--role"), "{}", error.message);
1480 assert!(error.message.contains("supervisor"), "{}", error.message);
1481 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1482 assert!(
1483 AdminUser::find_by_username("nope", &db)
1484 .await
1485 .unwrap()
1486 .is_none(),
1487 "a refused role must not create a row"
1488 );
1489 }
1490
1491 #[tokio::test]
1492 async fn role_changes_the_tier_revokes_sessions_and_refuses_an_unknown_user() {
1493 use acme_proxy_store::admin_session::AdminSession;
1494 use acme_proxy_store::admin_session::NewSession;
1495
1496 let db = db().await;
1497 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1498 .await
1499 .unwrap();
1500 run(create("root"), &format!("{GOOD}\n"), db.clone())
1503 .await
1504 .unwrap();
1505 let user = AdminUser::find_by_username("alice", &db)
1506 .await
1507 .unwrap()
1508 .unwrap();
1509 AdminSession::create(
1510 NewSession {
1511 user_id: user.id,
1512 token_hash: "hash",
1513 csrf_token: "csrf",
1514 created_ip: None,
1515 user_agent: None,
1516 },
1517 std::time::Duration::from_secs(60),
1518 &db,
1519 )
1520 .await
1521 .unwrap();
1522
1523 let role = |username: &str, role: &str| AdminCommand::User {
1524 command: AdminUserCommand::Role {
1525 username: username.to_string(),
1526 role: role.to_string(),
1527 },
1528 };
1529
1530 run(role("alice", "operator"), "", db.clone())
1531 .await
1532 .unwrap();
1533 assert_eq!(
1534 AdminUser::find_by_username("alice", &db)
1535 .await
1536 .unwrap()
1537 .unwrap()
1538 .role(),
1539 AdminRole::Operator
1540 );
1541 assert!(
1542 AdminSession::list_all(Some(user.id), &db)
1543 .await
1544 .unwrap()
1545 .is_empty(),
1546 "a role change revokes the operator's sessions"
1547 );
1548
1549 assert_eq!(
1550 run(role("nobody", "viewer"), "", db.clone())
1551 .await
1552 .unwrap_err(),
1553 CliError::bad_request("no such operator: nobody".to_string())
1554 );
1555
1556 let error = run(role("alice", "root"), "", db).await.unwrap_err();
1557 assert!(error.message.contains("role"), "{}", error.message);
1558 assert!(error.message.contains("root"), "{}", error.message);
1559 }
1560
1561 #[tokio::test]
1562 async fn delete_covers_not_found_cancelled_and_deleted() {
1563 let db = db().await;
1564 let delete = AdminCommand::User {
1565 command: AdminUserCommand::Delete {
1566 username: "alice".to_string(),
1567 },
1568 };
1569
1570 assert_eq!(
1571 run(
1572 AdminCommand::User {
1573 command: AdminUserCommand::Delete {
1574 username: "nobody".to_string()
1575 }
1576 },
1577 "",
1578 db.clone()
1579 )
1580 .await
1581 .unwrap_err(),
1582 CliError::bad_request("no such operator: nobody".to_string())
1583 );
1584
1585 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1586 .await
1587 .unwrap();
1588
1589 let mut no = b"n\n".as_slice();
1591 run_admin_command(
1592 AdminCommand::User {
1593 command: AdminUserCommand::Delete {
1594 username: "alice".to_string(),
1595 },
1596 },
1597 false,
1598 Palette::plain(),
1599 &mut no,
1600 &Config::default(),
1601 db.clone(),
1602 )
1603 .await
1604 .unwrap();
1605 assert!(
1606 AdminUser::find_by_username("alice", &db)
1607 .await
1608 .unwrap()
1609 .is_some()
1610 );
1611
1612 run(delete, "", db.clone()).await.unwrap();
1613 assert!(
1614 AdminUser::find_by_username("alice", &db)
1615 .await
1616 .unwrap()
1617 .is_none()
1618 );
1619 }
1620
1621 #[tokio::test]
1622 async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
1623 let db = db().await;
1624 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1625 .await
1626 .unwrap();
1627 let alice = AdminUser::find_by_username("alice", &db)
1628 .await
1629 .unwrap()
1630 .unwrap();
1631 AdminSession::create(
1632 NewSession {
1633 user_id: alice.id,
1634 token_hash: "hash-a",
1635 csrf_token: "csrf",
1636 created_ip: None,
1637 user_agent: None,
1638 },
1639 std::time::Duration::from_secs(60),
1640 &db,
1641 )
1642 .await
1643 .unwrap();
1644
1645 for (username, json) in [
1646 (None, true),
1647 (None, false),
1648 (Some("alice".to_string()), true),
1649 (Some("alice".to_string()), false),
1650 ] {
1651 run(
1652 AdminCommand::Session {
1653 command: AdminSessionCommand::List {
1654 user: username,
1655 limit: DEFAULT_LIMIT,
1656 offset: 0,
1657 json,
1658 },
1659 },
1660 "",
1661 db.clone(),
1662 )
1663 .await
1664 .unwrap();
1665 }
1666
1667 assert_eq!(
1668 run(
1669 AdminCommand::Session {
1670 command: AdminSessionCommand::List {
1671 user: Some("nobody".to_string()),
1672 limit: DEFAULT_LIMIT,
1673 offset: 0,
1674 json: false,
1675 },
1676 },
1677 "",
1678 db,
1679 )
1680 .await
1681 .unwrap_err(),
1682 CliError::bad_request("no such operator: nobody".to_string())
1683 );
1684 }
1685
1686 #[tokio::test]
1687 async fn session_revoke_handles_user_all_and_neither() {
1688 let db = db().await;
1689 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1690 .await
1691 .unwrap();
1692 let alice = AdminUser::find_by_username("alice", &db)
1693 .await
1694 .unwrap()
1695 .unwrap();
1696 for hash in ["a", "b"] {
1697 AdminSession::create(
1698 NewSession {
1699 user_id: alice.id,
1700 token_hash: hash,
1701 csrf_token: "csrf",
1702 created_ip: None,
1703 user_agent: None,
1704 },
1705 std::time::Duration::from_secs(60),
1706 &db,
1707 )
1708 .await
1709 .unwrap();
1710 }
1711
1712 assert_eq!(
1714 run(
1715 AdminCommand::Session {
1716 command: AdminSessionCommand::Revoke {
1717 user: None,
1718 all: false,
1719 session: None,
1720 },
1721 },
1722 "",
1723 db.clone(),
1724 )
1725 .await
1726 .unwrap_err(),
1727 CliError::bad_request(
1728 "say whose sessions to revoke: --user <username> (optionally --session <id>), \
1729 or --all"
1730 .to_string()
1731 )
1732 );
1733
1734 assert_eq!(
1735 run(
1736 AdminCommand::Session {
1737 command: AdminSessionCommand::Revoke {
1738 user: Some("nobody".to_string()),
1739 all: false,
1740 session: None,
1741 },
1742 },
1743 "",
1744 db.clone(),
1745 )
1746 .await
1747 .unwrap_err(),
1748 CliError::bad_request("no such operator: nobody".to_string())
1749 );
1750
1751 run(
1752 AdminCommand::Session {
1753 command: AdminSessionCommand::Revoke {
1754 user: Some("alice".to_string()),
1755 all: false,
1756 session: None,
1757 },
1758 },
1759 "",
1760 db.clone(),
1761 )
1762 .await
1763 .unwrap();
1764 assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
1765
1766 run(
1768 AdminCommand::Session {
1769 command: AdminSessionCommand::Revoke {
1770 user: None,
1771 all: true,
1772 session: None,
1773 },
1774 },
1775 "",
1776 db,
1777 )
1778 .await
1779 .unwrap();
1780 }
1781
1782 #[tokio::test]
1785 async fn session_revoke_targets_one_session_by_id() {
1786 let db = db().await;
1787 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1788 .await
1789 .unwrap();
1790 let alice = AdminUser::find_by_username("alice", &db)
1791 .await
1792 .unwrap()
1793 .unwrap();
1794 for hash in ["11111111aaaa", "22222222bbbb"] {
1796 AdminSession::create(
1797 NewSession {
1798 user_id: alice.id,
1799 token_hash: hash,
1800 csrf_token: "csrf",
1801 created_ip: None,
1802 user_agent: None,
1803 },
1804 std::time::Duration::from_secs(60),
1805 &db,
1806 )
1807 .await
1808 .unwrap();
1809 }
1810
1811 let revoke = |user: Option<&str>, session: Option<&str>| {
1812 let db = db.clone();
1813 let command = AdminCommand::Session {
1814 command: AdminSessionCommand::Revoke {
1815 user: user.map(str::to_string),
1816 all: false,
1817 session: session.map(str::to_string),
1818 },
1819 };
1820 async move { run(command, "", db).await }
1821 };
1822
1823 assert_eq!(
1825 revoke(Some("alice"), Some("deadbeef")).await.unwrap_err(),
1826 CliError::bad_request("no such session for alice: deadbeef".to_string())
1827 );
1828 assert_eq!(
1830 revoke(Some("nobody"), Some("11111111")).await.unwrap_err(),
1831 CliError::bad_request("no such operator: nobody".to_string())
1832 );
1833
1834 revoke(Some("alice"), Some("11111111")).await.unwrap();
1835
1836 assert!(
1837 AdminSession::find_by_token_hash("11111111aaaa", &db)
1838 .await
1839 .unwrap()
1840 .is_none(),
1841 "the named session is gone"
1842 );
1843 assert!(
1844 AdminSession::find_by_token_hash("22222222bbbb", &db)
1845 .await
1846 .unwrap()
1847 .is_some(),
1848 "the sibling session survives"
1849 );
1850 }
1851
1852 fn totp(command: AdminUserTotpCommand) -> AdminCommand {
1855 AdminCommand::User {
1856 command: AdminUserCommand::Totp { command },
1857 }
1858 }
1859
1860 async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
1863 let mut user = AdminUser::find_by_username(username, &database)
1864 .await
1865 .unwrap()
1866 .unwrap();
1867 let enrolment =
1868 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
1869 .await
1870 .unwrap();
1871 let code = admin::totp::totp_at(
1872 &enrolment.secret,
1873 admin::totp::step_at(acme_proxy_store::nonce::now_secs()),
1874 admin::totp::DIGITS,
1875 );
1876 mfa::confirm_totp_enrolment(&mut user, &code, None, database)
1877 .await
1878 .unwrap()
1879 .expect("a freshly generated code must confirm its own enrolment");
1880 user
1881 }
1882
1883 #[tokio::test]
1884 async fn totp_status_reports_all_three_states() {
1885 let db = db().await;
1886 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1887 .await
1888 .unwrap();
1889
1890 let status = |json| {
1891 totp(AdminUserTotpCommand::Status {
1892 username: "alice".to_string(),
1893 json,
1894 })
1895 };
1896
1897 run(status(false), "", db.clone()).await.unwrap();
1899 run(status(true), "", db.clone()).await.unwrap();
1900
1901 let mut user = AdminUser::find_by_username("alice", &db)
1905 .await
1906 .unwrap()
1907 .unwrap();
1908 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
1909 .await
1910 .unwrap();
1911 let line = render::render_admin_totp_line(&user, 0, Palette::plain());
1912 assert!(line.contains("pending"), "{line}");
1913 run(status(false), "", db.clone()).await.unwrap();
1914
1915 let user = enrol("alice", db.clone()).await;
1917 let line = render::render_admin_totp_line(&user, 10, Palette::plain());
1918 assert!(line.contains("totp=enabled"), "{line}");
1919 assert!(line.contains("recovery-codes=10"), "{line}");
1920 run(status(true), "", db).await.unwrap();
1921 }
1922
1923 #[tokio::test]
1924 async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
1925 let db = db().await;
1926 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1927 .await
1928 .unwrap();
1929 let user = enrol("alice", db.clone()).await;
1930
1931 AdminSession::create(
1932 NewSession {
1933 user_id: user.id,
1934 token_hash: "live-session",
1935 csrf_token: "csrf",
1936 created_ip: None,
1937 user_agent: None,
1938 },
1939 std::time::Duration::from_secs(3600),
1940 &db,
1941 )
1942 .await
1943 .unwrap();
1944
1945 let reset = || {
1946 totp(AdminUserTotpCommand::Reset {
1947 username: "alice".to_string(),
1948 })
1949 };
1950
1951 let mut no = b"n\n".as_slice();
1955 run_admin_command(
1956 reset(),
1957 false,
1958 Palette::plain(),
1959 &mut no,
1960 &Config::default(),
1961 db.clone(),
1962 )
1963 .await
1964 .unwrap();
1965 let unchanged = AdminUser::find_by_username("alice", &db)
1966 .await
1967 .unwrap()
1968 .unwrap();
1969 assert!(unchanged.has_totp());
1970
1971 run(reset(), "", db.clone()).await.unwrap();
1972
1973 let after = AdminUser::find_by_username("alice", &db)
1974 .await
1975 .unwrap()
1976 .unwrap();
1977 assert!(!after.has_totp());
1978 assert!(!after.has_pending_totp());
1979 assert_eq!(
1980 mfa::recovery_codes_remaining(after.id, db.clone())
1981 .await
1982 .unwrap(),
1983 0
1984 );
1985 assert!(
1986 AdminSession::list_all(Some(after.id), &db)
1987 .await
1988 .unwrap()
1989 .is_empty(),
1990 "a factor removed that left a live session behind is a change in name only"
1991 );
1992
1993 run(reset(), "", db).await.unwrap();
1995 }
1996
1997 #[tokio::test]
1998 async fn totp_recovery_codes_supersede_the_previous_set() {
1999 let db = db().await;
2000 run(create("alice"), &format!("{GOOD}\n"), db.clone())
2001 .await
2002 .unwrap();
2003 let user = enrol("alice", db.clone()).await;
2004
2005 let before =
2006 acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
2007 .await
2008 .unwrap();
2009 assert_eq!(before.len(), 10);
2010
2011 run(
2012 totp(AdminUserTotpCommand::RecoveryCodes {
2013 username: "alice".to_string(),
2014 }),
2015 "",
2016 db.clone(),
2017 )
2018 .await
2019 .unwrap();
2020
2021 let after =
2022 acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
2023 .await
2024 .unwrap();
2025 assert_eq!(after.len(), 10);
2026 assert!(
2027 after
2028 .iter()
2029 .all(|code| before.iter().all(|old| old.id != code.id)),
2030 "the previous set must stop working"
2031 );
2032 }
2033
2034 #[tokio::test]
2037 async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
2038 let db = db().await;
2039 run(create("alice"), &format!("{GOOD}\n"), db.clone())
2040 .await
2041 .unwrap();
2042
2043 let error = run(
2044 totp(AdminUserTotpCommand::RecoveryCodes {
2045 username: "alice".to_string(),
2046 }),
2047 "",
2048 db,
2049 )
2050 .await
2051 .unwrap_err();
2052 assert!(
2053 error.message.contains("no second factor"),
2054 "{}",
2055 error.message
2056 );
2057 assert_eq!(error.kind(), CliErrorKind::BadRequest);
2058 }
2059
2060 #[tokio::test]
2061 async fn every_totp_arm_refuses_an_unknown_operator() {
2062 let db = db().await;
2063 let expected = CliError::bad_request("no such operator: nobody".to_string());
2064
2065 for command in [
2066 AdminUserTotpCommand::Status {
2067 username: "nobody".to_string(),
2068 json: false,
2069 },
2070 AdminUserTotpCommand::Reset {
2071 username: "nobody".to_string(),
2072 },
2073 AdminUserTotpCommand::RecoveryCodes {
2074 username: "nobody".to_string(),
2075 },
2076 ] {
2077 assert_eq!(
2078 run(totp(command), "", db.clone()).await.unwrap_err(),
2079 expected
2080 );
2081 }
2082 }
2083}