1use std::io::{BufRead, IsTerminal};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::admin;
21use crate::admin::mfa;
22use crate::admin::ops::DeleteOutcome;
23use crate::admin::password::PasswordContext;
24use crate::admin::prompt::confirm;
25use crate::admin::users::{self, UserError};
26use crate::cli::CliError;
27use crate::cli::render;
28use crate::cli::style::Palette;
29use crate::config::Config;
30use crate::sqlite::admin_session::AdminSession;
31use crate::sqlite::admin_user::AdminUser;
32use crate::sqlite::db::Database;
33
34#[derive(Subcommand)]
35pub enum AdminCommand {
36 User {
38 #[command(subcommand)]
39 command: AdminUserCommand,
40 },
41 Session {
43 #[command(subcommand)]
44 command: AdminSessionCommand,
45 },
46}
47
48#[derive(Subcommand)]
49pub enum AdminUserCommand {
50 Create {
53 username: String,
54 #[arg(long = "password-file")]
57 password_file: Option<PathBuf>,
58 },
59 List {
61 #[arg(long)]
62 json: bool,
63 },
64 Passwd {
66 username: String,
67 #[arg(long = "password-file")]
68 password_file: Option<PathBuf>,
69 },
70 Delete { username: String },
72 Disable { username: String },
74 Enable { username: String },
76 Totp {
78 #[command(subcommand)]
79 command: AdminUserTotpCommand,
80 },
81}
82
83#[derive(Subcommand)]
92pub enum AdminUserTotpCommand {
93 Status {
96 username: String,
97 #[arg(long)]
98 json: bool,
99 },
100 Reset { username: String },
106 RecoveryCodes { username: String },
109}
110
111#[derive(Subcommand)]
112pub enum AdminSessionCommand {
113 List {
115 #[arg(long)]
117 username: Option<String>,
118 #[arg(long)]
119 json: bool,
120 },
121 Revoke {
123 #[arg(long, conflicts_with = "all")]
124 user: Option<String>,
125 #[arg(long, conflicts_with = "user")]
127 all: bool,
128 },
129}
130
131pub async fn run_admin_command(
132 command: AdminCommand,
133 yes: bool,
134 palette: Palette,
135 reader: &mut impl BufRead,
136 config: &Config,
137 database: Arc<Database>,
138) -> Result<(), CliError> {
139 match command {
140 AdminCommand::User { command } => {
141 run_user_command(command, yes, palette, reader, config, database).await
142 }
143 AdminCommand::Session { command } => run_session_command(command, palette, database).await,
144 }
145}
146
147async fn run_user_command(
148 command: AdminUserCommand,
149 yes: bool,
150 palette: Palette,
151 reader: &mut impl BufRead,
152 config: &Config,
153 database: Arc<Database>,
154) -> Result<(), CliError> {
155 match command {
156 AdminUserCommand::Create {
157 username,
158 password_file,
159 } => {
160 let password = read_password(password_file.as_deref(), reader)?;
161 let context = PasswordContext::from_config(config, &username);
162 let user = users::create_user(&username, &password, &context, database)
163 .await
164 .map_err(user_error)?;
165 println!("Created admin user {} ({}).", user.username, user.id);
167 }
168 AdminUserCommand::List { json } => {
169 let users = users::list_users(database).await?;
170 render::print_rows(&users, json, admin::render_admin_user_json, |user| {
171 render::render_admin_user_line(user, palette)
172 });
173 }
174 AdminUserCommand::Passwd {
175 username,
176 password_file,
177 } => {
178 let password = read_password(password_file.as_deref(), reader)?;
179 let context = PasswordContext::from_config(config, &username);
180 match users::set_password(&username, &password, &context, database)
181 .await
182 .map_err(user_error)?
183 {
184 None => return Err(not_found(&username)),
185 Some(user) => println!(
186 "Password changed for {}. Every session they held was revoked.",
187 user.username
188 ),
189 }
190 }
191 AdminUserCommand::Delete { username } => {
192 match users::confirm_delete_user(&username, yes, reader, database).await? {
193 DeleteOutcome::NotFound => return Err(not_found(&username)),
194 DeleteOutcome::Cancelled => println!("Cancelled."),
195 DeleteOutcome::Deleted => println!("Deleted admin user {username}."),
196 }
197 }
198 AdminUserCommand::Disable { username } => {
199 set_status_or_not_found(&username, "disabled", database).await?;
200 println!("Disabled {username}. Their sessions were revoked.");
201 }
202 AdminUserCommand::Enable { username } => {
203 set_status_or_not_found(&username, "active", database).await?;
204 println!("Enabled {username}.");
205 }
206 AdminUserCommand::Totp { command } => {
207 run_totp_command(command, yes, palette, reader, database).await?;
208 }
209 }
210 Ok(())
211}
212
213async fn run_totp_command(
214 command: AdminUserTotpCommand,
215 yes: bool,
216 palette: Palette,
217 reader: &mut impl BufRead,
218 database: Arc<Database>,
219) -> Result<(), CliError> {
220 match command {
221 AdminUserTotpCommand::Status { username, json } => {
222 let user = find_user(&username, database.clone()).await?;
223 let remaining = mfa::recovery_codes_remaining(&user.id, database).await?;
224
225 if json {
226 println!(
227 "{}",
228 serde_json::json!({
229 "username": user.username,
230 "totpEnabled": user.has_totp(),
231 "enrolmentPending": user.has_pending_totp(),
232 "recoveryCodesRemaining": remaining,
233 })
234 );
235 } else {
236 println!(
237 "{}",
238 render::render_admin_totp_line(&user, remaining, palette)
239 );
240 }
241 }
242 AdminUserTotpCommand::Reset { username } => {
243 let mut user = find_user(&username, database.clone()).await?;
244 if !user.has_totp() && !user.has_pending_totp() {
245 println!("{} has no second factor; nothing to reset.", user.username);
246 return Ok(());
247 }
248
249 let prompt = format!(
250 "Remove the second factor and every recovery code for {}, \
251 and revoke their sessions?",
252 user.username
253 );
254 if !confirm(&prompt, yes, reader) {
255 println!("Cancelled.");
256 return Ok(());
257 }
258
259 mfa::disable_totp(&mut user, None, database).await?;
262 println!(
263 "Removed the second factor for {}. Their sessions were revoked; \
264 they can sign in with a password alone until they enrol again.",
265 user.username
266 );
267 }
268 AdminUserTotpCommand::RecoveryCodes { username } => {
269 let user = find_user(&username, database.clone()).await?;
270 if !user.has_totp() {
271 return Err(CliError(format!(
272 "{} has no second factor, so recovery codes would recover nothing: \
273 enrol from the panel first",
274 user.username
275 )));
276 }
277
278 let codes = mfa::regenerate_recovery_codes(&user, database).await?;
279 println!(
282 "New recovery codes for {} — the previous set no longer works.\n\
283 Store these now; they are not recoverable.\n",
284 user.username
285 );
286 for code in &codes {
287 println!(" {code}");
288 }
289 }
290 }
291 Ok(())
292}
293
294async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
297 AdminUser::find_by_username(username, &database)
298 .await?
299 .ok_or_else(|| not_found(username))
300}
301
302async fn run_session_command(
303 command: AdminSessionCommand,
304 palette: Palette,
305 database: Arc<Database>,
306) -> Result<(), CliError> {
307 match command {
308 AdminSessionCommand::List { username, json } => {
309 let user_id = match username.as_deref() {
313 None => None,
314 Some(name) => match AdminUser::find_by_username(name, &database).await? {
315 None => return Err(not_found(name)),
316 Some(user) => Some(user.id),
317 },
318 };
319
320 let sessions = AdminSession::list_all(user_id.as_deref(), &database).await?;
321 render::print_rows(
322 &sessions,
323 json,
324 admin::render_admin_session_json,
325 |session| render::render_admin_session_line(session, palette),
326 );
327 }
328 AdminSessionCommand::Revoke { user, all } => match (user, all) {
329 (Some(username), _) => match users::revoke_sessions(&username, database).await? {
330 None => return Err(not_found(&username)),
331 Some(count) => println!("Revoked {count} session(s) for {username}."),
332 },
333 (None, true) => {
334 let count = AdminSession::delete_all(&database).await?;
335 println!("Revoked {count} session(s).");
336 }
337 (None, false) => {
338 return Err(CliError(
339 "say whose sessions to revoke: --user <username>, or --all".to_string(),
340 ));
341 }
342 },
343 }
344 Ok(())
345}
346
347async fn set_status_or_not_found(
348 username: &str,
349 status: &str,
350 database: Arc<Database>,
351) -> Result<(), CliError> {
352 if users::set_status(username, status, database)
353 .await?
354 .is_none()
355 {
356 return Err(not_found(username));
357 }
358 Ok(())
359}
360
361fn read_password(
367 path: Option<&std::path::Path>,
368 reader: &mut impl BufRead,
369) -> Result<String, CliError> {
370 match path {
371 Some(path) => {
372 let raw = std::fs::read_to_string(path)
373 .map_err(|error| CliError(format!("cannot read {}: {error}", path.display())))?;
374 Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
375 }
376 None => {
377 if std::io::stdin().is_terminal() {
381 eprintln!(
382 "Note: the password will be echoed. Use --password-file, or pipe it in:\n \
383 printf '%s' \"$password\" | acme-proxy admin user create <username>"
384 );
385 }
386 eprintln!("Enter the password, then press Enter:");
387 let mut line = String::new();
388 if reader.read_line(&mut line).unwrap_or(0) == 0 {
389 return Err(CliError("no password supplied".to_string()));
390 }
391 let password = line.strip_suffix('\n').unwrap_or(&line);
394 let password = password.strip_suffix('\r').unwrap_or(password);
395 Ok(password.to_string())
396 }
397 }
398}
399
400fn user_error(error: UserError) -> CliError {
401 match error {
402 UserError::Database(error) => CliError::from(error),
403 other => CliError(other.to_string()),
404 }
405}
406
407fn not_found(username: &str) -> CliError {
408 CliError(format!("no such admin user: {username}"))
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::sqlite::admin_session::NewSession;
415 use crate::testutil::TempDir;
416
417 const GOOD: &str = "a-long-enough-password";
418
419 async fn db() -> Arc<Database> {
420 Arc::new(Database::connect_in_memory().await.unwrap())
421 }
422
423 async fn run(
426 command: AdminCommand,
427 input: &str,
428 database: Arc<Database>,
429 ) -> Result<(), CliError> {
430 run_with_config(command, input, &Config::default(), database).await
431 }
432
433 async fn run_with_config(
436 command: AdminCommand,
437 input: &str,
438 config: &Config,
439 database: Arc<Database>,
440 ) -> Result<(), CliError> {
441 let mut reader = input.as_bytes();
442 run_admin_command(
443 command,
444 true,
445 Palette::plain(),
446 &mut reader,
447 config,
448 database,
449 )
450 .await
451 }
452
453 fn create(username: &str) -> AdminCommand {
454 AdminCommand::User {
455 command: AdminUserCommand::Create {
456 username: username.to_string(),
457 password_file: None,
458 },
459 }
460 }
461
462 #[tokio::test]
463 async fn create_reads_the_password_from_stdin() {
464 let db = db().await;
465 run(create("alice"), &format!("{GOOD}\n"), db.clone())
466 .await
467 .unwrap();
468
469 let user = AdminUser::find_by_username("alice", &db)
470 .await
471 .unwrap()
472 .unwrap();
473 assert!(user.is_active());
474 assert_eq!(
475 admin::password::verify_password(&user.password_hash, GOOD),
476 Ok(true)
477 );
478 }
479
480 #[tokio::test]
481 async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
482 let dir = TempDir::new("admin-passwd");
483 let path = dir.join("pw");
484 std::fs::write(&path, format!("{GOOD}\n")).unwrap();
485
486 let db = db().await;
487 run(
488 AdminCommand::User {
489 command: AdminUserCommand::Create {
490 username: "alice".to_string(),
491 password_file: Some(path),
492 },
493 },
494 "",
495 db.clone(),
496 )
497 .await
498 .unwrap();
499
500 let user = AdminUser::find_by_username("alice", &db)
501 .await
502 .unwrap()
503 .unwrap();
504 assert_eq!(
505 admin::password::verify_password(&user.password_hash, GOOD),
506 Ok(true),
507 "the trailing newline must not be part of the password"
508 );
509 }
510
511 #[tokio::test]
512 async fn create_refuses_a_missing_password_file() {
513 let db = db().await;
514 let error = run(
515 AdminCommand::User {
516 command: AdminUserCommand::Create {
517 username: "alice".to_string(),
518 password_file: Some(PathBuf::from("/nonexistent/pw")),
519 },
520 },
521 "",
522 db,
523 )
524 .await
525 .unwrap_err();
526 assert!(error.0.starts_with("cannot read /nonexistent/pw"));
527 }
528
529 #[tokio::test]
534 async fn create_surfaces_the_context_and_corpus_rules_in_words() {
535 let db = db().await;
536
537 let error = run(create("alice"), "passwordpassword\n", db.clone())
538 .await
539 .unwrap_err();
540 assert!(error.0.contains("commonly used"), "got: {}", error.0);
541
542 let mut config = Config::default();
543 config.server.base_url = "https://ca.contoso.example".to_string();
544 let error = run_with_config(
545 create("alice"),
546 "contoso-is-my-password\n",
547 &config,
548 db.clone(),
549 )
550 .await
551 .unwrap_err();
552 assert!(error.0.contains("contoso"), "got: {}", error.0);
553 assert!(
554 error.0.contains("names this deployment"),
555 "got: {}",
556 error.0
557 );
558
559 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
561 }
562
563 #[tokio::test]
564 async fn create_refuses_empty_stdin() {
565 let db = db().await;
566 let error = run(create("alice"), "", db).await.unwrap_err();
567 assert_eq!(error, CliError("no password supplied".to_string()));
568 }
569
570 #[tokio::test]
571 async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
572 let db = db().await;
573 let error = run(create("alice"), "short\n", db.clone())
574 .await
575 .unwrap_err();
576 assert!(error.0.contains("at least 12"), "got: {}", error.0);
577
578 run(create("alice"), &format!("{GOOD}\n"), db.clone())
579 .await
580 .unwrap();
581 let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
582 .await
583 .unwrap_err();
584 assert_eq!(
585 error,
586 CliError("an admin user named `alice` already exists".to_string())
587 );
588 }
589
590 #[tokio::test]
591 async fn passwd_changes_the_password_and_reports_an_unknown_user() {
592 let db = db().await;
593 run(create("alice"), &format!("{GOOD}\n"), db.clone())
594 .await
595 .unwrap();
596
597 let passwd = |username: &str| AdminCommand::User {
598 command: AdminUserCommand::Passwd {
599 username: username.to_string(),
600 password_file: None,
601 },
602 };
603
604 run(passwd("alice"), "another-long-password\n", db.clone())
605 .await
606 .unwrap();
607 let user = AdminUser::find_by_username("alice", &db)
608 .await
609 .unwrap()
610 .unwrap();
611 assert_eq!(
612 admin::password::verify_password(&user.password_hash, "another-long-password"),
613 Ok(true)
614 );
615
616 let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
617 .await
618 .unwrap_err();
619 assert_eq!(error, CliError("no such admin user: nobody".to_string()));
620 }
621
622 #[tokio::test]
623 async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
624 let db = db().await;
625 for json in [true, false] {
626 run(
627 AdminCommand::User {
628 command: AdminUserCommand::List { json },
629 },
630 "",
631 db.clone(),
632 )
633 .await
634 .unwrap();
635 }
636
637 run(create("alice"), &format!("{GOOD}\n"), db.clone())
638 .await
639 .unwrap();
640 for json in [true, false] {
641 run(
642 AdminCommand::User {
643 command: AdminUserCommand::List { json },
644 },
645 "",
646 db.clone(),
647 )
648 .await
649 .unwrap();
650 }
651 }
652
653 #[tokio::test]
654 async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
655 let db = db().await;
656 run(create("alice"), &format!("{GOOD}\n"), db.clone())
657 .await
658 .unwrap();
659
660 let disable = |username: &str| AdminCommand::User {
661 command: AdminUserCommand::Disable {
662 username: username.to_string(),
663 },
664 };
665 let enable = |username: &str| AdminCommand::User {
666 command: AdminUserCommand::Enable {
667 username: username.to_string(),
668 },
669 };
670
671 run(disable("alice"), "", db.clone()).await.unwrap();
672 assert!(
673 !AdminUser::find_by_username("alice", &db)
674 .await
675 .unwrap()
676 .unwrap()
677 .is_active()
678 );
679
680 run(enable("alice"), "", db.clone()).await.unwrap();
681 assert!(
682 AdminUser::find_by_username("alice", &db)
683 .await
684 .unwrap()
685 .unwrap()
686 .is_active()
687 );
688
689 for command in [disable("nobody"), enable("nobody")] {
690 assert_eq!(
691 run(command, "", db.clone()).await.unwrap_err(),
692 CliError("no such admin user: nobody".to_string())
693 );
694 }
695 }
696
697 #[tokio::test]
698 async fn delete_covers_not_found_cancelled_and_deleted() {
699 let db = db().await;
700 let delete = AdminCommand::User {
701 command: AdminUserCommand::Delete {
702 username: "alice".to_string(),
703 },
704 };
705
706 assert_eq!(
707 run(
708 AdminCommand::User {
709 command: AdminUserCommand::Delete {
710 username: "nobody".to_string()
711 }
712 },
713 "",
714 db.clone()
715 )
716 .await
717 .unwrap_err(),
718 CliError("no such admin user: nobody".to_string())
719 );
720
721 run(create("alice"), &format!("{GOOD}\n"), db.clone())
722 .await
723 .unwrap();
724
725 let mut no = b"n\n".as_slice();
727 run_admin_command(
728 AdminCommand::User {
729 command: AdminUserCommand::Delete {
730 username: "alice".to_string(),
731 },
732 },
733 false,
734 Palette::plain(),
735 &mut no,
736 &Config::default(),
737 db.clone(),
738 )
739 .await
740 .unwrap();
741 assert!(
742 AdminUser::find_by_username("alice", &db)
743 .await
744 .unwrap()
745 .is_some()
746 );
747
748 run(delete, "", db.clone()).await.unwrap();
749 assert!(
750 AdminUser::find_by_username("alice", &db)
751 .await
752 .unwrap()
753 .is_none()
754 );
755 }
756
757 #[tokio::test]
758 async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
759 let db = db().await;
760 run(create("alice"), &format!("{GOOD}\n"), db.clone())
761 .await
762 .unwrap();
763 let alice = AdminUser::find_by_username("alice", &db)
764 .await
765 .unwrap()
766 .unwrap();
767 AdminSession::create(
768 NewSession {
769 user_id: &alice.id,
770 token_hash: "hash-a",
771 csrf_token: "csrf",
772 created_ip: None,
773 user_agent: None,
774 },
775 std::time::Duration::from_secs(60),
776 &db,
777 )
778 .await
779 .unwrap();
780
781 for (username, json) in [
782 (None, true),
783 (None, false),
784 (Some("alice".to_string()), true),
785 (Some("alice".to_string()), false),
786 ] {
787 run(
788 AdminCommand::Session {
789 command: AdminSessionCommand::List { username, json },
790 },
791 "",
792 db.clone(),
793 )
794 .await
795 .unwrap();
796 }
797
798 assert_eq!(
799 run(
800 AdminCommand::Session {
801 command: AdminSessionCommand::List {
802 username: Some("nobody".to_string()),
803 json: false,
804 },
805 },
806 "",
807 db,
808 )
809 .await
810 .unwrap_err(),
811 CliError("no such admin user: nobody".to_string())
812 );
813 }
814
815 #[tokio::test]
816 async fn session_revoke_handles_user_all_and_neither() {
817 let db = db().await;
818 run(create("alice"), &format!("{GOOD}\n"), db.clone())
819 .await
820 .unwrap();
821 let alice = AdminUser::find_by_username("alice", &db)
822 .await
823 .unwrap()
824 .unwrap();
825 for hash in ["a", "b"] {
826 AdminSession::create(
827 NewSession {
828 user_id: &alice.id,
829 token_hash: hash,
830 csrf_token: "csrf",
831 created_ip: None,
832 user_agent: None,
833 },
834 std::time::Duration::from_secs(60),
835 &db,
836 )
837 .await
838 .unwrap();
839 }
840
841 assert_eq!(
843 run(
844 AdminCommand::Session {
845 command: AdminSessionCommand::Revoke {
846 user: None,
847 all: false
848 },
849 },
850 "",
851 db.clone(),
852 )
853 .await
854 .unwrap_err(),
855 CliError("say whose sessions to revoke: --user <username>, or --all".to_string())
856 );
857
858 assert_eq!(
859 run(
860 AdminCommand::Session {
861 command: AdminSessionCommand::Revoke {
862 user: Some("nobody".to_string()),
863 all: false
864 },
865 },
866 "",
867 db.clone(),
868 )
869 .await
870 .unwrap_err(),
871 CliError("no such admin user: nobody".to_string())
872 );
873
874 run(
875 AdminCommand::Session {
876 command: AdminSessionCommand::Revoke {
877 user: Some("alice".to_string()),
878 all: false,
879 },
880 },
881 "",
882 db.clone(),
883 )
884 .await
885 .unwrap();
886 assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
887
888 run(
890 AdminCommand::Session {
891 command: AdminSessionCommand::Revoke {
892 user: None,
893 all: true,
894 },
895 },
896 "",
897 db,
898 )
899 .await
900 .unwrap();
901 }
902
903 fn totp(command: AdminUserTotpCommand) -> AdminCommand {
906 AdminCommand::User {
907 command: AdminUserCommand::Totp { command },
908 }
909 }
910
911 async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
914 let mut user = AdminUser::find_by_username(username, &database)
915 .await
916 .unwrap()
917 .unwrap();
918 let enrolment =
919 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
920 .await
921 .unwrap();
922 let code = admin::totp::totp_at(
923 &enrolment.secret,
924 admin::totp::step_at(crate::sqlite::nonce::now_secs()),
925 admin::totp::DIGITS,
926 );
927 mfa::confirm_totp_enrolment(&mut user, &code, None, database)
928 .await
929 .unwrap()
930 .expect("a freshly generated code must confirm its own enrolment");
931 user
932 }
933
934 #[tokio::test]
935 async fn totp_status_reports_all_three_states() {
936 let db = db().await;
937 run(create("alice"), &format!("{GOOD}\n"), db.clone())
938 .await
939 .unwrap();
940
941 let status = |json| {
942 totp(AdminUserTotpCommand::Status {
943 username: "alice".to_string(),
944 json,
945 })
946 };
947
948 run(status(false), "", db.clone()).await.unwrap();
950 run(status(true), "", db.clone()).await.unwrap();
951
952 let mut user = AdminUser::find_by_username("alice", &db)
956 .await
957 .unwrap()
958 .unwrap();
959 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
960 .await
961 .unwrap();
962 let line = render::render_admin_totp_line(&user, 0, Palette::plain());
963 assert!(line.contains("pending"), "{line}");
964 run(status(false), "", db.clone()).await.unwrap();
965
966 let user = enrol("alice", db.clone()).await;
968 let line = render::render_admin_totp_line(&user, 10, Palette::plain());
969 assert!(line.contains("totp=enabled"), "{line}");
970 assert!(line.contains("recovery-codes=10"), "{line}");
971 run(status(true), "", db).await.unwrap();
972 }
973
974 #[tokio::test]
975 async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
976 let db = db().await;
977 run(create("alice"), &format!("{GOOD}\n"), db.clone())
978 .await
979 .unwrap();
980 let user = enrol("alice", db.clone()).await;
981
982 AdminSession::create(
983 NewSession {
984 user_id: &user.id,
985 token_hash: "live-session",
986 csrf_token: "csrf",
987 created_ip: None,
988 user_agent: None,
989 },
990 std::time::Duration::from_secs(3600),
991 &db,
992 )
993 .await
994 .unwrap();
995
996 let reset = || {
997 totp(AdminUserTotpCommand::Reset {
998 username: "alice".to_string(),
999 })
1000 };
1001
1002 let mut no = b"n\n".as_slice();
1006 run_admin_command(
1007 reset(),
1008 false,
1009 Palette::plain(),
1010 &mut no,
1011 &Config::default(),
1012 db.clone(),
1013 )
1014 .await
1015 .unwrap();
1016 let unchanged = AdminUser::find_by_username("alice", &db)
1017 .await
1018 .unwrap()
1019 .unwrap();
1020 assert!(unchanged.has_totp());
1021
1022 run(reset(), "", db.clone()).await.unwrap();
1023
1024 let after = AdminUser::find_by_username("alice", &db)
1025 .await
1026 .unwrap()
1027 .unwrap();
1028 assert!(!after.has_totp());
1029 assert!(!after.has_pending_totp());
1030 assert_eq!(
1031 mfa::recovery_codes_remaining(&after.id, db.clone())
1032 .await
1033 .unwrap(),
1034 0
1035 );
1036 assert!(
1037 AdminSession::list_all(Some(&after.id), &db)
1038 .await
1039 .unwrap()
1040 .is_empty(),
1041 "a factor removed that left a live session behind is a change in name only"
1042 );
1043
1044 run(reset(), "", db).await.unwrap();
1046 }
1047
1048 #[tokio::test]
1049 async fn totp_recovery_codes_supersede_the_previous_set() {
1050 let db = db().await;
1051 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1052 .await
1053 .unwrap();
1054 let user = enrol("alice", db.clone()).await;
1055
1056 let before =
1057 crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(&user.id, &db)
1058 .await
1059 .unwrap();
1060 assert_eq!(before.len(), 10);
1061
1062 run(
1063 totp(AdminUserTotpCommand::RecoveryCodes {
1064 username: "alice".to_string(),
1065 }),
1066 "",
1067 db.clone(),
1068 )
1069 .await
1070 .unwrap();
1071
1072 let after =
1073 crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(&user.id, &db)
1074 .await
1075 .unwrap();
1076 assert_eq!(after.len(), 10);
1077 assert!(
1078 after
1079 .iter()
1080 .all(|code| before.iter().all(|old| old.id != code.id)),
1081 "the previous set must stop working"
1082 );
1083 }
1084
1085 #[tokio::test]
1088 async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
1089 let db = db().await;
1090 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1091 .await
1092 .unwrap();
1093
1094 let error = run(
1095 totp(AdminUserTotpCommand::RecoveryCodes {
1096 username: "alice".to_string(),
1097 }),
1098 "",
1099 db,
1100 )
1101 .await
1102 .unwrap_err();
1103 assert!(error.0.contains("no second factor"), "{}", error.0);
1104 }
1105
1106 #[tokio::test]
1107 async fn every_totp_arm_refuses_an_unknown_operator() {
1108 let db = db().await;
1109 let expected = CliError("no such admin user: nobody".to_string());
1110
1111 for command in [
1112 AdminUserTotpCommand::Status {
1113 username: "nobody".to_string(),
1114 json: false,
1115 },
1116 AdminUserTotpCommand::Reset {
1117 username: "nobody".to_string(),
1118 },
1119 AdminUserTotpCommand::RecoveryCodes {
1120 username: "nobody".to_string(),
1121 },
1122 ] {
1123 assert_eq!(
1124 run(totp(command), "", db.clone()).await.unwrap_err(),
1125 expected
1126 );
1127 }
1128 }
1129}