summaryrefslogtreecommitdiff
path: root/lib/rubygems/gemcutter_utilities.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rubygems/gemcutter_utilities.rb')
-rw-r--r--lib/rubygems/gemcutter_utilities.rb195
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