pwned 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pwned/password_base"
4
+
5
+ module Pwned
6
+ ##
7
+ # This class represents a hashed password. It does all the work of talking to the
8
+ # Pwned Passwords API to find out if the password has been pwned.
9
+ # @see https://haveibeenpwned.com/API/v2#PwnedPasswords
10
+ class HashedPassword
11
+ include PasswordBase
12
+ ##
13
+ # Creates a new hashed password object.
14
+ #
15
+ # @example A simple password with the default request options
16
+ # password = Pwned::HashedPassword.new("ABC123")
17
+ # @example Setting the user agent and the read timeout of the request
18
+ # password = Pwned::HashedPassword.new("ABC123", headers: { "User-Agent" => "My user agent" }, read_timout: 10)
19
+ #
20
+ # @param hashed_password [String] The hash of the password you want to check against the API.
21
+ # @param [Hash] request_options Options that can be passed to +Net::HTTP.start+ when
22
+ # calling the API
23
+ # @option request_options [Symbol] :headers ({ "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}" })
24
+ # HTTP headers to include in the request
25
+ # @raise [TypeError] if the password is not a string.
26
+ # @since 2.1.0
27
+ def initialize(hashed_password, request_options={})
28
+ raise TypeError, "hashed_password must be of type String" unless hashed_password.is_a? String
29
+ @hashed_password = hashed_password.upcase
30
+ @request_options = Hash(request_options).dup
31
+ @request_headers = Hash(request_options.delete(:headers))
32
+ @request_headers = DEFAULT_REQUEST_HEADERS.merge(@request_headers)
33
+ end
34
+ end
35
+ end
@@ -61,16 +61,30 @@ class NotPwnedValidator < ActiveModel::EachValidator
61
61
  # In the case of an API error the validator will either mark the
62
62
  # record as valid or invalid. Alternatively it will run an associated proc or
63
63
  # re-raise the original error.
64
+ #
65
+ # The validation will short circuit and return with no errors added if the
66
+ # password is blank. The +Pwned::Password+ initializer expects the password to
67
+ # be a string and will throw a +TypeError+ if it is +nil+. Also, technically
68
+ # the empty string is not a password that is reported to be found in data
69
+ # breaches, so returns +false+, short circuiting that using +value.blank?+
70
+ # saves us a trip to the API.
71
+ #
72
+ # @param record [ActiveModel::Validations] The object being validated
73
+ # @param attribute [Symbol] The attribute on the record that is currently
74
+ # being validated.
75
+ # @param value [String] The value of the attribute on the record that is the
76
+ # subject of the validation
64
77
  def validate_each(record, attribute, value)
78
+ return if value.blank?
65
79
  begin
66
80
  pwned_check = Pwned::Password.new(value, request_options)
67
81
  if pwned_check.pwned_count > threshold
68
- record.errors.add(attribute, :not_pwned, options.merge(count: pwned_check.pwned_count))
82
+ record.errors.add(attribute, :not_pwned, **options.merge(count: pwned_check.pwned_count))
69
83
  end
70
84
  rescue Pwned::Error => error
71
85
  case on_error
72
86
  when :invalid
73
- record.errors.add(attribute, :pwned_error, options.merge(message: options[:error_message]))
87
+ record.errors.add(attribute, :pwned_error, **options.merge(message: options[:error_message]))
74
88
  when :valid
75
89
  # Do nothing, consider the record valid
76
90
  when Proc
@@ -115,4 +129,4 @@ end
115
129
  #
116
130
  # @since 1.1.0
117
131
  class PwnedValidator < NotPwnedValidator
118
- end
132
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "open-uri"
3
+ require "pwned/password_base"
5
4
 
6
5
  module Pwned
7
6
  ##
@@ -9,27 +8,7 @@ module Pwned
9
8
  # Pwned Passwords API to find out if the password has been pwned.
10
9
  # @see https://haveibeenpwned.com/API/v2#PwnedPasswords
11
10
  class Password
12
- ##
13
- # The base URL for the Pwned Passwords API
14
- API_URL = "https://api.pwnedpasswords.com/range/"
15
-
16
- ##
17
- # The number of characters from the start of the hash of the password that
18
- # are used to search for the range of passwords.
19
- HASH_PREFIX_LENGTH = 5
20
-
21
- ##
22
- # The total length of a SHA1 hash
23
- SHA1_LENGTH = 40
24
-
25
- ##
26
- # The default request options that are used to make HTTP requests to the
27
- # API. A user agent is provided as requested in the documentation.
28
- # @see https://haveibeenpwned.com/API/v2#UserAgent
29
- DEFAULT_REQUEST_OPTIONS = {
30
- "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
31
- }.freeze
32
-
11
+ include PasswordBase
33
12
  ##
