diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 05054f95..9fe97ac3 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -91,7 +91,19 @@ Two behaviours worth knowing before you rely on them: Passwords are deliberately out of scope here: regenerating a credential is a different intent from editing an attribute, and folding them means -an attribute edit can invalidate a login by accident. +an attribute edit can invalidate a login by accident. Resetting one is a +separate verb, `user reset-password`, which generates and hashes a new +password the same way `user add` does and refuses on an unknown user: + +```console +# swarmctl user reset-password mara +reset password for mara in /var/lib/authelia-swarm/users.yml +password: +this password is stored nowhere — record it now +``` + +Authelia's file store has no SMTP notifier, so this is the only way a +human account gets a new password once an operator forgets the old one. ## What secrets exist, and where each one lives diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 39d69995..810ab195 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -11,6 +11,7 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token) * [`swarmctl user`↴](#swarmctl-user) * [`swarmctl user add`↴](#swarmctl-user-add) +* [`swarmctl user reset-password`↴](#swarmctl-user-reset-password) * [`swarmctl user update`↴](#swarmctl-user-update) * [`swarmctl user list`↴](#swarmctl-user-list) * [`swarmctl forge`↴](#swarmctl-forge) @@ -137,6 +138,7 @@ Manage subjects in the swarm's SSO provider ###### **Subcommands:** * `add` — Add a user, generating a password for them +* `reset-password` — Set a new password for an existing user, generating it the same way `user add` does * `update` — Change an existing user's attributes * `list` — List every user in authelia's users database @@ -160,6 +162,20 @@ Add a user, generating a password for them +## `swarmctl user reset-password` + +Set a new password for an existing user, generating it the same way `user add` does. + +authelia's file store has no self-service reset (no SMTP notifier), so this is the only way a human account gets a new password once an operator forgets the old one. Refuses if the user doesn't exist — there's no separate "create" path here. + +**Usage:** `swarmctl user reset-password ` + +###### **Arguments:** + +* `` — Login name of an existing user + + + ## `swarmctl user update` Change an existing user's attributes. diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index eb041618..72276028 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -278,6 +278,14 @@ struct ForgeMakeAdminArgs { enum UserVerb { /// Add a user, generating a password for them. Add(AddArgs), + /// Set a new password for an existing user, generating it the same way + /// `user add` does. + /// + /// authelia's file store has no self-service reset (no SMTP notifier), + /// so this is the only way a human account gets a new password once an + /// operator forgets the old one. Refuses if the user doesn't exist — + /// there's no separate "create" path here. + ResetPassword(ResetPasswordArgs), /// Change an existing user's attributes. /// /// Every flag is optional and they compose, so one call can set @@ -310,6 +318,12 @@ struct AddArgs { groups: Vec, } +#[derive(Args)] +struct ResetPasswordArgs { + /// Login name of an existing user. + username: String, +} + #[derive(Args)] struct UpdateArgs { /// Login name of an existing user. @@ -369,6 +383,9 @@ fn main() -> Result<()> { Verb::User { command: UserVerb::Add(args), } => user_add(&paths.resolve()?, args), + Verb::User { + command: UserVerb::ResetPassword(args), + } => user_reset_password(&paths.resolve()?, &args), Verb::User { command: UserVerb::Update(args), } => user_update(&paths.resolve()?, args), @@ -432,6 +449,31 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> { Ok(()) } +fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> { + let mut store = load_store(&paths.users_file)?; + let Some(user) = store.users.get_mut(&args.username) else { + bail!( + "no user {:?} in {} — `swarmctl user add` creates one", + args.username, + paths.users_file.display() + ); + }; + + let generated = generate_password(&paths.authelia_bin)?; + users::reset_password(user, generated.digest); + + publish(paths, &mut store)?; + + println!( + "reset password for {} in {}", + args.username, + paths.users_file.display() + ); + println!("password: {}", generated.password); + println!("this password is stored nowhere — record it now"); + Ok(()) +} + fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> { let mut store = load_store(&paths.users_file)?; let Some(user) = store.users.get_mut(&args.username) else { @@ -791,6 +833,49 @@ mod tests { ); } + #[test] + fn reset_password_takes_only_a_username() { + let cli = Cli::try_parse_from(["swarmctl", "user", "reset-password", "mara"]) + .expect("the minimal form parses"); + let Verb::User { + command: UserVerb::ResetPassword(args), + } = cli.command + else { + panic!("expected `user reset-password`"); + }; + assert_eq!(args.username, "mara"); + assert!( + Cli::try_parse_from(["swarmctl", "user", "reset-password"]).is_err(), + "the user must be named" + ); + } + + /// The unknown-user check runs before authelia is ever invoked, so this + /// exercises it with a bogus `authelia_bin` — it would fail loudly if + /// this ever started shelling out before checking the user exists. + #[test] + fn reset_password_on_an_unknown_user_is_an_error_before_touching_authelia() { + let dir = std::env::temp_dir().join(format!("swarmctl-reset-{}", std::process::id())); + fs::create_dir_all(&dir).expect("temp dir"); + let users_file = dir.join("users.yml"); + fs::write(&users_file, "users: {}\n").expect("seed"); + + let paths = Paths { + authelia_bin: PathBuf::from("/nonexistent/authelia"), + users_file, + }; + let err = user_reset_password( + &paths, + &ResetPasswordArgs { + username: "mara".to_owned(), + }, + ) + .expect_err("resetting a user that doesn't exist must fail"); + assert!(err.to_string().contains("no user"), "{err}"); + + fs::remove_dir_all(&dir).ok(); + } + #[test] fn parses_authelia_hash_output() { let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n"; diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs index e6a3b99b..c4e9af27 100644 --- a/swarmctl/src/users.rs +++ b/swarmctl/src/users.rs @@ -287,6 +287,18 @@ pub fn apply_update(user: &mut User, update: &UserUpdate) -> Result> Ok(changes) } +/// Replace a user's password digest, leaving every other field untouched. +/// +/// A one-line function, but pulled out for the same reason [`apply_update`] +/// is: the caller has already looked the user up (and reports "no such +/// user" itself, the same way `user update` does), so what's left here is +/// exactly the part that's testable without a command line or a running +/// authelia — the digest comes from generating a real password, which +/// isn't. +pub fn reset_password(user: &mut User, digest: String) { + user.password = digest; +} + /// Group list for a message, so an empty one reads as a word rather than /// as a missing value. pub fn fmt_groups(groups: &[String]) -> String { @@ -632,6 +644,31 @@ users: assert_eq!(u.displayname, "Test User", "the user must be untouched"); } + /// The whole point of resetting one user's credential: the other + /// entries in the store, including their own passwords, must come out + /// exactly as they went in. + #[test] + fn reset_password_changes_only_the_targeted_users_digest() { + let mut store = UserStore::default(); + store + .users + .insert("mara".to_owned(), user("$argon2id$old-mara")); + store + .users + .insert("atlas".to_owned(), user("$argon2id$old-atlas")); + + reset_password( + store.users.get_mut("mara").expect("mara is in the store"), + "$argon2id$new-mara".to_owned(), + ); + + assert_eq!(store.users["mara"].password, "$argon2id$new-mara"); + assert_eq!( + store.users["atlas"].password, "$argon2id$old-atlas", + "an unrelated user's password must not change" + ); + } + /// Replaces `only_the_untouched_seed_reads_as_takeable`. That test /// pinned the seed check, which existed to decide whether overwriting /// `users.yml` was safe — a question that only arose because a *second*