From 0e24eecce1f7e6f0bdfa06a4fde934eccd1469a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 8 Nov 2018 08:05:32 +0000 Subject: [PATCH] Add support to manage authorized keys through the UI --- src/include/users/dialogs.rb | 117 ++++++++++++++++++++++++++++++ src/modules/Users.pm | 29 ++++---- src/modules/UsersPasswd.pm | 1 + test/dialogs_test.rb | 136 +++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 12 deletions(-) diff --git a/src/include/users/dialogs.rb b/src/include/users/dialogs.rb index a673673c9..f52fe93d9 100644 --- a/src/include/users/dialogs.rb +++ b/src/include/users/dialogs.rb @@ -26,6 +26,10 @@ # Jiri Suchomel # # $Id$ + +require "users/ssh_public_key" +require "yast2/popup" + module Yast module UsersDialogsInclude def initialize_users_dialogs(include_target) @@ -903,6 +907,10 @@ def EditUserDialog(what) ) end + if user["type"] == "local" || user["uid"] == "root" + tabs << Item(Id(:authorized_keys), _("SSH Public Keys")) + end + # Now initialize the list of plugins: we must know now if there is some available. # UsersPlugins will filter out plugins we cannot use for given type plugin_clients = UsersPlugins.Apply( @@ -1593,6 +1601,10 @@ def EditUserDialog(what) end end + if current == :authorized_keys + handle_authorized_keys_input(ret, user) + end + # inside plugins dialog if current == :plugins plugin_client = Convert.to_string( @@ -1778,6 +1790,11 @@ def EditUserDialog(what) Wizard.SetHelpText(EditUserPasswordDialogHelp()) current = ret end + if ret == :authorized_keys + display_authorized_keys_tab(user) + Wizard.SetHelpText("Some useful help!") + current = ret + end if ret == :plugins UI.ReplaceWidget(:tabContents, get_plugins_term.call) Wizard.SetHelpText(PluginDialogHelp()) @@ -2469,5 +2486,105 @@ def GroupSave Users.CommitGroup :next end + + # Handles authorized keys list events + # + # @param action [Symbol] Action to handle (:add_authorized_key and :remove_authorized_key) + # @param user [Hash] User to update + def handle_authorized_keys_input(action, user) + case action + when :add_authorized_key + add_authorized_key(user) + when :remove_authorized_key + remove_authorized_key(user) + end + end + + # Adds an authorized key to the list + # + # @note This method drives the UI and handle error conditions + # + # @param user [Hash] User to update + def add_authorized_key(user) + key = read_public_key + return if key.nil? + if user.fetch("authorized_keys", []).include?(key.to_s) + Yast2::Popup.show( + _("The selected public key is already present in the list"), headline: :error + ) + return + end + + user["authorized_keys"] ||= [] + user["authorized_keys"] << key.to_s + display_authorized_keys_tab(user) + end + + # Asks for the path and retrieves the public key + def read_public_key + path = Yast::UI.AskForExistingFile("", "*.pub", _("Select a public key")) + return if path.nil? + Y2Users::SSHPublicKey.new(File.read(path)) + rescue Y2Users::SSHPublicKey::InvalidKey + Yast2::Popup.show( + _("The selected file does not contain a valid public key"), headline: :error + ) + rescue Errno::ENOENT + Yast2::Popup.show( + _("Could not read the file containing the public key"), headline: :error + ) + end + + # Removes the selected key from the list + # + # @param user [Hash] User to update + def remove_authorized_key(user) + selected_row = UI.QueryWidget(Id(:authorized_keys_table), :CurrentItem) + user["authorized_keys"].delete_at(selected_row) + + rows_qty = UI.QueryWidget(Id(:authorized_keys_table), :Items).size - 1 + next_selected_row = selected_row == rows_qty ? selected_row - 1 : selected_row + display_authorized_keys_tab(user, next_selected_row) + end + + # Displays the authorized keys tab + # + # @param user [Hash] User to update + # @param selected_row [Integer] Current selected row + def display_authorized_keys_tab(user, selected_row = nil) + UI.ReplaceWidget(:tabContents, get_authorized_keys_term(user)) + UI.SetFocus(Id(:authorized_keys_table)) + UI.ChangeWidget(Id(:authorized_keys_table), :CurrentItem, selected_row) if selected_row + no_keys = user.fetch("authorized_keys", []).empty? + UI.ChangeWidget(Id(:remove_authorized_key), :Enabled, !no_keys) + end + + + # Generates content for the authorized keys tab + # + # @param user [Hash] User to get the list of authorized keys + def get_authorized_keys_term(user) + items = user.fetch("authorized_keys", []).each_with_index.map do |content, idx| + key = Y2Users::SSHPublicKey.new(content) + Item(Id(idx), key.formatted_fingerprint, key.comment) + end + + VBox( + Table( + Id(:authorized_keys_table), + Opt(:notify), + Header( + _("Fingerprint"), + _("Comment") + ), + items + ), + HBox( + PushButton(Id(:add_authorized_key), _("Add...")), + PushButton(Id(:remove_authorized_key), Yast::Label.RemoveButton), + HStretch() + ) + ) + end end end diff --git a/src/modules/Users.pm b/src/modules/Users.pm index c31581ac7..511560e77 100644 --- a/src/modules/Users.pm +++ b/src/modules/Users.pm @@ -3051,6 +3051,10 @@ sub AddUser { $user_in_work{"type"} = $type; $user_in_work{"what"} = "add_user"; + if (defined($data{"authorized_keys"})) { + $user_in_work{"authorized_keys"} = $data{"authorized_keys"}; + } + UsersCache->SetUserType ($type); if (!defined $user_in_work{"uidNumber"}) { @@ -3361,6 +3365,10 @@ sub UserReallyModified { if (($user{"plugin_modified"} || 0) == 1) { return 1; #TODO save special plugin_modified global value? } + + # check the list of authorized keys + return 1 unless ($user{"authorized_keys"} ~~ $org_user{"authorized_keys"}); + # grouplist can be ignored, it is a modification of groups while ( my ($key, $value) = each %org_user) { last if $ret; @@ -4485,8 +4493,6 @@ sub Write { $mode = $user{"home_mode"}; } UsersRoutines->ChmodHome($home, $mode); - # Write authorized keys to user's home (FATE#319471) - SSHAuthorizedKeys->write_keys($home); } } Syslog->Log ("User added by YaST: name=$username, uid=$uid, gid=$gid, home=$home"); @@ -4534,6 +4540,11 @@ sub Write { } } } + + # Write authorized keys to user's home (FATE#319471) + my $authorized_keys = $user{"authorized_keys"}; + SSHAuthorizedKeys->import_keys($home, $authorized_keys); + SSHAuthorizedKeys->write_keys($home); } } } @@ -5894,6 +5905,8 @@ sub ImportUser { "grouplist" => \%grouplist, "homeDirectory" => $user->{"homeDirectory"} || $user->{"home"} || $home, "type" => $type, + # Import authorized keys from profile (FATE#319471) + "authorized_keys" => $user->{"authorized_keys"}, "modified" => "imported" ); } @@ -5925,10 +5938,6 @@ sub ImportUser { $ret{"shadowLastChange"} = LastChangeIsNow (); } - # Import authorized keys from profile (FATE#319471) - if ($user->{"authorized_keys"} && $ret{"homeDirectory"}) { - SSHAuthorizedKeys->import_keys($ret{"homeDirectory"}, $user->{"authorized_keys"}); - } return \%ret; } @@ -6437,12 +6446,8 @@ sub ExportUser { if (%user_shadow) { $ret{"password_settings"} = \%user_shadow; } - if ($user->{"homeDirectory"}) { - # Export authorized keys to profile (FATE#319471) - my $keys = SSHAuthorizedKeys->export_keys($user->{"homeDirectory"}); - if (@$keys) { - $ret{"authorized_keys"} = $keys; - } + if ($user->{"homeDirectory"} && $user->{"authorized_keys"}) { + $ret{"authorized_keys"} = $user->{"authorized_keys"}; } return \%ret; } diff --git a/src/modules/UsersPasswd.pm b/src/modules/UsersPasswd.pm index 7b7fab324..905d26e70 100644 --- a/src/modules/UsersPasswd.pm +++ b/src/modules/UsersPasswd.pm @@ -304,6 +304,7 @@ sub read_group { sub read_authorized_keys { foreach my $user (values %{$users{"local"}}) { SSHAuthorizedKeys->read_keys($user->{"homeDirectory"}); + $user->{"authorized_keys"} = SSHAuthorizedKeys->export_keys($user->{"homeDirectory"}); } # Read authorized keys also from root's home (bsc#1066342) diff --git a/test/dialogs_test.rb b/test/dialogs_test.rb index 2ab1c8107..945699d14 100755 --- a/test/dialogs_test.rb +++ b/test/dialogs_test.rb @@ -46,4 +46,140 @@ def initialize expect(exp_date).to eq("1970-01-31") end end + + context "public keys handling" do + let(:user) do + { "username" => "root", "authorized_keys" => authorized_keys } + end + + describe "#handle_authorized_keys_input" do + let(:key1) { instance_double(Y2Users::SSHPublicKey, to_s: "ssh-rsa 1...") } + let(:key2) { instance_double(Y2Users::SSHPublicKey, to_s: "ssh-rsa 2...") } + + before do + allow(subject).to receive(:read_public_key).and_return(key1) + end + + context "when the user adds a public key" do + let(:authorized_keys) { [] } + + it "adds the public key to the user" do + subject.handle_authorized_keys_input(:add_authorized_key, user) + expect(user["authorized_keys"]).to_not be_empty + end + + context "and the public key was already selected" do + let(:authorized_keys) { [key1.to_s] } + + it "displays an error" do + expect(Yast2::Popup).to receive(:show) + .with("The selected public key is already present in the list", headline: :error) + subject.handle_authorized_keys_input(:add_authorized_key, user) + end + end + end + + context "when the user removes a public key" do + let(:authorized_keys) { [key1.to_s, key2.to_s] } + + before do + allow(Yast::UI).to receive(:QueryWidget).with(Id(:authorized_keys_table), :CurrentItem) + .and_return(1) + allow(Yast::UI).to receive(:QueryWidget).with(Id(:authorized_keys_table), :Items) + .and_return([Item(Id(0), "fingerprint#1", "comment#1")]) + end + + it "removes the public key from the user" do + subject.handle_authorized_keys_input(:remove_authorized_key, user) + expect(user["authorized_keys"]).to eq([key1.to_s]) + end + end + end + + describe "#read_public_key" do + let(:path) { FIXTURES_PATH.join("id_rsa.pub").to_s } + + before do + allow(Yast::UI).to receive(:AskForExistingFile).and_return(path) + end + + context "when the user selects a file" do + it "returns the public key" do + key = subject.read_public_key + expect(key.comment).to eq("dummy1@example.net") + end + end + + context "when the user selects an invalid file" do + let(:path) { FIXTURES_PATH.join("users.yml").to_s } + + it "displays an error" do + expect(Yast2::Popup).to receive(:show) + .with("The selected file does not contain a valid public key", headline: :error) + subject.read_public_key + end + end + + context "when the user file that does not exist" do + let(:path) { FIXTURES_PATH.join("non-existent").to_s } + + it "displays an error" do + expect(Yast2::Popup).to receive(:show) + .with("Could not read the file containing the public key", headline: :error) + subject.read_public_key + end + end + + context "when the user cancels the dialog" do + let(:path) { nil } + + it "returns nil" do + expect(subject.read_public_key).to eq(nil) + end + end + end + + describe "#display_authorized_keys_tab" do + let(:user) do + { "username" => "root", "authorized_keys" => authorized_keys } + end + + before do + allow(Yast::UI).to receive(:ChangeWidget) + .with(Id(:remove_authorized_key), :Enabled, anything) + end + + context "when a public keys is found" do + let(:authorized_keys) { ["ssh-rsa ..."] } + + it "enables the 'remove' button" do + expect(Yast::UI).to receive(:ChangeWidget) + .with(Id(:remove_authorized_key), :Enabled, true) + subject.display_authorized_keys_tab(user) + end + end + + context "when no public keys are found" do + let(:authorized_keys) { [] } + + it "disables the 'remove' button" do + expect(Yast::UI).to receive(:ChangeWidget) + .with(Id(:remove_authorized_key), :Enabled, false) + subject.display_authorized_keys_tab(user) + end + end + + context "when a row is selected" do + let(:authorized_keys) { ["ssh-rsa ..."] } + + it "selects the corresponding row in the table" do + expect(Yast::UI).to receive(:ChangeWidget) + .with(Id(:authorized_keys_table), :CurrentItem, 0) + subject.display_authorized_keys_tab(user, 0) + end + end + end + + describe "#get_authorized_keys_term" + end end