Merge RubyGems-3.4.12 and Bundler-2.4.12

This commit is contained in:
Hiroshi SHIBATA 2023-07-19 14:12:38 +09:00 committed by nagachika
parent dd91a17560
commit c71a89f785
19 changed files with 865 additions and 22 deletions

View file

@ -7,9 +7,5 @@ require "<%= config[:namespaced_path] %>"
# You can add fixtures and/or initialization code here to make experimenting # 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. # 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" require "irb"
IRB.start(__FILE__) IRB.start(__FILE__)

View file

@ -1,7 +1,7 @@
# frozen_string_literal: false # frozen_string_literal: false
module Bundler module Bundler
VERSION = "2.4.11".freeze VERSION = "2.4.12".freeze
def self.bundler_major_version def self.bundler_major_version
@bundler_major_version ||= VERSION.split(".").first.to_i @bundler_major_version ||= VERSION.split(".").first.to_i

View file

@ -8,7 +8,7 @@
require "rbconfig" require "rbconfig"
module Gem module Gem
VERSION = "3.4.11" VERSION = "3.4.12"
end end
# Must be first since it unloads the prelude from 1.9.2 # Must be first since it unloads the prelude from 1.9.2

View file

@ -98,8 +98,10 @@ permission to.
action = method == :delete ? "Removing" : "Adding" action = method == :delete ? "Removing" : "Adding"
with_response response, "#{action} #{owner}" with_response response, "#{action} #{owner}"
rescue rescue Gem::WebauthnVerificationError => e
# ignore raise e
rescue StandardError
# ignore early exits to allow for completing the iteration of all owners
end end
end end
end end

View file

@ -213,6 +213,16 @@ class Gem::RubyVersionMismatch < Gem::Exception; end
class Gem::VerificationError < 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 # Raised to indicate that a system exit should occur with the specified
# exit_code # exit_code

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "remote_fetcher" require_relative "remote_fetcher"
require_relative "text" require_relative "text"
require_relative "webauthn_listener"
## ##
# Utility methods for using the RubyGems API. # 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. # 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" require "net/http"
self.host = host if host self.host = host if host
@ -105,7 +106,7 @@ module Gem::GemcutterUtilities
response = request_with_otp(method, uri, &block) response = request_with_otp(method, uri, &block)
if mfa_unauthorized?(response) if mfa_unauthorized?(response)
ask_otp fetch_otp(credentials)
response = request_with_otp(method, uri, &block) response = request_with_otp(method, uri, &block)
end end
@ -167,11 +168,12 @@ module Gem::GemcutterUtilities
mfa_params = get_mfa_params(profile) mfa_params = get_mfa_params(profile)
all_params = scope_params.merge(mfa_params) all_params = scope_params.merge(mfa_params)
warning = profile["warning"] warning = profile["warning"]
credentials = { email: email, password: password }
say "#{warning}\n" if warning say "#{warning}\n" if warning
response = rubygems_api_request(:post, "api/v1/api_key", 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.basic_auth email, password
request["OTP"] = otp if otp request["OTP"] = otp if otp
request.body = URI.encode_www_form({ name: key_name }.merge(all_params)) request.body = URI.encode_www_form({ name: key_name }.merge(all_params))
@ -250,9 +252,49 @@ module Gem::GemcutterUtilities
end end
end end
def ask_otp 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." say "You have enabled multi-factor authentication. Please enter OTP code."
options[:otp] = ask "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 end
def pretty_host(host) def pretty_host(host)

View file

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

View file

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

View file

@ -330,6 +330,8 @@ EOF
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"), HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"), 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" @otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do use_ui @otp_ui do
@ -345,6 +347,8 @@ EOF
def test_otp_verified_failure 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." 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/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" @otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do use_ui @otp_ui do
@ -357,6 +361,69 @@ EOF
assert_equal "111111", @stub_fetcher.last_request["OTP"] assert_equal "111111", @stub_fetcher.last_request["OTP"]
end 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 def test_remove_owners_unathorized_api_key
response_forbidden = "The API key doesn't have access" response_forbidden = "The API key doesn't have access"
response_success = "Owner removed successfully." response_success = "Owner removed successfully."

View file

