module Gem::GemcutterUtilities
Utility methods for using the RubyGems API.
The WebauthnListener class retrieves an OTP after a user successfully WebAuthns with the Gem host. An instance opens a socket using the TCPServer instance given and listens for a request from the Gem host. The request should be a GET request to the root path and contains the OTP code in the form of a query parameter code. The listener will return the code which will be used as the OTP for API requests.
Types of responses sent by the listener after receiving a request:
- 200 OK: OTP code was successfully retrieved - 204 No Content: If the request was an OPTIONS request - 400 Bad Request: If the request did not contain a query parameter `code` - 404 Not Found: The request was not to the root path - 405 Method Not Allowed: OTP code was not retrieved because the request was not a GET/OPTIONS request
Example usage:
thread = Gem::WebauthnListener.listener_thread("https://rubygems.example", server) thread.join otp = thread[:otp] error = thread[:error]
The WebauthnListener Response class is used by the WebauthnListener to create responses to be sent to the Gem host. It creates a Gem::Net::HTTPResponse instance when initialized and can be converted to the appropriate format to be sent by a socket using to_s. Gem::Net::HTTPResponse instances cannot be directly sent over a socket.
Types of response classes:
- OkResponse - NoContentResponse - BadRequestResponse - NotFoundResponse - MethodNotAllowedResponse
Example usage:
server = TCPServer.new(0) socket = server.accept response = OkResponse.for("https://rubygems.example") socket.print response.to_s socket.close
The WebauthnPoller class retrieves an OTP after a user successfully WebAuthns. An instance polls the Gem host for the OTP code. The polling request (api/v1/webauthn_verification/<webauthn_token>/status.json) is sent to the Gem host every 5 seconds and will timeout after 5 minutes. If the status field in the json response is “success”, the code field will contain the OTP code.
Example usage:
thread = Gem::WebauthnPoller.poll_thread( {}, "RubyGems.org", "https://rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY", { email: "email@example.com", password: "password" } ) thread.join otp = thread[:otp] error = thread[:error]
Constants
- API_SCOPES
- ERROR_CODE
- EXCLUSIVELY_API_SCOPES
Attributes
Public Instance Methods
Source
# File lib/rubygems/gemcutter_utilities.rb, line 24 def add_key_option add_option("-k", "--key KEYNAME", Symbol, "Use the given API key", "from #{Gem.configuration.credentials_path}") do |value,options| options[:key] = value end end
Add the –key option
Source
# File lib/rubygems/gemcutter_utilities.rb, line 35 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| options[:otp] = value end end
Add the –otp option
Source
# File lib/rubygems/gemcutter_utilities.rb, line 46 def api_key if ENV["GEM_HOST_API_KEY"] ENV["GEM_HOST_API_KEY"] elsif options[:key] verify_api_key options[:key] elsif credential_store_key = Gem.configuration.credential_store_api_key_for(host) credential_store_key elsif Gem.configuration.api_keys.key?(host) Gem.configuration.api_keys[host] else key = Gem.configuration.rubygems_api_key # Once the store has refused to answer, this last resort would hand the # RubyGems.org key to a host that has one of its own, or come away with # nothing and let the caller ask for a password. if !@recognizing_session && Gem.configuration.credential_store_read_failed_for?(host) && (key.nil? || !default_host?) alert_error "The credential store could not be read, so no API key for #{host} could be found. " \ "Make the store readable and run the command again." terminate_interaction ERROR_CODE end key end end
The API key from the command options or from the user’s configuration.
Source
# File lib/rubygems/gemcutter_utilities.rb, line 86 def host configured_host = Gem.host unless Gem.configuration.disable_default_gem_server @host ||= begin env_rubygems_host = ENV["RUBYGEMS_HOST"] env_rubygems_host = nil if env_rubygems_host&.empty? env_rubygems_host || configured_host end end
The host to connect to either from the RUBYGEMS_HOST environment variable or from the user’s configuration
Source
# File lib/rubygems/gemcutter_utilities.rb, line 74 def otp options[:otp] || ENV["GEM_HOST_OTP_CODE"] end
The OTP code from the command options or from the user’s configuration.
# File lib/rubygems/gemcutter_utilities.rb, line 104 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 alert_error "You must specify a gem server" terminate_interaction(ERROR_CODE) end if allowed_push_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}" terminate_interaction(ERROR_CODE) end end uri = Gem::URI.parse "#{self.host}/#{path}" response = request_with_otp(method, uri, &block) if mfa_unauthorized?(response) fetch_otp(credentials) response = request_with_otp(method, uri, &block) end if api_key_forbidden?(response) update_scope(scope) request_with_otp(method, uri, &block) else response end end
Creates an RubyGems API to host and path with the given HTTP method.
If allowed_push_host metadata is present, then it will only allow that host.
Source
# File lib/rubygems/gemcutter_utilities.rb, line 282 def set_api_key(host, key) if default_host? Gem.configuration.rubygems_api_key = key else Gem.configuration.set_api_key host, key end end
Returns true when the user has enabled multifactor authentication from response text and no otp provided by options.
# File lib/rubygems/gemcutter_utilities.rb, line 168 def sign_in(sign_in_host = nil, scope: nil) sign_in_host ||= host pretty_host = pretty_host(sign_in_host) # Stopping because the store cannot be read would close the one command # that can re-authenticate. A flag rather than an argument keeps #api_key # callable with no arguments, as command plugins that override it define it. @recognizing_session = true signed_in = begin api_key ensure @recognizing_session = false end if signed_in say "You are already signed in on #{pretty_host}." return end say "Enter your #{pretty_host} credentials." say "Don't have an account yet? " \ "Create one at #{sign_in_host}/sign_up" 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, credentials: credentials, scope: scope) do |request| request.basic_auth identifier, password request.body = Gem::URI.encode_www_form({ name: key_name }.merge(all_params)) end with_response response do |resp| say "Signed in with API key: #{key_name}." set_api_key host, resp.body end end
Signs in with the RubyGems API at sign_in_host and sets the rubygems API key.
Source
# File lib/rubygems/gemcutter_utilities.rb, line 241 def stored_api_key_named(name) return nil unless name.to_s == "rubygems" Gem.configuration.credential_store_default_api_key end
The default key, when name is the name the credentials file knows it by. That file renames :rubygems_api_key to :rubygems on the way in, so the name survives only there, and moving the key into the store would otherwise put it out of reach of –key.
Only that one name. The store is keyed by host, and –key names a key, so looking any other name up there would let –key reach a host’s key and send it somewhere else. The credentials file keeps the two apart by type, since –key arrives as a Symbol and host entries are strings.
Source
# File lib/rubygems/gemcutter_utilities.rb, line 143 def update_scope(scope) 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." 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 identifier, password request.body = Gem::URI.encode_www_form({ api_key: api_key }.merge(update_scope_params)) end with_response response do |_resp| say "Added #{scope} scope to the existing API key" end end
Source
# File lib/rubygems/gemcutter_utilities.rb, line 219 def verify_api_key(key) if Gem.configuration.api_keys.key? key Gem.configuration.api_keys[key] elsif stored_key = stored_api_key_named(key) stored_key else alert_error "No such API key. Please add it to your configuration (done automatically on initial `gem push`)." terminate_interaction(ERROR_CODE) end end
Retrieves the pre-configured API key key or terminates interaction with an error.
Source
# File lib/rubygems/gemcutter_utilities.rb, line 78 def webauthn_enabled? options[:webauthn] end
# File lib/rubygems/gemcutter_utilities.rb, line 255 def with_response(response, error_prefix = nil) case response 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 say clean_text(message) terminate_interaction(ERROR_CODE) end end
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 the error_prefix and the response body. If the response was a permanent redirect, shows an error to the user including the redirect location.
Private Instance Methods
# File lib/rubygems/gemcutter_utilities.rb, line 437 def api_key_forbidden?(response) response.is_a?(Gem::Net::HTTPForbidden) && response.body.start_with?("The API key doesn't have access") end
Source
# File lib/rubygems/gemcutter_utilities.rb, line 397 def default_host? host == Gem::DEFAULT_HOST end
Source
# File lib/rubygems/gemcutter_utilities.rb, line 303 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 the following URL 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." say "" say url_with_port say "" 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 options[:webauthn] = true 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
Source
# File lib/rubygems/gemcutter_utilities.rb, line 423 def get_key_name(scope) hostname = Socket.gethostname || "unknown-host" user = ENV["USER"] || ENV["USERNAME"] || "unknown-user" 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
Source
# File lib/rubygems/gemcutter_utilities.rb, line 413 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
Source
# File lib/rubygems/gemcutter_utilities.rb, line 364 def get_scope_params(scope) scope_params = { index_rubygems: true, push_rubygem: true } if scope scope_params = { scope => true } else 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 scope_params end
# File lib/rubygems/gemcutter_utilities.rb, line 401 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
Source
# File lib/rubygems/gemcutter_utilities.rb, line 356 def pretty_host(host) if default_host? "RubyGems.org" else host end end
# File lib/rubygems/gemcutter_utilities.rb, line 292 def request_with_otp(method, uri, &block) 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 block.call(req) end ensure options[:otp] = nil if webauthn_enabled? end
# File lib/rubygems/gemcutter_utilities.rb, line 334 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
# File lib/rubygems/gemcutter_utilities.rb, line 345 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