[rubygems/rubygems] Add Webauthn verification poller to fetch OTP

https://github.com/rubygems/rubygems/commit/39c5e86a67
This commit is contained in:
Jenny Shen 2023-06-21 17:21:35 -04:00 committed by git
parent 836e4eb3cd
commit 023d0f662b
5 changed files with 415 additions and 15 deletions

View File

@ -253,36 +253,82 @@ module Gem::GemcutterUtilities
def fetch_otp(credentials)
options[:otp] = if webauthn_url = webauthn_verification_url(credentials)
wait_for_otp(webauthn_url)
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 #{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."
threads = [socket_thread(server), poll_thread(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
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
def wait_for_otp(webauthn_url)
server = TCPServer.new 0
port = server.addr[1].to_s
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
def socket_thread(server)
thread = Thread.new do
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(host, server)
rescue Gem::WebauthnVerificationError => e
Thread.current[:error] = e
ensure
server.close
end
thread.abort_on_exception = true
thread.report_on_exception = false
thread
end
def poll_thread(webauthn_url, credentials)
thread = Thread.new do
Timeout.timeout(300) do
loop do
response = webauthn_verification_poll_response(webauthn_url, credentials)
raise Gem::WebauthnVerificationError, response.message unless response.is_a?(Net::HTTPSuccess)
require "json"
parsed_response = JSON.parse(response.body)
case parsed_response["status"]
when "pending"
sleep 5
when "success"
Thread.current[:otp] = parsed_response["code"]
break
else
raise Gem::WebauthnVerificationError, parsed_response["message"]
end
end
end
rescue Gem::WebauthnVerificationError, Timeout::Error => e
Thread.current[:error] = e
end
thread.abort_on_exception = true
thread.report_on_exception = false
url_with_port = "#{webauthn_url}?port=#{port}"
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."
thread.join
if error = thread[:error]
alert_error error.message
terminate_interaction(1)
end
say "You are verified with a security device. You may close the browser window."
thread[:otp]
thread
end
def webauthn_verification_url(credentials)
@ -296,6 +342,17 @@ module Gem::GemcutterUtilities
response.is_a?(Net::HTTPSuccess) ? response.body : nil
end
def webauthn_verification_poll_response(webauthn_url, credentials)
webauthn_token = %r{(?<=\/)[^\/]+(?=$)}.match(webauthn_url)[0]
rubygems_api_request(:get, "api/v1/webauthn_verification/#{webauthn_token}/status.json") do |request|
if credentials.empty?
request.add_field "Authorization", api_key
else
request.basic_auth credentials[:email], credentials[:password]
end
end
end
def pretty_host(host)
if default_host?
"RubyGems.org"

View File

@ -374,6 +374,11 @@ EOF
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/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
@ -405,6 +410,11 @@ EOF
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/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
@ -425,6 +435,79 @@ EOF
refute_match response_success, @stub_ui.output
end
def test_with_webauthn_enabled_success_with_polling
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."
port = 5678
server = TCPServer.new(port)
@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"),
]
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"success\",\"code\":\"Uvh6T57tkWuUnWYo\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
use_ui @stub_ui do
@cmd.add_owners("freewill", ["user-new1@example.com"])
end
ensure
server.close
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match "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.", @stub_ui.output
assert_match "You are verified with a security device. You may close the browser window.", @stub_ui.output
assert_equal "Uvh6T57tkWuUnWYo", @stub_fetcher.last_request["OTP"]
assert_match response_success, @stub_ui.output
end
def test_with_webauthn_enabled_failure_with_polling
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."
port = 5678
server = TCPServer.new(port)
@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"),
]
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"expired\",\"message\":\"The token in the link you used has either expired or been used already.\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
use_ui @stub_ui do
@cmd.add_owners("freewill", ["user-new1@example.com"])
end
ensure
server.close
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match @stub_fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
assert_match "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.", @stub_ui.output
assert_match "ERROR: Security device verification failed: The token in the link you used has either expired " \
"or been used already.", @stub_ui.error
refute_match "You are verified with a security device. You may close the browser window.", @stub_ui.output
refute_match response_success, @stub_ui.output
end
def test_remove_owners_unathorized_api_key
response_forbidden = "The API key doesn't have access"
response_success = "Owner removed successfully."

View File

@ -438,6 +438,11 @@ class TestGemCommandsPushCommand < Gem::TestCase
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")
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
@ -469,6 +474,11 @@ class TestGemCommandsPushCommand < Gem::TestCase
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")
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
@ -491,6 +501,81 @@ class TestGemCommandsPushCommand < Gem::TestCase
refute_match response_success, @ui.output
end
def test_with_webauthn_enabled_success_with_polling
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 = "Successfully registered gem: freewill (1.0.0)"
port = 5678
server = TCPServer.new(port)
@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")
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"success\",\"code\":\"Uvh6T57tkWuUnWYo\"}",
code: 200,
msg: "OK"
)
TCPServer.stub(:new, server) do
use_ui @ui do
@cmd.send_gem(@path)
end
ensure
server.close
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match "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.", @ui.output
assert_match "You are verified with a security device. You may close the browser window.", @ui.output
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
assert_match response_success, @ui.output
end
def test_with_webauthn_enabled_failure_with_polling
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 = "Successfully registered gem: freewill (1.0.0)"
port = 5678
server = TCPServer.new(port)
@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")
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"expired\",\"message\":\"The token in the link you used has either expired or been used already.\"}",
code: 200,
msg: "OK"
)
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
use_ui @ui do
@cmd.send_gem(@path)
end
ensure
server.close
end
end
assert_equal 1, error.exit_code
assert_match @fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match "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.", @ui.output
assert_match "ERROR: Security device verification failed: The token in the link you used has either expired " \
"or been used already.", @ui.error
refute_match "You are verified with a security device. You may close the browser window.", @ui.output
refute_match response_success, @ui.output
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"

