зеркало из https://github.com/github/ruby.git
[rubygems/rubygems] Extract polling logic into its own class
https://github.com/rubygems/rubygems/commit/218b83abed
This commit is contained in:
Родитель
023d0f662b
Коммит
108cc38a76
|
@ -3,6 +3,7 @@
|
|||
require_relative "remote_fetcher"
|
||||
require_relative "text"
|
||||
require_relative "webauthn_listener"
|
||||
require_relative "gemcutter_utilities/webauthn_poller"
|
||||
|
||||
##
|
||||
# Utility methods for using the RubyGems API.
|
||||
|
@ -259,7 +260,7 @@ module Gem::GemcutterUtilities
|
|||
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)]
|
||||
threads = [socket_thread(server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)]
|
||||
otp_thread = wait_for_otp_thread(*threads)
|
||||
|
||||
threads.each(&:join)
|
||||
|
@ -302,35 +303,6 @@ module Gem::GemcutterUtilities
|
|||
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
|
||||
|
||||
thread
|
||||
end
|
||||
|
||||
def webauthn_verification_url(credentials)
|
||||
response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request|
|
||||
if credentials.empty?
|
||||
|
@ -342,17 +314,6 @@ 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"
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# 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]
|
||||
#
|
||||
|
||||
module Gem::GemcutterUtilities
|
||||
class WebauthnPoller
|
||||
include Gem::GemcutterUtilities
|
||||
TIMEOUT_IN_SECONDS = 300
|
||||
|
||||
attr_reader :options, :host
|
||||
|
||||
def initialize(options, host)
|
||||
@options = options
|
||||
@host = host
|
||||
end
|
||||
|
||||
def self.poll_thread(options, host, webauthn_url, credentials)
|
||||
thread = Thread.new do
|
||||
Thread.current[:otp] = new(options, host).poll_for_otp(webauthn_url, credentials)
|
||||
rescue Gem::WebauthnVerificationError, Timeout::Error => e
|
||||
Thread.current[:error] = e
|
||||
end
|
||||
thread.abort_on_exception = true
|
||||
thread.report_on_exception = false
|
||||
|
||||
thread
|
||||
end
|
||||
|
||||
def poll_for_otp(webauthn_url, credentials)
|
||||
Timeout.timeout(TIMEOUT_IN_SECONDS) 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"
|
||||
return parsed_response["code"]
|
||||
else
|
||||
raise Gem::WebauthnVerificationError, parsed_response.fetch("message", "Invalid response from server")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
end
|
||||
end
|
|
@ -0,0 +1,124 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "helper"
|
||||
require "rubygems/gemcutter_utilities/webauthn_poller"
|
||||
require "rubygems/gemcutter_utilities"
|
||||
|
||||
class WebauthnPollerTest < Gem::TestCase
|
||||
def setup
|
||||
super
|
||||
|
||||
@host = Gem.host
|
||||
@webauthn_url = "#{@host}/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
||||
@fetcher = Gem::FakeFetcher.new
|
||||
Gem::RemoteFetcher.fetcher = @fetcher
|
||||
@credentials = {
|
||||
email: "email@example.com",
|
||||
password: "password",
|
||||
}
|
||||
end
|
||||
|
||||
def test_poll_thread_success
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "{\"status\":\"success\",\"code\":\"Uvh6T57tkWuUnWYo\"}",
|
||||
code: 200,
|
||||
msg: "OK"
|
||||
)
|
||||
|
||||
thread = Gem::GemcutterUtilities::WebauthnPoller.poll_thread({}, @host, @webauthn_url, @credentials)
|
||||
thread.join
|
||||
|
||||
assert_equal thread[:otp], "Uvh6T57tkWuUnWYo"
|
||||
end
|
||||
|
||||
def test_poll_thread_webauthn_verification_error
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "HTTP Basic: Access denied.",
|
||||
code: 401,
|
||||
msg: "Unauthorized"
|
||||
)
|
||||
|
||||
thread = Gem::GemcutterUtilities::WebauthnPoller.poll_thread({}, @host, @webauthn_url, @credentials)
|
||||
thread.join
|
||||
|
||||
assert_equal thread[:error].message, "Security device verification failed: Unauthorized"
|
||||
end
|
||||
|
||||
def test_poll_thread_timeout_error
|
||||
raise_error = ->(*_args) { raise Timeout::Error, "execution expired" }
|
||||
Timeout.stub(:timeout, raise_error) do
|
||||
thread = Gem::GemcutterUtilities::WebauthnPoller.poll_thread({}, @host, @webauthn_url, @credentials)
|
||||
thread.join
|
||||
assert_equal thread[:error].message, "execution expired"
|
||||
end
|
||||
end
|
||||
|
||||
def test_poll_for_otp_success
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "{\"status\":\"success\",\"code\":\"Uvh6T57tkWuUnWYo\"}",
|
||||
code: 200,
|
||||
msg: "OK"
|
||||
)
|
||||
|
||||
otp = Gem::GemcutterUtilities::WebauthnPoller.new({}, @host).poll_for_otp(@webauthn_url, @credentials)
|
||||
|
||||
assert_equal otp, "Uvh6T57tkWuUnWYo"
|
||||
end
|
||||
|
||||
def test_poll_for_otp_pending_sleeps
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "{\"status\":\"pending\",\"message\":\"Security device authentication is still pending.\"}",
|
||||
code: 200,
|
||||
msg: "OK"
|
||||
)
|
||||
|
||||
assert_raises Timeout::Error do
|
||||
Timeout.timeout(0.1) do
|
||||
Gem::GemcutterUtilities::WebauthnPoller.new({}, @host).poll_for_otp(@webauthn_url, @credentials)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_poll_for_otp_not_http_success
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "HTTP Basic: Access denied.",
|
||||
code: 401,
|
||||
msg: "Unauthorized"
|
||||
)
|
||||
|
||||
error = assert_raises Gem::WebauthnVerificationError do
|
||||
Gem::GemcutterUtilities::WebauthnPoller.new({}, @host).poll_for_otp(@webauthn_url, @credentials)
|
||||
end
|
||||
|
||||
assert_equal error.message, "Security device verification failed: Unauthorized"
|
||||
end
|
||||
|
||||
def test_poll_for_otp_invalid_format
|
||||
@fetcher.data["#{@webauthn_url}/status.json"] = Gem::HTTPResponseFactory.create(
|
||||
body: "{}",
|
||||
code: 200,
|
||||
msg: "OK"
|
||||
)
|
||||
|
||||
error = assert_raises Gem::WebauthnVerificationError do
|
||||
Gem::GemcutterUtilities::WebauthnPoller.new({}, @host).poll_for_otp(@webauthn_url, @credentials)
|
||||
end
|
||||
|
||||
assert_equal error.message, "Security device verification failed: Invalid response from server"
|
||||
end
|
||||
|
||||
def test_poll_for_otp_invalid_status
|
||||
@fetcher.data["#{@webauthn_url}/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_raises Gem::WebauthnVerificationError do
|
||||
Gem::GemcutterUtilities::WebauthnPoller.new({}, @host).poll_for_otp(@webauthn_url, @credentials)
|
||||
end
|
||||
|
||||
assert_equal error.message,
|
||||
"Security device verification failed: The token in the link you used has either expired or been used already."
|
||||
end
|
||||
end
|
Загрузка…
Ссылка в новой задаче