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 @@ -213,6 +213,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