34
13
  # @return [String] the password that is being checked.
35
14
  # @since 1.0.0
@@ -40,88 +19,24 @@ module Pwned
40
19
  #
41
20
  # @example A simple password with the default request options
42
21
  # password = Pwned::Password.new("password")
43
- # @example Setting the user agent and the read timeout of the reques
44
- # password = Pwned::Password.new("password", "User-Agent" => "My user agent", :read_timout => 10)
22
+ # @example Setting the user agent and the read timeout of the request
23
+ # password = Pwned::Password.new("password", headers: { "User-Agent" => "My user agent" }, read_timout: 10)
45
24
  #
46
25
  # @param password [String] The password you want to check against the API.
47
- # @param [Hash] request_options Options that can be passed to +open+ when
26
+ # @param [Hash] request_options Options that can be passed to +Net::HTTP.start+ when
48
27
  # calling the API
49
- # @option request_options [String] 'User-Agent' ("Ruby Pwned::Password #{Pwned::VERSION}")
50
- # The user agent used when making an API request.
28
+ # @option request_options [Symbol] :headers ({ "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}" })
29
+ # HTTP headers to include in the request
51
30
  # @return [Boolean] Whether the password appears in the data breaches or not.
52
31
  # @raise [TypeError] if the password is not a string.
53
32
  # @since 1.1.0
54
33
  def initialize(password, request_options={})
55
34
  raise TypeError, "password must be of type String" unless password.is_a? String
56
35
  @password = password
57
- @request_options = DEFAULT_REQUEST_OPTIONS.merge(request_options)
58
- end
59
-
60
- ##
61
- # Returns the full SHA1 hash of the given password in uppercase.
62
- # @return [String] The full SHA1 hash of the given password.
63
- # @since 1.0.0
64
- def hashed_password
65
- @hashed_password ||= Digest::SHA1.hexdigest(password).upcase
66
- end
67
-
68
- ##
69
- # @example
70
- # password = Pwned::Password.new("password")
71
- # password.pwned? #=> true
72
- #
73
- # @return [Boolean] +true+ when the password has been pwned.
74
- # @raise [Pwned::Error] if there are errors with the HTTP request.
75
- # @raise [Pwned::TimeoutError] if the HTTP request times out.
76
- # @since 1.0.0
77
- def pwned?
78
- pwned_count > 0
79
- end
80
-
81
- ##
82
- # @example
83
- # password = Pwned::Password.new("password")
84
- # password.pwned_count #=> 3303003
85
- #
86
- # @return [Integer] the number of times the password has been pwned.
87
- # @raise [Pwned::Error] if there are errors with the HTTP request.
88
- # @raise [Pwned::TimeoutError] if the HTTP request times out.
89
- # @since 1.0.0
90
- def pwned_count
91
- @pwned_count ||= fetch_pwned_count
92
- end
93
-
94
- private
95
-
96
- def fetch_pwned_count
97
- for_each_response_line do |line|
98
- next unless line.start_with?(hashed_password_suffix)
99
- # Count starts after the suffix, followed by a colon
100
- return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
101
- end
102
-
103
- # The hash was not found, we can assume the password is not pwned [yet]
104
- 0
105
- end
106
-
107
- def for_each_response_line(&block)
108
- begin
109
- open("#{API_URL}#{hashed_password_prefix}", @request_options) do |io|
110
- io.each_line(&block)
111
- end
112
- rescue Timeout::Error => e
113
- raise Pwned::TimeoutError, e.message
114
- rescue => e
115
- raise Pwned::Error, e.message
116
- end
117
- end
118
-
119
- def hashed_password_prefix
120
- hashed_password[0...HASH_PREFIX_LENGTH]
121
- end
122
-
123
- def hashed_password_suffix
124
- hashed_password[HASH_PREFIX_LENGTH..-1]
36
+ @hashed_password = Pwned.hash_password(password)
37
+ @request_options = Hash(request_options).dup
38
+ @request_headers = Hash(request_options.delete(:headers))
39
+ @request_headers = DEFAULT_REQUEST_HEADERS.merge(@request_headers)
125
40
  end
126
41
  end
