diff options
Diffstat (limited to 'lib/rubygems/gemcutter_utilities.rb')
-rw-r--r-- | lib/rubygems/gemcutter_utilities.rb | 195 |
1 files changed, 148 insertions, 47 deletions
diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb index 0968e1a6f9..a8361b7ff1 100644 --- a/lib/rubygems/gemcutter_utilities.rb +++ b/lib/rubygems/gemcutter_utilities.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true -require_relative 'remote_fetcher' -require_relative 'text' + +require_relative "remote_fetcher" +require_relative "text" +require_relative "gemcutter_utilities/webauthn_listener" +require_relative "gemcutter_utilities/webauthn_poller" ## # Utility methods for using the RubyGems API. module Gem::GemcutterUtilities - ERROR_CODE = 1 - API_SCOPES = %i[index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks show_dashboard].freeze + API_SCOPES = [:index_rubygems, :push_rubygem, :yank_rubygem, :add_owner, :remove_owner, :access_webhooks].freeze + EXCLUSIVELY_API_SCOPES = [:show_dashboard].freeze include Gem::Text @@ -19,8 +22,8 @@ module Gem::GemcutterUtilities # Add the --key option def add_key_option - add_option('-k', '--key KEYNAME', Symbol, - 'Use the given API key', + add_option("-k", "--key KEYNAME", Symbol, + "Use the given API key", "from #{Gem.configuration.credentials_path}") do |value,options| options[:key] = value end @@ -30,9 +33,9 @@ module Gem::GemcutterUtilities # Add the --otp option def add_otp_option - add_option('--otp CODE', - 'Digit code for multifactor authentication', - 'You can also use the environment variable GEM_HOST_OTP_CODE') do |value, options| + add_option("--otp CODE", + "Digit code for multifactor authentication", + "You can also use the environment variable GEM_HOST_OTP_CODE") do |value, options| options[:otp] = value end end @@ -69,9 +72,8 @@ module Gem::GemcutterUtilities @host ||= begin - env_rubygems_host = ENV['RUBYGEMS_HOST'] - env_rubygems_host = nil if - env_rubygems_host and env_rubygems_host.empty? + env_rubygems_host = ENV["RUBYGEMS_HOST"] + env_rubygems_host = nil if env_rubygems_host&.empty? env_rubygems_host || configured_host end @@ -82,8 +84,8 @@ module Gem::GemcutterUtilities # # If +allowed_push_host+ metadata is present, then it will only allow that host. - def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, &block) - require 'net/http' + def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, credentials: {}, &block) + require_relative "vendored_net_http" self.host = host if host unless self.host @@ -92,8 +94,8 @@ module Gem::GemcutterUtilities end if allowed_push_host - allowed_host_uri = URI.parse(allowed_push_host) - host_uri = URI.parse(self.host) + allowed_host_uri = Gem::URI.parse(allowed_push_host) + host_uri = Gem::URI.parse(self.host) unless (host_uri.scheme == allowed_host_uri.scheme) && (host_uri.host == allowed_host_uri.host) alert_error "#{self.host.inspect} is not allowed by the gemspec, which only allows #{allowed_push_host.inspect}" @@ -101,11 +103,11 @@ module Gem::GemcutterUtilities end end - uri = URI.parse "#{self.host}/#{path}" + uri = Gem::URI.parse "#{self.host}/#{path}" response = request_with_otp(method, uri, &block) if mfa_unauthorized?(response) - ask_otp + fetch_otp(credentials) response = request_with_otp(method, uri, &block) end @@ -118,27 +120,27 @@ module Gem::GemcutterUtilities end def mfa_unauthorized?(response) - response.kind_of?(Net::HTTPUnauthorized) && response.body.start_with?('You have enabled multifactor authentication') + response.is_a?(Gem::Net::HTTPUnauthorized) && response.body.start_with?("You have enabled multifactor authentication") end def update_scope(scope) - sign_in_host = self.host + sign_in_host = host pretty_host = pretty_host(sign_in_host) update_scope_params = { scope => true } say "The existing key doesn't have access of #{scope} on #{pretty_host}. Please sign in to update access." - email = ask " Email: " - password = ask_for_password "Password: " + identifier = ask "Username/email: " + password = ask_for_password " Password: " response = rubygems_api_request(:put, "api/v1/api_key", sign_in_host, scope: scope) do |request| - request.basic_auth email, password + request.basic_auth identifier, password request["OTP"] = otp if otp - request.body = URI.encode_www_form({:api_key => api_key }.merge(update_scope_params)) + request.body = Gem::URI.encode_www_form({ api_key: api_key }.merge(update_scope_params)) end - with_response response do |resp| + with_response response do |_resp| say "Added #{scope} scope to the existing API key" end end @@ -148,27 +150,34 @@ module Gem::GemcutterUtilities # key. def sign_in(sign_in_host = nil, scope: nil) - sign_in_host ||= self.host + sign_in_host ||= host return if api_key pretty_host = pretty_host(sign_in_host) say "Enter your #{pretty_host} credentials." - say "Don't have an account yet? " + + say "Don't have an account yet? " \ "Create one at #{sign_in_host}/sign_up" - email = ask " Email: " - password = ask_for_password "Password: " + identifier = ask "Username/email: " + password = ask_for_password " Password: " say "\n" key_name = get_key_name(scope) scope_params = get_scope_params(scope) + profile = get_user_profile(identifier, password) + mfa_params = get_mfa_params(profile) + all_params = scope_params.merge(mfa_params) + warning = profile["warning"] + credentials = { identifier: identifier, password: password } + + say "#{warning}\n" if warning response = rubygems_api_request(:post, "api/v1/api_key", - sign_in_host, scope: scope) do |request| - request.basic_auth email, password + sign_in_host, credentials: credentials, scope: scope) do |request| + request.basic_auth identifier, password request["OTP"] = otp if otp - request.body = URI.encode_www_form({ name: key_name }.merge(scope_params)) + request.body = Gem::URI.encode_www_form({ name: key_name }.merge(all_params)) end with_response response do |resp| @@ -195,16 +204,23 @@ module Gem::GemcutterUtilities # block was given or shows the response body to the user. # # If the response was not successful, shows an error to the user including - # the +error_prefix+ and the response body. + # the +error_prefix+ and the response body. If the response was a permanent redirect, + # shows an error to the user including the redirect location. def with_response(response, error_prefix = nil) case response - when Net::HTTPSuccess then + when Gem::Net::HTTPSuccess then if block_given? yield response else say clean_text(response.body) end + when Gem::Net::HTTPPermanentRedirect, Gem::Net::HTTPRedirection then + message = "The request has redirected permanently to #{response["location"]}. Please check your defined push host URL." + message = "#{error_prefix}: #{message}" if error_prefix + + say clean_text(message) + terminate_interaction(ERROR_CODE) else message = response.body message = "#{error_prefix}: #{message}" if error_prefix @@ -219,7 +235,7 @@ module Gem::GemcutterUtilities # +response+ text and no otp provided by options. def set_api_key(host, key) - if host == Gem::DEFAULT_HOST + if default_host? Gem.configuration.rubygems_api_key = key else Gem.configuration.set_api_key host, key @@ -229,7 +245,7 @@ module Gem::GemcutterUtilities private def request_with_otp(method, uri, &block) - request_method = Net::HTTP.const_get method.to_s.capitalize + request_method = Gem::Net::HTTP.const_get method.to_s.capitalize Gem::RemoteFetcher.fetcher.request(uri, request_method) do |req| req["OTP"] = otp if otp @@ -237,29 +253,88 @@ module Gem::GemcutterUtilities end end - def ask_otp - say 'You have enabled multi-factor authentication. Please enter OTP code.' - options[:otp] = ask 'Code: ' + def fetch_otp(credentials) + options[:otp] = if webauthn_url = webauthn_verification_url(credentials) + server = TCPServer.new 0 + port = server.addr[1].to_s + + url_with_port = "#{webauthn_url}?port=#{port}" + say "You have enabled multi-factor authentication. Please visit #{url_with_port} to authenticate via security device. If you can't verify using WebAuthn but have OTP enabled, you can re-run the gem signin command with the `--otp [your_code]` option." + + threads = [WebauthnListener.listener_thread(host, server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)] + otp_thread = wait_for_otp_thread(*threads) + + threads.each(&:join) + + if error = otp_thread[:error] + alert_error error.message + terminate_interaction(1) + end + + say "You are verified with a security device. You may close the browser window." + otp_thread[:otp] + else + say "You have enabled multi-factor authentication. Please enter OTP code." + ask "Code: " + end + end + + def wait_for_otp_thread(*threads) + loop do + threads.each do |otp_thread| + return otp_thread unless otp_thread.alive? + end + sleep 0.1 + end + ensure + threads.each(&:exit) + end + + def webauthn_verification_url(credentials) + response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request| + if credentials.empty? + request.add_field "Authorization", api_key + else + request.basic_auth credentials[:identifier], credentials[:password] + end + end + response.is_a?(Gem::Net::HTTPSuccess) ? response.body : nil end def pretty_host(host) - if Gem::DEFAULT_HOST == host - 'RubyGems.org' + if default_host? + "RubyGems.org" else host end end def get_scope_params(scope) - scope_params = {} + scope_params = { index_rubygems: true } if scope scope_params = { scope => true } else - say "Please select scopes you want to enable for the API key (y/n)" - API_SCOPES.each do |scope| - selected = ask "#{scope} [y/N]: " - scope_params[scope] = true if selected =~ /^[yY](es)?$/ + say "The default access scope is:" + scope_params.each do |k, _v| + say " #{k}: y" + end + say "\n" + customise = ask_yes_no("Do you want to customise scopes?", false) + if customise + EXCLUSIVELY_API_SCOPES.each do |excl_scope| + selected = ask_yes_no("#{excl_scope} (exclusive scope, answering yes will not prompt for other scopes)", false) + next unless selected + + return { excl_scope => true } + end + + scope_params = {} + + API_SCOPES.each do |s| + selected = ask_yes_no(s.to_s, false) + scope_params[s] = true if selected + end end say "\n" end @@ -267,6 +342,32 @@ module Gem::GemcutterUtilities scope_params end + def default_host? + host == Gem::DEFAULT_HOST + end + + def get_user_profile(identifier, password) + return {} unless default_host? + + response = rubygems_api_request(:get, "api/v1/profile/me.yaml") do |request| + request.basic_auth identifier, password + end + + with_response response do |resp| + Gem::ConfigFile.load_with_rubygems_config_hash(clean_text(resp.body)) + end + end + + def get_mfa_params(profile) + mfa_level = profile["mfa"] + params = {} + if ["ui_only", "ui_and_gem_signin"].include?(mfa_level) + selected = ask_yes_no("Would you like to enable MFA for this key? (strongly recommended)") + params["mfa"] = true if selected + end + params + end + def get_key_name(scope) hostname = Socket.gethostname || "unknown-host" user = ENV["USER"] || ENV["USERNAME"] || "unknown-user" @@ -282,6 +383,6 @@ module Gem::GemcutterUtilities end def api_key_forbidden?(response) - response.kind_of?(Net::HTTPForbidden) && response.body.start_with?("The API key doesn't have access") + response.is_a?(Gem::Net::HTTPForbidden) && response.body.start_with?("The API key doesn't have access") end end |