2016-02-01 15:43:26 +03:00
# frozen_string_literal: true
2023-03-17 12:36:42 +03:00
2019-04-22 14:56:16 +03:00
require_relative " remote_fetcher "
require_relative " text "
2023-02-15 18:52:32 +03:00
require_relative " webauthn_listener "
2010-02-22 05:52:35 +03:00
2013-09-14 12:59:02 +04:00
##
# Utility methods for using the RubyGems API.
2010-02-22 05:52:35 +03:00
module Gem::GemcutterUtilities
2019-07-26 07:11:37 +03:00
ERROR_CODE = 1
2023-03-16 07:32:03 +03:00
API_SCOPES = [ :index_rubygems , :push_rubygem , :yank_rubygem , :add_owner , :remove_owner , :access_webhooks , :show_dashboard ] . freeze
2019-07-26 07:11:37 +03:00
2019-03-05 06:32:58 +03:00
include Gem :: Text
2013-09-14 12:59:02 +04:00
attr_writer :host
2020-12-08 10:33:39 +03:00
attr_writer :scope
2013-09-14 12:59:02 +04:00
2011-03-01 12:41:32 +03:00
##
# Add the --key option
def add_key_option
2011-03-10 01:32:29 +03:00
add_option ( " -k " , " --key KEYNAME " , Symbol ,
" Use the given API key " ,
2020-04-23 13:16:06 +03:00
" from #{ Gem . configuration . credentials_path } " ) do | value , options |
2011-03-01 12:41:32 +03:00
options [ :key ] = value
end
end
2018-12-01 14:01:00 +03:00
##
# Add the --otp option
def add_otp_option
add_option ( " --otp CODE " ,
2021-07-13 14:58:08 +03:00
" Digit code for multifactor authentication " ,
" You can also use the environment variable GEM_HOST_OTP_CODE " ) do | value , options |
2018-12-01 14:01:00 +03:00
options [ :otp ] = value
end
end
2013-09-14 12:59:02 +04:00
##
# The API key from the command options or from the user's configuration.
2011-03-01 12:41:32 +03:00
def api_key
2019-01-22 09:28:04 +03:00
if ENV [ " GEM_HOST_API_KEY " ]
ENV [ " GEM_HOST_API_KEY " ]
elsif options [ :key ]
2011-03-01 12:41:32 +03:00
verify_api_key options [ :key ]
2012-11-29 10:52:18 +04:00
elsif Gem . configuration . api_keys . key? ( host )
Gem . configuration . api_keys [ host ]
2011-03-01 12:41:32 +03:00
else
Gem . configuration . rubygems_api_key
end
end
2010-02-22 05:52:35 +03:00
2021-07-07 08:07:29 +03:00
##
# The OTP code from the command options or from the user's configuration.
def otp
options [ :otp ] || ENV [ " GEM_HOST_OTP_CODE " ]
end
2013-09-14 12:59:02 +04:00
##
# The host to connect to either from the RUBYGEMS_HOST environment variable
# or from the user's configuration
2010-02-22 05:52:35 +03:00
2013-09-13 23:58:57 +04:00
def host
configured_host = Gem . host unless
Gem . configuration . disable_default_gem_server
2010-02-22 05:52:35 +03:00
2013-09-13 23:58:57 +04:00
@host || =
begin
env_rubygems_host = ENV [ " RUBYGEMS_HOST " ]
2022-12-26 08:00:11 +03:00
env_rubygems_host = nil if env_rubygems_host & . empty?
2013-09-13 23:58:57 +04:00
2019-02-14 15:59:03 +03:00
env_rubygems_host || configured_host
2013-09-13 23:58:57 +04:00
end
end
2013-09-14 12:59:02 +04:00
##
# Creates an RubyGems API to +host+ and +path+ with the given HTTP +method+.
2013-09-19 01:29:41 +04:00
#
# If +allowed_push_host+ metadata is present, then it will only allow that host.
2013-09-14 12:59:02 +04:00
2022-12-21 00:01:08 +03:00
def rubygems_api_request ( method , path , host = nil , allowed_push_host = nil , scope : nil , credentials : { } , & block )
2013-09-13 23:58:57 +04:00
require " net/http "
self . host = host if host
unless self . host
alert_error " You must specify a gem server "
2019-07-26 07:11:37 +03:00
terminate_interaction ( ERROR_CODE )
2012-11-29 10:52:18 +04:00
end
2013-09-13 23:58:57 +04:00
2016-02-01 15:43:26 +03:00
if allowed_push_host
allowed_host_uri = URI . parse ( allowed_push_host )
host_uri = 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 } "
2019-08-16 10:02:32 +03:00
terminate_interaction ( ERROR_CODE )
2016-02-01 15:43:26 +03:00
end
2013-09-19 01:29:41 +04:00
end
2013-09-13 23:58:57 +04:00
uri = URI . parse " #{ self . host } / #{ path } "
2020-12-18 06:13:33 +03:00
response = request_with_otp ( method , uri , & block )
2019-03-05 06:32:58 +03:00
2020-12-08 10:33:39 +03:00
if mfa_unauthorized? ( response )
2023-02-15 18:46:47 +03:00
fetch_otp ( credentials )
2020-12-18 06:13:33 +03:00
response = request_with_otp ( method , uri , & block )
2020-12-08 10:33:39 +03:00
end
if api_key_forbidden? ( response )
update_scope ( scope )
2020-12-18 06:13:33 +03:00
request_with_otp ( method , uri , & block )
2020-12-08 10:33:39 +03:00
else
response
2019-03-05 06:32:58 +03:00
end
end
2013-09-13 23:58:57 +04:00
2019-03-05 06:32:58 +03:00
def mfa_unauthorized? ( response )
2023-03-16 07:08:50 +03:00
response . is_a? ( Net :: HTTPUnauthorized ) && response . body . start_with? ( " You have enabled multifactor authentication " )
2019-03-05 06:32:58 +03:00
end
2020-12-08 10:33:39 +03:00
def update_scope ( scope )
2023-03-16 07:12:38 +03:00
sign_in_host = host
2020-12-08 10:33:39 +03:00
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: "
response = rubygems_api_request ( :put , " api/v1/api_key " ,
sign_in_host , scope : scope ) do | request |
request . basic_auth email , password
2021-07-07 08:07:29 +03:00
request [ " OTP " ] = otp if otp
2022-05-20 11:15:15 +03:00
request . body = URI . encode_www_form ( { :api_key = > api_key } . merge ( update_scope_params ) )
2020-12-08 10:33:39 +03:00
end
2023-03-16 04:46:45 +03:00
with_response response do | _resp |
2020-12-08 10:33:39 +03:00
say " Added #{ scope } scope to the existing API key "
end
end
2013-09-14 12:59:02 +04:00
##
# Signs in with the RubyGems API at +sign_in_host+ and sets the rubygems API
# key.
2013-07-09 02:41:03 +04:00
2020-12-08 10:33:39 +03:00
def sign_in ( sign_in_host = nil , scope : nil )
2023-03-16 07:12:38 +03:00
sign_in_host || = host
2014-09-14 07:30:02 +04:00
return if api_key
2013-09-14 12:59:02 +04:00
2020-12-08 10:33:39 +03:00
pretty_host = pretty_host ( sign_in_host )
2013-09-14 12:59:02 +04:00
say " Enter your #{ pretty_host } credentials. "
2023-04-06 05:33:10 +03:00
say " Don't have an account yet? " \
2013-09-14 12:59:02 +04:00
" Create one at #{ sign_in_host } /sign_up "
2019-02-14 15:59:03 +03:00
email = ask " Email: "
2013-09-14 12:59:02 +04:00
password = ask_for_password " Password: "
say " \n "
2020-12-08 10:33:39 +03:00
key_name = get_key_name ( scope )
scope_params = get_scope_params ( scope )
2022-07-22 20:11:52 +03:00
profile = get_user_profile ( email , password )
mfa_params = get_mfa_params ( profile )
2022-01-25 01:09:25 +03:00
all_params = scope_params . merge ( mfa_params )
2022-07-22 20:11:52 +03:00
warning = profile [ " warning " ]
2022-12-21 00:01:08 +03:00
credentials = { email : email , password : password }
2022-07-22 20:11:52 +03:00
say " #{ warning } \n " if warning
2022-01-25 01:09:25 +03:00
2020-12-08 10:33:39 +03:00
response = rubygems_api_request ( :post , " api/v1/api_key " ,
2022-12-21 00:01:08 +03:00
sign_in_host , credentials : credentials , scope : scope ) do | request |
2013-09-14 12:59:02 +04:00
request . basic_auth email , password
2021-07-07 08:07:29 +03:00
request [ " OTP " ] = otp if otp
2022-01-25 01:09:25 +03:00
request . body = URI . encode_www_form ( { name : key_name } . merge ( all_params ) )
2018-12-01 14:01:00 +03:00
end
2013-09-14 12:59:02 +04:00
with_response response do | resp |
2020-12-08 10:33:39 +03:00
say " Signed in with API key: #{ key_name } . "
2016-03-04 03:29:40 +03:00
set_api_key host , resp . body
2010-02-22 05:52:35 +03:00
end
end
2013-09-14 12:59:02 +04:00
##
# Retrieves the pre-configured API key +key+ or terminates interaction with
# an error.
2013-09-13 23:58:57 +04:00
def verify_api_key ( key )
2018-11-21 13:20:47 +03:00
if Gem . configuration . api_keys . key? key
2013-09-13 23:58:57 +04:00
Gem . configuration . api_keys [ key ]
else
alert_error " No such API key. Please add it to your configuration (done automatically on initial `gem push`). "
2019-07-26 07:11:37 +03:00
terminate_interaction ( ERROR_CODE )
2013-09-13 23:58:57 +04:00
end
end
2013-07-10 03:21:36 +04:00
2013-09-14 12:59:02 +04:00
##
# If +response+ is an HTTP Success (2XX) response, yields the response if a
# block was given or shows the response body to the user.
#
# If the response was not successful, shows an error to the user including
2022-09-16 15:48:38 +03:00
# the +error_prefix+ and the response body. If the response was a permanent redirect,
# shows an error to the user including the redirect location.
2013-09-14 12:59:02 +04:00
2018-11-21 13:20:47 +03:00
def with_response ( response , error_prefix = nil )
2013-09-14 12:59:02 +04:00
case response
when Net :: HTTPSuccess then
2018-11-21 13:20:47 +03:00
if block_given?
2013-09-14 12:59:02 +04:00
yield response
else
2019-03-05 06:32:58 +03:00
say clean_text ( response . body )
2013-09-14 12:59:02 +04:00
end
2022-09-16 15:48:38 +03:00
when Net :: HTTPPermanentRedirect , Net :: HTTPRedirection then
2023-03-16 07:00:54 +03:00
message = " The request has redirected permanently to #{ response [ " location " ] } . Please check your defined push host URL. "
2022-09-16 15:48:38 +03:00
message = " #{ error_prefix } : #{ message } " if error_prefix
say clean_text ( message )
terminate_interaction ( ERROR_CODE )
2013-09-14 12:59:02 +04:00
else
message = response . body
message = " #{ error_prefix } : #{ message } " if error_prefix
2019-03-05 06:32:58 +03:00
say clean_text ( message )
2019-07-26 07:11:37 +03:00
terminate_interaction ( ERROR_CODE )
2013-09-14 12:59:02 +04:00
end
end
2018-12-01 14:01:00 +03:00
##
# Returns true when the user has enabled multifactor authentication from
2019-03-05 06:32:58 +03:00
# +response+ text and no otp provided by options.
2018-11-21 13:20:47 +03:00
def set_api_key ( host , key )
2022-03-08 16:15:37 +03:00
if default_host?
2016-03-04 03:29:40 +03:00
Gem . configuration . rubygems_api_key = key
else
Gem . configuration . set_api_key host , key
end
end
2020-12-08 10:33:39 +03:00
private
2020-12-18 06:13:33 +03:00
def request_with_otp ( method , uri , & block )
request_method = Net :: HTTP . const_get method . to_s . capitalize
Gem :: RemoteFetcher . fetcher . request ( uri , request_method ) do | req |
2021-07-07 08:07:29 +03:00
req [ " OTP " ] = otp if otp
2020-12-18 06:13:33 +03:00
block . call ( req )
end
end
2023-02-15 18:46:47 +03:00
def fetch_otp ( credentials )
options [ :otp ] = if webauthn_url = webauthn_verification_url ( credentials )
wait_for_otp ( webauthn_url )
2022-12-21 00:01:08 +03:00
else
2022-12-23 17:34:02 +03:00
say " You have enabled multi-factor authentication. Please enter OTP code. "
2023-02-15 18:46:47 +03:00
ask " Code: "
2022-12-21 00:01:08 +03:00
end
2023-02-15 18:46:47 +03:00
end
def wait_for_otp ( webauthn_url )
2023-02-15 18:52:32 +03:00
server = TCPServer . new 0
port = server . addr [ 1 ] . to_s
2023-02-15 18:46:47 +03:00
thread = Thread . new do
2023-02-15 18:52:32 +03:00
Thread . current [ :otp ] = Gem :: WebauthnListener . wait_for_otp_code ( host , server )
2023-02-17 00:14:36 +03:00
rescue Gem :: WebauthnVerificationError = > e
2023-02-21 20:37:33 +03:00
Thread . current [ :error ] = e
2023-02-15 18:46:47 +03:00
end
thread . abort_on_exception = true
thread . report_on_exception = false
2023-02-15 18:52:32 +03:00
url_with_port = " #{ webauthn_url } ?port= #{ port } "
2023-03-20 21:44:25 +03:00
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. "
2022-12-21 00:01:08 +03:00
2023-02-15 18:46:47 +03:00
thread . join
2023-02-21 20:37:33 +03:00
if error = thread [ :error ]
alert_error error . message
terminate_interaction ( 1 )
end
2023-02-15 18:46:47 +03:00
say " You are verified with a security device. You may close the browser window. "
thread [ :otp ]
2020-12-18 06:13:33 +03:00
end
2022-12-21 00:01:08 +03:00
def webauthn_verification_url ( credentials )
response = rubygems_api_request ( :post , " api/v1/webauthn_verification " ) do | request |
2023-03-29 20:20:47 +03:00
if credentials . empty?
2022-12-21 00:01:08 +03:00
request . add_field " Authorization " , api_key
2023-03-29 20:20:47 +03:00
else
request . basic_auth credentials [ :email ] , credentials [ :password ]
2022-12-21 00:01:08 +03:00
end
end
response . is_a? ( Net :: HTTPSuccess ) ? response . body : nil
end
2020-12-08 10:33:39 +03:00
def pretty_host ( host )
2022-03-08 16:15:37 +03:00
if default_host?
2020-12-08 10:33:39 +03:00
" RubyGems.org "
else
host
end
end
def get_scope_params ( scope )
scope_params = { }
if scope
scope_params = { scope = > true }
else
say " Please select scopes you want to enable for the API key (y/n) "
2023-03-22 06:55:33 +03:00
API_SCOPES . each do | s |
selected = ask_yes_no ( s . to_s , false )
scope_params [ s ] = true if selected
2020-12-08 10:33:39 +03:00
end
say " \n "
end
scope_params
end
2022-03-08 16:15:37 +03:00
def default_host?
2023-03-16 07:12:38 +03:00
host == Gem :: DEFAULT_HOST
2022-03-08 16:15:37 +03:00
end
2022-07-22 20:11:52 +03:00
def get_user_profile ( email , password )
2022-03-08 16:15:37 +03:00
return { } unless default_host?
2022-02-24 18:16:32 +03:00
2022-02-24 18:50:17 +03:00
response = rubygems_api_request ( :get , " api/v1/profile/me.yaml " ) do | request |
2022-01-24 23:25:28 +03:00
request . basic_auth email , password
end
2022-03-08 16:15:37 +03:00
2022-01-24 23:25:28 +03:00
with_response response do | resp |
2023-04-18 03:57:56 +03:00
Gem :: ConfigFile . load_with_rubygems_config_hash ( clean_text ( resp . body ) )
2022-01-24 23:25:28 +03:00
end
end
2022-07-22 20:11:52 +03:00
def get_mfa_params ( profile )
mfa_level = profile [ " mfa " ]
params = { }
2023-03-16 07:58:06 +03:00
if [ " ui_only " , " ui_and_gem_signin " ] . include? ( mfa_level )
2022-07-22 20:11:52 +03:00
selected = ask_yes_no ( " Would you like to enable MFA for this key? (strongly recommended) " )
params [ " mfa " ] = true if selected
end
params
end
2020-12-08 10:33:39 +03:00
def get_key_name ( scope )
2020-12-21 04:54:24 +03:00
hostname = Socket . gethostname || " unknown-host "
user = ENV [ " USER " ] || ENV [ " USERNAME " ] || " unknown-user "
2020-12-08 10:33:39 +03:00
ts = Time . now . strftime ( " %Y%m%d%H%M%S " )
default_key_name = " #{ hostname } - #{ user } - #{ ts } "
key_name = ask " API Key name [ #{ default_key_name } ]: " unless scope
if key_name . nil? || key_name . empty?
default_key_name
else
key_name
end
end
def api_key_forbidden? ( response )
2023-03-16 07:08:50 +03:00
response . is_a? ( Net :: HTTPForbidden ) && response . body . start_with? ( " The API key doesn't have access " )
2020-12-08 10:33:39 +03:00
end
2013-09-13 23:58:57 +04:00
end