pwned 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca12ee3bc8b9cc61d462c9d373c18830f633b018c54661eee21f8045b7ce27c4
4
- data.tar.gz: 72a1bf40821939dd027b0f5523e21db5037a4a19dedbe4f8d4bb486d33055a2f
3
+ metadata.gz: 502db43dbcbc77b9ea32dbac1b642d4f682f3969b810b13c2ec02b381c1b50e9
4
+ data.tar.gz: 5ef819185c54637d5544b7fd27134d1ae61c07ed2eaf464b7040e39fa8b15937
5
5
  SHA512:
6
- metadata.gz: 6258fd06e590b92865957056245670e9b482766b8b249608c1981561ad8ce774068f79f3a16ee80b6292176676749b8ac5a89cff6bcefeaa58ca23d5bbca5f35
7
- data.tar.gz: c9f1a8a256f56c382eb7f1b6a6517b05769d982e87d9a19a601652488159d4d97745d74edf2daa18ffb7b4479be81d4299b1fbbd860b91dd8465aff3c2452bb6
6
+ metadata.gz: fd00a0191f6d8a379ef7380cda0e7de42bdcc996483c45256185c535fb16d6a61b40a850d0f9383efda0e4441cde66ec5e24b73152a0e9af3d78da9b74a3a63f
7
+ data.tar.gz: bf4299bd181ff6d7b37754ed3cfefd52f9ea5fb72f9219b48a403137d5950c530be60ec85f9dfe58f412d1dbcbdb7958e0e0dc062ff8006728aa5d454899c6ae
@@ -1,12 +1,23 @@
1
1
  sudo: false
2
2
  language: ruby
3
+
4
+ env:
5
+ matrix:
6
+ - RAILS_VERSION=4.2.0
7
+ - RAILS_VERSION=5.0.0
8
+ - RAILS_VERSION=5.1.0
9
+ - RAILS_VERSION=5.2.0.rc1
10
+
3
11
  rvm:
4
12
  - 2.5.0
5
13
  - 2.4.0
6
14
  - 2.3.0
7
15
  - jruby
8
16
  - ruby-head
17
+
9
18
  before_install: gem install bundler -v 1.16.1
19
+
10
20
  matrix:
11
21
  allow_failures:
