[rubygems/rubygems] Extract polling logic into its own class

https://github.com/rubygems/rubygems/commit/218b83abed
This commit is contained in:
Jenny Shen 2023-06-29 15:39:57 -04:00 коммит произвёл git
Родитель 023d0f662b
Коммит 108cc38a76
3 изменённых файлов: 205 добавлений и 41 удалений

Просмотреть файл

@ -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