[rubygems/rubygems] Move WebauthnListener into the Gem::GemcutterUtilities namespace

https://github.com/rubygems/rubygems/commit/3080394f81
This commit is contained in:
Jenny Shen 2023-06-29 16:10:22 -04:00 коммит произвёл git
Родитель 108cc38a76
Коммит fce04f9a6c
11 изменённых файлов: 288 добавлений и 284 удалений

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

@ -2,7 +2,7 @@
require_relative "remote_fetcher"
require_relative "text"
require_relative "webauthn_listener"
require_relative "gemcutter_utilities/webauthn_listener"
require_relative "gemcutter_utilities/webauthn_poller"
##
@ -260,7 +260,7 @@ module Gem::GemcutterUtilities
url_with_port = "#{webauthn_url}?port=#{port}"
say "You have enabled multi-factor authentication. Please visit #{url_with_port} to authenticate via security device. If you can't verify using WebAuthn but have OTP enabled, you can re-run the gem signin command with the `--otp [your_code]` option."
threads = [socket_thread(server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)]
threads = [WebauthnListener.listener_thread(host, server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)]
otp_thread = wait_for_otp_thread(*threads)
threads.each(&:join)
@ -289,20 +289,6 @@ module Gem::GemcutterUtilities
threads.each(&:exit)
end
def socket_thread(server)
thread = Thread.new do
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(host, server)
rescue Gem::WebauthnVerificationError => e
Thread.current[:error] = e
ensure
server.close
end
thread.abort_on_exception = true
thread.report_on_exception = false
thread
end
def webauthn_verification_url(credentials)
response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request|
if credentials.empty?

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

@ -0,0 +1,108 @@
# 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)
#
module Gem::GemcutterUtilities
class WebauthnListener
attr_reader :host
def initialize(host)
@host = host
end
def self.listener_thread(host, server)
thread = Thread.new do
Thread.current[:otp] = wait_for_otp_code(host, server)
rescue Gem::WebauthnVerificationError => e
Thread.current[:error] = e
ensure
server.close
end
thread.abort_on_exception = true
thread.report_on_exception = false
thread
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
end

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

@ -0,0 +1,163 @@
# 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
#
module Gem::GemcutterUtilities
class 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
end

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

@ -1,92 +0,0 @@
# 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

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

@ -1,161 +0,0 @@
# 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

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

@ -381,7 +381,7 @@ EOF
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
use_ui @stub_ui do
@cmd.add_owners("freewill", ["user-new1@example.com"])
end
@ -417,7 +417,7 @@ EOF
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
use_ui @stub_ui do
@cmd.add_owners("freewill", ["user-new1@example.com"])
end

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

@ -445,7 +445,7 @@ class TestGemCommandsPushCommand < Gem::TestCase
)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
use_ui @ui do
@cmd.send_gem(@path)
end
@ -482,7 +482,7 @@ class TestGemCommandsPushCommand < Gem::TestCase
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
use_ui @ui do
@cmd.send_gem(@path)
end

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

@ -141,7 +141,7 @@ class TestGemCommandsYankCommand < Gem::TestCase
@cmd.options[:version] = req("= 1.0")
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
use_ui @ui do
@cmd.execute
end
@ -185,7 +185,7 @@ class TestGemCommandsYankCommand < Gem::TestCase
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
use_ui @ui do
@cmd.execute
end

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

@ -229,7 +229,7 @@ class TestGemGemcutterUtilities < Gem::TestCase
@fetcher.respond_with_require_otp
@fetcher.respond_with_webauthn_url(webauthn_verification_url)
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
util_sign_in
end
ensure
@ -252,7 +252,7 @@ class TestGemGemcutterUtilities < Gem::TestCase
@fetcher.respond_with_webauthn_url(webauthn_verification_url)
error = assert_raise Gem::MockGemUi::TermError do
TCPServer.stub(:new, server) do
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
Gem::GemcutterUtilities::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
util_sign_in
end
ensure

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

@ -106,7 +106,7 @@ class WebauthnListenerTest < Gem::TestCase
def wait_for_otp_code
@thread = Thread.new do
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
Thread.current[:otp] = Gem::GemcutterUtilities::WebauthnListener.wait_for_otp_code(Gem.host, @server)
end
@thread.abort_on_exception = true
@thread.report_on_exception = false
@ -115,7 +115,7 @@ class WebauthnListenerTest < Gem::TestCase
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)
Thread.current[:otp] = Gem::GemcutterUtilities::WebauthnListener.wait_for_otp_code(Gem.host, @server)
end
assert_equal message, error.message

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

@ -10,7 +10,7 @@ class WebauthnListenerResponseTest < Gem::TestCase
end
def test_ok_response_to_s
to_s = Gem::WebauthnListener::OkResponse.new(@host).to_s
to_s = Gem::GemcutterUtilities::WebauthnListener::OkResponse.new(@host).to_s
expected_to_s = <<~RESPONSE
HTTP/1.1 200 OK\r
@ -28,7 +28,7 @@ class WebauthnListenerResponseTest < Gem::TestCase
end
def test_no_to_s_response_to_s
to_s = Gem::WebauthnListener::NoContentResponse.new(@host).to_s
to_s = Gem::GemcutterUtilities::WebauthnListener::NoContentResponse.new(@host).to_s
expected_to_s = <<~RESPONSE
HTTP/1.1 204 No Content\r
@ -43,7 +43,7 @@ class WebauthnListenerResponseTest < Gem::TestCase
end
def test_method_not_allowed_response_to_s
to_s = Gem::WebauthnListener::MethodNotAllowedResponse.new(@host).to_s
to_s = Gem::GemcutterUtilities::WebauthnListener::MethodNotAllowedResponse.new(@host).to_s
expected_to_s = <<~RESPONSE
HTTP/1.1 405 Method Not Allowed\r
@ -59,7 +59,7 @@ class WebauthnListenerResponseTest < Gem::TestCase
end
def test_method_not_found_response_to_s
to_s = Gem::WebauthnListener::NotFoundResponse.new(@host).to_s
to_s = Gem::GemcutterUtilities::WebauthnListener::NotFoundResponse.new(@host).to_s
expected_to_s = <<~RESPONSE
HTTP/1.1 404 Not Found\r
@ -74,7 +74,7 @@ class WebauthnListenerResponseTest < Gem::TestCase
end
def test_bad_request_response_to_s
to_s = Gem::WebauthnListener::BadRequestResponse.new(@host).to_s
to_s = Gem::GemcutterUtilities::WebauthnListener::BadRequestResponse.new(@host).to_s
expected_to_s = <<~RESPONSE
HTTP/1.1 400 Bad Request\r