View File

@ -121,6 +121,7 @@ class TestGemCommandsYankCommand < Gem::TestCase
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"
status_uri = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"
port = 5678
server = TCPServer.new(port)
@ -129,6 +130,11 @@ class TestGemCommandsYankCommand < Gem::TestCase
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
]
@fetcher.data[status_uri] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
@ -157,6 +163,7 @@ class TestGemCommandsYankCommand < Gem::TestCase
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"
status_uri = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"
port = 5678
server = TCPServer.new(port)
raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
@ -166,6 +173,11 @@ class TestGemCommandsYankCommand < Gem::TestCase
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
]
@fetcher.data[status_uri] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
code: 200,
msg: "OK"
)
@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
@ -194,6 +206,96 @@ class TestGemCommandsYankCommand < Gem::TestCase
refute_match "Successfully yanked", @ui.output
end
def test_with_webauthn_enabled_success_with_polling
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"
status_uri = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"
port = 5678
server = TCPServer.new(port)
@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"),
]
@fetcher.data[status_uri] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"success\",\"code\":\"Uvh6T57tkWuUnWYo\"}",
code: 200,
msg: "OK"
)
@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
@cmd.options[:version] = req("= 1.0")
TCPServer.stub(:new, server) do
use_ui @ui do
@cmd.execute
end
ensure
server.close
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match %r{Yanking gem from http://example}, @ui.output
assert_match "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.", @ui.output
assert_match "You are verified with a security device. You may close the browser window.", @ui.output
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
assert_match "Successfully yanked", @ui.output
end
def test_with_webauthn_enabled_failure_with_polling
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"
status_uri = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"
port = 5678
server = TCPServer.new(port)
@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"),
]
@fetcher.data[status_uri] = Gem::HTTPResponseFactory.create(
body: "{\"status\":\"expired\",\"message\":\"The token in the link you used has either expired or been used already.\"}",
code: 200,
msg: "OK"
)
@cmd.options[:args] = %w[a]
@cmd.options[:added_platform] = true
@cmd.options[:version] = req("= 1.0")
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
use_ui @ui do
@cmd.execute
end
ensure
server.close
end
end
assert_equal 1, error.exit_code
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match @fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
assert_match %r{Yanking gem from http://example}, @ui.output
assert_match "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.", @ui.output
assert_match "ERROR: Security device verification failed: The token in the link you used has either expired " \
"or been used already.", @ui.error
refute_match "You are verified with a security device. You may close the browser window.", @ui.output
refute_match "Successfully yanked", @ui.output
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")

View File

@ -268,6 +268,52 @@ class TestGemGemcutterUtilities < Gem::TestCase
refute_match "Signed in with API key:", @sign_in_ui.output
end
def test_sign_in_with_webauthn_enabled_with_polling
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
port = 5678
server = TCPServer.new(port)
@fetcher.respond_with_require_otp
@fetcher.respond_with_webauthn_url(webauthn_verification_url)
@fetcher.respond_with_webauthn_polling("Uvh6T57tkWuUnWYo")
TCPServer.stub(:new, server) do
util_sign_in
ensure
server.close
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match "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.", @sign_in_ui.output
assert_match "You are verified with a security device. You may close the browser window.", @sign_in_ui.output
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
end
def test_sign_in_with_webauthn_enabled_with_polling_failure
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
port = 5678
server = TCPServer.new(port)
@fetcher.respond_with_require_otp
@fetcher.respond_with_webauthn_url(webauthn_verification_url)
@fetcher.respond_with_webauthn_polling_failure
assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
util_sign_in
ensure
server.close
end
end
url_with_port = "#{webauthn_verification_url}?port=#{port}"
assert_match "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.", @sign_in_ui.output
assert_match "ERROR: Security device verification failed: " \
"The token in the link you used has either expired or been used already.", @sign_in_ui.error
end
def util_sign_in(args: [], extra_input: "")
email = "you@example.com"
password = "secret"
@ -320,7 +366,34 @@ class TestGemGemcutterUtilities < Gem::TestCase
end
def respond_with_webauthn_url(url)
require "json"
@data["#{@host}/api/v1/webauthn_verification"] = Gem::HTTPResponseFactory.create(body: url, code: 200, msg: "OK")
@data["#{@host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: { status: "pending", message: "Security device authentication is still pending." }.to_json,
code: 200,
msg: "OK"
)
end
def respond_with_webauthn_polling(code)
require "json"
@data["#{@host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: { status: "success", code: code }.to_json,
code: 200,
msg: "OK"
)
end
def respond_with_webauthn_polling_failure
require "json"
@data["#{@host}/api/v1/webauthn_verification/odow34b93t6aPCdY/status.json"] = Gem::HTTPResponseFactory.create(
body: {
status: "expired",
message: "The token in the link you used has either expired or been used already.",
}.to_json,
code: 200,
msg: "OK"
)
end
def respond_with_require_otp