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::cli::window::{DEFAULT_LIMIT, Window};
30use crate::config::Config;
31use crate::sqlite::admin_session::AdminSession;
32use crate::sqlite::admin_user::AdminUser;
33use crate::sqlite::db::Database;
34
35#[derive(Subcommand)]
36pub enum AdminCommand {
37 User {
39 #[command(subcommand)]
40 command: AdminUserCommand,
41 },
42 Session {
44 #[command(subcommand)]
45 command: AdminSessionCommand,
46 },
47}
48
49#[derive(Subcommand)]
50pub enum AdminUserCommand {
51 Create {
54 username: String,
55 #[arg(long = "password-file")]
58 password_file: Option<PathBuf>,
59 },
60 List {
62 #[arg(long, default_value_t = DEFAULT_LIMIT)]
63 limit: i64,
64 #[arg(long, default_value_t = 0)]
65 offset: i64,
66 #[arg(long)]
67 json: bool,
68 },
69 Show {
71 username: String,
72 #[arg(long)]
73 json: bool,
74 },
75 Passwd {
77 username: String,
78 #[arg(long = "password-file")]
79 password_file: Option<PathBuf>,
80 },
81 Delete { username: String },
83 Disable { username: String },
85 Enable { username: String },
87 Totp {
89 #[command(subcommand)]
90 command: AdminUserTotpCommand,
91 },
92}
93
94#[derive(Subcommand)]
103pub enum AdminUserTotpCommand {
104 Status {
107 username: String,
108 #[arg(long)]
109 json: bool,
110 },
111 Reset { username: String },
117 RecoveryCodes { username: String },
120}
121
122#[derive(Subcommand)]
123pub enum AdminSessionCommand {
124 List {
126 #[arg(long)]
128 username: Option<String>,
129 #[arg(long, default_value_t = DEFAULT_LIMIT)]
130 limit: i64,
131 #[arg(long, default_value_t = 0)]
132 offset: i64,
133 #[arg(long)]
134 json: bool,
135 },
136 Revoke {
138 #[arg(long, conflicts_with = "all")]
139 user: Option<String>,
140 #[arg(long, conflicts_with = "user")]
142 all: bool,
143 },
144}
145
146pub async fn run_admin_command(
147 command: AdminCommand,
148 yes: bool,
149 palette: Palette,
150 reader: &mut impl BufRead,
151 config: &Config,
152 database: Arc<Database>,
153) -> Result<(), CliError> {
154 match command {
155 AdminCommand::User { command } => {
156 run_user_command(command, yes, palette, reader, config, database).await
157 }
158 AdminCommand::Session { command } => run_session_command(command, palette, database).await,
159 }
160}
161
162async fn run_user_command(
163 command: AdminUserCommand,
164 yes: bool,
165 palette: Palette,
166 reader: &mut impl BufRead,
167 config: &Config,
168 database: Arc<Database>,
169) -> Result<(), CliError> {
170 match command {
171 AdminUserCommand::Create {
172 username,
173 password_file,
174 } => {
175 let password = read_password(password_file.as_deref(), reader)?;
176 let context = PasswordContext::from_config(config, &username);
177 let user = users::create_user(&username, &password, &context, database)
178 .await
179 .map_err(user_error)?;
180 println!("Created admin user {} ({}).", user.username, user.id);
182 }
183 AdminUserCommand::List {
184 limit,
185 offset,
186 json,
187 } => {
188 let window = Window::resolve(limit, offset);
189 let (users, total) = users::list_users(window.limit, window.offset, database).await?;
190 render::print_page(
191 &users,
192 total,
193 window,
194 json,
195 admin::render_admin_user_json,
196 |user| render::render_admin_user_line(user, palette),
197 );
198 }
199 AdminUserCommand::Show { username, json } => {
200 let user = find_user(&username, database.clone()).await?;
205 let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
206
207 if json {
208 println!("{}", admin::render_admin_user_detail_json(&user, remaining));
209 } else {
210 print!(
211 "{}",
212 render::render_admin_user_detail_text(&user, remaining, palette)
213 );
214 }
215 }
216 AdminUserCommand::Passwd {
217 username,
218 password_file,
219 } => {
220 let password = read_password(password_file.as_deref(), reader)?;
221 let context = PasswordContext::from_config(config, &username);
222 match users::set_password(&username, &password, &context, database)
223 .await
224 .map_err(user_error)?
225 {
226 None => return Err(not_found(&username)),
227 Some(user) => println!(
228 "Password changed for {}. Every session they held was revoked.",
229 user.username
230 ),
231 }
232 }
233 AdminUserCommand::Delete { username } => {
234 match users::confirm_delete_user(&username, yes, reader, database).await? {
235 DeleteOutcome::NotFound => return Err(not_found(&username)),
236 DeleteOutcome::Cancelled => println!("Cancelled."),
237 DeleteOutcome::Deleted => println!("Deleted admin user {username}."),
238 }
239 }
240 AdminUserCommand::Disable { username } => {
241 set_status_or_not_found(&username, "disabled", database).await?;
242 println!("Disabled {username}. Their sessions were revoked.");
243 }
244 AdminUserCommand::Enable { username } => {
245 set_status_or_not_found(&username, "active", database).await?;
246 println!("Enabled {username}.");
247 }
248 AdminUserCommand::Totp { command } => {
249 run_totp_command(command, yes, palette, reader, database).await?;
250 }
251 }
252 Ok(())
253}
254
255async fn run_totp_command(
256 command: AdminUserTotpCommand,
257 yes: bool,
258 palette: Palette,
259 reader: &mut impl BufRead,
260 database: Arc<Database>,
261) -> Result<(), CliError> {
262 match command {
263 AdminUserTotpCommand::Status { username, json } => {
264 let user = find_user(&username, database.clone()).await?;
265 let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
266
267 if json {
268 println!(
269 "{}",
270 serde_json::json!({
271 "username": user.username,
272 "totpEnabled": user.has_totp(),
273 "enrolmentPending": user.has_pending_totp(),
274 "recoveryCodesRemaining": remaining,
275 })
276 );
277 } else {
278 println!(
279 "{}",
280 render::render_admin_totp_line(&user, remaining, palette)
281 );
282 }
283 }
284 AdminUserTotpCommand::Reset { username } => {
285 let mut user = find_user(&username, database.clone()).await?;
286 if !user.has_totp() && !user.has_pending_totp() {
287 println!("{} has no second factor; nothing to reset.", user.username);
288 return Ok(());
289 }
290
291 let prompt = format!(
292 "Remove the second factor and every recovery code for {}, \
293 and revoke their sessions?",
294 user.username
295 );
296 if !confirm(&prompt, yes, reader) {
297 println!("Cancelled.");
298 return Ok(());
299 }
300
301 mfa::disable_totp(&mut user, None, database).await?;
304 println!(
305 "Removed the second factor for {}. Their sessions were revoked; \
306 they can sign in with a password alone until they enrol again.",
307 user.username
308 );
309 }
310 AdminUserTotpCommand::RecoveryCodes { username } => {
311 let user = find_user(&username, database.clone()).await?;
312 if !user.has_totp() {
313 return Err(CliError(format!(
314 "{} has no second factor, so recovery codes would recover nothing: \
315 enrol from the panel first",
316 user.username
317 )));
318 }
319
320 let codes = mfa::regenerate_recovery_codes(&user, database).await?;
321 println!(
324 "New recovery codes for {} — the previous set no longer works.\n\
325 Store these now; they are not recoverable.\n",
326 user.username
327 );
328 for code in &codes {
329 println!(" {code}");
330 }
331 }
332 }
333 Ok(())
334}
335
336async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
339 AdminUser::find_by_username(username, &database)
340 .await?
341 .ok_or_else(|| not_found(username))
342}
343
344async fn run_session_command(
345 command: AdminSessionCommand,
346 palette: Palette,
347 database: Arc<Database>,
348) -> Result<(), CliError> {
349 match command {
350 AdminSessionCommand::List {
351 username,
352 limit,
353 offset,
354 json,
355 } => {
356 let user_id = match username.as_deref() {
360 None => None,
361 Some(name) => match AdminUser::find_by_username(name, &database).await? {
362 None => return Err(not_found(name)),
363 Some(user) => Some(user.id),
364 },
365 };
366
367 let window = Window::resolve(limit, offset);
368 let (sessions, total) =
369 AdminSession::search(user_id, window.limit, window.offset, &database).await?;
370 render::print_page(
371 &sessions,
372 total,
373 window,
374 json,
375 admin::render_admin_session_json,
376 |session| render::render_admin_session_line(session, palette),
377 );
378 }
379 AdminSessionCommand::Revoke { user, all } => match (user, all) {
380 (Some(username), _) => match users::revoke_sessions(&username, database).await? {
381 None => return Err(not_found(&username)),
382 Some(count) => println!("Revoked {count} session(s) for {username}."),
383 },
384 (None, true) => {
385 let count = AdminSession::delete_all(&database).await?;
386 println!("Revoked {count} session(s).");
387 }
388 (None, false) => {
389 return Err(CliError(
390 "say whose sessions to revoke: --user <username>, or --all".to_string(),
391 ));
392 }
393 },
394 }
395 Ok(())
396}
397
398async fn set_status_or_not_found(
399 username: &str,
400 status: &str,
401 database: Arc<Database>,
402) -> Result<(), CliError> {
403 if users::set_status(username, status, database)
404 .await?
405 .is_none()
406 {
407 return Err(not_found(username));
408 }
409 Ok(())
410}
411
412fn read_password(
418 path: Option<&std::path::Path>,
419 reader: &mut impl BufRead,
420) -> Result<String, CliError> {
421 match path {
422 Some(path) => {
423 let raw = std::fs::read_to_string(path)
424 .map_err(|error| CliError(format!("cannot read {}: {error}", path.display())))?;
425 Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
426 }
427 None => {
428 if std::io::stdin().is_terminal() {
432 eprintln!(
433 "Note: the password will be echoed. Use --password-file, or pipe it in:\n \
434 printf '%s' \"$password\" | acme-proxy admin user create <username>"
435 );
436 }
437 eprintln!("Enter the password, then press Enter:");
438 let mut line = String::new();
439 if reader.read_line(&mut line).unwrap_or(0) == 0 {
440 return Err(CliError("no password supplied".to_string()));
441 }
442 let password = line.strip_suffix('\n').unwrap_or(&line);
445 let password = password.strip_suffix('\r').unwrap_or(password);
446 Ok(password.to_string())
447 }
448 }
449}
450
451fn user_error(error: UserError) -> CliError {
452 match error {
453 UserError::Database(error) => CliError::from(error),
454 other => CliError(other.to_string()),
455 }
456}
457
458fn not_found(username: &str) -> CliError {
459 CliError(format!("no such admin user: {username}"))
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::sqlite::admin_session::NewSession;
466 use crate::testutil::TempDir;
467
468 const GOOD: &str = "a-long-enough-password";
469
470 async fn db() -> Arc<Database> {
471 Arc::new(Database::connect_in_memory().await.unwrap())
472 }
473
474 async fn run(
477 command: AdminCommand,
478 input: &str,
479 database: Arc<Database>,
480 ) -> Result<(), CliError> {
481 run_with_config(command, input, &Config::default(), database).await
482 }
483
484 async fn run_with_config(
487 command: AdminCommand,
488 input: &str,
489 config: &Config,
490 database: Arc<Database>,
491 ) -> Result<(), CliError> {
492 let mut reader = input.as_bytes();
493 run_admin_command(
494 command,
495 true,
496 Palette::plain(),
497 &mut reader,
498 config,
499 database,
500 )
501 .await
502 }
503
504 fn create(username: &str) -> AdminCommand {
505 AdminCommand::User {
506 command: AdminUserCommand::Create {
507 username: username.to_string(),
508 password_file: None,
509 },
510 }
511 }
512
513 #[tokio::test]
514 async fn create_reads_the_password_from_stdin() {
515 let db = db().await;
516 run(create("alice"), &format!("{GOOD}\n"), db.clone())
517 .await
518 .unwrap();
519
520 let user = AdminUser::find_by_username("alice", &db)
521 .await
522 .unwrap()
523 .unwrap();
524 assert!(user.is_active());
525 assert_eq!(
526 admin::password::verify_password(&user.password_hash, GOOD),
527 Ok(true)
528 );
529 }
530
531 #[tokio::test]
532 async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
533 let dir = TempDir::new("admin-passwd");
534 let path = dir.join("pw");
535 std::fs::write(&path, format!("{GOOD}\n")).unwrap();
536
537 let db = db().await;
538 run(
539 AdminCommand::User {
540 command: AdminUserCommand::Create {
541 username: "alice".to_string(),
542 password_file: Some(path),
543 },
544 },
545 "",
546 db.clone(),
547 )
548 .await
549 .unwrap();
550
551 let user = AdminUser::find_by_username("alice", &db)
552 .await
553 .unwrap()
554 .unwrap();
555 assert_eq!(
556 admin::password::verify_password(&user.password_hash, GOOD),
557 Ok(true),
558 "the trailing newline must not be part of the password"
559 );
560 }
561
562 #[tokio::test]
563 async fn create_refuses_a_missing_password_file() {
564 let db = db().await;
565 let error = run(
566 AdminCommand::User {
567 command: AdminUserCommand::Create {
568 username: "alice".to_string(),
569 password_file: Some(PathBuf::from("/nonexistent/pw")),
570 },
571 },
572 "",
573 db,
574 )
575 .await
576 .unwrap_err();
577 assert!(error.0.starts_with("cannot read /nonexistent/pw"));
578 }
579
580 #[tokio::test]
585 async fn create_surfaces_the_context_and_corpus_rules_in_words() {
586 let db = db().await;
587
588 let error = run(create("alice"), "passwordpassword\n", db.clone())
589 .await
590 .unwrap_err();
591 assert!(error.0.contains("commonly used"), "got: {}", error.0);
592
593 let mut config = Config::default();
594 config.server.base_url = "https://ca.contoso.example".to_string();
595 let error = run_with_config(
596 create("alice"),
597 "contoso-is-my-password\n",
598 &config,
599 db.clone(),
600 )
601 .await
602 .unwrap_err();
603 assert!(error.0.contains("contoso"), "got: {}", error.0);
604 assert!(
605 error.0.contains("names this deployment"),
606 "got: {}",
607 error.0
608 );
609
610 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
612 }
613
614 #[tokio::test]
615 async fn create_refuses_empty_stdin() {
616 let db = db().await;
617 let error = run(create("alice"), "", db).await.unwrap_err();
618 assert_eq!(error, CliError("no password supplied".to_string()));
619 }
620
621 #[tokio::test]
622 async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
623 let db = db().await;
624 let error = run(create("alice"), "short\n", db.clone())
625 .await
626 .unwrap_err();
627 assert!(error.0.contains("at least 12"), "got: {}", error.0);
628
629 run(create("alice"), &format!("{GOOD}\n"), db.clone())
630 .await
631 .unwrap();
632 let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
633 .await
634 .unwrap_err();
635 assert_eq!(
636 error,
637 CliError("an admin user named `alice` already exists".to_string())
638 );
639 }
640
641 #[tokio::test]
642 async fn passwd_changes_the_password_and_reports_an_unknown_user() {
643 let db = db().await;
644 run(create("alice"), &format!("{GOOD}\n"), db.clone())
645 .await
646 .unwrap();
647
648 let passwd = |username: &str| AdminCommand::User {
649 command: AdminUserCommand::Passwd {
650 username: username.to_string(),
651 password_file: None,
652 },
653 };
654
655 run(passwd("alice"), "another-long-password\n", db.clone())
656 .await
657 .unwrap();
658 let user = AdminUser::find_by_username("alice", &db)
659 .await
660 .unwrap()
661 .unwrap();
662 assert_eq!(
663 admin::password::verify_password(&user.password_hash, "another-long-password"),
664 Ok(true)
665 );
666
667 let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
668 .await
669 .unwrap_err();
670 assert_eq!(error, CliError("no such admin user: nobody".to_string()));
671 }
672
673 #[tokio::test]
677 async fn show_reports_the_row_and_the_second_factor() {
678 let db = db().await;
679 run(create("alice"), &format!("{GOOD}\n"), db.clone())
680 .await
681 .unwrap();
682
683 let show = |username: &str, json: bool| AdminCommand::User {
684 command: AdminUserCommand::Show {
685 username: username.to_string(),
686 json,
687 },
688 };
689 for json in [true, false] {
690 run(show("alice", json), "", db.clone()).await.unwrap();
691 }
692 assert_eq!(
693 run(show("nobody", false), "", db.clone())
694 .await
695 .unwrap_err(),
696 CliError("no such admin user: nobody".to_string())
697 );
698
699 let user = AdminUser::find_by_username("alice", &db)
704 .await
705 .unwrap()
706 .unwrap();
707 let rendered = admin::render_admin_user_detail_json(&user, 0);
708 assert_eq!(rendered["totpEnabled"], false);
709 assert_eq!(rendered["enrolmentPending"], false);
710 assert_eq!(rendered["recoveryCodesRemaining"], 0);
711 let listed = admin::render_admin_user_json(&user);
713 assert!(listed.get("enrolmentPending").is_none());
714 assert!(listed.get("recoveryCodesRemaining").is_none());
715
716 let enrolled = enrol("alice", db.clone()).await;
717 let rendered = admin::render_admin_user_detail_json(&enrolled, 10);
718 assert_eq!(rendered["totpEnabled"], true);
719 assert_eq!(rendered["recoveryCodesRemaining"], 10);
720 run(show("alice", true), "", db).await.unwrap();
721 }
722
723 #[tokio::test]
727 async fn the_user_listing_pages_oldest_first() {
728 let db = db().await;
729 for name in ["alice", "bob", "carol"] {
730 run(create(name), &format!("{GOOD}\n"), db.clone())
731 .await
732 .unwrap();
733 }
734
735 let (first, total) = users::list_users(2, 0, db.clone()).await.unwrap();
736 let (second, also_total) = users::list_users(2, 2, db.clone()).await.unwrap();
737 assert_eq!((total, also_total), (3, 3), "the total is the table");
738 let walked: Vec<&str> = first
739 .iter()
740 .chain(second.iter())
741 .map(|user| user.username.as_str())
742 .collect();
743 assert_eq!(walked, ["alice", "bob", "carol"]);
744
745 for (limit, offset) in [(0, 0), (-5, -5)] {
749 run(
750 AdminCommand::User {
751 command: AdminUserCommand::List {
752 limit,
753 offset,
754 json: true,
755 },
756 },
757 "",
758 db.clone(),
759 )
760 .await
761 .unwrap();
762 }
763 }
764
765 #[tokio::test]
766 async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
767 let db = db().await;
768 for json in [true, false] {
769 run(
770 AdminCommand::User {
771 command: AdminUserCommand::List {
772 limit: DEFAULT_LIMIT,
773 offset: 0,
774 json,
775 },
776 },
777 "",
778 db.clone(),
779 )
780 .await
781 .unwrap();
782 }
783
784 run(create("alice"), &format!("{GOOD}\n"), db.clone())
785 .await
786 .unwrap();
787 for json in [true, false] {
788 run(
789 AdminCommand::User {
790 command: AdminUserCommand::List {
791 limit: DEFAULT_LIMIT,
792 offset: 0,
793 json,
794 },
795 },
796 "",
797 db.clone(),
798 )
799 .await
800 .unwrap();
801 }
802 }
803
804 #[tokio::test]
805 async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
806 let db = db().await;
807 run(create("alice"), &format!("{GOOD}\n"), db.clone())
808 .await
809 .unwrap();
810
811 let disable = |username: &str| AdminCommand::User {
812 command: AdminUserCommand::Disable {
813 username: username.to_string(),
814 },
815 };
816 let enable = |username: &str| AdminCommand::User {
817 command: AdminUserCommand::Enable {
818 username: username.to_string(),
819 },
820 };
821
822 run(disable("alice"), "", db.clone()).await.unwrap();
823 assert!(
824 !AdminUser::find_by_username("alice", &db)
825 .await
826 .unwrap()
827 .unwrap()
828 .is_active()
829 );
830
831 run(enable("alice"), "", db.clone()).await.unwrap();
832 assert!(
833 AdminUser::find_by_username("alice", &db)
834 .await
835 .unwrap()
836 .unwrap()
837 .is_active()
838 );
839
840 for command in [disable("nobody"), enable("nobody")] {
841 assert_eq!(
842 run(command, "", db.clone()).await.unwrap_err(),
843 CliError("no such admin user: nobody".to_string())
844 );
845 }
846 }
847
848 #[tokio::test]
849 async fn delete_covers_not_found_cancelled_and_deleted() {
850 let db = db().await;
851 let delete = AdminCommand::User {
852 command: AdminUserCommand::Delete {
853 username: "alice".to_string(),
854 },
855 };
856
857 assert_eq!(
858 run(
859 AdminCommand::User {
860 command: AdminUserCommand::Delete {
861 username: "nobody".to_string()
862 }
863 },
864 "",
865 db.clone()
866 )
867 .await
868 .unwrap_err(),
869 CliError("no such admin user: nobody".to_string())
870 );
871
872 run(create("alice"), &format!("{GOOD}\n"), db.clone())
873 .await
874 .unwrap();
875
876 let mut no = b"n\n".as_slice();
878 run_admin_command(
879 AdminCommand::User {
880 command: AdminUserCommand::Delete {
881 username: "alice".to_string(),
882 },
883 },
884 false,
885 Palette::plain(),
886 &mut no,
887 &Config::default(),
888 db.clone(),
889 )
890 .await
891 .unwrap();
892 assert!(
893 AdminUser::find_by_username("alice", &db)
894 .await
895 .unwrap()
896 .is_some()
897 );
898
899 run(delete, "", db.clone()).await.unwrap();
900 assert!(
901 AdminUser::find_by_username("alice", &db)
902 .await
903 .unwrap()
904 .is_none()
905 );
906 }
907
908 #[tokio::test]
909 async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
910 let db = db().await;
911 run(create("alice"), &format!("{GOOD}\n"), db.clone())
912 .await
913 .unwrap();
914 let alice = AdminUser::find_by_username("alice", &db)
915 .await
916 .unwrap()
917 .unwrap();
918 AdminSession::create(
919 NewSession {
920 user_id: alice.id,
921 token_hash: "hash-a",
922 csrf_token: "csrf",
923 created_ip: None,
924 user_agent: None,
925 },
926 std::time::Duration::from_secs(60),
927 &db,
928 )
929 .await
930 .unwrap();
931
932 for (username, json) in [
933 (None, true),
934 (None, false),
935 (Some("alice".to_string()), true),
936 (Some("alice".to_string()), false),
937 ] {
938 run(
939 AdminCommand::Session {
940 command: AdminSessionCommand::List {
941 username,
942 limit: DEFAULT_LIMIT,
943 offset: 0,
944 json,
945 },
946 },
947 "",
948 db.clone(),
949 )
950 .await
951 .unwrap();
952 }
953
954 assert_eq!(
955 run(
956 AdminCommand::Session {
957 command: AdminSessionCommand::List {
958 username: Some("nobody".to_string()),
959 limit: DEFAULT_LIMIT,
960 offset: 0,
961 json: false,
962 },
963 },
964 "",
965 db,
966 )
967 .await
968 .unwrap_err(),
969 CliError("no such admin user: nobody".to_string())
970 );
971 }
972
973 #[tokio::test]
974 async fn session_revoke_handles_user_all_and_neither() {
975 let db = db().await;
976 run(create("alice"), &format!("{GOOD}\n"), db.clone())
977 .await
978 .unwrap();
979 let alice = AdminUser::find_by_username("alice", &db)
980 .await
981 .unwrap()
982 .unwrap();
983 for hash in ["a", "b"] {
984 AdminSession::create(
985 NewSession {
986 user_id: alice.id,
987 token_hash: hash,
988 csrf_token: "csrf",
989 created_ip: None,
990 user_agent: None,
991 },
992 std::time::Duration::from_secs(60),
993 &db,
994 )
995 .await
996 .unwrap();
997 }
998
999 assert_eq!(
1001 run(
1002 AdminCommand::Session {
1003 command: AdminSessionCommand::Revoke {
1004 user: None,
1005 all: false
1006 },
1007 },
1008 "",
1009 db.clone(),
1010 )
1011 .await
1012 .unwrap_err(),
1013 CliError("say whose sessions to revoke: --user <username>, or --all".to_string())
1014 );
1015
1016 assert_eq!(
1017 run(
1018 AdminCommand::Session {
1019 command: AdminSessionCommand::Revoke {
1020 user: Some("nobody".to_string()),
1021 all: false
1022 },
1023 },
1024 "",
1025 db.clone(),
1026 )
1027 .await
1028 .unwrap_err(),
1029 CliError("no such admin user: nobody".to_string())
1030 );
1031
1032 run(
1033 AdminCommand::Session {
1034 command: AdminSessionCommand::Revoke {
1035 user: Some("alice".to_string()),
1036 all: false,
1037 },
1038 },
1039 "",
1040 db.clone(),
1041 )
1042 .await
1043 .unwrap();
1044 assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
1045
1046 run(
1048 AdminCommand::Session {
1049 command: AdminSessionCommand::Revoke {
1050 user: None,
1051 all: true,
1052 },
1053 },
1054 "",
1055 db,
1056 )
1057 .await
1058 .unwrap();
1059 }
1060
1061 fn totp(command: AdminUserTotpCommand) -> AdminCommand {
1064 AdminCommand::User {
1065 command: AdminUserCommand::Totp { command },
1066 }
1067 }
1068
1069 async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
1072 let mut user = AdminUser::find_by_username(username, &database)
1073 .await
1074 .unwrap()
1075 .unwrap();
1076 let enrolment =
1077 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
1078 .await
1079 .unwrap();
1080 let code = admin::totp::totp_at(
1081 &enrolment.secret,
1082 admin::totp::step_at(crate::sqlite::nonce::now_secs()),
1083 admin::totp::DIGITS,
1084 );
1085 mfa::confirm_totp_enrolment(&mut user, &code, None, database)
1086 .await
1087 .unwrap()
1088 .expect("a freshly generated code must confirm its own enrolment");
1089 user
1090 }
1091
1092 #[tokio::test]
1093 async fn totp_status_reports_all_three_states() {
1094 let db = db().await;
1095 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1096 .await
1097 .unwrap();
1098
1099 let status = |json| {
1100 totp(AdminUserTotpCommand::Status {
1101 username: "alice".to_string(),
1102 json,
1103 })
1104 };
1105
1106 run(status(false), "", db.clone()).await.unwrap();
1108 run(status(true), "", db.clone()).await.unwrap();
1109
1110 let mut user = AdminUser::find_by_username("alice", &db)
1114 .await
1115 .unwrap()
1116 .unwrap();
1117 mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
1118 .await
1119 .unwrap();
1120 let line = render::render_admin_totp_line(&user, 0, Palette::plain());
1121 assert!(line.contains("pending"), "{line}");
1122 run(status(false), "", db.clone()).await.unwrap();
1123
1124 let user = enrol("alice", db.clone()).await;
1126 let line = render::render_admin_totp_line(&user, 10, Palette::plain());
1127 assert!(line.contains("totp=enabled"), "{line}");
1128 assert!(line.contains("recovery-codes=10"), "{line}");
1129 run(status(true), "", db).await.unwrap();
1130 }
1131
1132 #[tokio::test]
1133 async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
1134 let db = db().await;
1135 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1136 .await
1137 .unwrap();
1138 let user = enrol("alice", db.clone()).await;
1139
1140 AdminSession::create(
1141 NewSession {
1142 user_id: user.id,
1143 token_hash: "live-session",
1144 csrf_token: "csrf",
1145 created_ip: None,
1146 user_agent: None,
1147 },
1148 std::time::Duration::from_secs(3600),
1149 &db,
1150 )
1151 .await
1152 .unwrap();
1153
1154 let reset = || {
1155 totp(AdminUserTotpCommand::Reset {
1156 username: "alice".to_string(),
1157 })
1158 };
1159
1160 let mut no = b"n\n".as_slice();
1164 run_admin_command(
1165 reset(),
1166 false,
1167 Palette::plain(),
1168 &mut no,
1169 &Config::default(),
1170 db.clone(),
1171 )
1172 .await
1173 .unwrap();
1174 let unchanged = AdminUser::find_by_username("alice", &db)
1175 .await
1176 .unwrap()
1177 .unwrap();
1178 assert!(unchanged.has_totp());
1179
1180 run(reset(), "", db.clone()).await.unwrap();
1181
1182 let after = AdminUser::find_by_username("alice", &db)
1183 .await
1184 .unwrap()
1185 .unwrap();
1186 assert!(!after.has_totp());
1187 assert!(!after.has_pending_totp());
1188 assert_eq!(
1189 mfa::recovery_codes_remaining(after.id, db.clone())
1190 .await
1191 .unwrap(),
1192 0
1193 );
1194 assert!(
1195 AdminSession::list_all(Some(after.id), &db)
1196 .await
1197 .unwrap()
1198 .is_empty(),
1199 "a factor removed that left a live session behind is a change in name only"
1200 );
1201
1202 run(reset(), "", db).await.unwrap();
1204 }
1205
1206 #[tokio::test]
1207 async fn totp_recovery_codes_supersede_the_previous_set() {
1208 let db = db().await;
1209 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1210 .await
1211 .unwrap();
1212 let user = enrol("alice", db.clone()).await;
1213
1214 let before =
1215 crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
1216 .await
1217 .unwrap();
1218 assert_eq!(before.len(), 10);
1219
1220 run(
1221 totp(AdminUserTotpCommand::RecoveryCodes {
1222 username: "alice".to_string(),
1223 }),
1224 "",
1225 db.clone(),
1226 )
1227 .await
1228 .unwrap();
1229
1230 let after =
1231 crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
1232 .await
1233 .unwrap();
1234 assert_eq!(after.len(), 10);
1235 assert!(
1236 after
1237 .iter()
1238 .all(|code| before.iter().all(|old| old.id != code.id)),
1239 "the previous set must stop working"
1240 );
1241 }
1242
1243 #[tokio::test]
1246 async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
1247 let db = db().await;
1248 run(create("alice"), &format!("{GOOD}\n"), db.clone())
1249 .await
1250 .unwrap();
1251
1252 let error = run(
1253 totp(AdminUserTotpCommand::RecoveryCodes {
1254 username: "alice".to_string(),
1255 }),
1256 "",
1257 db,
1258 )
1259 .await
1260 .unwrap_err();
1261 assert!(error.0.contains("no second factor"), "{}", error.0);
1262 }
1263
1264 #[tokio::test]
1265 async fn every_totp_arm_refuses_an_unknown_operator() {
1266 let db = db().await;
1267 let expected = CliError("no such admin user: nobody".to_string());
1268
1269 for command in [
1270 AdminUserTotpCommand::Status {
1271 username: "nobody".to_string(),
1272 json: false,
1273 },
1274 AdminUserTotpCommand::Reset {
1275 username: "nobody".to_string(),
1276 },
1277 AdminUserTotpCommand::RecoveryCodes {
1278 username: "nobody".to_string(),
1279 },
1280 ] {
1281 assert_eq!(
1282 run(totp(command), "", db.clone()).await.unwrap_err(),
1283 expected
1284 );
1285 }
1286 }
1287}