diff options
Diffstat (limited to 'lib/rubygems/gemcutter_utilities.rb')
-rw-r--r-- | lib/rubygems/gemcutter_utilities.rb | 148 |
1 files changed, 105 insertions, 43 deletions
diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb index 3477422b79..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 "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 @@ -70,8 +73,7 @@ module Gem::GemcutterUtilities @host ||= begin env_rubygems_host = ENV["RUBYGEMS_HOST"] - env_rubygems_host = nil if - env_rubygems_host && env_rubygems_host.empty? + 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,33 +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(email, password) + 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(all_params)) + request.body = Gem::URI.encode_www_form({ name: key_name }.merge(all_params)) end with_response response do |resp| @@ -206,14 +209,14 @@ module Gem::GemcutterUtilities 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 Net::HTTPPermanentRedirect, Net::HTTPRedirection then - message = "The request has redirected permanently to #{response['location']}. Please check your defined push host URL." + 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) @@ -242,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 @@ -250,9 +253,52 @@ 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) @@ -264,15 +310,31 @@ module Gem::GemcutterUtilities 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_yes_no("#{scope}", false) - scope_params[scope] = true if selected + 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 @@ -281,25 +343,25 @@ module Gem::GemcutterUtilities end def default_host? - self.host == Gem::DEFAULT_HOST + host == Gem::DEFAULT_HOST end - def get_user_profile(email, password) + 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 email, password + request.basic_auth identifier, password end with_response response do |resp| - Gem::SafeYAML.load clean_text(resp.body) + Gem::ConfigFile.load_with_rubygems_config_hash(clean_text(resp.body)) end end def get_mfa_params(profile) mfa_level = profile["mfa"] params = {} - if mfa_level == "ui_only" || mfa_level == "ui_and_gem_signin" + 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 @@ -321,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 |