Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions lib/rubygems/gemcutter_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def host
#
# 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)
def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, credentials: {}, &block)
require "net/http"

self.host = host if host
Expand All @@ -105,7 +105,7 @@ def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scop
response = request_with_otp(method, uri, &block)

if mfa_unauthorized?(response)
ask_otp
ask_otp(credentials)
response = request_with_otp(method, uri, &block)
end

Expand Down Expand Up @@ -167,11 +167,12 @@ def sign_in(sign_in_host = nil, scope: nil)
mfa_params = get_mfa_params(profile)
all_params = scope_params.merge(mfa_params)
warning = profile["warning"]
credentials = { email: email, password: password }

say "#{warning}\n" if warning

response = rubygems_api_request(:post, "api/v1/api_key",
sign_in_host, scope: scope) do |request|
sign_in_host, credentials: credentials, scope: scope) do |request|
request.basic_auth email, password
request["OTP"] = otp if otp
request.body = URI.encode_www_form({ name: key_name }.merge(all_params))
Expand Down Expand Up @@ -250,11 +251,27 @@ def request_with_otp(method, uri, &block)
end
end

def ask_otp
say "You have enabled multi-factor authentication. Please enter OTP code."
def ask_otp(credentials)
if webauthn_url = webauthn_verification_url(credentials)
say "You have enabled multi-factor authentication. Please enter OTP code from your security device by visiting #{webauthn_url}."
else
say "You have enabled multi-factor authentication. Please enter OTP code."
end

options[:otp] = ask "Code: "
end

def webauthn_verification_url(credentials)
response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request|
if credentials
request.basic_auth credentials[:email], credentials[:password]
else
request.add_field "Authorization", api_key
end
end
response.is_a?(Net::HTTPSuccess) ? response.body : nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For hosts that do not have webauthn / "api/v1/webauthn_verification" implemented yet, I'm guessing that there's not going to be a change in behaviour since this method would return nil.

end

def pretty_host(host)
if default_host?
"RubyGems.org"
Expand Down
26 changes: 26 additions & 0 deletions test/rubygems/test_gem_commands_owner_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ def test_otp_verified_success
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
]
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
Expand All @@ -345,6 +347,8 @@ def test_otp_verified_success
def test_otp_verified_failure
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
@stub_fetcher.data["#{Gem.host}/api/v1/gems/freewill/owners"] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
Expand All @@ -357,6 +361,28 @@ def test_otp_verified_failure
assert_equal "111111", @stub_fetcher.last_request["OTP"]
end

def test_webauthn_otp_verified_success
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
response_success = "Owner added successfully."

@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
@stub_fetcher.data["#{Gem.host}/api/v1/gems/freewill/owners"] = [
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
]

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
@cmd.add_owners("freewill", ["user-new1@example.com"])
end

assert_match "You have enabled multi-factor authentication. Please enter OTP code from your security device by visiting #{webauthn_verification_url}", @otp_ui.output
assert_match "Code: ", @otp_ui.output
assert_match response_success, @otp_ui.output
assert_equal "111111", @stub_fetcher.last_request["OTP"]
end

def test_remove_owners_unathorized_api_key
response_forbidden = "The API key doesn't have access"
response_success = "Owner removed successfully."
Expand Down
30 changes: 30 additions & 0 deletions test/rubygems/test_gem_commands_push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ def test_otp_verified_success
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
]
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
Expand All @@ -406,6 +408,8 @@ def test_otp_verified_success
def test_otp_verified_failure
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@otp_ui = Gem::MockGemUi.new "111111\n"
assert_raise Gem::MockGemUi::TermError do
Expand All @@ -420,6 +424,28 @@ def test_otp_verified_failure
assert_equal "111111", @fetcher.last_request["OTP"]
end

def test_webauthn_otp_verified_success
webauthn_verification_url = "#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY"
response_fail = "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)"

@fetcher.data["#{Gem.host}/api/v1/gems"] = [
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
]
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
@cmd.send_gem(@path)
end

assert_match "You have enabled multi-factor authentication. Please enter OTP code from your security device by visiting #{webauthn_verification_url}", @otp_ui.output
assert_match "Code: ", @otp_ui.output
assert_match response_success, @otp_ui.output
assert_equal "111111", @fetcher.last_request["OTP"]
end

