pwned 1.2.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.