Skip to main content

acme_proxy/cli/
webadmin.rs

1//! `acme-proxy admin …` — the web admin's operators and their sessions.
2//!
3//! This is how the panel is bootstrapped: it has no sign-up page and never
4//! will, so the first operator is created here, from a shell on the host.
5//!
6//! ## The password never goes in argv
7//!
8//! There is deliberately no `--password` flag. argv is visible to every
9//! process on the host via `ps` and is routinely written to shell history —
10//! the same reasoning `upstream register` already applies to the EAB secret,
11//! and pinned by the same kind of negative test. A password arrives either
12//! through `--password-file` or on stdin.
13
14use 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    /// Manage the operators who can sign in to the web admin.
37    User {
38        #[command(subcommand)]
39        command: AdminUserCommand,
40    },
41    /// Inspect and revoke logged-in browser sessions.
42    Session {
43        #[command(subcommand)]
44        command: AdminSessionCommand,
45    },
46}
47
48#[derive(Subcommand)]
49pub enum AdminUserCommand {
50    /// Create an operator. The password is read from `--password-file`, or
51    /// from stdin.
52    Create {
53        username: String,
54        /// Read the password from this file instead of stdin. A single
55        /// trailing newline is stripped.
56        #[arg(long = "password-file")]
57        password_file: Option<PathBuf>,
58    },
59    /// List every operator. Never shows a password hash.
60    List {
61        #[arg(long)]
62        json: bool,
63    },
64    /// Replace an operator's password, revoking every session they hold.
65    Passwd {
66        username: String,
67        #[arg(long = "password-file")]
68        password_file: Option<PathBuf>,
69    },
70    /// Delete an operator and every session of theirs.
71    Delete { username: String },
72    /// Bar an operator from signing in, dropping their current sessions.
73    Disable { username: String },
74    /// Undo `disable`.
75    Enable { username: String },
76    /// Inspect or remove an operator's second factor.
77    Totp {
78        #[command(subcommand)]
79        command: AdminUserTotpCommand,
80    },
81}
82
83/// The operator-side half of the second factor.
84///
85/// There is deliberately **no `enrol`** here, and the omission is the same one
86/// that keeps a password out of argv: there is no way to enrol from a terminal
87/// that does not put the base32 secret into scrollback and the shell's own
88/// history. The panel shows it once, behind `Cache-Control: no-store`, on a
89/// loopback listener. What a shell is for is the case the panel cannot serve --
90/// an operator who has lost the factor and so cannot sign in to fix it.
91#[derive(Subcommand)]
92pub enum AdminUserTotpCommand {
93    /// Whether an operator has a second factor, and how many recovery codes
94    /// are left.
95    Status {
96        username: String,
97        #[arg(long)]
98        json: bool,
99    },
100    /// Remove an operator's second factor and every recovery code, and revoke
101    /// their sessions.
102    ///
103    /// The lockout lever: a lost phone is a shell command on the host, not a
104    /// database edit. Asks first, because it takes a security control away.
105    Reset { username: String },
106    /// Mint a fresh set of recovery codes, printed once. The previous set stops
107    /// working immediately.
108    RecoveryCodes { username: String },
109}
110
111#[derive(Subcommand)]
112pub enum AdminSessionCommand {
113    /// List live sessions, newest first.
114    List {
115        /// Only this operator's sessions.
116        #[arg(long)]
117        username: Option<String>,
118        #[arg(long)]
119        json: bool,
120    },
121    /// Revoke sessions: one operator's, or everyone's.
122    Revoke {
123        #[arg(long, conflicts_with = "all")]
124        user: Option<String>,
125        /// Revoke every session on the server.
126        #[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            // The id, not the password: nothing echoes a credential back.
166            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            // `None`: this is a change made on the operator's behalf, from a
260            // shell they are not signed in from, so there is no session to keep.
261            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            // The `eab create` treatment: printed once, stored one-way, and the
280            // previous set is already dead by the time this prints.
281            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
294/// Resolves a username, reporting an unknown one in words rather than as a
295/// silent no-op.
296async 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            // Resolved to an id first: `admin_sessions` carries the user id,
310            // and an unknown name must say so rather than quietly listing
311            // every session on the server.
312            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
361/// Reads a password from a file, or one line of `reader`.
362///
363/// The file form strips a single trailing newline, so
364/// `printf '%s\n' "$pw" > file` and `printf '%s' "$pw" > file` mean the same
365/// thing — an operator should not have to know which their editor wrote.
366fn 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            // No `rpassword`: echo suppression needs a real TTY, which would
378            // break the injectable-reader testability this whole layer is
379            // built on. Warn instead, and point at the flag that avoids it.
380            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            // Only the line terminator, never surrounding whitespace: a
392            // password may legitimately begin or end with a space.
393            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    /// Runs a command with a stdin that supplies `input`, against a default
424    /// configuration.
425    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    /// [`run`] with the configuration spelled out, for the tests that care
434    /// what [`PasswordContext::from_config`] derived from it.
435    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    /// The words the *configuration* produced have to reach the terminal, or
530    /// the operator is told their password is unacceptable and not why. This
531    /// is also the only test that proves `dispatch`'s `&Config` is threaded
532    /// all the way to `PasswordContext::from_config` rather than dropped.
533    #[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        // Neither attempt created anything.
560        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        // Declined: `yes` is false here, so the reader's "n" decides.
726        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        // Neither flag: refuse rather than guess which was meant.
842        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        // `--all` on an empty table is a no-op, not a failure.
889        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    // --- Second factor ----------------------------------------------------
904
905    fn totp(command: AdminUserTotpCommand) -> AdminCommand {
906        AdminCommand::User {
907            command: AdminUserCommand::Totp { command },
908        }
909    }
910
911    /// Enrols `username` through the operation layer, the way the panel would,
912    /// so the CLI arms have a real factor to act on.
913    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        // No factor.
949        run(status(false), "", db.clone()).await.unwrap();
950        run(status(true), "", db.clone()).await.unwrap();
951
952        // Enrolment started and never confirmed: still not a factor, but it
953        // must not read the same as "off" -- an operator who thinks they
954        // enrolled has no other way to find out.
955        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        // Confirmed.
967        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        // Declined: `yes` is false, so the reader's "n" decides and nothing
1003        // moves. Removing a security control is confirm-gated, unlike
1004        // `order revoke`, which only ever tightens trust.
1005        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        // Idempotent, and says so rather than asking again.
1045        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    /// Recovery codes for an operator with no factor would recover nothing, so
1086    /// the command says so rather than minting ten useless strings.
1087    #[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}