Skip to content

Commit

Permalink
[rubygems/rubygems] Enable mfa on specific keys during gem signin
Browse files Browse the repository at this point in the history
  • Loading branch information
aellispierce authored and matzbot committed Apr 5, 2022
1 parent 8ee4a82 commit b3f1b3c
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 5 deletions.
25 changes: 24 additions & 1 deletion lib/rubygems/gemcutter_utilities.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative 'remote_fetcher'
require_relative 'text'
require 'json'

##
# Utility methods for using the RubyGems API.
Expand Down Expand Up @@ -163,12 +164,13 @@ def sign_in(sign_in_host = nil, scope: nil)

key_name = get_key_name(scope)
scope_params = get_scope_params(scope)
mfa_params = get_mfa_params(email, password)

response = rubygems_api_request(:post, "api/v1/api_key",
sign_in_host, scope: scope) do |request|
request.basic_auth email, password
request["OTP"] = otp if otp
request.body = URI.encode_www_form({ name: key_name }.merge(scope_params))
request.body = URI.encode_www_form({ name: key_name }.merge(scope_params, mfa_params))
end

with_response response do |resp|
Expand Down Expand Up @@ -267,6 +269,27 @@ def get_scope_params(scope)
scope_params
end

def get_mfa_params(email, password)
mfa_level = get_user_mfa_level(email, password)
params = {}
if mfa_level == "ui_only" || mfa_level == "ui_and_gem_sign"
selected = ask "Would you like to enable MFA for this key? [y/N]"
params["mfa"] = true if selected =~ /^[yY](es)?$/
elsif mfa_level == "ui_and_api"
params["mfa"] = true
end
params
end

def get_user_mfa_level(email, password)
response = rubygems_api_request(:get, "api/v1/profile") do |request|
request.basic_auth email, password
end
with_response response do |resp|
JSON.parse(resp.body)["mfa"]
end
end

def get_key_name(scope)
hostname = Socket.gethostname || "unknown-host"
user = ENV["USER"] || ENV["USERNAME"] || "unknown-user"
Expand Down
5 changes: 5 additions & 0 deletions test/rubygems/test_gem_commands_push_command.rb
Expand Up @@ -435,6 +435,7 @@ def test_sending_gem_with_no_local_creds

response_mfa_enabled = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
response_success = 'Successfully registered gem: freewill (1.0.0)'
response_profile = {"mfa" => "disabled"}.to_json

@fetcher.data["#{@host}/api/v1/gems"] = [
[response_success, 200, "OK"],
Expand All @@ -445,6 +446,10 @@ def test_sending_gem_with_no_local_creds
["", 200, "OK"],
]

@fetcher.data["#{@host}/api/v1/profile"] = [
[response_profile, 200, "OK"],
]

@cmd.instance_variable_set :@scope, :push_rubygem
@cmd.options[:args] = [@path]
@cmd.options[:host] = @host
Expand Down
61 changes: 57 additions & 4 deletions test/rubygems/test_gem_commands_signin_command.rb
Expand Up @@ -105,18 +105,71 @@ def test_execute_with_key_name_and_scope
assert_equal api_key, credentials[:rubygems_api_key]
end

def test_execute_with_key_name_scope_and_mfa
email = 'you@example.com'
password = 'secret'
api_key = '1234'
fetcher = Gem::RemoteFetcher.fetcher

key_name_ui = Gem::MockGemUi.new "#{email}\n#{password}\ntest-key\n\ny\n\n\n\n\n\ny"
util_capture_with_mfa_enabled(key_name_ui, nil, api_key, fetcher) { @cmd.execute }

user = ENV["USER"] || ENV["USERNAME"]

assert_match "API Key name [#{Socket.gethostname}-#{user}", key_name_ui.output
assert_match "index_rubygems [y/N]", key_name_ui.output
assert_match "push_rubygem [y/N]", key_name_ui.output
assert_match "yank_rubygem [y/N]", key_name_ui.output
assert_match "add_owner [y/N]", key_name_ui.output
assert_match "remove_owner [y/N]", key_name_ui.output
assert_match "access_webhooks [y/N]", key_name_ui.output
assert_match "show_dashboard [y/N]", key_name_ui.output
assert_match "Would you like to enable MFA for this key? [y/N]", key_name_ui.output
assert_equal "name=test-key&push_rubygem=true&mfa=true", fetcher.last_request.body

credentials = load_yaml_file Gem.configuration.credentials_path
assert_equal api_key, credentials[:rubygems_api_key]
end

# Utility method to capture IO/UI within the block passed

def util_capture(ui_stub = nil, host = nil, api_key = nil, fetcher = Gem::FakeFetcher.new)
api_key ||= 'a5fdbb6ba150cbb83aad2bb2fede64cf040453903'
response = [api_key, 200, 'OK']
email = 'you@example.com'
password = 'secret'
api_key ||= 'a5fdbb6ba150cbb83aad2bb2fede64cf040453903'
response = [api_key, 200, 'OK']
profile_response =[{"mfa" => "disabled"}.to_json, 200, 'OK']
email = 'you@example.com'
password = 'secret'

# Set the expected response for the Web-API supplied
ENV['RUBYGEMS_HOST'] = host || Gem::DEFAULT_HOST
data_key = "#{ENV['RUBYGEMS_HOST']}/api/v1/api_key"
fetcher.data[data_key] = response
profile = "#{ENV['RUBYGEMS_HOST']}/api/v1/profile"
fetcher.data[profile] = profile_response
Gem::RemoteFetcher.fetcher = fetcher

sign_in_ui = ui_stub || Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n")

use_ui sign_in_ui do
yield
end

sign_in_ui
end

def util_capture_with_mfa_enabled(ui_stub = nil, host = nil, api_key = nil, fetcher = Gem::FakeFetcher.new)
api_key ||= 'a5fdbb6ba150cbb83aad2bb2fede64cf040453903'
response = [api_key, 200, 'OK']
profile_response =[{"mfa" => "ui_only"}.to_json, 200, 'OK']
email = 'you@example.com'
password = 'secret'

# Set the expected response for the Web-API supplied
ENV['RUBYGEMS_HOST'] = host || Gem::DEFAULT_HOST
data_key = "#{ENV['RUBYGEMS_HOST']}/api/v1/api_key"
fetcher.data[data_key] = response
profile = "#{ENV['RUBYGEMS_HOST']}/api/v1/profile"
fetcher.data[profile] = profile_response
Gem::RemoteFetcher.fetcher = fetcher

sign_in_ui = ui_stub || Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n")
Expand Down
2 changes: 2 additions & 0 deletions test/rubygems/test_gem_gemcutter_utilities.rb
Expand Up @@ -229,6 +229,7 @@ def test_sign_in_with_incorrect_otp_code
def util_sign_in(response, host = nil, args = [], extra_input = '')
email = 'you@example.com'
password = 'secret'
profile_response =[{"mfa" => "disabled"}.to_json, 200, 'OK']

if host
ENV['RUBYGEMS_HOST'] = host
Expand All @@ -238,6 +239,7 @@ def util_sign_in(response, host = nil, args = [], extra_input = '')

@fetcher = Gem::FakeFetcher.new
@fetcher.data["#{host}/api/v1/api_key"] = response
@fetcher.data["#{host}/api/v1/profile"] = profile_response
Gem::RemoteFetcher.fetcher = @fetcher

@sign_in_ui = Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n" + extra_input)
Expand Down

0 comments on commit b3f1b3c

Please sign in to comment.