mirror of
https://github.com/ruby/ruby.git
synced 2025-08-27 15:06:10 +02:00
[rubygems/rubygems] Add WebauthnListener class
d42ddbb73c
Co-authored-by: Ashley Ellis Pierce <anellis12@gmail.com>
Co-authored-by: Jacques Chester <jacques.chester@shopify.com>
This commit is contained in:
parent
27322e51a7
commit
6e7bf0677d
2 changed files with 203 additions and 0 deletions
83
lib/rubygems/webauthn_listener.rb
Normal file
83
lib/rubygems/webauthn_listener.rb
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "webauthn_listener/response/response_ok"
|
||||||
|
require_relative "webauthn_listener/response/response_no_content"
|
||||||
|
require_relative "webauthn_listener/response/response_bad_request"
|
||||||
|
require_relative "webauthn_listener/response/response_not_found"
|
||||||
|
require_relative "webauthn_listener/response/response_method_not_allowed"
|
||||||
|
|
||||||
|
##
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
unless root_path?(req_uri)
|
||||||
|
ResponseNotFound.send(socket, host)
|
||||||
|
raise Gem::WebauthnVerificationError, "Page at #{req_uri.path} not found."
|
||||||
|
end
|
||||||
|
|
||||||
|
case method.upcase
|
||||||
|
when "OPTIONS"
|
||||||
|
ResponseNoContent.send(socket, host)
|
||||||
|
next # will be GET
|
||||||
|
when "GET"
|
||||||
|
if otp = parse_otp_from_uri(req_uri)
|
||||||
|
ResponseOk.send(socket, host)
|
||||||
|
return otp
|
||||||
|
end
|
||||||
|
ResponseBadRequest.send(socket, host)
|
||||||
|
raise Gem::WebauthnVerificationError, "Did not receive OTP from #{host}."
|
||||||
|
else
|
||||||
|
ResponseMethodNotAllowed.send(socket, 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
|
||||||
|
end
|
120
test/rubygems/test_webauthn_listener.rb
Normal file
120
test/rubygems/test_webauthn_listener.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue