mirror of
https://github.com/webmin/webmin.git
synced 2026-06-27 22:40:26 +01:00
Merge pull request #2771 from webmin/dev/useradmin-edit-ssh-keys
Add editable SSH public keys for existing Unix users
This commit is contained in:
@@ -221,11 +221,11 @@ print &ui_table_row(&hlink($text{'pass'}, "pass"),
|
||||
$text{'uedit_disabled'}, $disabled) : "")
|
||||
);
|
||||
|
||||
# Show SSH public key field, for new users
|
||||
if ($n eq '') {
|
||||
print &ui_table_row(&hlink($text{'sshkey'}, "sshkey"),
|
||||
&ui_textarea("sshkey", undef, 3, 60), 3);
|
||||
}
|
||||
# Show SSH public key field. Existing users only display the Webmin-managed
|
||||
# key, identified by our marker in authorized_keys; unrelated keys stay hidden.
|
||||
my $sshkey = $n ne '' ? &get_user_ssh_pubkey(\%uinfo) : undef;
|
||||
print &ui_table_row(&hlink($text{'sshkey'}, "sshkey"),
|
||||
&ui_textarea("sshkey", $sshkey, 4, 60), 3);
|
||||
|
||||
print &ui_table_end();
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<header>SSH public key</header>
|
||||
|
||||
If supplied, the SSH key will be added to the new user's
|
||||
<tt>authorized_keys</tt> file to allow an SSH login without a password using
|
||||
the matching private key. <p>
|
||||
If supplied, the SSH key will be added to the user's <tt>authorized_keys</tt>
|
||||
file to allow an SSH login without a password using the matching private key.
|
||||
For existing users, only the key managed by Webmin is shown and updated. Other
|
||||
keys in <tt>authorized_keys</tt> are left unchanged. <p>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -78,6 +78,14 @@ encrypted=Pre-encrypted password
|
||||
nochange=Leave unchanged
|
||||
clear=Normal password
|
||||
sshkey=SSH public key
|
||||
sshkey_eempty=Missing SSH public key
|
||||
sshkey_einvalid=Invalid SSH public key : $1
|
||||
sshkey_eformat=Unrecognized public key format
|
||||
sshkey_ehome=Cannot save SSH public key because the user's home directory does not exist
|
||||
sshkey_esshdir=Cannot save SSH public key because $1 is not a directory
|
||||
sshkey_esshdirlink=Cannot save SSH public key because the .ssh directory is a symbolic link
|
||||
sshkey_efilelink=Cannot save SSH public key because authorized_keys is a symbolic link
|
||||
sshkey_euser=Cannot save SSH public key because the user does not exist on this system
|
||||
home=Home directory
|
||||
uedit_auto=Automatic
|
||||
uedit_manual=Directory
|
||||
|
||||
@@ -46,7 +46,7 @@ $in{'gid'} =~ s/\r|\n//g;
|
||||
$in{'uid'} =~ s/\r|\n//g;
|
||||
$in{'uid'} = int($in{'uid'});
|
||||
$in{'othersh'} =~ s/\r|\n//g;
|
||||
$in{'sshkey'} =~ s/\r|\n//g;
|
||||
$in{'sshkey'} =~ s/\r//g;
|
||||
|
||||
# Validate username
|
||||
$user{'user'} = $in{'user'};
|
||||
@@ -232,11 +232,20 @@ else {
|
||||
}
|
||||
$real_home ||= $user{'home'};
|
||||
if (!$access{'autohome'}) {
|
||||
# Manual home directories must still be absolute and ACL-permitted.
|
||||
$user{'home'} =~ /^\// || &error(&text('usave_ehome', $in{'home'}));
|
||||
if (!&is_under_directory($access{'home'}, $user{'home'})) {
|
||||
&error(&text('usave_ehomepath', $user{'home'}));
|
||||
}
|
||||
}
|
||||
|
||||
# Normalize and validate once, before any user/group changes are committed.
|
||||
my $sshkey = &normalize_ssh_pubkey($in{'sshkey'});
|
||||
if ($sshkey) {
|
||||
# Empty field means "remove the Webmin-managed key"; non-empty must parse.
|
||||
my $sshkeyerr = &validate_ssh_pubkey($sshkey, $user{'user'});
|
||||
&error($sshkeyerr) if ($sshkeyerr);
|
||||
}
|
||||
$user{'shell'} = $in{'shell'};
|
||||
@sgnames = $config{'secmode'} == 2 ? &split_quoted_string($in{'sgid'})
|
||||
: split(/\r?\n/, $in{'sgid'});
|
||||
@@ -531,6 +540,13 @@ if (%ouser) {
|
||||
$user{'plainpass'} = $in{'pass'} if ($in{'passmode'} == 3);
|
||||
&modify_user(\%ouser, \%user);
|
||||
|
||||
# Add, update or remove the SSH public key managed by this module.
|
||||
# Use the real home path, if configured, because that is where files live.
|
||||
my %sshuser = %user;
|
||||
$sshuser{'home'} = $real_home;
|
||||
my $ssherr = &save_user_ssh_pubkey(\%sshuser, \%ouser, $sshkey);
|
||||
&error($ssherr) if ($ssherr);
|
||||
|
||||
# Rename group if needed and if possible
|
||||
if ($user{'user'} ne $ouser{'user'} &&
|
||||
$user{'gid'} == $ouser{'gid'} &&
|
||||
@@ -603,23 +619,13 @@ else {
|
||||
©_skel_files($uf, $real_home, $user{'uid'}, $user{'gid'});
|
||||
}
|
||||
|
||||
# Grant access from the given SSH key
|
||||
if ($in{'sshkey'} =~ /\S/ && -d $real_home) {
|
||||
my $sshdir = $real_home."/.ssh";
|
||||
if (!-e $sshdir) {
|
||||
&make_dir($sshdir, 0700);
|
||||
&set_ownership_permissions(
|
||||
$user{'uid'}, $user{'gid'}, 0700, $sshdir);
|
||||
}
|
||||
my $sshfile = $sshdir."/authorized_keys";
|
||||
my $ex = -e $sshfile;
|
||||
&open_tempfile(SSHFILE, ">>$sshfile");
|
||||
&print_tempfile(SSHFILE, $in{'sshkey'},"\n");
|
||||
&close_tempfile(SSHFILE);
|
||||
if (!$ex) {
|
||||
&set_ownership_permissions(
|
||||
$user{'uid'}, $user{'gid'}, 0600, $sshfile);
|
||||
}
|
||||
# Grant access from the given SSH key. For new users an empty field is a
|
||||
# no-op, so no .ssh directory is created unless a key was supplied.
|
||||
if ($sshkey) {
|
||||
my %sshuser = %user;
|
||||
$sshuser{'home'} = $real_home;
|
||||
my $ssherr = &save_user_ssh_pubkey(\%sshuser, undef, $sshkey);
|
||||
&error($ssherr) if ($ssherr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2853,4 +2853,283 @@ return -1 if ($?);
|
||||
return $out =~ /\(ALL\)\s+ALL|\(ALL\)\s+NOPASSWD:\s+ALL|\(ALL\s*:\s*ALL\)\s+ALL|\(ALL\s*:\s*ALL\)\s+NOPASSWD:\s+ALL/ ? 1 : 0;
|
||||
}
|
||||
|
||||
# escape_ssh_key_identifier_user(username)
|
||||
# Escapes a username for use inside the managed SSH public key marker.
|
||||
sub escape_ssh_key_identifier_user
|
||||
{
|
||||
my ($user) = @_;
|
||||
|
||||
# Keep normal usernames readable, but encode characters that would break the
|
||||
# bracketed marker or make it span multiple authorized_keys lines.
|
||||
$user =~ s/([\\\]\r\n\t])/sprintf("\\x%02X", ord($1))/ge;
|
||||
return $user;
|
||||
}
|
||||
|
||||
# unescape_ssh_key_identifier_user(username)
|
||||
# Reverses escape_ssh_key_identifier_user.
|
||||
sub unescape_ssh_key_identifier_user
|
||||
{
|
||||
my ($user) = @_;
|
||||
|
||||
# The marker stores only explicit \xHH escapes, so this is deterministic and
|
||||
# does not reinterpret ordinary backslashes in usernames.
|
||||
$user =~ s/\\x([0-9A-Fa-f]{2})/chr(hex($1))/ge;
|
||||
return $user;
|
||||
}
|
||||
|
||||
# get_ssh_key_identifier(&user)
|
||||
# Returns the marker used for the SSH public key managed by this module.
|
||||
sub get_ssh_key_identifier
|
||||
{
|
||||
my ($user) = @_;
|
||||
|
||||
# A readable marker lets admins recognize Webmin-managed keys in the file.
|
||||
return "[webmin-useradmin:".
|
||||
&escape_ssh_key_identifier_user($user->{'user'})."]";
|
||||
}
|
||||
|
||||
# normalize_ssh_pubkey(pubkey)
|
||||
# Returns a single-line SSH public key from form input.
|
||||
sub normalize_ssh_pubkey
|
||||
{
|
||||
my ($pubkey) = @_;
|
||||
|
||||
# Form input may wrap long keys. SSH wants one logical authorized_keys line.
|
||||
$pubkey =~ s/\r/ /g;
|
||||
$pubkey =~ s/\n/ /g;
|
||||
|
||||
# Collapse repeated whitespace so re-saving an unchanged key does not drift.
|
||||
$pubkey =~ s/^\s+|\s+$//g;
|
||||
$pubkey =~ s/\s+/ /g;
|
||||
return $pubkey;
|
||||
}
|
||||
|
||||
# ssh_pubkey_validation_user([preferred-user])
|
||||
# Returns a non-root local user to run ssh-keygen for key validation.
|
||||
sub ssh_pubkey_validation_user
|
||||
{
|
||||
my ($preferred) = @_;
|
||||
my %seen;
|
||||
|
||||
# Prefer the edited account, but never run the parser as UID 0.
|
||||
foreach my $user ($preferred, "nobody") {
|
||||
next if (!$user || $seen{$user}++);
|
||||
my @uinfo = getpwnam($user);
|
||||
next if (!@uinfo || $uinfo[2] == 0);
|
||||
return $uinfo[0];
|
||||
}
|
||||
|
||||
# Systems without a usable unprivileged account will use the regex fallback.
|
||||
return undef;
|
||||
}
|
||||
|
||||
# ssh_pubkey_validation_tempfile(pubkey)
|
||||
# Writes a public key to a read-only temp file for unprivileged validation.
|
||||
sub ssh_pubkey_validation_tempfile
|
||||
{
|
||||
my ($pubkey) = @_;
|
||||
my $tmpdir = &webmin_temp_dir_path(&tempname_dir_sys());
|
||||
|
||||
# Keep the temp file in a root-owned, non-writable directory under system temp.
|
||||
if (!-d $tmpdir) {
|
||||
&make_dir($tmpdir, 0755, 0) || return undef;
|
||||
}
|
||||
my @dst = lstat($tmpdir);
|
||||
my $dmode = @dst ? $dst[2] & 0777 : 0;
|
||||
return undef if (!@dst || !-d _ || $dst[4] != $<);
|
||||
return undef if ($dmode != 0755);
|
||||
|
||||
# Use Webmin's temp-file writer inside a root-owned directory.
|
||||
&seed_random();
|
||||
for(my $i = 0; $i < 20; $i++) {
|
||||
my $pubkeyfile = $tmpdir."/sshkey-".$$."-".
|
||||
int(rand(1000000000000)).".pub";
|
||||
next if (-e $pubkeyfile);
|
||||
&open_tempfile(SSHKEY, ">$pubkeyfile", 1) || return undef;
|
||||
&print_tempfile(SSHKEY, $pubkey."\n");
|
||||
if (!&close_tempfile(SSHKEY) || !chmod(0444, $pubkeyfile)) {
|
||||
&unlink_file($pubkeyfile);
|
||||
return undef;
|
||||
}
|
||||
push(@main::temporary_files, $pubkeyfile);
|
||||
return $pubkeyfile;
|
||||
}
|
||||
return undef;
|
||||
}
|
||||
|
||||
# validate_ssh_pubkey(pubkey, [run-as-user])
|
||||
# Returns an error message if a public key does not look usable.
|
||||
sub validate_ssh_pubkey
|
||||
{
|
||||
my ($pubkey, $run_as) = @_;
|
||||
|
||||
# Callers pass empty string only when they mean "remove"; validation is for add.
|
||||
return $text{'sshkey_eempty'} if (!$pubkey);
|
||||
my $ssh_keygen = &has_command("ssh-keygen");
|
||||
my $validation_user = &ssh_pubkey_validation_user($run_as);
|
||||
if ($ssh_keygen && $validation_user) {
|
||||
# Prefer OpenSSH's parser when available, but keep it unprivileged.
|
||||
my $pubkeyfile = &ssh_pubkey_validation_tempfile($pubkey);
|
||||
if (!$pubkeyfile) {
|
||||
$ssh_keygen = undef;
|
||||
}
|
||||
else {
|
||||
my ($out, $err);
|
||||
my $cmd = &command_as_user($validation_user, 0,
|
||||
$ssh_keygen, "-l", "-f",
|
||||
$pubkeyfile);
|
||||
&execute_command($cmd, undef, \$out, \$err);
|
||||
&unlink_file($pubkeyfile);
|
||||
if ($?) {
|
||||
# Strip the temp path from ssh-keygen errors before showing them.
|
||||
$err =~ s/\s+$//g;
|
||||
return &text('sshkey_einvalid',
|
||||
&html_escape($err || $text{'sshkey_eformat'}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((!$ssh_keygen || !$validation_user) && $pubkey !~
|
||||
/^(ssh-rsa|ssh-dss|ecdsa-sha2-nistp256|rsa-sha2-512|rsa-sha2-256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-ed25519|sk-ecdsa-sha2-nistp256|sk-ssh-ed25519)\s+\S+(?:\s+.*)?$/) {
|
||||
# Minimal fallback for systems without ssh-keygen.
|
||||
return &text('sshkey_einvalid', $text{'sshkey_eformat'});
|
||||
}
|
||||
return undef;
|
||||
}
|
||||
|
||||
# user_ssh_authorized_keys_file(&user)
|
||||
# Returns the user's authorized_keys file, if the home directory is usable.
|
||||
sub user_ssh_authorized_keys_file
|
||||
{
|
||||
my ($user) = @_;
|
||||
|
||||
# No home directory means there is nowhere safe to create .ssh.
|
||||
return undef if (!$user->{'home'} || !-d $user->{'home'});
|
||||
my $sshdir = $user->{'home'}."/.ssh";
|
||||
if (-l $sshdir) {
|
||||
# Refuse symlinks in the user-controlled path to avoid root-follow races.
|
||||
return (undef, $text{'sshkey_esshdirlink'});
|
||||
}
|
||||
if (-e $sshdir && !-d $sshdir) {
|
||||
# A non-directory .ssh cannot contain authorized_keys.
|
||||
return (undef, &text('sshkey_esshdir', "<tt>$sshdir</tt>"));
|
||||
}
|
||||
return ($sshdir."/authorized_keys", undef);
|
||||
}
|
||||
|
||||
# get_user_ssh_pubkey(&user)
|
||||
# Returns the SSH public key managed by this module, if any.
|
||||
sub get_user_ssh_pubkey
|
||||
{
|
||||
my ($user) = @_;
|
||||
my ($sshfile, $err) = &user_ssh_authorized_keys_file($user);
|
||||
|
||||
# Missing files simply mean Webmin has not managed a key for this user.
|
||||
return undef if (!$sshfile || !-f $sshfile || -l $sshfile);
|
||||
my $identifier = &get_ssh_key_identifier($user);
|
||||
|
||||
# Read as the target user so a raced path cannot expose root-only files. Fall
|
||||
# back to a plain read when the account is not resolvable locally (e.g. an
|
||||
# alternate password_file), so the edit page never fails just to show a key.
|
||||
my $lines;
|
||||
if (getpwnam($user->{'user'})) {
|
||||
$lines = &eval_as_unix_user($user->{'user'}, sub {
|
||||
return &read_file_lines($sshfile, 1);
|
||||
});
|
||||
}
|
||||
else {
|
||||
$lines = &read_file_lines($sshfile, 1);
|
||||
}
|
||||
foreach my $line (@$lines) {
|
||||
if ($line =~ /\s+\Q$identifier\E\s*$/) {
|
||||
# Only a marker at end-of-line counts; ordinary comments are ignored.
|
||||
$line =~ s/\s+\Q$identifier\E\s*$//;
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
return undef;
|
||||
}
|
||||
|
||||
# save_user_ssh_pubkey(&user, [&olduser], pubkey)
|
||||
# Adds, updates or deletes this module's managed SSH public key.
|
||||
sub save_user_ssh_pubkey
|
||||
{
|
||||
my ($user, $olduser, $pubkey) = @_;
|
||||
my ($sshfile, $err) = &user_ssh_authorized_keys_file($user);
|
||||
if (!$sshfile) {
|
||||
# Adding requires a real home; removing from a missing home is already done.
|
||||
return $pubkey ? $err || $text{'sshkey_ehome'} : undef;
|
||||
}
|
||||
my $sshdir = $user->{'home'}."/.ssh";
|
||||
my @pwent = getpwnam($user->{'user'});
|
||||
my $user_exists = scalar(@pwent);
|
||||
|
||||
# Clearing an empty/nonexistent setup should not create .ssh or authorized_keys.
|
||||
return undef if (!$pubkey && !-e $sshdir);
|
||||
|
||||
# All changes are made as the target user, so there must be a resolvable local
|
||||
# account to drop privileges to. Report this cleanly when adding or updating a
|
||||
# key, rather than failing later with an internal error.
|
||||
return $text{'sshkey_euser'} if ($pubkey && !$user_exists);
|
||||
if (!-d $sshdir) {
|
||||
# Create .ssh as the user, not root, to avoid symlink/ownership races.
|
||||
&eval_as_unix_user($user->{'user'}, sub {
|
||||
&make_dir($sshdir, 0700);
|
||||
chmod(0700, $sshdir);
|
||||
});
|
||||
}
|
||||
|
||||
# Refuse symlinked authorized_keys after .ssh creation, just before use.
|
||||
return $text{'sshkey_efilelink'} if (-l $sshfile);
|
||||
return undef if (!$pubkey && !-f $sshfile);
|
||||
return $text{'sshkey_euser'} if (!$user_exists);
|
||||
|
||||
my $identifier = &get_ssh_key_identifier($user);
|
||||
my $oldidentifier = $olduser ? &get_ssh_key_identifier($olduser)
|
||||
: $identifier;
|
||||
&eval_as_unix_user($user->{'user'}, sub {
|
||||
# Lock as the user before reading, otherwise a concurrent edit can be lost.
|
||||
&lock_file($sshfile);
|
||||
|
||||
my @lines;
|
||||
if (-f $sshfile) {
|
||||
# Read the existing file as the account owner, preserving all other keys.
|
||||
my $lines = &read_file_lines($sshfile, 1);
|
||||
@lines = @$lines;
|
||||
}
|
||||
|
||||
my $found;
|
||||
for (my $i = 0; $i < @lines; $i++) {
|
||||
if ($lines[$i] =~ /\s+\Q$oldidentifier\E\s*$/) {
|
||||
if ($pubkey) {
|
||||
# Replace the managed key and update the marker after rename.
|
||||
$lines[$i] = "$pubkey $identifier";
|
||||
}
|
||||
else {
|
||||
# Empty field removes only the Webmin-managed key.
|
||||
splice(@lines, $i, 1);
|
||||
}
|
||||
$found = 1;
|
||||
last;
|
||||
}
|
||||
}
|
||||
|
||||
# If no previous marker exists, add a new Webmin-managed entry.
|
||||
push(@lines, "$pubkey $identifier") if ($pubkey && !$found);
|
||||
|
||||
# Removing a non-existent managed key is a no-op.
|
||||
if (!$pubkey && !$found) {
|
||||
&unlock_file($sshfile);
|
||||
return;
|
||||
}
|
||||
|
||||
my $contents = join("", map { $_."\n" } @lines);
|
||||
|
||||
# Write and chmod as the target user; root must not follow this path.
|
||||
&write_file_contents($sshfile, $contents);
|
||||
chmod(0600, $sshfile);
|
||||
&unlock_file($sshfile);
|
||||
});
|
||||
return undef;
|
||||
}
|
||||
|
||||
1;
|
||||
|
||||
Reference in New Issue
Block a user