12
- - rvm: ruby-head
22
+ - rvm: ruby-head
23
+ - env: RAILS_VERSION=5.2.0.rc1
@@ -0,0 +1,20 @@
1
+ # Changelog for `Pwned`
2
+
3
+ ## Ongoing [☰](https://github.com/philnash/pwned/compare/v1.1.0...master)
4
+
5
+ ## 1.1.0 (March 12, 2018) [☰](https://github.com/philnash/pwned/commits/v1.1.0)
6
+
7
+ * Major updates
8
+ * Refactors exception handling with built in Ruby method ([PR #1](https://github.com/philnash/pwned/pull/1) thanks [@kpumuk](https://github.com/kpumuk))
9
+ * Passwords must be strings, the initializer will raise a `TypeError` unless `password.is_a? String`. ([dbf7697](https://github.com/philnash/pwned/commit/dbf7697e878d87ac74aed1e715cee19b73473369))
10
+ * Added Ruby on Rails validator ([PR #3](https://github.com/philnash/pwned/pull/3) & [PR #6](https://github.com/philnash/pwned/pull/6))
11
+ * Added simplified accessors `Pwned.pwned?` and `Pwned.pwned_count` ([PR #4](https://github.com/philnash/pwned/pull/4))
12
+
13
+ * Minor updates
14
+ * SHA1 is only calculated once
15
+ * Frozen string literal to make sure Ruby does not copy strings over and over again
16
+ * Removal of `@match_data`, since we only use it to retrieve the counter. Caching the counter instead (all [PR #2](https://github.com/philnash/pwned/pull/2) thanks [@kpumuk](https://github.com/kpumuk))
17
+
18
+ ## 1.0.0 (March 6, 2018) [☰](https://github.com/philnash/pwned/commits/v1.0.0)
19
+
20
+ Initial release. Includes basic features for checking passwords and their count from the Pwned Passwords API. Allows setting of request headers and other options for open-uri.
data/Gemfile CHANGED
@@ -4,3 +4,6 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
4
 
5
5
  # Specify your gem's dependencies in pwned.gemspec
6
6
  gemspec
7
+
8
+ # Allows to switch Rails version in the build matrix
9
+ gem "activemodel", ENV["RAILS_VERSION"] ? "~> #{ENV["RAILS_VERSION"]}" : nil
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Pwned
2
2
 
3
+ An easy, Ruby way to use the Pwned Passwords API.
4
+
5
+ [![Build Status](https://travis-ci.org/philnash/pwned.svg?branch=master)](https://travis-ci.org/philnash/pwned)
6
+
3
7
  Troy Hunt's [Pwned Passwords API V2](https://haveibeenpwned.com/API/v2#PwnedPasswords) allows you to check if a password has been found in any of the huge data breaches.
4
8
 
5
9
  `Pwned` is a Ruby library to use the Pwned Passwords API's [k-Anonymity model](https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/#cloudflareprivacyandkanonymity) to test a password against the API without sending the entire password to the service.
@@ -30,6 +34,8 @@ To test a password against the API, instantiate a `Pwned::Password` object and t
30
34
  password = Pwned::Password.new("password")
31
35
  password.pwned?
32
36
  #=> true
37
+ password.pwned_count
38
+ #=> 3303003
33
39
  ```
34
40
 
35
41
  You can also check how many times the password appears in the dataset.
@@ -51,7 +57,16 @@ rescue Pwned::Error => e
51
57
  end
52
58
  ```
53
59
 
54
- ### Advanced
60
+ Most of the times you only care if the password has been pwned before or not. You can use simplified accessors to check whether the password has been pwned, or how many times it was pwned:
61
+
62
+ ```ruby
63
+ Pwned.pwned?("password")
64
+ #=> true
65
+ Pwned.pwned_count("password")
66
+ #=> 3303003
67
+ ```
68
+
69
+ #### Advanced
55
70
 
56
71
  You can set options and headers to be used with `open-uri` when making the request to the API. HTTP headers must be string keys and the [other options are available in the `OpenURI::OpenRead` module](https://ruby-doc.org/stdlib-2.5.0/libdoc/open-uri/rdoc/OpenURI/OpenRead.html#method-i-open).
57
72
 
@@ -59,9 +74,83 @@ You can set options and headers to be used with `open-uri` when making the reque
59
74
  password = Pwned::Password.new("password", { 'User-Agent' => 'Super fun new user agent' })
60
75
  ```
61
76
 
77
+ ### ActiveRecord Validator
78
+
79
+ There is a custom validator available for your ActiveRecord models:
80
+
81
+ ```ruby
82
+ class User < ApplicationRecord
83
+ validates :password, pwned: true
84
+ # or
85
+ validates :password, pwned: { message: "has been pwned %{count} times" }
86
+ end
87
+ ```
88
+
89
+ #### I18n
90
+
91
+ You can change the error message using I18n (use `%{count}` to interpolate the number of times the password was seen in the data breaches):
92
+
93
+ ```yaml
94
+ en:
95
+ errors:
96
+ messages:
97
+ pwned: has been pwned %{count} times
98
+ pwned_error: might be pwned
99
+ ```
100
+
101
+ #### Threshold
102
+
103
+ If you are ok with the password appearing a certain number of times before you decide it is invalid, you can set a threshold. The validator will check whether the `pwned_count` is greater than the threshold.
104
+
105
+ ```ruby
106
+ class User < ApplicationRecord
107
+ # The record is marked as valid if the password has been used once in the breached data
108
+ validates :password, pwned: { threshold: 1 }
109
+ end
110
+ ```
111
+
112
+ #### Network Errors Handling
113
+
114
+ By default the record will be treated as valid when we cannot reach the [haveibeenpwned.com](https://haveibeenpwned.com/) servers. This can be changed with the `:on_error` validator parameter:
115
+
116
+ ```ruby
117
+ class User < ApplicationRecord
118
+ # The record is marked as valid on network errors.
119
+ validates :password, pwned: true
120
+ validates :password, pwned: { on_error: :valid }
121
+
122
+ # The record is marked as invalid on network errors
123
+ # (error message "could not be verified against the past data breaches".)
124
+ validates :password, pwned: { on_error: :invalid }
125
+
126
+ # The record is marked as invalid on network errors with custom error.
127
+ validates :password, pwned: { on_error: :invalid, error_message: "might be pwned" }
128
+
129
+ # We will raise an error on network errors.
130
+ # This means that `record.valid?` will raise `Pwned::Error`.
131
+ # Not recommended to use in production.
132
+ validates :password, pwned: { on_error: :raise_error }
133
+
134
+ # Call custom proc on error. For example, capture errors in Sentry,
135
+ # but do not mark the record as invalid.
136
+ validates :password, pwned: {
137
+ on_error: ->(record, error) { Raven.capture_exception(error) }
138
+ }
139
+ end
140
+ ```
141
+
142
+ #### Custom Request Options
143
+
144
+ You can configure network requests made from the validator using `:request_options` (see [OpenURI::OpenRead#open](http://ruby-doc.org/stdlib-2.5.0/libdoc/open-uri/rdoc/OpenURI/OpenRead.html#method-i-open) for the list of available options, string keys represent custom network request headers, e.g. `"User-Agent"`):
145
+
146
+ ```ruby
147
+ validates :password, pwned: {
148
+ request_options: { read_timeout: 5, open_timeout: 1, "User-Agent" => "Super fun user agent" }
149
+ }
150
+ ```
151
+
62
152
  ## TODO
63
153
 
64
- - [ ] Rails validator
65
154
  - [ ] Devise plugin
66
155
 
67
156
  ## Development
@@ -0,0 +1,5 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ pwned: has previously appeared in a data breach and should not be used
5
+ pwned_error: could not be verified against the past data breaches
@@ -1,6 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "pwned/version"
2
4
  require "pwned/error"
3
5
  require "pwned/password"
4
6
 
7
+ begin
8
+ # Load Rails and our custom validator
9
+ require "active_model"
10
+ require "pwned/pwned_validator"
11
+
12
+ # Initialize I18n (validation error message)
13
+ require "active_support/i18n"
14
+ I18n.load_path.concat Dir[File.expand_path('locale/*.yml', __dir__)]
15
+ rescue LoadError
16
+ # Not a Rails project, no need to do anything
17
+ end
18
+
5
19
  module Pwned
20
+ # Returns true when the password has been pwned.
21
+ def self.pwned?(password, request_options={})
22
+ Pwned::Password.new(password, request_options).pwned?
23
+ end
24
+
25
+ # Returns number of times the password has been pwned.
26
+ def self.pwned_count(password, request_options={})
27
+ Pwned::Password.new(password, request_options).pwned_count
28
+ end
6
29
  end
@@ -1,13 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pwned
2
4
  class Error < StandardError
3
- attr_reader :original_error
4
-
5
- def initialize(message, original_error)
6
- @original_error = original_error
7
- super(message)
8
- end
9
5
  end
10
6
 
11
7
  class TimeoutError < Error
12
8
  end
13
- end
9
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "digest"
2
4
  require "open-uri"
3
5
 
@@ -5,51 +7,66 @@ module Pwned
5
7
  class Password
6
8
  API_URL = "https://api.pwnedpasswords.com/range/"
7
9
  HASH_PREFIX_LENGTH = 5
10
+ SHA1_LENGTH = 40
8
11
  DEFAULT_REQUEST_OPTIONS = {
9
12
  "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
10
- }
13
+ }.freeze
11
14
 
12
15
  attr_reader :password
13
16
 
14
17
  def initialize(password, request_options={})
18
+ raise TypeError, "password must be of type String" unless password.is_a? String
15
19
  @password = password
16
20
  @request_options = DEFAULT_REQUEST_OPTIONS.merge(request_options)
17
21
  end
18
22
 
23
+ # Returns the full SHA1 hash of the given password.
19
24
  def hashed_password
20
- Digest::SHA1.hexdigest(password).upcase
25
+ @hashed_password ||= Digest::SHA1.hexdigest(password).upcase
21
26
  end
22
27
 
28
+ # Returns true when the password has been pwned.
23
29
  def pwned?
24
- !!match_data
30
+ pwned_count > 0
25
31
  end
26
32
 
33
+ # Returns number of times the password has been pwned.
27
34
  def pwned_count
28
- match_data ? match_data[1].to_i : 0
35
+ @pwned_count ||= fetch_pwned_count
29
36
  end
30
37
 
31
38
  private
32
39
 
33
- def hashes
34
- @hashes || get_hashes
40
+ def fetch_pwned_count
41
+ suffix = hashed_password_suffix
42
+ for_each_response_line do |line|
43
+ next unless line.start_with?(suffix)
44
+ # Count starts after the suffix, followed by a colon
45
+ return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
46
+ end
47
+
48
+ # The hash was not found, we can assume the password is not pwned [yet]
49
+ 0
35
50
  end
36
51
 
37
- def get_hashes
52
+ def for_each_response_line(&block)
38
53
  begin
39
- open("#{API_URL}#{hashed_password[0..(HASH_PREFIX_LENGTH-1)]}", @request_options) do |io|
40
- @hashes = io.read
54
+ open("#{API_URL}#{hashed_password_prefix}", @request_options) do |io|
55
+ io.each_line(&block)
41
56
  end
42
- @hashes
43
57
  rescue Timeout::Error => e
44
- raise Pwned::TimeoutError.new(e.message, e)
58
+ raise Pwned::TimeoutError, e.message
45
59
  rescue => e
46
- raise Pwned::Error.new(e.message, e)
60
+ raise Pwned::Error, e.message
47
61
  end
48
62
  end
49
63
 
50
- def match_data
51
- @match_data ||= hashes.match(/#{hashed_password[HASH_PREFIX_LENGTH..-1]}:(\d+)/)
64
+ def hashed_password_prefix
65
+ hashed_password[0...HASH_PREFIX_LENGTH]
52
66
  end
53
67
 
68
+ def hashed_password_suffix
69
+ hashed_password[HASH_PREFIX_LENGTH..-1]
70
+ end
54
71
  end
55
- end
72
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PwnedValidator < ActiveModel::EachValidator
4
+ # We do not want to break customer sign-up process when the service is down.
5
+ DEFAULT_ON_ERROR = :valid
6
+ DEFAULT_THRESHOLD = 0
7
+
8
+ def validate_each(record, attribute, value)
9
+ begin
10
+ pwned_check = Pwned::Password.new(value, request_options)
11
+ if pwned_check.pwned_count > threshold
12
+ record.errors.add(attribute, :pwned, options.merge(count: pwned_check.pwned_count))
13
+ end
14
+ rescue Pwned::Error => error
15
+ case on_error
16
+ when :invalid
17
+ record.errors.add(attribute, :pwned_error, options.merge(message: options[:error_message]))
18
+ when :valid
19
+ # Do nothing, consider the record valid
20
+ when Proc
21
+ on_error.call(record, error)
22
+ else
23
+ raise
24
+ end
25
+ end
26
+ end
27
+
28
+ def on_error
29
+ options[:on_error] || DEFAULT_ON_ERROR
30
+ end
31
+
32
+ def request_options
33
+ options[:request_options] || {}
34
+ end
35
+
36
+ def threshold
37
+ threshold = options[:threshold] || DEFAULT_THRESHOLD
38
+ raise TypeError, "PwnedValidator option 'threshold' must be of type Integer" unless threshold.is_a? Integer
39
+ threshold
40
+ end
41
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pwned
2
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
3
5
  end
@@ -16,8 +16,6 @@ Gem::Specification.new do |spec|
16
16
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
17
  f.match(%r{^(test|spec|features)/})
18
18
  end
19
- spec.bindir = "exe"
20
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
19
  spec.require_paths = ["lib"]
22
20
 
23
21
  spec.add_development_dependency "bundler", "~> 1.16"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwned
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Phil Nash
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2018-03-06 00:00:00.000000000 Z
11
+ date: 2018-03-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -76,6 +76,7 @@ files:
76
76
  - ".gitignore"
77
77
  - ".rspec"
78
78
  - ".travis.yml"
79
+ - CHANGELOG.md
79
80
  - CODE_OF_CONDUCT.md
80
81
  - Gemfile
81
82
  - LICENSE.txt
@@ -83,9 +84,11 @@ files:
83
84
  - Rakefile
84
85
  - bin/console
85
86
  - bin/setup
87
+ - lib/locale/en.yml
86
88
  - lib/pwned.rb
87
89
  - lib/pwned/error.rb
88
90
  - lib/pwned/password.rb
91
+ - lib/pwned/pwned_validator.rb
89
92
  - lib/pwned/version.rb
90
93
  - pwned.gemspec
91
94
  homepage: https://github.com/philnash/pwned