127
42
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "net/http"
5
+
6
+ module Pwned
7
+ ##
8
+ # This class represents a password. It does all the work of talking to the
9
+ # Pwned Passwords API to find out if the password has been pwned.
10
+ # @see https://haveibeenpwned.com/API/v2#PwnedPasswords
11
+ module PasswordBase
12
+ ##
13
+ # The base URL for the Pwned Passwords API
14
+ API_URL = "https://api.pwnedpasswords.com/range/"
15
+
16
+ ##
17
+ # The number of characters from the start of the hash of the password that
18
+ # are used to search for the range of passwords.
19
+ HASH_PREFIX_LENGTH = 5
20
+
21
+ ##
22
+ # The total length of a SHA1 hash
23
+ SHA1_LENGTH = 40
24
+
25
+ ##
26
+ # The default request headers that are used to make HTTP requests to the
27
+ # API. A user agent is provided as requested in the documentation.
28
+ # @see https://haveibeenpwned.com/API/v2#UserAgent
29
+ DEFAULT_REQUEST_HEADERS = {
30
+ "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
31
+ }.freeze
32
+
33
+ ##
34
+ # @example
35
+ # password = Pwned::Password.new("password")
36
+ # password.pwned? #=> true
37
+ # password.pwned? #=> true
38
+ #
39
+ # @return [Boolean] +true+ when the password has been pwned.
40
+ # @raise [Pwned::Error] if there are errors with the HTTP request.
41
+ # @raise [Pwned::TimeoutError] if the HTTP request times out.
42
+ # @since 1.0.0
43
+ def pwned?
44
+ pwned_count > 0
45
+ end
46
+
47
+ ##
48
+ # @example
49
+ # password = Pwned::Password.new("password")
50
+ # password.pwned_count #=> 3303003
51
+ #
52
+ # @return [Integer] the number of times the password has been pwned.
53
+ # @raise [Pwned::Error] if there are errors with the HTTP request.
54
+ # @raise [Pwned::TimeoutError] if the HTTP request times out.
55
+ # @since 1.0.0
56
+ def pwned_count
57
+ @pwned_count ||= fetch_pwned_count
58
+ end
59
+
60
+ ##
61
+ # Returns the full SHA1 hash of the given password in uppercase.
62
+ # @return [String] The full SHA1 hash of the given password.
63
+ # @since 1.0.0
64
+ attr_reader :hashed_password
65
+
66
+ private
67
+
68
+ attr_reader :request_options, :request_headers
69
+
70
+ def fetch_pwned_count
71
+ for_each_response_line do |line|
72
+ next unless line.start_with?(hashed_password_suffix)
73
+ # Count starts after the suffix, followed by a colon
74
+ return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
75
+ end
76
+
77
+ # The hash was not found, we can assume the password is not pwned [yet]
78
+ 0
79
+ end
80
+
81
+ def for_each_response_line(&block)
82
+ begin
83
+ with_http_response "#{API_URL}#{hashed_password_prefix}" do |response|
84
+ response.value # raise if request was unsuccessful
85
+ stream_response_lines(response, &block)
86
+ end
87
+ rescue Timeout::Error => e
88
+ raise Pwned::TimeoutError, e.message
89
+ rescue => e
90
+ raise Pwned::Error, e.message
91
+ end
92
+ end
93
+
94
+ def hashed_password_prefix
95
+ @hashed_password[0...HASH_PREFIX_LENGTH]
96
+ end
97
+
98
+ def hashed_password_suffix
99
+ @hashed_password[HASH_PREFIX_LENGTH..-1]
100
+ end
101
+
102
+ # Make a HTTP GET request given the url and headers.
103
+ # Yields a `Net::HTTPResponse`.
104
+ def with_http_response(url, &block)
105
+ uri = URI(url)
106
+
107
+ request = Net::HTTP::Get.new(uri)
108
+ request.initialize_http_header(request_headers)
109
+ request_options[:use_ssl] = true
110
+
111
+ Net::HTTP.start(uri.host, uri.port, request_options) do |http|
112
+ http.request(request, &block)
113
+ end
114
+ end
115
+
116
+ # Stream a Net::HTTPResponse by line, handling lines that cross chunks.
117
+ def stream_response_lines(response, &block)
118
+ last_line = ""
119
+
120
+ response.read_body do |chunk|
121
+ chunk_lines = (last_line + chunk).lines
122
+ # This could end with half a line, so save it for next time. If
123
+ # chunk_lines is empty, pop returns nil, so this also ensures last_line
124
+ # is always a string.
125
+ last_line = chunk_lines.pop || ""
126
+ chunk_lines.each(&block)
127
+ end
128
+
129
+ yield last_line unless last_line.empty?
130
+ end
131
+
132
+ end
133
+ end
@@ -3,5 +3,5 @@
3
3
  module Pwned
4
4
  ##
5
5
  # The current version of the +pwned+ gem.
6
- VERSION = "1.2.0"
6
+ VERSION = "2.1.0"
7
7
  end
@@ -13,13 +13,22 @@ Gem::Specification.new do |spec|
13
13
  spec.homepage = "https://github.com/philnash/pwned"
14
14
  spec.license = "MIT"
15
15
 
