aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorJenny Shen <jenny.shen@shopify.com>2023-06-29 16:10:22 -0400
committergit <svn-admin@ruby-lang.org>2023-07-28 16:08:08 +0000
commitfce04f9a6c9935ef3f188558dce177e277b17711 (patch)
tree9ba20fcc264f156ba6c7dc142870d3c59f021dd7 /lib
parent108cc38a7658bfb8e9457f95baa5cdfbd175b64d (diff)
downloadruby-fce04f9a6c9935ef3f188558dce177e277b17711.tar.gz
[rubygems/rubygems] Move WebauthnListener into the Gem::GemcutterUtilities namespace
https://github.com/rubygems/rubygems/commit/3080394f81
Diffstat (limited to 'lib')
-rw-r--r--lib/rubygems/gemcutter_utilities.rb18
-rw-r--r--lib/rubygems/gemcutter_utilities/webauthn_listener.rb108
-rw-r--r--lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb163
-rw-r--r--lib/rubygems/webauthn_listener.rb92
-rw-r--r--lib/rubygems/webauthn_listener/response.rb161
5 files changed, 273 insertions, 269 deletions
diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb
index fb1a42b5ce..074a0df334 100644
--- a/lib/rubygems/gemcutter_utilities.rb
+++ b/lib/rubygems/gemcutter_utilities.rb
@@ -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?
diff --git a/lib/rubygems/gemcutter_utilities/webauthn_listener.rb b/lib/rubygems/gemcutter_utilities/webauthn_listener.rb
new file mode 100644
index 0000000000..5db6604f1a
--- /dev/null
+++ b/lib/rubygems/gemcutter_utilities/webauthn_listener.rb
@@ -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
diff --git a/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb b/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb
new file mode 100644
index 0000000000..c0e2778485
--- /dev/null
+++ b/lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb
@@ -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
diff --git a/lib/rubygems/webauthn_listener.rb b/lib/rubygems/webauthn_listener.rb
deleted file mode 100644
index 22f7ea2011..0000000000
--- a/lib/rubygems/webauthn_listener.rb
+++ /dev/null
@@ -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
diff --git a/lib/rubygems/webauthn_listener/response.rb b/lib/rubygems/webauthn_listener/response.rb
deleted file mode 100644
index baa769c4ae..0000000000
--- a/lib/rubygems/webauthn_listener/response.rb
+++ /dev/null
@@ -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