Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: add user reset-password

authelia's file backend has no self-service reset (no SMTP notifier),
so the only way a human account got a new password after the old one
was forgotten was hand-editing users.yml as root. `user add` already
hashes a password into the file; this verb does the same for an
existing user instead of refusing on the name.

Mirrors `user add`'s UX exactly: no password flag, authelia generates
and hashes it (never crosses argv), and it's printed once and never
stored. Refuses on an unknown user before ever invoking authelia. Same
publish path as add/update, so the same atomic write and no-restart
(authelia watches the file) behaviour apply.

Split the digest-replacement into users::reset_password so it's
testable without a command line or a running authelia, same pattern
as apply_update.
This commit is contained in:
atlas 2026-09-29 12:31:21 +02:00
commit 69ae23f801
4 changed files with 151 additions and 1 deletions

View file

@ -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: <generated>
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

View file

@ -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 <USERNAME>`
###### **Arguments:**
* `<USERNAME>` — Login name of an existing user
## `swarmctl user update`
Change an existing user's attributes.

View file

@ -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<String>,
}
#[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";

View file

@ -287,6 +287,18 @@ pub fn apply_update(user: &mut User, update: &UserUpdate) -> Result<Vec<String>>
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*