16
+ spec.metadata = {
17
+ "bug_tracker_uri" => "https://github.com/philnash/pwned/issues",
18
+ "change_log_uri" => "https://github.com/philnash/pwned/blob/master/CHANGELOG.md",
19
+ "documentation_uri" => "https://www.rubydoc.info/gems/pwned",
20
+ "homepage_uri" => "https://github.com/philnash/pwned",
21
+ "source_code_uri" => "https://github.com/philnash/pwned"
22
+ }
23
+
16
24
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
25
  f.match(%r{^(test|spec|features)/})
18
26
  end
19
27
  spec.require_paths = ["lib"]
28
+ spec.executables = ["pwned"]
20
29
 
21
- spec.add_development_dependency "bundler", "~> 1.16"
22
- spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "bundler", ">= 1.16", "< 3.0"
31
+ spec.add_development_dependency "rake", "~> 13.0"
23
32
  spec.add_development_dependency "rspec", "~> 3.0"
24
33
  spec.add_development_dependency "webmock", "~> 3.3"
25
34
  spec.add_development_dependency "yard", "~> 0.9.12"
metadata CHANGED
@@ -1,43 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwned
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Phil Nash
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-03-15 00:00:00.000000000 Z
11
+ date: 2020-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.16'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
20
23
  type: :development
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '1.16'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rake
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
37
  - - "~>"
32
38
  - !ruby/object:Gem::Version
33
- version: '10.0'
39
+ version: '13.0'
34
40
  type: :development
35
41
  prerelease: false
36
42
  version_requirements: !ruby/object:Gem::Requirement
37
43
  requirements:
38
44
  - - "~>"
39
45
  - !ruby/object:Gem::Version
40
- version: '10.0'
46
+ version: '13.0'
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: rspec
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -83,13 +89,15 @@ dependencies:
83
89
  description: Tools to use the Pwned Passwords API.
84
90
  email:
85
91
  - philnash@gmail.com
86
- executables: []
92
+ executables:
93
+ - pwned
87
94
  extensions: []
88
95
  extra_rdoc_files: []
89
96
  files:
90
97
  - ".gitignore"
91
98
  - ".rspec"
92
99
  - ".travis.yml"
100
+ - ".yardopts"
93
101
  - CHANGELOG.md
94
102
  - CODE_OF_CONDUCT.md
95
103
  - Gemfile
@@ -97,38 +105,26 @@ files:
97
105
  - README.md
98
106
  - Rakefile
99
107
  - bin/console
108
+ - bin/pwned
100
109
  - bin/setup
101
- - docs/NotPwnedValidator.html
102
- - docs/Pwned.html
103
- - docs/Pwned/Error.html
104
- - docs/Pwned/Password.html
105
- - docs/Pwned/TimeoutError.html
106
- - docs/PwnedValidator.html
107
- - docs/_index.html
108
- - docs/class_list.html
109
- - docs/css/common.css
110
- - docs/css/full_list.css
111
- - docs/css/style.css
112
- - docs/file.README.html
113
- - docs/file_list.html
114
- - docs/frames.html
115
- - docs/index.html
116
- - docs/js/app.js
117
- - docs/js/full_list.js
118
- - docs/js/jquery.js
119
- - docs/method_list.html
120
- - docs/top-level-namespace.html
121
110
  - lib/locale/en.yml
122
111
  - lib/pwned.rb
123
112
  - lib/pwned/error.rb
113
+ - lib/pwned/hashed_password.rb
124
114
  - lib/pwned/not_pwned_validator.rb
125
115
  - lib/pwned/password.rb
116
+ - lib/pwned/password_base.rb
126
117
  - lib/pwned/version.rb
127
118
  - pwned.gemspec
128
119
  homepage: https://github.com/philnash/pwned
129
120
  licenses:
130
121
  - MIT
131
- metadata: {}
122
+ metadata:
123
+ bug_tracker_uri: https://github.com/philnash/pwned/issues
124
+ change_log_uri: https://github.com/philnash/pwned/blob/master/CHANGELOG.md
125
+ documentation_uri: https://www.rubydoc.info/gems/pwned
126
+ homepage_uri: https://github.com/philnash/pwned
127
+ source_code_uri: https://github.com/philnash/pwned
132
128
  post_install_message:
133
129
  rdoc_options: []
134
130
  require_paths:
@@ -144,8 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
140
  - !ruby/object:Gem::Version
145
141
  version: '0'
146
142
  requirements: []
147
- rubyforge_project:
148
- rubygems_version: 2.7.6
143
+ rubygems_version: 3.0.3
149
144
  signing_key:
150
145
  specification_version: 4
151
146
  summary: Tools to use the Pwned Passwords API.