Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
35389cd
Add multifactor-auth prompt to push command.
ecnelises Jul 27, 2018
9d2235e
Add OTP requirement to `owner_command`.
ecnelises Jul 28, 2018
8162789
Change `Gemcutter` in comment to `server`.
ecnelises Jul 31, 2018
17824f6
Rename `run_mfa_check` to `check_mfa`.
ecnelises Jul 31, 2018
ec1b878
Rename `otp` in code to `mfa`.
ecnelises Jul 31, 2018
5fcf469
Add a method for `--mfa` command options.
ecnelises Aug 6, 2018
09c8b9a
Remove `suppress_mfa` option for testing.
ecnelises Aug 6, 2018
2a85f4a
Check response text to determine if mfa needed.
ecnelises Aug 7, 2018
f606f78
Add support to callable objects to data of `FakeFetcher#request`.
ecnelises Aug 10, 2018
c5c4cc2
Add multifactor auth related test to gemcutter utilities.
ecnelises Aug 10, 2018
03636c3
Add multifactor auth related test to `push` and `owner` command.
ecnelises Aug 10, 2018
bba2a3f
Add `--mfa` option to `signin` command.
ecnelises Aug 11, 2018
73b6ad2
Wrap api request needing mfa into single methods.
ecnelises Aug 11, 2018
3b789c4
Unify all multi-factor auth related names to `otp`.
ecnelises Aug 11, 2018
1c232f8
Move otp asking logic into `need_otp?`.
ecnelises Aug 11, 2018
89f6c2b
Add test for OTP fields in request header.
ecnelises Aug 12, 2018
c45f786
Keep OTP after signed in.
ecnelises Oct 24, 2018
f24d666
Tweak MFA prompt text.
ecnelises Nov 12, 2018
4726d54
Merge branch 'master' into multifactor-auth
hsbt Dec 1, 2018
6a625d5
Merge branch 'multifactor-auth' of https://github.com/ecnelises/rubyg…
hsbt Dec 1, 2018
ee0cbc9
rubocop -a
hsbt Dec 1, 2018
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
18 changes: 15 additions & 3 deletions lib/rubygems/commands/owner_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def initialize
super 'owner', 'Manage gem owners of a gem on the push server'
add_proxy_option
add_key_option
add_otp_option
defaults.merge! :add => [], :remove => []

add_option '-a', '--add EMAIL', 'Add an owner' do |value, options|
Expand Down Expand Up @@ -84,9 +85,10 @@ def remove_owners(name, owners)
def manage_owners(method, name, owners)
owners.each do |owner|
begin
response = rubygems_api_request method, "api/v1/gems/#{name}/owners" do |request|
request.set_form_data 'email' => owner
request.add_field "Authorization", api_key
response = send_owner_request(method, name, owner)

if need_otp? response
response = send_owner_request(method, name, owner, true)
end

action = method == :delete ? "Removing" : "Adding"
Expand All @@ -98,4 +100,14 @@ def manage_owners(method, name, owners)
end
end

private

def send_owner_request(method, name, owner, use_otp = false)
rubygems_api_request method, "api/v1/gems/#{name}/owners" do |request|
request.set_form_data 'email' => owner
request.add_field "Authorization", api_key
request.add_field "OTP", options[:otp] if use_otp
end
end

end
20 changes: 15 additions & 5 deletions lib/rubygems/commands/push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def initialize

add_proxy_option
add_key_option
add_otp_option

