diff options
author | Jenny Shen <jenny.shen@shopify.com> | 2023-06-29 16:10:22 -0400 |
---|---|---|
committer | git <svn-admin@ruby-lang.org> | 2023-07-28 16:08:08 +0000 |
commit | fce04f9a6c9935ef3f188558dce177e277b17711 (patch) | |
tree | 9ba20fcc264f156ba6c7dc142870d3c59f021dd7 /lib | |
parent | 108cc38a7658bfb8e9457f95baa5cdfbd175b64d (diff) | |
download | ruby-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.rb | 18 | ||||
-rw-r--r-- | lib/rubygems/gemcutter_utilities/webauthn_listener.rb | 108 | ||||
-rw-r--r-- | lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb | 163 | ||||
-rw-r--r-- | lib/rubygems/webauthn_listener.rb | 92 | ||||
-rw-r--r-- | lib/rubygems/webauthn_listener/response.rb | 161 |
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 |