rubygems-update 3.4.11 → 3.4.13
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Manifest.txt +5 -0
- data/bundler/CHANGELOG.md +16 -0
- data/bundler/lib/bundler/build_metadata.rb +2 -2
- data/bundler/lib/bundler/man/bundle-cache.1 +2 -2
- data/bundler/lib/bundler/man/bundle-cache.1.ronn +2 -2
- data/bundler/lib/bundler/safe_marshal.rb +31 -0
- data/bundler/lib/bundler/templates/newgem/bin/console.tt +0 -4
- data/bundler/lib/bundler/version.rb +1 -1
- data/bundler/lib/bundler.rb +2 -11
- data/lib/rubygems/commands/owner_command.rb +4 -2
- data/lib/rubygems/exceptions.rb +10 -0
- data/lib/rubygems/gemcutter_utilities.rb +48 -6
- data/lib/rubygems/webauthn_listener/response.rb +161 -0
- data/lib/rubygems/webauthn_listener.rb +92 -0
- data/lib/rubygems.rb +1 -1
- data/rubygems-update.gemspec +1 -1
- data/test/rubygems/test_gem_commands_owner_command.rb +67 -0
- data/test/rubygems/test_gem_commands_push_command.rb +73 -0
- data/test/rubygems/test_gem_commands_yank_command.rb +84 -0
- data/test/rubygems/test_gem_gemcutter_utilities.rb +72 -4
- data/test/rubygems/test_webauthn_listener.rb +120 -0
- data/test/rubygems/test_webauthn_listener_response.rb +93 -0
- data/test/rubygems/utilities.rb +35 -0
- metadata +8 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 71052fdbd665d26b95bc8c6421c6d1b70434b4b9feea468c6d9ced57bca5e39c
|
4
|
+
data.tar.gz: 597a3e2df64060ed8f118cec9538463d372cb69b5da29e105202ade63635d32b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f9de905f5b7850a006d8f1812fed59c13fef02018e58d0a247c83de76df2fbd530c547a1733586cfd96a28740d7793e1f179f9f245ea7bf8073e43bdfb1c2393
|
7
|
+
data.tar.gz: cd73b0615a21d38d59988a6619d5196fcc2e232e0bd24861162bf7db090e353baae7ea23dde77ffc8a86f4f81071c296578956b8447e0528383d6073919522e3
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,17 @@
|
|
1
|
+
# 3.4.13 / 2023-05-09
|
2
|
+
|
3
|
+
## Enhancements:
|
4
|
+
|
5
|
+
* Installs bundler 2.4.13 as a default gem.
|
6
|
+
|
7
|
+
# 3.4.12 / 2023-04-11
|
8
|
+
|
9
|
+
## Enhancements:
|
10
|
+
|
11
|
+
* [Experimental] Add WebAuthn Support to the CLI. Pull request
|
12
|
+
[#6560](https://github.com/rubygems/rubygems/pull/6560) by jenshenny
|
13
|
+
* Installs bundler 2.4.12 as a default gem.
|
14
|
+
|
1
15
|
# 3.4.11 / 2023-04-10
|
2
16
|
|
3
17
|
## Enhancements:
|
data/Manifest.txt
CHANGED
@@ -180,6 +180,7 @@ bundler/lib/bundler/rubygems_ext.rb
|
|
180
180
|
bundler/lib/bundler/rubygems_gem_installer.rb
|
181
181
|
bundler/lib/bundler/rubygems_integration.rb
|
182
182
|
bundler/lib/bundler/runtime.rb
|
183
|
+
bundler/lib/bundler/safe_marshal.rb
|
183
184
|
bundler/lib/bundler/self_manager.rb
|
184
185
|
bundler/lib/bundler/settings.rb
|
185
186
|
bundler/lib/bundler/settings/validator.rb
|
@@ -540,6 +541,8 @@ lib/rubygems/util/list.rb
|
|
540
541
|
lib/rubygems/validator.rb
|
541
542
|
lib/rubygems/version.rb
|
542
543
|
lib/rubygems/version_option.rb
|
544
|
+
lib/rubygems/webauthn_listener.rb
|
545
|
+
lib/rubygems/webauthn_listener/response.rb
|
543
546
|
rubygems-update.gemspec
|
544
547
|
setup.rb
|
545
548
|
test/rubygems/alternate_cert.pem
|
@@ -753,6 +756,8 @@ test/rubygems/test_project_sanity.rb
|
|
753
756
|
test/rubygems/test_remote_fetch_error.rb
|
754
757
|
test/rubygems/test_require.rb
|
755
758
|
test/rubygems/test_rubygems.rb
|
759
|
+
test/rubygems/test_webauthn_listener.rb
|
760
|
+
test/rubygems/test_webauthn_listener_response.rb
|
756
761
|
test/rubygems/utilities.rb
|
757
762
|
test/rubygems/wrong_key_cert.pem
|
758
763
|
test/rubygems/wrong_key_cert_32.pem
|
data/bundler/CHANGELOG.md
CHANGED
@@ -1,3 +1,19 @@
|
|
1
|
+
# 2.4.13 (May 9, 2023)
|
2
|
+
|
3
|
+
## Bug fixes:
|
4
|
+
|
5
|
+
- Fix unexpected fallbacks to full index by adding FalseClass and Time to the SafeMarshal list [#6655](https://github.com/rubygems/rubygems/pull/6655)
|
6
|
+
|
7
|
+
## Documentation:
|
8
|
+
|
9
|
+
- Fix broken hyperlinks in bundle cache documentation [#6606](https://github.com/rubygems/rubygems/pull/6606)
|
10
|
+
|
11
|
+
# 2.4.12 (April 11, 2023)
|
12
|
+
|
13
|
+
## Enhancements:
|
14
|
+
|
15
|
+
- Remove reference to `pry` gem from generated `bin/console` file [#6515](https://github.com/rubygems/rubygems/pull/6515)
|
16
|
+
|
1
17
|
# 2.4.11 (April 10, 2023)
|
2
18
|
|
3
19
|
## Security:
|
@@ -4,8 +4,8 @@ module Bundler
|
|
4
4
|
# Represents metadata from when the Bundler gem was built.
|
5
5
|
module BuildMetadata
|
6
6
|
# begin ivars
|
7
|
-
@built_at = "2023-
|
8
|
-
@git_commit_sha = "
|
7
|
+
@built_at = "2023-05-10".freeze
|
8
|
+
@git_commit_sha = "26eb456c6c".freeze
|
9
9
|
@release = true
|
10
10
|
# end ivars
|
11
11
|
|
@@ -13,7 +13,7 @@
|
|
13
13
|
alias: \fBpackage\fR, \fBpack\fR
|
14
14
|
.
|
15
15
|
.SH "DESCRIPTION"
|
16
|
-
Copy all of the \fB\.gem\fR files needed to run the application into the \fBvendor/cache\fR directory\. In the future, when running
|
16
|
+
Copy all of the \fB\.gem\fR files needed to run the application into the \fBvendor/cache\fR directory\. In the future, when running \fBbundle install(1)\fR \fIbundle\-install\.1\.html\fR, use the gems in the cache in preference to the ones on \fBrubygems\.org\fR\.
|
17
17
|
.
|
18
18
|
.SH "GIT AND PATH GEMS"
|
19
19
|
The \fBbundle cache\fR command can also package \fB:git\fR and \fB:path\fR dependencies besides \.gem files\. This needs to be explicitly enabled via the \fB\-\-all\fR option\. Once used, the \fB\-\-all\fR option will be remembered\.
|
@@ -22,7 +22,7 @@ The \fBbundle cache\fR command can also package \fB:git\fR and \fB:path\fR depen
|
|
22
22
|
When using gems that have different packages for different platforms, Bundler supports caching of gems for other platforms where the Gemfile has been resolved (i\.e\. present in the lockfile) in \fBvendor/cache\fR\. This needs to be enabled via the \fB\-\-all\-platforms\fR option\. This setting will be remembered in your local bundler configuration\.
|
23
23
|
.
|
24
24
|
.SH "REMOTE FETCHING"
|
25
|
-
By default, if you run \fBbundle install(1)\fR
|
25
|
+
By default, if you run \fBbundle install(1)\fR \fIbundle\-install\.1\.html\fR after running bundle cache(1) \fIbundle\-cache\.1\.html\fR, bundler will still connect to \fBrubygems\.org\fR to check whether a platform\-specific gem exists for any of the gems in \fBvendor/cache\fR\.
|
26
26
|
.
|
27
27
|
.P
|
28
28
|
For instance, consider this Gemfile(5):
|
@@ -10,7 +10,7 @@ alias: `package`, `pack`
|
|
10
10
|
## DESCRIPTION
|
11
11
|
|
12
12
|
Copy all of the `.gem` files needed to run the application into the
|
13
|
-
`vendor/cache` directory. In the future, when running [bundle install(1)]
|
13
|
+
`vendor/cache` directory. In the future, when running [`bundle install(1)`](bundle-install.1.html),
|
14
14
|
use the gems in the cache in preference to the ones on `rubygems.org`.
|
15
15
|
|
16
16
|
## GIT AND PATH GEMS
|
@@ -29,7 +29,7 @@ bundler configuration.
|
|
29
29
|
|
30
30
|
## REMOTE FETCHING
|
31
31
|
|
32
|
-
By default, if you run `bundle install(1)`](bundle-install.1.html) after running
|
32
|
+
By default, if you run [`bundle install(1)`](bundle-install.1.html) after running
|
33
33
|
[bundle cache(1)](bundle-cache.1.html), bundler will still connect to `rubygems.org`
|
34
34
|
to check whether a platform-specific gem exists for any of the gems
|
35
35
|
in `vendor/cache`.
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module SafeMarshal
|
5
|
+
ALLOWED_CLASSES = [
|
6
|
+
Array,
|
7
|
+
FalseClass,
|
8
|
+
Gem::Specification,
|
9
|
+
Gem::Version,
|
10
|
+
Hash,
|
11
|
+
String,
|
12
|
+
Symbol,
|
13
|
+
Time,
|
14
|
+
TrueClass,
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
ERROR = "Unexpected class %s present in marshaled data. Only %s are allowed."
|
18
|
+
|
19
|
+
PROC = proc do |object|
|
20
|
+
object.tap do
|
21
|
+
unless ALLOWED_CLASSES.include?(object.class)
|
22
|
+
raise TypeError, format(ERROR, object.class, ALLOWED_CLASSES.join(", "))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.proc
|
28
|
+
PROC
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -7,9 +7,5 @@ require "<%= config[:namespaced_path] %>"
|
|
7
7
|
# You can add fixtures and/or initialization code here to make experimenting
|
8
8
|
# with your gem easier. You can also use a different console, if you like.
|
9
9
|
|
10
|
-
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
-
# require "pry"
|
12
|
-
# Pry.start
|
13
|
-
|
14
10
|
require "irb"
|
15
11
|
IRB.start(__FILE__)
|
data/bundler/lib/bundler.rb
CHANGED
@@ -39,16 +39,6 @@ module Bundler
|
|
39
39
|
environment_preserver.replace_with_backup
|
40
40
|
SUDO_MUTEX = Thread::Mutex.new
|
41
41
|
|
42
|
-
SAFE_MARSHAL_CLASSES = [Symbol, TrueClass, String, Array, Hash, Gem::Version, Gem::Specification].freeze
|
43
|
-
SAFE_MARSHAL_ERROR = "Unexpected class %s present in marshaled data. Only %s are allowed."
|
44
|
-
SAFE_MARSHAL_PROC = proc do |object|
|
45
|
-
object.tap do
|
46
|
-
unless SAFE_MARSHAL_CLASSES.include?(object.class)
|
47
|
-
raise TypeError, format(SAFE_MARSHAL_ERROR, object.class, SAFE_MARSHAL_CLASSES.join(", "))
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
42
|
autoload :Definition, File.expand_path("bundler/definition", __dir__)
|
53
43
|
autoload :Dependency, File.expand_path("bundler/dependency", __dir__)
|
54
44
|
autoload :Deprecate, File.expand_path("bundler/deprecate", __dir__)
|
@@ -86,6 +76,7 @@ module Bundler
|
|
86
76
|
autoload :UI, File.expand_path("bundler/ui", __dir__)
|
87
77
|
autoload :URICredentialsFilter, File.expand_path("bundler/uri_credentials_filter", __dir__)
|
88
78
|
autoload :URINormalizer, File.expand_path("bundler/uri_normalizer", __dir__)
|
79
|
+
autoload :SafeMarshal, File.expand_path("bundler/safe_marshal", __dir__)
|
89
80
|
|
90
81
|
class << self
|
91
82
|
def configure
|
@@ -523,7 +514,7 @@ EOF
|
|
523
514
|
end
|
524
515
|
|
525
516
|
def safe_load_marshal(data)
|
526
|
-
load_marshal(data, :marshal_proc =>
|
517
|
+
load_marshal(data, :marshal_proc => SafeMarshal.proc)
|
527
518
|
end
|
528
519
|
|
529
520
|
def load_gemspec(file, validate = false)
|
@@ -98,8 +98,10 @@ permission to.
|
|
98
98
|
action = method == :delete ? "Removing" : "Adding"
|
99
99
|
|
100
100
|
with_response response, "#{action} #{owner}"
|
101
|
-
rescue
|
102
|
-
|
101
|
+
rescue Gem::WebauthnVerificationError => e
|
102
|
+
raise e
|
103
|
+
rescue StandardError
|
104
|
+
# ignore early exits to allow for completing the iteration of all owners
|
103
105
|
end
|
104
106
|
end
|
105
107
|
end
|
data/lib/rubygems/exceptions.rb
CHANGED
@@ -213,6 +213,16 @@ class Gem::RubyVersionMismatch < Gem::Exception; end
|
|
213
213
|
|
214
214
|
class Gem::VerificationError < Gem::Exception; end
|
215
215
|
|
216
|
+
##
|
217
|
+
# Raised by Gem::WebauthnListener when an error occurs during security
|
218
|
+
# device verification.
|
219
|
+
|
220
|
+
class Gem::WebauthnVerificationError < Gem::Exception
|
221
|
+
def initialize(message)
|
222
|
+
super "Security device verification failed: #{message}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
216
226
|
##
|
217
227
|
# Raised to indicate that a system exit should occur with the specified
|
218
228
|
# exit_code
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
require_relative "remote_fetcher"
|
3
3
|
require_relative "text"
|
4
|
+
require_relative "webauthn_listener"
|
4
5
|
|
5
6
|
##
|
6
7
|
# Utility methods for using the RubyGems API.
|
@@ -82,7 +83,7 @@ module Gem::GemcutterUtilities
|
|
82
83
|
#
|
83
84
|
# If +allowed_push_host+ metadata is present, then it will only allow that host.
|
84
85
|
|
85
|
-
def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, &block)
|
86
|
+
def rubygems_api_request(method, path, host = nil, allowed_push_host = nil, scope: nil, credentials: {}, &block)
|
86
87
|
require "net/http"
|
87
88
|
|
88
89
|
self.host = host if host
|
@@ -105,7 +106,7 @@ module Gem::GemcutterUtilities
|
|
105
106
|
response = request_with_otp(method, uri, &block)
|
106
107
|
|
107
108
|
if mfa_unauthorized?(response)
|
108
|
-
|
109
|
+
fetch_otp(credentials)
|
109
110
|
response = request_with_otp(method, uri, &block)
|
110
111
|
end
|
111
112
|
|
@@ -167,11 +168,12 @@ module Gem::GemcutterUtilities
|
|
167
168
|
mfa_params = get_mfa_params(profile)
|
168
169
|
all_params = scope_params.merge(mfa_params)
|
169
170
|
warning = profile["warning"]
|
171
|
+
credentials = { email: email, password: password }
|
170
172
|
|
171
173
|
say "#{warning}\n" if warning
|
172
174
|
|
173
175
|
response = rubygems_api_request(:post, "api/v1/api_key",
|
174
|
-
sign_in_host, scope: scope) do |request|
|
176
|
+
sign_in_host, credentials: credentials, scope: scope) do |request|
|
175
177
|
request.basic_auth email, password
|
176
178
|
request["OTP"] = otp if otp
|
177
179
|
request.body = URI.encode_www_form({ name: key_name }.merge(all_params))
|
@@ -250,9 +252,49 @@ module Gem::GemcutterUtilities
|
|
250
252
|
end
|
251
253
|
end
|
252
254
|
|
253
|
-
def
|
254
|
-
|
255
|
-
|
255
|
+
def fetch_otp(credentials)
|
256
|
+
options[:otp] = if webauthn_url = webauthn_verification_url(credentials)
|
257
|
+
wait_for_otp(webauthn_url)
|
258
|
+
else
|
259
|
+
say "You have enabled multi-factor authentication. Please enter OTP code."
|
260
|
+
ask "Code: "
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def wait_for_otp(webauthn_url)
|
265
|
+
server = TCPServer.new 0
|
266
|
+
port = server.addr[1].to_s
|
267
|
+
|
268
|
+
thread = Thread.new do
|
269
|
+
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(host, server)
|
270
|
+
rescue Gem::WebauthnVerificationError => e
|
271
|
+
Thread.current[:error] = e
|
272
|
+
end
|
273
|
+
thread.abort_on_exception = true
|
274
|
+
thread.report_on_exception = false
|
275
|
+
|
276
|
+
url_with_port = "#{webauthn_url}?port=#{port}"
|
277
|
+
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."
|
278
|
+
|
279
|
+
thread.join
|
280
|
+
if error = thread[:error]
|
281
|
+
alert_error error.message
|
282
|
+
terminate_interaction(1)
|
283
|
+
end
|
284
|
+
|
285
|
+
say "You are verified with a security device. You may close the browser window."
|
286
|
+
thread[:otp]
|
287
|
+
end
|
288
|
+
|
289
|
+
def webauthn_verification_url(credentials)
|
290
|
+
response = rubygems_api_request(:post, "api/v1/webauthn_verification") do |request|
|
291
|
+
if credentials.empty?
|
292
|
+
request.add_field "Authorization", api_key
|
293
|
+
else
|
294
|
+
request.basic_auth credentials[:email], credentials[:password]
|
295
|
+
end
|
296
|
+
end
|
297
|
+
response.is_a?(Net::HTTPSuccess) ? response.body : nil
|
256
298
|
end
|
257
299
|
|
258
300
|
def pretty_host(host)
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# The WebauthnListener Response class is used by the WebauthnListener to create
|
5
|
+
# responses to be sent to the Gem host. It creates a Net::HTTPResponse instance
|
6
|
+
# when initialized and can be converted to the appropriate format to be sent by a socket using `to_s`.
|
7
|
+
# Net::HTTPResponse instances cannot be directly sent over a socket.
|
8
|
+
#
|
9
|
+
# Types of response classes:
|
10
|
+
# - OkResponse
|
11
|
+
# - NoContentResponse
|
12
|
+
# - BadRequestResponse
|
13
|
+
# - NotFoundResponse
|
14
|
+
# - MethodNotAllowedResponse
|
15
|
+
#
|
16
|
+
# Example usage:
|
17
|
+
#
|
18
|
+
# server = TCPServer.new(0)
|
19
|
+
# socket = server.accept
|
20
|
+
#
|
21
|
+
# response = OkResponse.for("https://rubygems.example")
|
22
|
+
# socket.print response.to_s
|
23
|
+
# socket.close
|
24
|
+
#
|
25
|
+
|
26
|
+
class Gem::WebauthnListener
|
27
|
+
class Response
|
28
|
+
attr_reader :http_response
|
29
|
+
|
30
|
+
def self.for(host)
|
31
|
+
new(host)
|
32
|
+
end
|
33
|
+
|
34
|
+
def initialize(host)
|
35
|
+
@host = host
|
36
|
+
|
37
|
+
build_http_response
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
status_line = "HTTP/#{@http_response.http_version} #{@http_response.code} #{@http_response.message}\r\n"
|
42
|
+
headers = @http_response.to_hash.map {|header, value| "#{header}: #{value.join(", ")}\r\n" }.join + "\r\n"
|
43
|
+
body = @http_response.body ? "#{@http_response.body}\n" : ""
|
44
|
+
|
45
|
+
status_line + headers + body
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# Must be implemented in subclasses
|
51
|
+
def code
|
52
|
+
raise NotImplementedError
|
53
|
+
end
|
54
|
+
|
55
|
+
def reason_phrase
|
56
|
+
raise NotImplementedError
|
57
|
+
end
|
58
|
+
|
59
|
+
def body; end
|
60
|
+
|
61
|
+
def build_http_response
|
62
|
+
response_class = Net::HTTPResponse::CODE_TO_OBJ[code.to_s]
|
63
|
+
@http_response = response_class.new("1.1", code, reason_phrase)
|
64
|
+
@http_response.instance_variable_set(:@read, true)
|
65
|
+
|
66
|
+
add_connection_header
|
67
|
+
add_access_control_headers
|
68
|
+
add_body
|
69
|
+
end
|
70
|
+
|
71
|
+
def add_connection_header
|
72
|
+
@http_response["connection"] = "close"
|
73
|
+
end
|
74
|
+
|
75
|
+
def add_access_control_headers
|
76
|
+
@http_response["access-control-allow-origin"] = @host
|
77
|
+
@http_response["access-control-allow-methods"] = "POST"
|
78
|
+
@http_response["access-control-allow-headers"] = %w[Content-Type Authorization x-csrf-token]
|
79
|
+
end
|
80
|
+
|
81
|
+
def add_body
|
82
|
+
return unless body
|
83
|
+
@http_response["content-type"] = "text/plain"
|
84
|
+
@http_response["content-length"] = body.bytesize
|
85
|
+
@http_response.instance_variable_set(:@body, body)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
class OkResponse < Response
|
90
|
+
private
|
91
|
+
|
92
|
+
def code
|
93
|
+
200
|
94
|
+
end
|
95
|
+
|
96
|
+
def reason_phrase
|
97
|
+
"OK"
|
98
|
+
end
|
99
|
+
|
100
|
+
def body
|
101
|
+
"success"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class NoContentResponse < Response
|
106
|
+
private
|
107
|
+
|
108
|
+
def code
|
109
|
+
204
|
110
|
+
end
|
111
|
+
|
112
|
+
def reason_phrase
|
113
|
+
"No Content"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
class BadRequestResponse < Response
|
118
|
+
private
|
119
|
+
|
120
|
+
def code
|
121
|
+
400
|
122
|
+
end
|
123
|
+
|
124
|
+
def reason_phrase
|
125
|
+
"Bad Request"
|
126
|
+
end
|
127
|
+
|
128
|
+
def body
|
129
|
+
"missing code parameter"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
class NotFoundResponse < Response
|
134
|
+
private
|
135
|
+
|
136
|
+
def code
|
137
|
+
404
|
138
|
+
end
|
139
|
+
|
140
|
+
def reason_phrase
|
141
|
+
"Not Found"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
class MethodNotAllowedResponse < Response
|
146
|
+
private
|
147
|
+
|
148
|
+
def code
|
149
|
+
405
|
150
|
+
end
|
151
|
+
|
152
|
+
def reason_phrase
|
153
|
+
"Method Not Allowed"
|
154
|
+
end
|
155
|
+
|
156
|
+
def add_access_control_headers
|
157
|
+
super
|
158
|
+
@http_response["allow"] = %w[GET OPTIONS]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "webauthn_listener/response"
|
4
|
+
|
5
|
+
##
|
6
|
+
# The WebauthnListener class retrieves an OTP after a user successfully WebAuthns with the Gem host.
|
7
|
+
# An instance opens a socket using the TCPServer instance given and listens for a request from the Gem host.
|
8
|
+
# The request should be a GET request to the root path and contains the OTP code in the form
|
9
|
+
# of a query parameter `code`. The listener will return the code which will be used as the OTP for
|
10
|
+
# API requests.
|
11
|
+
#
|
12
|
+
# Types of responses sent by the listener after receiving a request:
|
13
|
+
# - 200 OK: OTP code was successfully retrieved
|
14
|
+
# - 204 No Content: If the request was an OPTIONS request
|
15
|
+
# - 400 Bad Request: If the request did not contain a query parameter `code`
|
16
|
+
# - 404 Not Found: The request was not to the root path
|
17
|
+
# - 405 Method Not Allowed: OTP code was not retrieved because the request was not a GET/OPTIONS request
|
18
|
+
#
|
19
|
+
# Example usage:
|
20
|
+
#
|
21
|
+
# server = TCPServer.new(0)
|
22
|
+
# otp = Gem::WebauthnListener.wait_for_otp_code("https://rubygems.example", server)
|
23
|
+
#
|
24
|
+
|
25
|
+
class Gem::WebauthnListener
|
26
|
+
attr_reader :host
|
27
|
+
|
28
|
+
def initialize(host)
|
29
|
+
@host = host
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.wait_for_otp_code(host, server)
|
33
|
+
new(host).fetch_otp_from_connection(server)
|
34
|
+
end
|
35
|
+
|
36
|
+
def fetch_otp_from_connection(server)
|
37
|
+
loop do
|
38
|
+
socket = server.accept
|
39
|
+
request_line = socket.gets
|
40
|
+
|
41
|
+
method, req_uri, _protocol = request_line.split(" ")
|
42
|
+
req_uri = URI.parse(req_uri)
|
43
|
+
|
44
|
+
responder = SocketResponder.new(socket)
|
45
|
+
|
46
|
+
unless root_path?(req_uri)
|
47
|
+
responder.send(NotFoundResponse.for(host))
|
48
|
+
raise Gem::WebauthnVerificationError, "Page at #{req_uri.path} not found."
|
49
|
+
end
|
50
|
+
|
51
|
+
case method.upcase
|
52
|
+
when "OPTIONS"
|
53
|
+
responder.send(NoContentResponse.for(host))
|
54
|
+
next # will be GET
|
55
|
+
when "GET"
|
56
|
+
if otp = parse_otp_from_uri(req_uri)
|
57
|
+
responder.send(OkResponse.for(host))
|
58
|
+
return otp
|
59
|
+
end
|
60
|
+
responder.send(BadRequestResponse.for(host))
|
61
|
+
raise Gem::WebauthnVerificationError, "Did not receive OTP from #{host}."
|
62
|
+
else
|
63
|
+
responder.send(MethodNotAllowedResponse.for(host))
|
64
|
+
raise Gem::WebauthnVerificationError, "Invalid HTTP method #{method.upcase} received."
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def root_path?(uri)
|
72
|
+
uri.path == "/"
|
73
|
+
end
|
74
|
+
|
75
|
+
def parse_otp_from_uri(uri)
|
76
|
+
require "cgi"
|
77
|
+
|
78
|
+
return if uri.query.nil?
|
79
|
+
CGI.parse(uri.query).dig("code", 0)
|
80
|
+
end
|
81
|
+
|
82
|
+
class SocketResponder
|
83
|
+
def initialize(socket)
|
84
|
+
@socket = socket
|
85
|
+
end
|
86
|
+
|
87
|
+
def send(response)
|
88
|
+
@socket.print response.to_s
|
89
|
+
@socket.close
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/rubygems.rb
CHANGED
data/rubygems-update.gemspec
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
Gem::Specification.new do |s|
|
4
4
|
s.name = "rubygems-update"
|
5
|
-
s.version = "3.4.
|
5
|
+
s.version = "3.4.13"
|
6
6
|
s.authors = ["Jim Weirich", "Chad Fowler", "Eric Hodel", "Luis Lavena", "Aaron Patterson", "Samuel Giddins", "André Arko", "Evan Phoenix", "Hiroshi SHIBATA"]
|
7
7
|
s.email = ["", "", "drbrain@segment7.net", "luislavena@gmail.com", "aaron@tenderlovemaking.com", "segiddins@segiddins.me", "andre@arko.net", "evan@phx.io", "hsbt@ruby-lang.org"]
|
8
8
|
|
@@ -330,6 +330,8 @@ EOF
|
|
330
330
|
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
331
331
|
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
332
332
|
]
|
333
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
|
334
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
333
335
|
|
334
336
|
@otp_ui = Gem::MockGemUi.new "111111\n"
|
335
337
|
use_ui @otp_ui do
|
@@ -345,6 +347,8 @@ EOF
|
|
345
347
|
def test_otp_verified_failure
|
346
348
|
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
347
349
|
@stub_fetcher.data["#{Gem.host}/api/v1/gems/freewill/owners"] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
|
350
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
|
351
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
348
352
|
|
349
353
|
@otp_ui = Gem::MockGemUi.new "111111\n"
|
350
354
|
use_ui @otp_ui do
|
@@ -357,6 +361,69 @@ EOF
|
|
357
361
|
assert_equal "111111", @stub_fetcher.last_request["OTP"]
|
358
362
|
end
|
359
363
|
|
364
|
+
def test_with_webauthn_enabled_success
|
365
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
366
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
367
|
+
response_success = "Owner added successfully."
|
368
|
+
port = 5678
|
369
|
+
server = TCPServer.new(port)
|
370
|
+
|
371
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
372
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/gems/freewill/owners"] = [
|
373
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
374
|
+
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
375
|
+
]
|
376
|
+
|
377
|
+
TCPServer.stub(:new, server) do
|
378
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
|
379
|
+
use_ui @stub_ui do
|
380
|
+
@cmd.add_owners("freewill", ["user-new1@example.com"])
|
381
|
+
end
|
382
|
+
end
|
383
|
+
ensure
|
384
|
+
server.close
|
385
|
+
end
|
386
|
+
|
387
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
388
|
+
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
|
389
|
+
assert_match "You are verified with a security device. You may close the browser window.", @stub_ui.output
|
390
|
+
assert_equal "Uvh6T57tkWuUnWYo", @stub_fetcher.last_request["OTP"]
|
391
|
+
assert_match response_success, @stub_ui.output
|
392
|
+
end
|
393
|
+
|
394
|
+
def test_with_webauthn_enabled_failure
|
395
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
396
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
397
|
+
response_success = "Owner added successfully."
|
398
|
+
port = 5678
|
399
|
+
server = TCPServer.new(port)
|
400
|
+
raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
|
401
|
+
|
402
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
403
|
+
@stub_fetcher.data["#{Gem.host}/api/v1/gems/freewill/owners"] = [
|
404
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
405
|
+
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
406
|
+
]
|
407
|
+
|
408
|
+
TCPServer.stub(:new, server) do
|
409
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
|
410
|
+
use_ui @stub_ui do
|
411
|
+
@cmd.add_owners("freewill", ["user-new1@example.com"])
|
412
|
+
end
|
413
|
+
end
|
414
|
+
ensure
|
415
|
+
server.close
|
416
|
+
end
|
417
|
+
|
418
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
419
|
+
|
420
|
+
assert_match @stub_fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
|
421
|
+
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
|
422
|
+
assert_match "ERROR: Security device verification failed: Something went wrong", @stub_ui.error
|
423
|
+
refute_match "You are verified with a security device. You may close the browser window.", @stub_ui.output
|
424
|
+
refute_match response_success, @stub_ui.output
|
425
|
+
end
|
426
|
+
|
360
427
|
def test_remove_owners_unathorized_api_key
|
361
428
|
response_forbidden = "The API key doesn't have access"
|
362
429
|
response_success = "Owner removed successfully."
|
@@ -391,6 +391,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
|
|
391
391
|
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
392
392
|
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
393
393
|
]
|
394
|
+
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
|
395
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
394
396
|
|
395
397
|
@otp_ui = Gem::MockGemUi.new "111111\n"
|
396
398
|
use_ui @otp_ui do
|
@@ -406,6 +408,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
|
|
406
408
|
def test_otp_verified_failure
|
407
409
|
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
408
410
|
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
|
411
|
+
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] =
|
412
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
409
413
|
|
410
414
|
@otp_ui = Gem::MockGemUi.new "111111\n"
|
411
415
|
assert_raise Gem::MockGemUi::TermError do
|
@@ -420,6 +424,71 @@ class TestGemCommandsPushCommand < Gem::TestCase
|
|
420
424
|
assert_equal "111111", @fetcher.last_request["OTP"]
|
421
425
|
end
|
422
426
|
|
427
|
+
def test_with_webauthn_enabled_success
|
428
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
429
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
430
|
+
response_success = "Successfully registered gem: freewill (1.0.0)"
|
431
|
+
port = 5678
|
432
|
+
server = TCPServer.new(port)
|
433
|
+
|
434
|
+
@fetcher.data["#{Gem.host}/api/v1/gems"] = [
|
435
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
436
|
+
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
437
|
+
]
|
438
|
+
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
439
|
+
|
440
|
+
TCPServer.stub(:new, server) do
|
441
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
|
442
|
+
use_ui @ui do
|
443
|
+
@cmd.send_gem(@path)
|
444
|
+
end
|
445
|
+
end
|
446
|
+
ensure
|
447
|
+
server.close
|
448
|
+
end
|
449
|
+
|
450
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
451
|
+
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
|
452
|
+
assert_match "You are verified with a security device. You may close the browser window.", @ui.output
|
453
|
+
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
|
454
|
+
assert_match response_success, @ui.output
|
455
|
+
end
|
456
|
+
|
457
|
+
def test_with_webauthn_enabled_failure
|
458
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
459
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
460
|
+
response_success = "Successfully registered gem: freewill (1.0.0)"
|
461
|
+
port = 5678
|
462
|
+
server = TCPServer.new(port)
|
463
|
+
raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
|
464
|
+
|
465
|
+
@fetcher.data["#{Gem.host}/api/v1/gems"] = [
|
466
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
467
|
+
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
468
|
+
]
|
469
|
+
@fetcher.data["#{Gem.host}/api/v1/webauthn_verification"] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
470
|
+
|
471
|
+
error = assert_raise Gem::MockGemUi::TermError do
|
472
|
+
TCPServer.stub(:new, server) do
|
473
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
|
474
|
+
use_ui @ui do
|
475
|
+
@cmd.send_gem(@path)
|
476
|
+
end
|
477
|
+
end
|
478
|
+
ensure
|
479
|
+
server.close
|
480
|
+
end
|
481
|
+
end
|
482
|
+
assert_equal 1, error.exit_code
|
483
|
+
|
484
|
+
assert_match @fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
|
485
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
486
|
+
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
|
487
|
+
assert_match "ERROR: Security device verification failed: Something went wrong", @ui.error
|
488
|
+
refute_match "You are verified with a security device. You may close the browser window.", @ui.output
|
489
|
+
refute_match response_success, @ui.output
|
490
|
+
end
|
491
|
+
|
423
492
|
def test_sending_gem_unathorized_api_key_with_mfa_enabled
|
424
493
|
response_mfa_enabled = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
425
494
|
response_forbidden = "The API key doesn't have access"
|
@@ -430,6 +499,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
|
|
430
499
|
HTTPResponseFactory.create(body: response_forbidden, code: 403, msg: "Forbidden"),
|
431
500
|
HTTPResponseFactory.create(body: response_success, code: 200, msg: "OK"),
|
432
501
|
]
|
502
|
+
@fetcher.data["#{@host}/api/v1/webauthn_verification"] =
|
503
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
433
504
|
|
434
505
|
@fetcher.data["#{@host}/api/v1/api_key"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK")
|
435
506
|
@cmd.instance_variable_set :@host, @host
|
@@ -470,6 +541,8 @@ class TestGemCommandsPushCommand < Gem::TestCase
|
|
470
541
|
@fetcher.data["#{@host}/api/v1/profile/me.yaml"] = [
|
471
542
|
HTTPResponseFactory.create(body: response_profile, code: 200, msg: "OK"),
|
472
543
|
]
|
544
|
+
@fetcher.data["#{@host}/api/v1/webauthn_verification"] =
|
545
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
473
546
|
|
474
547
|
@cmd.instance_variable_set :@scope, :push_rubygem
|
475
548
|
@cmd.options[:args] = [@path]
|
@@ -72,6 +72,9 @@ class TestGemCommandsYankCommand < Gem::TestCase
|
|
72
72
|
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
73
73
|
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
|
74
74
|
]
|
75
|
+
webauthn_uri = "http://example/api/v1/webauthn_verification"
|
76
|
+
@fetcher.data[webauthn_uri] =
|
77
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
75
78
|
|
76
79
|
@cmd.options[:args] = %w[a]
|
77
80
|
@cmd.options[:added_platform] = true
|
@@ -93,6 +96,9 @@ class TestGemCommandsYankCommand < Gem::TestCase
|
|
93
96
|
response = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
94
97
|
yank_uri = "http://example/api/v1/gems/yank"
|
95
98
|
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: response, code: 401, msg: "Unauthorized")
|
99
|
+
webauthn_uri = "http://example/api/v1/webauthn_verification"
|
100
|
+
@fetcher.data[webauthn_uri] =
|
101
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
96
102
|
|
97
103
|
@cmd.options[:args] = %w[a]
|
98
104
|
@cmd.options[:added_platform] = true
|
@@ -109,6 +115,84 @@ class TestGemCommandsYankCommand < Gem::TestCase
|
|
109
115
|
assert_equal "111111", @fetcher.last_request["OTP"]
|
110
116
|
end
|
111
117
|
|
118
|
+
def test_with_webauthn_enabled_success
|
119
|
+
webauthn_verification_url = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
120
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
121
|
+
yank_uri = "http://example/api/v1/gems/yank"
|
122
|
+
webauthn_uri = "http://example/api/v1/webauthn_verification"
|
123
|
+
port = 5678
|
124
|
+
server = TCPServer.new(port)
|
125
|
+
|
126
|
+
@fetcher.data[webauthn_uri] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
127
|
+
@fetcher.data[yank_uri] = [
|
128
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
129
|
+
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
|
130
|
+
]
|
131
|
+
|
132
|
+
@cmd.options[:args] = %w[a]
|
133
|
+
@cmd.options[:added_platform] = true
|
134
|
+
@cmd.options[:version] = req("= 1.0")
|
135
|
+
|
136
|
+
TCPServer.stub(:new, server) do
|
137
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
|
138
|
+
use_ui @ui do
|
139
|
+
@cmd.execute
|
140
|
+
end
|
141
|
+
end
|
142
|
+
ensure
|
143
|
+
server.close
|
144
|
+
end
|
145
|
+
|
146
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
147
|
+
assert_match %r{Yanking gem from http://example}, @ui.output
|
148
|
+
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
|
149
|
+
assert_match "You are verified with a security device. You may close the browser window.", @ui.output
|
150
|
+
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
|
151
|
+
assert_match "Successfully yanked", @ui.output
|
152
|
+
end
|
153
|
+
|
154
|
+
def test_with_webauthn_enabled_failure
|
155
|
+
webauthn_verification_url = "http://example/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
156
|
+
response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry."
|
157
|
+
yank_uri = "http://example/api/v1/gems/yank"
|
158
|
+
webauthn_uri = "http://example/api/v1/webauthn_verification"
|
159
|
+
port = 5678
|
160
|
+
server = TCPServer.new(port)
|
161
|
+
raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
|
162
|
+
|
163
|
+
@fetcher.data[webauthn_uri] = HTTPResponseFactory.create(body: webauthn_verification_url, code: 200, msg: "OK")
|
164
|
+
@fetcher.data[yank_uri] = [
|
165
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized"),
|
166
|
+
HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK"),
|
167
|
+
]
|
168
|
+
|
169
|
+
@cmd.options[:args] = %w[a]
|
170
|
+
@cmd.options[:added_platform] = true
|
171
|
+
@cmd.options[:version] = req("= 1.0")
|
172
|
+
|
173
|
+
error = assert_raise Gem::MockGemUi::TermError do
|
174
|
+
TCPServer.stub(:new, server) do
|
175
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
|
176
|
+
use_ui @ui do
|
177
|
+
@cmd.execute
|
178
|
+
end
|
179
|
+
end
|
180
|
+
ensure
|
181
|
+
server.close
|
182
|
+
end
|
183
|
+
end
|
184
|
+
assert_equal 1, error.exit_code
|
185
|
+
|
186
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
187
|
+
|
188
|
+
assert_match @fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key
|
189
|
+
assert_match %r{Yanking gem from http://example}, @ui.output
|
190
|
+
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
|
191
|
+
assert_match "ERROR: Security device verification failed: Something went wrong", @ui.error
|
192
|
+
refute_match "You are verified with a security device. You may close the browser window.", @ui.output
|
193
|
+
refute_match "Successfully yanked", @ui.output
|
194
|
+
end
|
195
|
+
|
112
196
|
def test_execute_key
|
113
197
|
yank_uri = "http://example/api/v1/gems/yank"
|
114
198
|
@fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK")
|
@@ -230,10 +230,77 @@ class TestGemGemcutterUtilities < Gem::TestCase
|
|
230
230
|
assert_equal "111111", @fetcher.last_request["OTP"]
|
231
231
|
end
|
232
232
|
|
233
|
-
def
|
234
|
-
|
235
|
-
|
236
|
-
|
233
|
+
def test_sign_in_with_webauthn_enabled
|
234
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
235
|
+
response_fail = "You have enabled multifactor authentication"
|
236
|
+
api_key = "a5fdbb6ba150cbb83aad2bb2fede64cf040453903"
|
237
|
+
port = 5678
|
238
|
+
server = TCPServer.new(port)
|
239
|
+
|
240
|
+
TCPServer.stub(:new, server) do
|
241
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, "Uvh6T57tkWuUnWYo") do
|
242
|
+
util_sign_in(proc do
|
243
|
+
@call_count ||= 0
|
244
|
+
if (@call_count += 1).odd?
|
245
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized")
|
246
|
+
else
|
247
|
+
HTTPResponseFactory.create(body: api_key, code: 200, msg: "OK")
|
248
|
+
end
|
249
|
+
end, nil, [], "", webauthn_verification_url)
|
250
|
+
end
|
251
|
+
ensure
|
252
|
+
server.close
|
253
|
+
end
|
254
|
+
|
255
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
256
|
+
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
|
257
|
+
assert_match "You are verified with a security device. You may close the browser window.", @sign_in_ui.output
|
258
|
+
assert_equal "Uvh6T57tkWuUnWYo", @fetcher.last_request["OTP"]
|
259
|
+
end
|
260
|
+
|
261
|
+
def test_sign_in_with_webauthn_enabled_with_error
|
262
|
+
webauthn_verification_url = "rubygems.org/api/v1/webauthn_verification/odow34b93t6aPCdY"
|
263
|
+
response_fail = "You have enabled multifactor authentication"
|
264
|
+
api_key = "a5fdbb6ba150cbb83aad2bb2fede64cf040453903"
|
265
|
+
port = 5678
|
266
|
+
server = TCPServer.new(port)
|
267
|
+
raise_error = ->(*_args) { raise Gem::WebauthnVerificationError, "Something went wrong" }
|
268
|
+
|
269
|
+
error = assert_raise Gem::MockGemUi::TermError do
|
270
|
+
TCPServer.stub(:new, server) do
|
271
|
+
Gem::WebauthnListener.stub(:wait_for_otp_code, raise_error) do
|
272
|
+
util_sign_in(proc do
|
273
|
+
@call_count ||= 0
|
274
|
+
if (@call_count += 1).odd?
|
275
|
+
HTTPResponseFactory.create(body: response_fail, code: 401, msg: "Unauthorized")
|
276
|
+
else
|
277
|
+
HTTPResponseFactory.create(body: api_key, code: 200, msg: "OK")
|
278
|
+
end
|
279
|
+
end, nil, [], "", webauthn_verification_url)
|
280
|
+
end
|
281
|
+
ensure
|
282
|
+
server.close
|
283
|
+
end
|
284
|
+
end
|
285
|
+
assert_equal 1, error.exit_code
|
286
|
+
|
287
|
+
url_with_port = "#{webauthn_verification_url}?port=#{port}"
|
288
|
+
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
|
289
|
+
assert_match "ERROR: Security device verification failed: Something went wrong", @sign_in_ui.error
|
290
|
+
refute_match "You are verified with a security device. You may close the browser window.", @sign_in_ui.output
|
291
|
+
refute_match "Signed in with API key:", @sign_in_ui.output
|
292
|
+
end
|
293
|
+
|
294
|
+
def util_sign_in(response, host = nil, args = [], extra_input = "", webauthn_url = nil)
|
295
|
+
email = "you@example.com"
|
296
|
+
password = "secret"
|
297
|
+
profile_response = HTTPResponseFactory.create(body: "mfa: disabled\n", code: 200, msg: "OK")
|
298
|
+
webauthn_response =
|
299
|
+
if webauthn_url
|
300
|
+
HTTPResponseFactory.create(body: webauthn_url, code: 200, msg: "OK")
|
301
|
+
else
|
302
|
+
HTTPResponseFactory.create(body: "You don't have any security devices", code: 422, msg: "Unprocessable Entity")
|
303
|
+
end
|
237
304
|
|
238
305
|
if host
|
239
306
|
ENV["RUBYGEMS_HOST"] = host
|
@@ -244,6 +311,7 @@ class TestGemGemcutterUtilities < Gem::TestCase
|
|
244
311
|
@fetcher = Gem::FakeFetcher.new
|
245
312
|
@fetcher.data["#{host}/api/v1/api_key"] = response
|
246
313
|
@fetcher.data["#{host}/api/v1/profile/me.yaml"] = profile_response
|
314
|
+
@fetcher.data["#{host}/api/v1/webauthn_verification"] = webauthn_response
|
247
315
|
Gem::RemoteFetcher.fetcher = @fetcher
|
248
316
|
|
249
317
|
@sign_in_ui = Gem::MockGemUi.new("#{email}\n#{password}\n\n\n\n\n\n\n\n\n" + extra_input)
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "helper"
|
4
|
+
require "rubygems/webauthn_listener"
|
5
|
+
|
6
|
+
class WebauthnListenerTest < Gem::TestCase
|
7
|
+
def setup
|
8
|
+
super
|
9
|
+
@server = TCPServer.new 0
|
10
|
+
@port = @server.addr[1].to_s
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_wait_for_otp_code_get_follows_options
|
14
|
+
wait_for_otp_code
|
15
|
+
assert Gem::MockBrowser.options(URI("http://localhost:#{@port}?code=xyz")).is_a? Net::HTTPNoContent
|
16
|
+
assert Gem::MockBrowser.get(URI("http://localhost:#{@port}?code=xyz")).is_a? Net::HTTPOK
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_wait_for_otp_code_options_request
|
20
|
+
wait_for_otp_code
|
21
|
+
response = Gem::MockBrowser.options URI("http://localhost:#{@port}?code=xyz")
|
22
|
+
|
23
|
+
assert response.is_a? Net::HTTPNoContent
|
24
|
+
assert_equal Gem.host, response["access-control-allow-origin"]
|
25
|
+
assert_equal "POST", response["access-control-allow-methods"]
|
26
|
+
assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
|
27
|
+
assert_equal "close", response["Connection"]
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_wait_for_otp_code_get_request
|
31
|
+
wait_for_otp_code
|
32
|
+
response = Gem::MockBrowser.get URI("http://localhost:#{@port}?code=xyz")
|
33
|
+
|
34
|
+
assert response.is_a? Net::HTTPOK
|
35
|
+
assert_equal "text/plain", response["Content-Type"]
|
36
|
+
assert_equal "7", response["Content-Length"]
|
37
|
+
assert_equal Gem.host, response["access-control-allow-origin"]
|
38
|
+
assert_equal "POST", response["access-control-allow-methods"]
|
39
|
+
assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
|
40
|
+
assert_equal "close", response["Connection"]
|
41
|
+
assert_equal "success", response.body
|
42
|
+
|
43
|
+
@thread.join
|
44
|
+
assert_equal "xyz", @thread[:otp]
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_wait_for_otp_code_invalid_post_req_method
|
48
|
+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Invalid HTTP method POST received.")
|
49
|
+
response = Gem::MockBrowser.post URI("http://localhost:#{@port}?code=xyz")
|
50
|
+
|
51
|
+
assert response
|
52
|
+
assert response.is_a? Net::HTTPMethodNotAllowed
|
53
|
+
assert_equal "GET, OPTIONS", response["allow"]
|
54
|
+
assert_equal "close", response["Connection"]
|
55
|
+
|
56
|
+
@thread.join
|
57
|
+
assert_nil @thread[:otp]
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_wait_for_otp_code_incorrect_path
|
61
|
+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Page at /path not found.")
|
62
|
+
response = Gem::MockBrowser.post URI("http://localhost:#{@port}/path?code=xyz")
|
63
|
+
|
64
|
+
assert response.is_a? Net::HTTPNotFound
|
65
|
+
assert_equal "close", response["Connection"]
|
66
|
+
|
67
|
+
@thread.join
|
68
|
+
assert_nil @thread[:otp]
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_wait_for_otp_code_no_params_response
|
72
|
+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
|
73
|
+
response = Gem::MockBrowser.get URI("http://localhost:#{@port}")
|
74
|
+
|
75
|
+
assert response.is_a? Net::HTTPBadRequest
|
76
|
+
assert_equal "text/plain", response["Content-Type"]
|
77
|
+
assert_equal "22", response["Content-Length"]
|
78
|
+
assert_equal "close", response["Connection"]
|
79
|
+
assert_equal "missing code parameter", response.body
|
80
|
+
|
81
|
+
@thread.join
|
82
|
+
assert_nil @thread[:otp]
|
83
|
+
end
|
84
|
+
|
85
|
+
def test_wait_for_otp_code_incorrect_params
|
86
|
+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
|
87
|
+
response = Gem::MockBrowser.get URI("http://localhost:#{@port}?param=xyz")
|
88
|
+
|
89
|
+
assert response.is_a? Net::HTTPBadRequest
|
90
|
+
assert_equal "text/plain", response["Content-Type"]
|
91
|
+
assert_equal "22", response["Content-Length"]
|
92
|
+
assert_equal "close", response["Connection"]
|
93
|
+
assert_equal "missing code parameter", response.body
|
94
|
+
|
95
|
+
@thread.join
|
96
|
+
assert_nil @thread[:otp]
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def wait_for_otp_code
|
102
|
+
@thread = Thread.new do
|
103
|
+
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
|
104
|
+
end
|
105
|
+
@thread.abort_on_exception = true
|
106
|
+
@thread.report_on_exception = false
|
107
|
+
end
|
108
|
+
|
109
|
+
def wait_for_otp_code_expect_error_with_message(message)
|
110
|
+
@thread = Thread.new do
|
111
|
+
error = assert_raise Gem::WebauthnVerificationError do
|
112
|
+
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
|
113
|
+
end
|
114
|
+
|
115
|
+
assert_equal message, error.message
|
116
|
+
end
|
117
|
+
@thread.abort_on_exception = true
|
118
|
+
@thread.report_on_exception = false
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "helper"
|
4
|
+
require "rubygems/webauthn_listener/response"
|
5
|
+
|
6
|
+
class WebauthnListenerResponseTest < Gem::TestCase
|
7
|
+
def setup
|
8
|
+
super
|
9
|
+
@host = "rubygems.example"
|
10
|
+
end
|
11
|
+
|
12
|
+
def test_ok_response_to_s
|
13
|
+
to_s = Gem::WebauthnListener::OkResponse.new(@host).to_s
|
14
|
+
|
15
|
+
expected_to_s = <<~RESPONSE
|
16
|
+
HTTP/1.1 200 OK\r
|
17
|
+
connection: close\r
|
18
|
+
access-control-allow-origin: rubygems.example\r
|
19
|
+
access-control-allow-methods: POST\r
|
20
|
+
access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
|
21
|
+
content-type: text/plain\r
|
22
|
+
content-length: 7\r
|
23
|
+
\r
|
24
|
+
success
|
25
|
+
RESPONSE
|
26
|
+
|
27
|
+
assert_equal expected_to_s, to_s
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_no_to_s_response_to_s
|
31
|
+
to_s = Gem::WebauthnListener::NoContentResponse.new(@host).to_s
|
32
|
+
|
33
|
+
expected_to_s = <<~RESPONSE
|
34
|
+
HTTP/1.1 204 No Content\r
|
35
|
+
connection: close\r
|
36
|
+
access-control-allow-origin: rubygems.example\r
|
37
|
+
access-control-allow-methods: POST\r
|
38
|
+
access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
|
39
|
+
\r
|
40
|
+
RESPONSE
|
41
|
+
|
42
|
+
assert_equal expected_to_s, to_s
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_method_not_allowed_response_to_s
|
46
|
+
to_s = Gem::WebauthnListener::MethodNotAllowedResponse.new(@host).to_s
|
47
|
+
|
48
|
+
expected_to_s = <<~RESPONSE
|
49
|
+
HTTP/1.1 405 Method Not Allowed\r
|
50
|
+
connection: close\r
|
51
|
+
access-control-allow-origin: rubygems.example\r
|
52
|
+
access-control-allow-methods: POST\r
|
53
|
+
access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
|
54
|
+
allow: GET, OPTIONS\r
|
55
|
+
\r
|
56
|
+
RESPONSE
|
57
|
+
|
58
|
+
assert_equal expected_to_s, to_s
|
59
|
+
end
|
60
|
+
|
61
|
+
def test_method_not_found_response_to_s
|
62
|
+
to_s = Gem::WebauthnListener::NotFoundResponse.new(@host).to_s
|
63
|
+
|
64
|
+
expected_to_s = <<~RESPONSE
|
65
|
+
HTTP/1.1 404 Not Found\r
|
66
|
+
connection: close\r
|
67
|
+
access-control-allow-origin: rubygems.example\r
|
68
|
+
access-control-allow-methods: POST\r
|
69
|
+
access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
|
70
|
+
\r
|
71
|
+
RESPONSE
|
72
|
+
|
73
|
+
assert_equal expected_to_s, to_s
|
74
|
+
end
|
75
|
+
|
76
|
+
def test_bad_request_response_to_s
|
77
|
+
to_s = Gem::WebauthnListener::BadRequestResponse.new(@host).to_s
|
78
|
+
|
79
|
+
expected_to_s = <<~RESPONSE
|
80
|
+
HTTP/1.1 400 Bad Request\r
|
81
|
+
connection: close\r
|
82
|
+
access-control-allow-origin: rubygems.example\r
|
83
|
+
access-control-allow-methods: POST\r
|
84
|
+
access-control-allow-headers: Content-Type, Authorization, x-csrf-token\r
|
85
|
+
content-type: text/plain\r
|
86
|
+
content-length: 22\r
|
87
|
+
\r
|
88
|
+
missing code parameter
|
89
|
+
RESPONSE
|
90
|
+
|
91
|
+
assert_equal expected_to_s, to_s
|
92
|
+
end
|
93
|
+
end
|
data/test/rubygems/utilities.rb
CHANGED
@@ -186,6 +186,41 @@ class Gem::HTTPResponseFactory
|
|
186
186
|
end
|
187
187
|
end
|
188
188
|
|
189
|
+
##
|
190
|
+
# A Gem::MockBrowser is used in tests to mock a browser in that it can
|
191
|
+
# send HTTP requests to the defined URI.
|
192
|
+
#
|
193
|
+
# Example:
|
194
|
+
#
|
195
|
+
# # Sends a get request to http://localhost:5678
|
196
|
+
# Gem::MockBrowser.get URI("http://localhost:5678")
|
197
|
+
#
|
198
|
+
# See RubyGems' tests for more examples of MockBrowser.
|
199
|
+
#
|
200
|
+
|
201
|
+
class Gem::MockBrowser
|
202
|
+
def self.options(uri)
|
203
|
+
options = Net::HTTP::Options.new(uri)
|
204
|
+
Net::HTTP.start(uri.hostname, uri.port) do |http|
|
205
|
+
http.request(options)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def self.get(uri)
|
210
|
+
get = Net::HTTP::Get.new(uri)
|
211
|
+
Net::HTTP.start(uri.hostname, uri.port) do |http|
|
212
|
+
http.request(get)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def self.post(uri)
|
217
|
+
post = Net::HTTP::Post.new(uri)
|
218
|
+
Net::HTTP.start(uri.hostname, uri.port) do |http|
|
219
|
+
http.request(post)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
189
224
|
# :stopdoc:
|
190
225
|
class Gem::RemoteFetcher
|
191
226
|
def self.fetcher=(fetcher)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rubygems-update
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.4.
|
4
|
+
version: 3.4.13
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jim Weirich
|
@@ -16,7 +16,7 @@ authors:
|
|
16
16
|
autorequire:
|
17
17
|
bindir: bin
|
18
18
|
cert_chain: []
|
19
|
-
date: 2023-
|
19
|
+
date: 2023-05-10 00:00:00.000000000 Z
|
20
20
|
dependencies: []
|
21
21
|
description: |-
|
22
22
|
A package (also known as a library) contains a set of functionality
|
@@ -236,6 +236,7 @@ files:
|
|
236
236
|
- bundler/lib/bundler/rubygems_gem_installer.rb
|
237
237
|
- bundler/lib/bundler/rubygems_integration.rb
|
238
238
|
- bundler/lib/bundler/runtime.rb
|
239
|
+
- bundler/lib/bundler/safe_marshal.rb
|
239
240
|
- bundler/lib/bundler/self_manager.rb
|
240
241
|
- bundler/lib/bundler/settings.rb
|
241
242
|
- bundler/lib/bundler/settings/validator.rb
|
@@ -596,6 +597,8 @@ files:
|
|
596
597
|
- lib/rubygems/validator.rb
|
597
598
|
- lib/rubygems/version.rb
|
598
599
|
- lib/rubygems/version_option.rb
|
600
|
+
- lib/rubygems/webauthn_listener.rb
|
601
|
+
- lib/rubygems/webauthn_listener/response.rb
|
599
602
|
- rubygems-update.gemspec
|
600
603
|
- setup.rb
|
601
604
|
- test/rubygems/alternate_cert.pem
|
@@ -809,6 +812,8 @@ files:
|
|
809
812
|
- test/rubygems/test_remote_fetch_error.rb
|
810
813
|
- test/rubygems/test_require.rb
|
811
814
|
- test/rubygems/test_rubygems.rb
|
815
|
+
- test/rubygems/test_webauthn_listener.rb
|
816
|
+
- test/rubygems/test_webauthn_listener_response.rb
|
812
817
|
- test/rubygems/utilities.rb
|
813
818
|
- test/rubygems/wrong_key_cert.pem
|
814
819
|
- test/rubygems/wrong_key_cert_32.pem
|
@@ -836,7 +841,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
836
841
|
- !ruby/object:Gem::Version
|
837
842
|
version: '0'
|
838
843
|
requirements: []
|
839
|
-
rubygems_version: 3.4.
|
844
|
+
rubygems_version: 3.4.13
|
840
845
|
signing_key:
|
841
846
|
specification_version: 4
|
842
847
|
summary: RubyGems is a package management framework for Ruby.
|