mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
usermod: add -p/--password flag for pre-hashed passwords (#114)
Ansible's user module calls `usermod -p <hash>` to set passwords. This was the most significant gap for Ansible interop. Implementation: - Add PASSWORD option constant and clap argument (-p/--password) - Write hash directly to shadow entry, update last_change to today - Follows the same lock → read → mutate → atomic_write pattern Tests: - test_set_password: basic -p flag with hash verification - test_set_password_long_flag: --password long form - test_set_password_preserves_other_fields: all 9 shadow fields intact Also updated e2e Ansible playbook to use native `user: password:` parameter instead of the chpasswd -e workaround. Fixes #114
This commit is contained in:
@@ -35,6 +35,7 @@ mod options {
|
||||
pub const LOGIN: &str = "login";
|
||||
pub const SHELL: &str = "shell";
|
||||
pub const UID: &str = "uid";
|
||||
pub const PASSWORD: &str = "password";
|
||||
pub const ROOT: &str = "root";
|
||||
pub const PREFIX: &str = "prefix";
|
||||
pub const USER: &str = "USER";
|
||||
@@ -168,10 +169,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
let do_unlock = matches.get_flag(options::UNLOCK);
|
||||
let expire = matches.get_one::<String>(options::EXPIREDATE);
|
||||
let inactive = matches.get_one::<i64>(options::INACTIVE);
|
||||
let new_password = matches.get_one::<String>(options::PASSWORD);
|
||||
|
||||
let login_changing = new_login.is_some();
|
||||
if shadow_path.exists()
|
||||
&& (do_lock || do_unlock || expire.is_some() || inactive.is_some() || login_changing)
|
||||
&& (do_lock
|
||||
|| do_unlock
|
||||
|| expire.is_some()
|
||||
|| inactive.is_some()
|
||||
|| new_password.is_some()
|
||||
|| login_changing)
|
||||
{
|
||||
let slock = FileLock::acquire(&shadow_path)
|
||||
.map_err(|e| UsermodError::CantUpdate(format!("cannot lock shadow: {e}")))?;
|
||||
@@ -200,6 +207,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
if let Some(&i) = inactive {
|
||||
s.inactive_days = if i < 0 { None } else { Some(i) };
|
||||
}
|
||||
if let Some(pw) = new_password {
|
||||
s.passwd.clone_from(pw);
|
||||
s.last_change = Some(days_since_epoch());
|
||||
}
|
||||
if let Some(new_name) = new_login {
|
||||
s.name.clone_from(new_name);
|
||||
}
|
||||
@@ -287,6 +298,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn days_since_epoch() -> i64 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
.unwrap_or(0);
|
||||
now / 86400
|
||||
}
|
||||
|
||||
/// Recursively chown all files and directories under `path` that are owned by
|
||||
/// `old_uid` to `new_uid`. Files owned by other users are left untouched.
|
||||
///
|
||||
@@ -408,6 +427,13 @@ pub fn uu_app() -> Command {
|
||||
.value_name("NEW_LOGIN")
|
||||
.help("New login name"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::PASSWORD)
|
||||
.short('p')
|
||||
.long("password")
|
||||
.value_name("PASSWORD")
|
||||
.help("New encrypted password (crypt(3) hash)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::SHELL)
|
||||
.short('s')
|
||||
|
||||
@@ -369,3 +369,88 @@ fn test_uid_collision_fails() {
|
||||
let code = run_with_prefix(&dir, &["-u", "1001", "bob"]);
|
||||
assert_eq!(code, 4, "UID collision should exit 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_password() {
|
||||
if skip_unless_root() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = setup_prefix(
|
||||
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
|
||||
"testuser:*:19500:0:99999:7:::\n",
|
||||
"testuser:x:1000:\n",
|
||||
);
|
||||
|
||||
let hash = "$6$rounds=5000$saltsalt$hashvalue";
|
||||
let code = run_with_prefix(&dir, &["-p", hash, "testuser"]);
|
||||
assert_eq!(code, 0, "setting password should succeed");
|
||||
|
||||
let content = read_shadow(&dir);
|
||||
assert!(
|
||||
content.contains(&format!("testuser:{hash}:")),
|
||||
"shadow should contain the new hash, got: {content}"
|
||||
);
|
||||
// last_change should be updated (not 19500 anymore)
|
||||
assert!(
|
||||
!content.contains(":19500:"),
|
||||
"last_change should be updated from 19500, got: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_password_long_flag() {
|
||||
if skip_unless_root() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = setup_prefix(
|
||||
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
|
||||
"testuser:!:19500:0:99999:7:::\n",
|
||||
"testuser:x:1000:\n",
|
||||
);
|
||||
|
||||
let hash = "$6$newsalt$newhash";
|
||||
let code = run_with_prefix(&dir, &["--password", hash, "testuser"]);
|
||||
assert_eq!(code, 0, "--password long flag should succeed");
|
||||
|
||||
let content = read_shadow(&dir);
|
||||
assert!(
|
||||
content.contains(&format!("testuser:{hash}:")),
|
||||
"shadow should contain the new hash, got: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_password_preserves_other_fields() {
|
||||
if skip_unless_root() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = setup_prefix(
|
||||
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
|
||||
"testuser:$6$old:19500:1:90:14:30:20000:\n",
|
||||
"testuser:x:1000:\n",
|
||||
);
|
||||
|
||||
let hash = "$6$new$newhash";
|
||||
let code = run_with_prefix(&dir, &["-p", hash, "testuser"]);
|
||||
assert_eq!(code, 0);
|
||||
|
||||
let content = read_shadow(&dir);
|
||||
// min_age=1, max_age=90, warn=14, inactive=30, expire=20000 should be preserved
|
||||
let line = content
|
||||
.lines()
|
||||
.find(|l| l.starts_with("testuser:"))
|
||||
.unwrap();
|
||||
let fields: Vec<&str> = line.split(':').collect();
|
||||
assert_eq!(fields[0], "testuser");
|
||||
assert_eq!(fields[1], hash);
|
||||
// fields[2] = last_change (updated, not 19500)
|
||||
assert_ne!(fields[2], "19500", "last_change should be updated");
|
||||
assert_eq!(fields[3], "1", "min_age should be preserved");
|
||||
assert_eq!(fields[4], "90", "max_age should be preserved");
|
||||
assert_eq!(fields[5], "14", "warn_days should be preserved");
|
||||
assert_eq!(fields[6], "30", "inactive_days should be preserved");
|
||||
assert_eq!(fields[7], "20000", "expire_date should be preserved");
|
||||
}
|
||||
|
||||
@@ -64,12 +64,10 @@
|
||||
|
||||
# ── Modify ──────────────────────────────────────────────────────
|
||||
|
||||
# Set password via chpasswd -e (usermod -p not yet implemented)
|
||||
- name: Set user password via chpasswd
|
||||
ansible.builtin.shell: |
|
||||
echo "{{ test_user }}:$(openssl passwd -6 '{{ test_password }}')" | chpasswd -e
|
||||
changed_when: true
|
||||
no_log: true
|
||||
- name: Set user password
|
||||
ansible.builtin.user:
|
||||
name: "{{ test_user }}"
|
||||
password: "{{ test_password | password_hash('sha512') }}"
|
||||
|
||||
- name: Modify user shell
|
||||
ansible.builtin.user:
|
||||
|
||||
Reference in New Issue
Block a user