summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHiroshi SHIBATA <hsbt@ruby-lang.org>2023-07-19 14:12:38 +0900
committernagachika <nagachika@ruby-lang.org>2023-08-29 23:25:18 +0900
commitc71a89f7854bd68bc631747681b4ea6c91d845d5 (patch)
tree529a5dc46641c665396caec6ee328bfb4b5e6229
parentdd91a17560946db1cb33c0ae1ecf3abe555ed1d0 (diff)
Merge RubyGems-3.4.12 and Bundler-2.4.12
-rw-r--r--lib/bundler/templates/newgem/bin/console.tt4
-rw-r--r--lib/bundler/version.rb2
-rw-r--r--lib/rubygems.rb2
-rw-r--r--lib/rubygems/commands/owner_command.rb6
-rw-r--r--lib/rubygems/exceptions.rb10
-rw-r--r--lib/rubygems/gemcutter_utilities.rb54
-rw-r--r--lib/rubygems/webauthn_listener.rb92
-rw-r--r--lib/rubygems/webauthn_listener/response.rb161
-rw-r--r--test/rubygems/test_gem_commands_owner_command.rb67
-rw-r--r--test/rubygems/test_gem_commands_push_command.rb73
-rw-r--r--test/rubygems/test_gem_commands_yank_command.rb84
-rw-r--r--test/rubygems/test_gem_gemcutter_utilities.rb76
-rw-r--r--test/rubygems/test_webauthn_listener.rb120
-rw-r--r--test/rubygems/test_webauthn_listener_response.rb93
-rw-r--r--test/rubygems/utilities.rb35
-rw-r--r--tool/bundler/dev_gems.rb.lock2
-rw-r--r--tool/bundler/rubocop_gems.rb.lock2
-rw-r--r--tool/bundler/standard_gems.rb.lock2
-rw-r--r--tool/bundler/test_gems.rb.lock2
19 files changed, 865 insertions, 22 deletions
diff --git a/lib/bundler/templates/newgem/bin/console.tt b/lib/bundler/templates/newgem/bin/console.tt
index 08dfaaef69..c91ee65f93 100644
--- a/lib/bundler/templates/newgem/bin/console.tt
+++ b/lib/bundler/templates/newgem/bin/console.tt
@@ -7,9 +7,5 @@ require "<%= config[:namespaced_path] %>"
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
-# (If you use this, don't forget to add pry to your Gemfile!)
-# require "pry"
-# Pry.start
-
require "irb"
IRB.start(__FILE__)
diff --git a/lib/bundler/version.rb b/lib/bundler/version.rb
index e4eb1ca946..8e7c9425c2 100644
--- a/lib/bundler/version.rb
+++ b/lib/bundler/version.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: false
module Bundler
- VERSION = "2.4.11".freeze
+ VERSION = "2.4.12".freeze
def self.bundler_major_version
@bundler_major_version ||= VERSION.split(".").first.to_i
diff --git a/lib/rubygems.rb b/lib/rubygems.rb
index 1a0942af22..d79491dc92 100644
--- a/lib/rubygems.rb
+++ b/lib/rubygems.rb
@@ -8,7 +8,7 @@
require "rbconfig"
module Gem
- VERSION = "3.4.11"
+ VERSION = "3.4.12"
end
# Must be first since it unloads the prelude from 1.9.2
diff --git a/lib/rubygems/commands/owner_command.rb b/lib/rubygems/commands/owner_command.rb
index 959a6186dc..0ec59428d8 100644
--- a/lib/rubygems/commands/owner_command.rb
+++ b/lib/rubygems/commands/owner_command.rb
@@ -98,8 +98,10 @@ permission to.
action = method == :delete ? "Removing" : "Adding"
with_response response, "#{action} #{owner}"
- rescue
- # ignore
+ rescue Gem::WebauthnVerificationError => e
+ raise e
+ rescue StandardError
+ # ignore early exits to allow for completing the iteration of all owners
end
end
end
diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb
index ca4fbb20de..b92396f0ef 100644
--- a/lib/rubygems/exceptions.rb
+++ b/lib/rubygems/exceptions.rb
@@ -214,6 +214,16 @@ class Gem::RubyVersionMismatch < Gem::Exception; end
class Gem::VerificationError < Gem::Exception; end
##
+# Raised by Gem::WebauthnListener when an error occurs during security
+# device verification.
+
+class Gem::WebauthnVerificationError < Gem::Exception
+ def initialize(message)
+ super "Security device verification failed: #{message}"
+ end
+end
+
+##
# Raised to indicate that a system exit should occur with the specified
# exit_code
diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb
index 3477422b79..4a1b5fe79d 100644
--- a/lib/rubygems/gemcutter_utilities.rb
+++ b/lib/rubygems/gemcutter_utilities.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative "remote_fetcher"
require_relative "text"
+require_relative "webauthn_listener"
##
# Utility methods for using the RubyGems API.
@@ -82,7 +83,7 @@ module Gem::GemcutterUtilities
#
# 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
@@ -105,7 +106,7 @@ module Gem::GemcutterUtilities
response = request_with_otp(method, uri, &block)
if mfa_unauthorized?(response)
- ask_otp
+ fetch_otp(credentials)
response = request_with_otp(method, uri, &block)
end
@@ -167,11 +168,12 @@ module Gem::GemcutterUtilities
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))
@@ -250,9 +252,49 @@ module Gem::GemcutterUtilities
end
end
- def ask_otp
- say "You have enabled multi-factor authentication. Please enter OTP code."
- options[:otp] = ask "Code: "
+ def fetch_otp(credentials)
+ options[:otp] = if webauthn_url = webauthn_verification_url(credentials)
+ wait_for_otp(webauthn_url)
+ 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
+
+ thread = Thread.new do
+ Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(host, server)
+ rescue Gem::WebauthnVerificationError => 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]
+ end
+
+ def webauthn_verification_url(credentials)
+ response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request|
+ if credentials.empty?
+ request.add_field "Authorization", api_key
+ else
+ request.basic_auth credentials[:email], credentials[:password]
+ end
+ end
+ response.is_a?(Net::HTTPSuccess) ? response.body : nil
end
def pretty_host(host)
diff --git a/lib/rubygems/webauthn_listener.rb b/lib/rubygems/webauthn_listener.rb
new file mode 100644
index 0000000000..22f7ea2011
--- /dev/null
+++ b/lib/rubygems/webauthn_listener.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require_relative "webauthn_listener/response"
+
+##
+# The WebauthnListener class retrieves an OTP after a user successfully WebAuthns with the Gem host.
+# An instance opens a socket using the TCPServer instance given and listens for a request from the Gem host.
+# The request should be a GET request to the root path and contains the OTP code in the form
+# of a query parameter `code`. The listener will return the code which will be used as the OTP for
+# API requests.
+#
+# Types of responses sent by the listener after receiving a request:
+# - 200 OK: OTP code was successfully retrieved
+# - 204 No Content: If the request was an OPTIONS request
+# - 400 Bad Request: If the request did not contain a query parameter `code`
+# - 404 Not Found: The request was not to the root path
+# - 405 Method Not Allowed: OTP code was not retrieved because the request was not a GET/OPTIONS request
+#
+# Example usage:
+#
+# server = TCPServer.new(0)
+# otp = Gem::WebauthnListener.wait_for_otp_code("https://rubygems.example", server)
+#
+
+class Gem::WebauthnListener
+ attr_reader :host
+
+ def initialize(host)
+ @host = host
+ end
+
+ def self.wait_for_otp_code(host, server)
+ new(host).fetch_otp_from_connection(server)
+ end
+
+ def fetch_otp_from_connection(server)
+ loop do
+ socket = server.accept
+ request_line = socket.gets
+
+ method, req_uri, _protocol = request_line.split(" ")
+ req_uri = URI.parse(req_uri)
+
+ responder = SocketResponder.new(socket)
+
+ unless root_path?(req_uri)
+ responder.send(NotFoundResponse.for(host))
+ raise Gem::WebauthnVerificationError, "Page at #{req_uri.path} not found."
+ end
+
+ case method.upcase
+ when "OPTIONS"
+ responder.send(NoContentResponse.for(host))
+ next # will be GET
+ when "GET"
+ if otp = parse_otp_from_uri(req_uri)
+ responder.send(OkResponse.for(host))
+ return otp
+ end
+ responder.send(BadRequestResponse.for(host))
+ raise Gem::WebauthnVerificationError, "Did not receive OTP from #{host}."
+ else
+ responder.send(MethodNotAllowedResponse.for(host))
+ raise Gem::WebauthnVerificationError, "Invalid HTTP method #{method.upcase} received."
+ end
+ end
+ end
+
+ private
+
+ def root_path?(uri)
+ uri.path == "/"
+ end
+
+ def parse_otp_from_uri(uri)
+ require "cgi"
+
+ return if uri.query.nil?
+ CGI.parse(uri.query).dig("code", 0)
+ end
+
+ class SocketResponder
+ def initialize(socket)
+ @socket = socket
+ end
+
+ def send(response)
+ @socket.print response.to_s
+ @socket.close
+ end
+ end
+end
diff --git a/lib/rubygems/webauthn_listener/response.rb b/lib/rubygems/webauthn_listener/response.rb
new file mode 100644
index 0000000000..baa769c4ae
--- /dev/null
+++ b/lib/rubygems/webauthn_listener/response.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+##
+# The WebauthnListener Response class is used by the WebauthnListener to create
+# responses to be sent to the Gem host. It creates a Net::HTTPResponse instance
+# when initialized and can be converted to the appropriate format to be sent by a socket using `to_s`.
+# Net::HTTPResponse instances cannot be directly sent over a socket.
+#
+# Types of response classes:
+# - OkResponse
+# - NoContentResponse
+# - BadRequestResponse
+# - NotFoundResponse
+# - MethodNotAllowedResponse
+#
+# Example usage:
+#
+# server = TCPServer.new(0)
+# socket = server.accept
+#
+# response = OkResponse.for("https://rubygems.example")
+# socket.print response.to_s
+# socket.close
+#
+
+class Gem::WebauthnListener
+ class Response
+ attr_reader :http_response
+
+ def self.for(host)
+ new(host)
+ end
+
+ def initialize(host)
+ @host = host
+
+ build_http_response
+ end
+
+ def to_s
+ status_line = "HTTP/#{@http_response.http_version} #{@http_response.code} #{@http_response.message}\r\n"
+ headers = @http_response.to_hash.map {|header, value| "#{header}: #{value.join(", ")}\r\n" }.join + "\r\n"
+ body = @http_response.body ? "#{@http_response.body}\n" : ""
+
+ status_line + headers + body
+ end
+
+ private
+
+ # Must be implemented in subclasses
+ def code
+ raise NotImplementedError
+ end
+
+ def reason_phrase
+ raise NotImplementedError
+ end
+
+ def body; end
+
+ def build_http_response
+ response_class = Net::HTTPResponse::CODE_TO_OBJ[code.to_s]
+ @http_response = response_class.new("1.1", code, reason_phrase)
+ @http_response.instance_variable_set(:@read, true)
+
+ add_connection_header
+ add_access_control_headers
+ add_body
+ end
+
+ def add_connection_header
+ @http_response["connection"] = "close"
+ end
+
+ def add_access_control_headers
+ @http_response["access-control-allow-origin"] = @host
+ @http_response["access-control-allow-methods"] = "POST"
+ @http_response["access-control-allow-headers"] = %w[Content-Type Authorization x-csrf-token]
+ end
+
+ def add_body
+ return unless body
+ @http_response["content-type"] = "text/plain"
+ @http_response["content-length"] = body.bytesize
+ @http_response.instance_variable_set(:@body, body)
+ end
+ end
+
+ class OkResponse < Response
+ private
+
+ def code
+ 200
+ end
+
+ def reason_phrase
+ "OK"
+ end
+
+ def body
+ "success"
+ end
+ end
+
+ class NoContentResponse < Response
+ private
+
+ def code
+ 204
+ end
+
+ def reason_phrase
+ "No Content"
+ end
+ end
+
+ class BadRequestResponse < Response
+ private
+
+ def code
+ 400
+ end
+
+ def reason_phrase
+ "Bad Request"
+ end
+
+ def body
+ "missing code parameter"
+ end
+ end
+
+ class NotFoundResponse < Response
+ private
+
+ def code
+ 404
+ end
+
+ def reason_phrase
+ "Not Found"
+ end
+ end
+
+ class MethodNotAllowedResponse < Response
+ private
+
+ def code
+ 405
+ end
+
+ def reason_phrase
+ "Method Not Allowed"
+ end
+
+ def add_access_control_headers
+ super
+ @http_response["allow"] = %w[GET OPTIONS]
+ end
+ end
+end
diff --git a/test/rubygems/test_gem_commands_owner_command.rb b/test/rubygems/test_gem_commands_owner_command.rb
index 32bfbb7a66..fc916db628 100644
--- a/test/rubygems/test_gem_commands_owner_command.rb
+++ b/test/rubygems/test_gem_commands_owner_command.rb
@@ -330,6 +330,8 @@ 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"] =
+ 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
@@ -345,6 +347,8 @@ EOF
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
@@ -357,6 +361,69 @@ EOF
assert_equal "111111", @stub_fetcher.last_request["OTP"]
end
+ def test_with_webauthn_enabled_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."
+ 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"),
+ ]
+
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
+ use_ui @stub_ui do
+ @cmd.add_owners("freewill", ["user-new1@example.com"])
+ end
+ 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
+ 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)
+ raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
+
+ @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"),
+ ]
+
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
+ use_ui @stub_ui do
+ @cmd.add_owners("freewill", ["user-new1@example.com"])
+ end
+ 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: Something went wrong", @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."
diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb
index ef7730558d..2e0f52e75e 100644
--- a/test/rubygems/test_gem_commands_push_command.rb
+++ b/test/rubygems/test_gem_commands_push_command.rb
@@ -391,6 +391,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
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
@@ -406,6 +408,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
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
@@ -420,6 +424,71 @@ class TestGemCommandsPushCommand < Gem::TestCase
assert_equal "111111", @fetcher.last_request["OTP"]
end
+ def test_with_webauthn_enabled_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 = "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")
+
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
+ use_ui @ui do
+ @cmd.send_gem(@path)
+ end
+ 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
+ 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)
+ raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
+
+ @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")
+
+ error = assert_raise Gem::MockGemUi::TermError do
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
+ use_ui @ui do
+ @cmd.send_gem(@path)
+ end
+ 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: Something went wrong", @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"
@@ -430,6 +499,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
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
@@ -470,6 +541,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
@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]
diff --git a/test/rubygems/test_gem_commands_yank_command.rb b/test/rubygems/test_gem_commands_yank_command.rb
index d14395a75e..55228f4485 100644
--- a/test/rubygems/test_gem_commands_yank_command.rb
+++ b/test/rubygems/test_gem_commands_yank_command.rb
@@ -72,6 +72,9 @@ class TestGemCommandsYankCommand < Gem::TestCase
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
@@ -93,6 +96,9 @@ class TestGemCommandsYankCommand < Gem::TestCase
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
@@ -109,6 +115,84 @@ class TestGemCommandsYankCommand < Gem::TestCase
assert_equal "111111", @fetcher.last_request["OTP"]
end
+ def test_with_webauthn_enabled_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"
+ 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"),
+ ]
+
+ @cmd.options[:args] = %w[a]
+ @cmd.options[:added_platform] = true
+ @cmd.options[:version] = req("= 1.0")
+
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
+ use_ui @ui do
+ @cmd.execute
+ end
+ 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
+ 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"
+ port = 5678
+ server = TCPServer.new(port)
+ raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
+
+ @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")
+
+ error = assert_raise Gem::MockGemUi::TermError do
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
+ use_ui @ui do
+ @cmd.execute
+ end
+ 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: Something went wrong", @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")
diff --git a/test/rubygems/test_gem_gemcutter_utilities.rb b/test/rubygems/test_gem_gemcutter_utilities.rb
index 5e59733d5a..0ce81c549e 100644
--- a/test/rubygems/test_gem_gemcutter_utilities.rb
+++ b/test/rubygems/test_gem_gemcutter_utilities.rb
@@ -230,10 +230,77 @@ class TestGemGemcutterUtilities < Gem::TestCase
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_enabled
+ webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
+ response_fail = "You have enabled multifactor authentication"
+ api_key = "a5fdbb6ba150cbb83aad2bb2fede64cf040453903"
+ port = 5678
+ server = TCPServer.new(port)
+
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
+ 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, [], "", webauthn_verification_url)
+ 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.", @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_error
+ webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
+ response_fail = "You have enabled multifactor authentication"
+ api_key = "a5fdbb6ba150cbb83aad2bb2fede64cf040453903"
+ port = 5678
+ server = TCPServer.new(port)
+ raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
+
+ error = assert_raise Gem::MockGemUi::TermError do
+ TCPServer.stub(:new, server) do
+ Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
+ 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, [], "", webauthn_verification_url)
+ end
+ ensure
+ server.close
+ end
+ end
+ assert_equal 1, error.exit_code
+
+ 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: Something went wrong", @sign_in_ui.error
+ refute_match "You are verified with a security device. You may close the browser window.", @sign_in_ui.output
+ refute_match "Signed in with API key:", @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
@@ -244,6 +311,7 @@ class TestGemGemcutterUtilities < Gem::TestCase
@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)
diff --git a/test/rubygems/test_webauthn_listener.rb b/test/rubygems/test_webauthn_listener.rb
new file mode 100644
index 0000000000..5677546e42
--- /dev/null
+++ b/test/rubygems/test_webauthn_listener.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+require "rubygems/webauthn_listener"
+
+class WebauthnListenerTest < Gem::TestCase
+ def setup
+ super
+ @server = TCPServer.new 0
+ @port = @server.addr[1].to_s
+ end
+
+ def test_wait_for_otp_code_get_follows_options
+ wait_for_otp_code
+ assert Gem::MockBrowser.options(URI("http://localhost:#{@port}?code=xyz")).is_a? Net::HTTPNoContent
+ assert Gem::MockBrowser.get(URI("http://localhost:#{@port}?code=xyz")).is_a? Net::HTTPOK
+ end
+
+ def test_wait_for_otp_code_options_request
+ wait_for_otp_code
+ response = Gem::MockBrowser.options URI("http://localhost:#{@port}?code=xyz")
+
+ assert response.is_a? Net::HTTPNoContent
+ assert_equal Gem.host, response["access-control-allow-origin"]
+ assert_equal "POST", response["access-control-allow-methods"]
+ assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
+ assert_equal "close", response["Connection"]
+ end
+
+ def test_wait_for_otp_code_get_request
+ wait_for_otp_code
+ response = Gem::MockBrowser.get URI("http://localhost:#{@port}?code=xyz")
+
+ assert response.is_a? Net::HTTPOK
+ assert_equal "text/plain", response["Content-Type"]
+ assert_equal "7", response["Content-Length"]
+ assert_equal Gem.host, response["access-control-allow-origin"]
+ assert_equal "POST", response["access-control-allow-methods"]
+ assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
+ assert_equal "close", response["Connection"]
+ assert_equal "success", response.body
+
+ @thread.join
+ assert_equal "xyz", @thread[:otp]
+ end
+
+ def test_wait_for_otp_code_invalid_post_req_method
+ wait_for_otp_code_expect_error_with_message("Security device verification failed: Invalid HTTP method POST received.")
+ response = Gem::MockBrowser.post URI("http://localhost:#{@port}?code=xyz")
+
+ assert response
+ assert response.is_a? Net::HTTPMethodNotAllowed
+ assert_equal "GET, OPTIONS", response["allow"]
+ assert_equal "close", response["Connection"]
+
+ @thread.join
+ assert_nil @thread[:otp]
+ end
+
+ def test_wait_for_otp_code_incorrect_path
+ wait_for_otp_code_expect_error_with_message("Security device verification failed: Page at /path not found.")
+ response = Gem::MockBrowser.post URI("http://localhost:#{@port}/path?code=xyz")
+
+ assert response.is_a? Net::HTTPNotFound
+ assert_equal "close", response["Connection"]
+
+ @thread.join
+ assert_nil @thread[:otp]
+ end
+
+ def test_wait_for_otp_code_no_params_response
+ wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
+ response = Gem::MockBrowser.get URI("http://localhost:#{@port}")
+
+ assert response.is_a? Net::HTTPBadRequest
+ assert_equal "text/plain", response["Content-Type"]
+ assert_equal "22", response["Content-Length"]
+ assert_equal "close", response["Connection"]
+ assert_equal "missing code parameter", response.body
+
+ @thread.join
+ assert_nil @thread[:otp]
+ end
+
+ def test_wait_for_otp_code_incorrect_params
+ wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
+ response = Gem::MockBrowser.get URI("http://localhost:#{@port}?param=xyz")
+
+ assert response.is_a? Net::HTTPBadRequest
+ assert_equal "text/plain", response["Content-Type"]
+ assert_equal "22", response["Content-Length"]
+ assert_equal "close", response["Connection"]
+ assert_equal "missing code parameter", response.body
+
+ @thread.join
+ assert_nil @thread[:otp]
+ end
+
+ private
+
+ def wait_for_otp_code
+ @thread = Thread.new do
+ Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
+ end
+ @thread.abort_on_exception = true
+ @thread.report_on_exception = false
+ end
+
+ def wait_for_otp_code_expect_error_with_message(message)
+ @thread = Thread.new do
+ error = assert_raise Gem::WebauthnVerificationError do
+ Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
+ end
+
+ assert_equal message, error.message
+ end
+ @thread.abort_on_exception = true
+ @thread.report_on_exception = false
+ end
+end
diff --git a/test/rubygems/test_webauthn_listener_response.rb b/test/rubygems/test_webauthn_listener_response.rb
new file mode 100644
index 0000000000..79e88f1f02
--- /dev/null
+++ b/test/rubygems/test_webauthn_listener_response.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+require "rubygems/webauthn_listener/response"
+
+class WebauthnListenerResponseTest < Gem::TestCase
+ def setup
+ super
+ @host = "rubygems.example"
+ end
+
+ def test_ok_response_to_s
+ to_s = Gem::WebauthnListener::OkResponse.new(@host).to_s
+
+ expected_to_s = <<~RESPONSE
+ HTTP/1.1 200 OK\r
+ connection: close\r
+ access-control-allow-origin: rubygems.example\r
+ access-control-allow-methods: POST\r
+ access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
+ content-type: text/plain\r
+ content-length: 7\r
+ \r
+ success
+ RESPONSE
+
+ assert_equal expected_to_s, to_s
+ end
+
+ def test_no_to_s_response_to_s
+ to_s = Gem::WebauthnListener::NoContentResponse.new(@host).to_s
+
+ expected_to_s = <<~RESPONSE
+ HTTP/1.1 204 No Content\r
+ connection: close\r
+ access-control-allow-origin: rubygems.example\r
+ access-control-allow-methods: POST\r
+ access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
+ \r
+ RESPONSE
+
+ assert_equal expected_to_s, to_s
+ end
+
+ def test_method_not_allowed_response_to_s
+ to_s = Gem::WebauthnListener::MethodNotAllowedResponse.new(@host).to_s
+
+ expected_to_s = <<~RESPONSE
+ HTTP/1.1 405 Method Not Allowed\r
+ connection: close\r
+ access-control-allow-origin: rubygems.example\r
+ access-control-allow-methods: POST\r
+ access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
+ allow: GET, OPTIONS\r
+ \r
+ RESPONSE
+
+ assert_equal expected_to_s, to_s
+ end
+
+ def test_method_not_found_response_to_s
+ to_s = Gem::WebauthnListener::NotFoundResponse.new(@host).to_s
+
+ expected_to_s = <<~RESPONSE
+ HTTP/1.1 404 Not Found\r
+ connection: close\r
+ access-control-allow-origin: rubygems.example\r
+ access-control-allow-methods: POST\r
+ access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
+ \r
+ RESPONSE
+
+ assert_equal expected_to_s, to_s
+ end
+
+ def test_bad_request_response_to_s
+ to_s = Gem::WebauthnListener::BadRequestResponse.new(@host).to_s
+
+ expected_to_s = <<~RESPONSE
+ HTTP/1.1 400 Bad Request\r
+ connection: close\r
+ access-control-allow-origin: rubygems.example\r
+ access-control-allow-methods: POST\r
+ access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
+ content-type: text/plain\r
+ content-length: 22\r
+ \r
+ missing code parameter
+ RESPONSE
+
+ assert_equal expected_to_s, to_s
+ end
+end
diff --git a/test/rubygems/utilities.rb b/test/rubygems/utilities.rb
index 33e50b3eb9..46f87ecc72 100644
--- a/test/rubygems/utilities.rb
+++ b/test/rubygems/utilities.rb
@@ -186,6 +186,41 @@ class Gem::HTTPResponseFactory
end
end
+##
+# A Gem::MockBrowser is used in tests to mock a browser in that it can
+# send HTTP requests to the defined URI.
+#
+# Example:
+#
+# # Sends a get request to http://localhost:5678
+# Gem::MockBrowser.get URI("http://localhost:5678")
+#
+# See RubyGems' tests for more examples of MockBrowser.
+#
+
+class Gem::MockBrowser
+ def self.options(uri)
+ options = Net::HTTP::Options.new(uri)
+ Net::HTTP.start(uri.hostname, uri.port) do |http|
+ http.request(options)
+ end
+ end
+
+ def self.get(uri)
+ get = Net::HTTP::Get.new(uri)
+ Net::HTTP.start(uri.hostname, uri.port) do |http|
+ http.request(get)
+ end
+ end
+
+ def self.post(uri)
+ post = Net::HTTP::Post.new(uri)
+ Net::HTTP.start(uri.hostname, uri.port) do |http|
+ http.request(post)
+ end
+ end
+end
+
# :stopdoc:
class Gem::RemoteFetcher
def self.fetcher=(fetcher)
diff --git a/tool/bundler/dev_gems.rb.lock b/tool/bundler/dev_gems.rb.lock
index 8c409ef940..7951d1727f 100644
--- a/tool/bundler/dev_gems.rb.lock
+++ b/tool/bundler/dev_gems.rb.lock
@@ -54,4 +54,4 @@ DEPENDENCIES
webrick (~> 1.6)
BUNDLED WITH
- 2.4.11
+ 2.4.12
diff --git a/tool/bundler/rubocop_gems.rb.lock b/tool/bundler/rubocop_gems.rb.lock
index 5d101bf964..3415f29628 100644
--- a/tool/bundler/rubocop_gems.rb.lock
+++ b/tool/bundler/rubocop_gems.rb.lock
@@ -70,4 +70,4 @@ DEPENDENCIES
test-unit
BUNDLED WITH
- 2.4.11
+ 2.4.12
diff --git a/tool/bundler/standard_gems.rb.lock b/tool/bundler/standard_gems.rb.lock
index 2d111420b8..1c4ee23f00 100644
--- a/tool/bundler/standard_gems.rb.lock
+++ b/tool/bundler/standard_gems.rb.lock
@@ -78,4 +78,4 @@ DEPENDENCIES
test-unit
BUNDLED WITH
- 2.4.11
+ 2.4.12
diff --git a/tool/bundler/test_gems.rb.lock b/tool/bundler/test_gems.rb.lock
index 5d3c0c138e..f6371f4606 100644
--- a/tool/bundler/test_gems.rb.lock
+++ b/tool/bundler/test_gems.rb.lock
@@ -42,4 +42,4 @@ DEPENDENCIES
webrick (= 1.7.0)
BUNDLED WITH
- 2.4.11
+ 2.4.12