add_option('--host HOST',
'Push to another gemcutter-compatible host',
Expand Down Expand Up @@ -113,18 +114,27 @@ def send_gem(name)

say "Pushing gem to #{@host || Gem.host}..."

response = rubygems_api_request(*args) do |request|
request.body = Gem.read_binary name
request.add_field "Content-Length", request.body.size
request.add_field "Content-Type", "application/octet-stream"
request.add_field "Authorization", api_key
response = send_push_request(name, args)

if need_otp? response
response = send_push_request(name, args, true)
end

with_response response
end

private

def send_push_request(name, args, use_otp = false)
rubygems_api_request(*args) do |request|
request.body = Gem.read_binary name
request.add_field "Content-Length", request.body.size
request.add_field "Content-Type", "application/octet-stream"
request.add_field "Authorization", api_key
request.add_field "OTP", options[:otp] if use_otp
end
end

def get_hosts_for(name)
gem_metadata = Gem::Package.new(name).spec.metadata

Expand Down
2 changes: 2 additions & 0 deletions lib/rubygems/commands/signin_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def initialize
add_option('--host HOST', 'Push to another gemcutter-compatible host') do |value, options|
options[:host] = value
end

add_otp_option
end

def description # :nodoc:
Expand Down
31 changes: 31 additions & 0 deletions lib/rubygems/gemcutter_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ def add_key_option
end
end

##
# Add the --otp option

def add_otp_option
add_option('--otp CODE',
'Digit code for multifactor authentication') do |value, options|
options[:otp] = value
end
end

##
# The API key from the command options or from the user's configuration.

Expand Down Expand Up @@ -113,6 +123,13 @@ def sign_in(sign_in_host = nil)
request.basic_auth email, password
end

if need_otp? response
response = rubygems_api_request(:get, "api/v1/api_key", sign_in_host) do |request|
request.basic_auth email, password
request.add_field "OTP", options[:otp]
end
end

with_response response do |resp|
say "Signed in."
set_api_key host, resp.body
Expand Down Expand Up @@ -156,6 +173,20 @@ def with_response(response, error_prefix = nil)
end
end

##
# Returns true when the user has enabled multifactor authentication from
# +response+ text.

def need_otp?(response)
return unless response.kind_of?(Net::HTTPUnauthorized) &&
response.body.start_with?('You have enabled multifactor authentication')
return true if options[:otp]

say 'You have enabled multi-factor authentication. Please enter OTP code.'
options[:otp] = ask 'Code: '
true
end

def set_api_key(host, key)
if host == Gem::DEFAULT_HOST
Gem.configuration.rubygems_api_key = key
Expand Down
2 changes: 1 addition & 1 deletion lib/rubygems/test_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def open_uri_or_path(path)

def request(uri, request_class, last_modified = nil)
data = find_data(uri)
body, code, msg = data
body, code, msg = (data.respond_to?(:call) ? data.call : data)

@last_request = request_class.new uri.request_uri
yield @last_request if block_given?
Expand Down
35 changes: 35 additions & 0 deletions test/rubygems/test_gem_commands_owner_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,39 @@ def test_remove_owners_missing
assert_equal "Removing missing@example: #{response}\n", @stub_ui.output
end

def test_otp_verified_success
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/gems/freewill/owners"] = proc do
@call_count ||= 0
(@call_count += 1).odd? ? [response_fail, 401, 'Unauthorized'] : [response_success, 200, 'OK']
end

@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.', @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_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"] = [response, 401, 'Unauthorized']

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

assert_match response, @otp_ui.output
assert_match 'You have enabled multi-factor authentication. Please enter OTP code.', @otp_ui.output
assert_match 'Code: ', @otp_ui.output
assert_equal '111111', @stub_fetcher.last_request['OTP']
end

end
39 changes: 39 additions & 0 deletions test/rubygems/test_gem_commands_push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def test_sending_gem_to_metadata_host

@response = "Successfully registered gem: freebird (1.0.1)"
@fetcher.data["#{@host}/api/v1/gems"] = [@response, 200, 'OK']

send_battery
end

Expand Down Expand Up @@ -230,6 +231,7 @@ def test_sending_gem_to_disallowed_default_host
spec.metadata['allowed_push_host'] = "https://privategemserver.example"
end


response = %{ERROR: "#{@host}" is not allowed by the gemspec, which only allows "https://privategemserver.example"}

assert_raises Gem::MockGemUi::TermError do
Expand Down Expand Up @@ -347,4 +349,41 @@ def test_sending_gem_key
@fetcher.last_request["Authorization"]
end

def test_otp_verified_success
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"] = proc do
@call_count ||= 0
(@call_count += 1).odd? ? [response_fail, 401, 'Unauthorized'] : [response_success, 200, 'OK']
end

@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.', @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_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"] = [response, 401, 'Unauthorized']

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

assert_match response, @otp_ui.output
assert_match 'You have enabled multi-factor authentication. Please enter OTP code.', @otp_ui.output
assert_match 'Code: ', @otp_ui.output
assert_equal '111111', @fetcher.last_request['OTP']
end

end
32 changes: 30 additions & 2 deletions test/rubygems/test_gem_gemcutter_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,35 @@ def test_sign_in_with_bad_credentials
assert_match %r{Access Denied.}, @sign_in_ui.output
end

def util_sign_in(response, host = nil, args = [])
def test_sign_in_with_correct_otp_code
api_key = 'a5fdbb6ba150cbb83aad2bb2fede64cf040453903'
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."

util_sign_in(proc do
@call_count ||= 0
(@call_count += 1).odd? ? [response_fail, 401, 'Unauthorized'] : [api_key, 200, 'OK']
end, nil, [], "111111\n")

assert_match 'You have enabled multi-factor authentication. Please enter OTP code.', @sign_in_ui.output
assert_match 'Code: ', @sign_in_ui.output
assert_match 'Signed in.', @sign_in_ui.output
assert_equal '111111', @fetcher.last_request['OTP']
end

def test_sign_in_with_incorrect_otp_code
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."

assert_raises Gem::MockGemUi::TermError do
util_sign_in [response, 401, 'Unauthorized'], nil, [], "111111\n"
end

assert_match 'You have enabled multi-factor authentication. Please enter OTP code.', @sign_in_ui.output
assert_match 'Code: ', @sign_in_ui.output
assert_match response, @sign_in_ui.output
assert_equal '111111', @fetcher.last_request['OTP']
end

def util_sign_in(response, host = nil, args = [], extra_input = '')
email = 'you@example.com'
password = 'secret'

Expand All @@ -201,7 +229,7 @@ def util_sign_in(response, host = nil, args = [])
@fetcher.data["#{host}/api/v1/api_key"] = response
Gem::RemoteFetcher.fetcher = @fetcher

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

use_ui @sign_in_ui do
if args.length > 0
Expand Down