@ -391,6 +391,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"), HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"), 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" @otp_ui = Gem::MockGemUi.new "111111\n"
use_ui @otp_ui do use_ui @otp_ui do
@ -406,6 +408,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
def test_otp_verified_failure 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." 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/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" @otp_ui = Gem::MockGemUi.new "111111\n"
assert_raise Gem::MockGemUi::TermError do assert_raise Gem::MockGemUi::TermError do
@ -420,6 +424,71 @@ class TestGemCommandsPushCommand < Gem::TestCase
assert_equal "111111", @fetcher.last_request["OTP"] assert_equal "111111", @fetcher.last_request["OTP"]
end 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 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_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" 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_forbidden, code: 403, msg: "Forbidden"),
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"), 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") @fetcher.data["#{@host}/api/v1/api_key"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK")
@cmd.instance_variable_set :@host, @host @cmd.instance_variable_set :@host, @host
@ -470,6 +541,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
@fetcher.data["#{@host}/api/v1/profile/me.yaml"] = [ @fetcher.data["#{@host}/api/v1/profile/me.yaml"] = [
HTTPResponseFactory.create(body: response_profile, code: 200, msg: "OK"), 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.instance_variable_set :@scope, :push_rubygem
@cmd.options[:args] = [@path] @cmd.options[:args] = [@path]

View file

@ -72,6 +72,9 @@ class TestGemCommandsYankCommand < Gem::TestCase
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"), HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"), 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[:args] = %w[a]
@cmd.options[:added_platform] = true @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." 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" yank_uri = "http://example/api/v1/gems/yank"
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized") @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[:args] = %w[a]
@cmd.options[:added_platform] = true @cmd.options[:added_platform] = true
@ -109,6 +115,84 @@ class TestGemCommandsYankCommand < Gem::TestCase
assert_equal "111111", @fetcher.last_request["OTP"] assert_equal "111111", @fetcher.last_request["OTP"]
end 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 def test_execute_key
yank_uri = "http://example/api/v1/gems/yank" yank_uri = "http://example/api/v1/gems/yank"
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK") @fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK")

View file

@ -230,10 +230,77 @@ class TestGemGemcutterUtilities < Gem::TestCase
assert_equal "111111", @fetcher.last_request["OTP"] assert_equal "111111", @fetcher.last_request["OTP"]
end end
def util_sign_in(response, host = nil, args = [], extra_input = "") 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" email = "you@example.com"
password = "secret" password = "secret"
profile_response = HTTPResponseFactory.create(body: "mfa: disabled\n", code: 200, msg: "OK") 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 if host
ENV["RUBYGEMS_HOST"] = host ENV["RUBYGEMS_HOST"] = host
@ -244,6 +311,7 @@ class TestGemGemcutterUtilities < Gem::TestCase
@fetcher = Gem::FakeFetcher.new @fetcher = Gem::FakeFetcher.new
@fetcher.data["#{host}/api/v1/api_key"] = response @fetcher.data["#{host}/api/v1/api_key"] = response
@fetcher.data["#{host}/api/v1/profile/me.yaml"] = profile_response @fetcher.data["#{host}/api/v1/profile/me.yaml"] = profile_response
@fetcher.data["#{host}/api/v1/webauthn_verification"] = webauthn_response
Gem::RemoteFetcher.fetcher = @fetcher Gem::RemoteFetcher.fetcher = @fetcher
@sign_in_ui = Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n" + extra_input) @sign_in_ui = Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n" + extra_input)

View file

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

View file

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

View file

@ -186,6 +186,41 @@ class Gem::HTTPResponseFactory
end end
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: # :stopdoc:
class Gem::RemoteFetcher class Gem::RemoteFetcher
def self.fetcher=(fetcher) def self.fetcher=(fetcher)

View file

@ -54,4 +54,4 @@ DEPENDENCIES
webrick (~> 1.6) webrick (~> 1.6)
BUNDLED WITH BUNDLED WITH
2.4.11 2.4.12

View file

@ -70,4 +70,4 @@ DEPENDENCIES
test-unit test-unit
BUNDLED WITH BUNDLED WITH
2.4.11 2.4.12

View file

@ -78,4 +78,4 @@ DEPENDENCIES
test-unit test-unit
BUNDLED WITH BUNDLED WITH
2.4.11 2.4.12

View file

@ -42,4 +42,4 @@ DEPENDENCIES
webrick (= 1.7.0) webrick (= 1.7.0)
BUNDLED WITH BUNDLED WITH
2.4.11 2.4.12