Fix password reset handling for Unix-backed Webmin users

ⓘ Warn when a Webmin user matches a Unix account and allow choosing Unix authentication or a separate Webmin-only password. Preserve explicit and legacy password behavior with regression coverage.

https://forum.virtualmin.com/t/resetting-root-admin-password/137615/4
This commit is contained in:
Ilia Ross
2026-07-23 17:25:39 +02:00
parent ac62ad5f94
commit 7e41f339a3
3 changed files with 276 additions and 22 deletions
+160 -12
View File
@@ -19,6 +19,8 @@ sub main
'config|c=s' => \$opt{'config'},
'user|u=s' => \$opt{'user'},
'password|p=s' => \$opt{'password'},
'unix' => \$opt{'unix'},
'webmin-only|webmin' => \$opt{'webmin'},
'stdout|o!' => \$opt{'stdout'});
# If username passed as regular param
@@ -135,6 +137,29 @@ sub change_password
RESET, "\n");
}
# A Webmin user can either authenticate as a Unix user or have a separate
# password in miniserv.users. Make this distinction explicit whenever both
# accounts exist, as silently setting the latter overrides Unix
# authentication and leaves SSH and Webmin with different passwords.
my @unix_info = getpwnam($user);
my $target = choose_password_target(
$optref, $user, scalar(@unix_info));
if ($target eq 'unix') {
change_unix_password($user);
# Ensure Webmin uses the newly changed Unix password. No restart is
# needed when the account was already configured this way.
if (($uinfos{$user}->[0] // '') ne 'x') {
$uinfos{$user}->[0] = 'x';
map {$ulines{$_} = join(":", @{ $uinfos{$_} })} keys %uinfos;
store_webmin_users(
$confdif, $minserv_uconf_file, \%ulines);
}
say "Unix password for user ", BRIGHT_YELLOW, $user, RESET,
" updated successfully; Webmin will use Unix authentication";
exit 0;
}
# Ask for password on stdin
my $suc_pre_msg = "";
my $suc_msg = 'updated successfully';
@@ -171,13 +196,8 @@ sub change_password
$uinfos{$user}->[5] = time() if ($uinfos{$user}->[5]);
map {$ulines{$_} = join(":", @{ $uinfos{$_} })} keys %uinfos;
# Store original file first
copy_source_dest($minserv_uconf_file, "$minserv_uconf_file-");
# Restart Webmin and write new user config file
system("$confdif/stop >/dev/null 2>&1");
write_file($minserv_uconf_file, \%ulines, ":");
system("$confdif/start >/dev/null 2>&1");
# Write the new user config and restart Webmin
store_webmin_users($confdif, $minserv_uconf_file, \%ulines);
# Print user message
say "${suc_pre_msg}Password for Webmin user ", BRIGHT_YELLOW, $user, RESET, " $suc_msg";
@@ -185,6 +205,119 @@ sub change_password
exit 0;
}
sub choose_password_target
{
my ($optref, $user, $unix_exists, $interactive) = @_;
die BRIGHT_RED, "Error: ", RESET,
"The --unix and --webmin-only options cannot be used together\n"
if ($optref->{'unix'} && $optref->{'webmin'});
die BRIGHT_RED, "Error: ", RESET,
"The --unix and --stdout options cannot be used together\n"
if ($optref->{'unix'} && $optref->{'stdout'});
die BRIGHT_RED, "Error: ", RESET,
"The --unix and --password options cannot be used together; ",
"the system passwd command reads the password securely\n"
if ($optref->{'unix'} && defined($optref->{'password'}));
die BRIGHT_RED, "Error: ", RESET, "Unix user ", BRIGHT_YELLOW,
$user, RESET, " doesn't exist\n"
if ($optref->{'unix'} && !$unix_exists);
return 'webmin' if ($optref->{'stdout'} || !$unix_exists);
return 'unix' if ($optref->{'unix'});
return 'webmin' if ($optref->{'webmin'});
if (defined($optref->{'password'})) {
print STDERR unix_password_warning($user),
" The --password option explicitly sets a separate Webmin-only ",
"password; use --unix without --password to change the Unix ",
"password instead.\n";
return 'webmin';
}
$interactive = -t STDIN if (!defined($interactive));
if ($interactive) {
return prompt_password_target($user);
}
print STDERR unix_password_warning($user),
" Non-interactive input prevents asking which password to change; ",
"continuing with a separate Webmin-only password. Pass --unix or ",
"--webmin-only to select explicitly.\n";
return 'webmin';
}
sub prompt_password_target
{
my ($user) = @_;
say unix_password_warning($user);
say " 1. Change the Unix password and use Unix authentication in Webmin",
" (recommended)";
say " 2. Set a separate Webmin-only password in miniserv.users";
while (1) {
print "Select password type [1]: ";
my $choice = <STDIN>;
die BRIGHT_RED, "\nError: ", RESET,
"No password type was selected\n" if (!defined($choice));
chomp $choice;
$choice = lc($choice);
return 'unix' if ($choice eq '' || $choice eq '1' ||
$choice eq 'u' || $choice eq 'unix');
return 'webmin' if ($choice eq '2' || $choice eq 'w' ||
$choice eq 'webmin');
say BRIGHT_RED, "Invalid selection.", RESET,
" Enter 1 for Unix or 2 for Webmin-only.";
}
}
sub unix_password_warning
{
my ($user) = @_;
return BRIGHT_YELLOW . "Warning: " . RESET . "Webmin user " .
BRIGHT_YELLOW . $user . RESET .
" is also a Unix user. A separate Webmin password overrides Unix " .
"authentication, so Webmin and SSH can have different passwords.";
}
sub change_unix_password
{
my ($user) = @_;
my $passwd = has_command('passwd');
die BRIGHT_RED, "Error: ", RESET,
"The system passwd command could not be found\n" if (!$passwd);
die BRIGHT_RED, "Error: ", RESET,
"Changing a Unix password requires an interactive terminal. ",
"Run ", BRIGHT_YELLOW, "$passwd $user", RESET, " directly instead.\n"
if (!-t STDIN);
my $status = system { $passwd } $passwd, $user;
if ($status == -1) {
die BRIGHT_RED, "Error: ", RESET,
"Failed to run $passwd: $!\n";
}
elsif ($status & 127) {
die BRIGHT_RED, "Error: ", RESET,
"The system passwd command was interrupted\n";
}
elsif ($status >> 8) {
die BRIGHT_RED, "Error: ", RESET,
"The system passwd command failed\n";
}
}
sub store_webmin_users
{
my ($confdif, $minserv_uconf_file, $ulines) = @_;
# Store original file first
copy_source_dest($minserv_uconf_file, "$minserv_uconf_file-");
# Restart Webmin and write new user config file
system("$confdif/stop >/dev/null 2>&1");
write_file($minserv_uconf_file, $ulines, ":");
system("$confdif/start >/dev/null 2>&1");
}
sub root
{
my ($config, $conf_check) = @_;
@@ -221,7 +354,9 @@ sub root
=head1 DESCRIPTION
This program allows you to change the password of a user in the Webmin password file
This program allows you to change the password used by a Webmin user.
When a matching Unix user exists, it can change the Unix password or set a
separate password in the Webmin password file.
=head1 SYNOPSIS
@@ -238,8 +373,10 @@ sub root
Examples of usage:
- webmin passwd root
- webmin passwd --user root
- webmin passwd --user root --password ycwyMQRVAZY
- webmin passwd --config /usr/local/etc/webmin --user root --password ycwyMQRVAZY
- webmin passwd --user root --unix
- webmin passwd --user admin --webmin-only
- webmin passwd --user admin --webmin-only --password ycwyMQRVAZY
- webmin passwd --config /usr/local/etc/webmin --user admin --webmin-only --password ycwyMQRVAZY
- webmin passwd --config /usr/local/etc/webmin --user root --password ycwyMQRVAZY --stdout
=item --config, -c
@@ -252,7 +389,19 @@ sub root
=item --password, -p
Set new user password. Using this option may be unsecure.
Set a new Webmin-only password. Using this option may be insecure because the
password can be exposed in the process list.
=item --unix
Change the matching Unix user's password with the system C<passwd> command,
and configure Webmin to use Unix authentication. This is the recommended mode
when the Webmin username is also a Unix username.
=item --webmin-only, --webmin
Set a separate password in C<miniserv.users>, even if a matching Unix user
exists. This password overrides Unix authentication for the Webmin user.
=back
@@ -261,4 +410,3 @@ Set new user password. Using this option may be unsecure.
Copyright 2018 Jamie Cameron <jcameron@webmin.com>
Joe Cooper <joe@virtualmin.com>
Ilia Ross <ilia@virtualmin.com>
+4 -10
View File
@@ -10,16 +10,10 @@ $cwd =~ s/(.*)\/.*/$1/;
usage() if (@ARGV != 3);
my ($config, $user, $pass) = @ARGV;
my $status = system("$cwd/bin/webmin passwd --config $config --user $user --pass $pass");
if ($status != 0) {
if ($! =~ /no such file/i) {
print "Error: Webmin CLI command cannot be found\n";
}
else {
print "Error: $!\n";
}
}
exit $status;
exec "$cwd/bin/webmin", "passwd", "--webmin-only",
"--config", $config, "--user", $user, "--pass", $pass;
print STDERR "Error: Failed to execute Webmin CLI command: $!\n";
exit 1;
sub usage
{
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/perl
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
my $root = File::Spec->rel2abs(
File::Spec->catdir(dirname(__FILE__), '..'));
unshift(@INC, $root) if (!grep { $_ eq $root } @INC);
my $script = File::Spec->catfile($root, 'bin', 'passwd');
do $script or die "failed to load $script: $@ $!";
sub capture_stderr
{
my ($code) = @_;
my $output = '';
open(my $stderr, '>', \$output) or die "open captured stderr: $!";
{
local *STDERR = $stderr;
$code->();
}
return $output;
}
sub prompt_with
{
my ($input) = @_;
my $output = '';
open(my $stdin, '<', \$input) or die "open simulated stdin: $!";
open(my $stdout, '>', \$output) or die "open captured stdout: $!";
my $target;
{
local *STDIN = $stdin;
local *STDOUT = $stdout;
$target = prompt_password_target('root');
}
return ($target, $output);
}
is(choose_password_target({}, 'web-only', 0, 0), 'webmin',
'Webmin-only account does not need a target choice');
is(choose_password_target({ webmin => 1 }, 'root', 1, 0), 'webmin',
'Webmin-only target can be selected explicitly');
is(choose_password_target({ unix => 1 }, 'root', 1, 1), 'unix',
'Unix target can be selected explicitly');
is(choose_password_target({ stdout => 1 }, 'root', 1, 1), 'webmin',
'hash-only output does not prompt for a password target');
my $explicit_webmin_warning = capture_stderr(sub {
is(choose_password_target(
{ webmin => 1, password => 'secret' }, 'root', 1, 1),
'webmin',
'explicit Webmin-only password does not prompt');
});
is($explicit_webmin_warning, '',
'explicit Webmin-only password does not emit a Unix-user warning');
my $password_warning = capture_stderr(sub {
is(choose_password_target({ password => 'secret' }, 'root', 1, 1),
'webmin',
'command-line password explicitly selects Webmin-only behavior');
});
like($password_warning, qr/--password option explicitly sets/,
'command-line password warns about separate Webmin-only authentication');
my $warning = capture_stderr(sub {
is(choose_password_target({}, 'root', 1, 0), 'webmin',
'non-interactive compatibility path keeps Webmin-only behavior');
});
like($warning, qr/is also a Unix user/,
'non-interactive compatibility path warns about the matching Unix user');
like($warning, qr/--unix or --webmin-only/,
'non-interactive warning explains how to select a target');
my ($default_target, $default_output) = prompt_with("\n");
is($default_target, 'unix', 'interactive prompt defaults to Unix password');
like($default_output, qr/Unix authentication in Webmin.*recommended/s,
'interactive prompt labels Unix authentication as recommended');
like($default_output, qr/separate Webmin-only password/,
'interactive prompt explains the separate password choice');
my ($webmin_target) = prompt_with("2\n");
is($webmin_target, 'webmin',
'interactive prompt accepts the Webmin-only password choice');
my ($retry_target, $retry_output) = prompt_with("invalid\nu\n");
is($retry_target, 'unix', 'interactive prompt accepts Unix shorthand');
like($retry_output, qr/Invalid selection/,
'interactive prompt retries invalid selections');
foreach my $case (
[ { unix => 1, webmin => 1 }, qr/cannot be used together/,
'conflicting targets are rejected' ],
[ { unix => 1, stdout => 1 }, qr/cannot be used together/,
'Unix target cannot be combined with hash-only output' ],
[ { unix => 1, password => 'secret' }, qr/system passwd command/,
'Unix target rejects a command-line password' ],
[ { unix => 1 }, qr/doesn't exist/,
'Unix target requires a matching Unix user' ],
) {
my ($options, $error, $name) = @$case;
my $ok = eval {
choose_password_target($options, 'missing-user', 0, 0);
1;
};
ok(!$ok, $name);
like($@, $error, "$name reports the reason");
}
done_testing();