def test_sending_gem_unathorized_api_key_with_mfa_enabled
response_mfa_enabled = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
response_forbidden = "The API key doesn't have access"
Expand All @@ -430,6 +456,8 @@ def test_sending_gem_unathorized_api_key_with_mfa_enabled
HTTPResponseFactory.create(body: response_forbidden, code: 403, msg: "Forbidden"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
]
@fetcher.data["#{@host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@fetcher.data["#{@host}/api/v1/api_key"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK")
@cmd.instance_variable_set :@host, @host
Expand Down Expand Up @@ -470,6 +498,8 @@ def test_sending_gem_with_no_local_creds
@fetcher.data["#{@host}/api/v1/profile/me.yaml"] = [
HTTPResponseFactory.create(body: response_profile, code: 200, msg: "OK"),
]
@fetcher.data["#{@host}/api/v1/webauthn_verification"] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@cmd.instance_variable_set :@scope, :push_rubygem
@cmd.options[:args] = [@path]
Expand Down
33 changes: 33 additions & 0 deletions test/rubygems/test_gem_commands_yank_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ def test_execute_with_otp_success
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
]
webauthn_uri = "http://example/api/v1/webauthn_verification"
@fetcher.data[webauthn_uri] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
Expand All @@ -93,6 +96,9 @@ def test_execute_with_otp_failure
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
yank_uri = "http://example/api/v1/gems/yank"
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
webauthn_uri = "http://example/api/v1/webauthn_verification"
@fetcher.data[webauthn_uri] =
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")

@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
Expand All @@ -109,6 +115,33 @@ def test_execute_with_otp_failure
assert_equal "111111", @fetcher.last_request["OTP"]
end

def test_execute_with_webauthn_otp_success
webauthn_verification_url = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY"
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
yank_uri = "http://example/api/v1/gems/yank"
webauthn_uri = "http://example/api/v1/webauthn_verification"
@fetcher.data[webauthn_uri] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
@fetcher.data[yank_uri] = [
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
]

@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
@cmd.options[:version] = req("= 1.0")

@otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do
@cmd.execute
end

assert_match "You have enabled multi-factor authentication. Please enter OTP code from your security device by visiting #{webauthn_verification_url}", @otp_ui.output
assert_match "Code: ", @otp_ui.output
assert_match %r{Yanking gem from http://example}, @otp_ui.output
assert_match %r{Successfully yanked}, @otp_ui.output
assert_equal "111111", @fetcher.last_request["OTP"]
end

def test_execute_key
yank_uri = "http://example/api/v1/gems/yank"
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK")
Expand Down
32 changes: 28 additions & 4 deletions test/rubygems/test_gem_gemcutter_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,33 @@ def test_sign_in_with_incorrect_otp_code
assert_equal "111111", @fetcher.last_request["OTP"]
end

def util_sign_in(response, host = nil, args = [], extra_input = "")
email = "you@example.com"
password = "secret"
profile_response = HTTPResponseFactory.create(body: "mfa: disabled\n", code: 200, msg: "OK")
def test_sign_in_with_webauthn_otp
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
response_fail = "You have enabled multifactor authentication"
api_key = "a5fdbb6ba150cbb83aad2bb2fede64cf040453903"

util_sign_in(proc do
@call_count ||= 0
if (@call_count += 1).odd?
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized")
else
HTTPResponseFactory.create(body: api_key, code: 200, msg: "OK")
end
end, nil, [], "111111\n", webauthn_verification_url)

assert_match "You have enabled multi-factor authentication. Please enter OTP code from your security device by visiting #{webauthn_verification_url}", @sign_in_ui.output
end

def util_sign_in(response, host = nil, args = [], extra_input = "", webauthn_url = nil)
email = "you@example.com"
password = "secret"
profile_response = HTTPResponseFactory.create(body: "mfa: disabled\n", code: 200, msg: "OK")
webauthn_response =
if webauthn_url
HTTPResponseFactory.create(body: webauthn_url, code: 200, msg: "OK")
else
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
end

if host
ENV["RUBYGEMS_HOST"] = host
Expand All @@ -244,6 +267,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/me.yaml"] = profile_response
@fetcher.data["#{host}/api/v1/webauthn_verification"] = webauthn_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