Skip to content

Commit

Permalink
Merge pull request #3 from kpumuk/dmytro/rails-validator
Browse files Browse the repository at this point in the history
Added Rails validator
  • Loading branch information
philnash committed Mar 7, 2018
2 parents cd7dd5e + bf4945d commit df81220
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 24 deletions.
11 changes: 10 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
sudo: false
language: ruby

env:
matrix:
- RAILS_VERSION=4.2.0
- RAILS_VERSION=5.0.0
- RAILS_VERSION=5.1.0

rvm:
- 2.5.0
- 2.4.0
- 2.3.0
- jruby
- ruby-head

before_install: gem install bundler -v 1.16.1

matrix:
allow_failures:
- rvm: ruby-head
- rvm: ruby-head
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Changelog for `Pwned`

## Ongoing
## Ongoing [](https://github.com/philnash/pwned/compare/v1.0.0...master)

* 2 major updates
* Major updates
* Refactors exception handling with built in Ruby method ([PR #1](https://github.com/philnash/pwned/pull/1) thanks [@kpumuk](https://github.com/kpumuk))
* Passwords must be strings, the initializer will raise a `TypeError` unless `password.is_a? String`. ([dbf7697](https://github.com/philnash/pwned/commit/dbf7697e878d87ac74aed1e715cee19b73473369))
* Added Ruby on Rails validator ([PR #3](https://github.com/philnash/pwned/pull/3))

* Minor updates
* SHA1 is only calculated once
* Frozen string literal to make sure Ruby does not copy strings over and over again
* 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))

## 1.0.0 / 2018-03-06
## 1.0.0 (March 6, 2018) [](https://github.com/philnash/pwned/commits/v1.0.0)

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.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# Specify your gem's dependencies in pwned.gemspec
gemspec

# Allows to switch Rails version in the build matrix
gem "activemodel", ENV["RAILS_VERSION"] ? "~> #{ENV["RAILS_VERSION"]}" : nil
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,80 @@ rescue Pwned::Error => e
end
```

### Advanced
#### Advanced

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).

```ruby
password = Pwned::Password.new("password", { 'User-Agent' => 'Super fun new user agent' })
```

### ActiveRecord Validator

There is a custom validator available for your ActiveRecord models:

```ruby
class User < ApplicationRecord
validates :password, pwned: true
# or
validates :password, pwned: { message: "has been pwned %{count} times" }
end
```

#### I18n

You can change the error message using I18n (use `%{count}` to interpolate the number of times the password was seen in the data breaches):

```yaml
en:
errors:
messages:
pwned: has been pwned %{count} times
pwned_error: might be pwned
```
#### Network Errors Handling
By default the record will be treated as valid when we cannot reach [haveibeenpwned.com](https://haveibeenpwned.com/) servers. This could be changed via validator parameters:
```ruby
class User < ApplicationRecord
# The record is marked as valid on network errors.
validates :password, pwned: true
validates :password, pwned: { on_error: :valid }

# The record is marked as invalid on network errors
# (error message "could not be verified against the past data breaches".)
validates :password, pwned: { on_error: :invalid }

# The record is marked as invalid on network errors with custom error.
validates :password, pwned: { on_error: :invalid, error_message: "might be pwned" }

# We will raise an error on network errors.
# This means that `record.valid?` will raise `Pwned::Error`.
# Not recommended to use in production.
validates :password, pwned: { on_error: :raise_error }

# Call custom proc on error. For example, capture errors in Sentry,
# but do not mark the record as invalid.
validates :password, pwned: {
on_error: ->(record, error) { Raven.capture_exception(error) }
}
end
```

#### Custom Request Options

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"`):

```ruby
validates :password, pwned: {
request_options: { read_timeout: 5, open_timeout: 1, "User-Agent" => "Super fun user agent" }
}
```

## TODO

- [ ] Rails validator
- [ ] Devise plugin

## Development
Expand Down
5 changes: 5 additions & 0 deletions lib/locale/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
en:
errors:
messages:
pwned: has previously appeared in a data breach and should not be used
pwned_error: could not be verified against the past data breaches
12 changes: 12 additions & 0 deletions lib/pwned.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,17 @@
require "pwned/error"
require "pwned/password"

begin
# Load Rails and our custom validator
require "active_model"
require_relative "pwned_validator"

# Initialize I18n (validation error message)
require "active_support/i18n"
I18n.load_path.concat Dir[File.expand_path('locale/*.yml', __dir__)]
rescue LoadError
# Not a Rails project, no need to do anything
end

module Pwned
end
34 changes: 34 additions & 0 deletions lib/pwned_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

class PwnedValidator < ActiveModel::EachValidator
# We do not want to break customer sign-up process when the service is down.
DEFAULT_ON_ERROR = :valid

def validate_each(record, attribute, value)
begin
pwned_check = Pwned::Password.new(value, request_options)
if pwned_check.pwned?
record.errors.add(attribute, :pwned, options.merge(count: pwned_check.pwned_count))
end
rescue Pwned::Error => error
case on_error
when :invalid
record.errors.add(attribute, :pwned_error, options.merge(message: options[:error_message]))
when :valid
# Do nothing, consider the record valid
when Proc
on_error.call(record, error)
else
raise
end
end
end

def on_error
options[:on_error] || DEFAULT_ON_ERROR
end

def request_options
options[:request_options] || {}
end
end
21 changes: 3 additions & 18 deletions spec/pwned/password_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
RSpec.describe Pwned::Password do
let(:password) { Pwned::Password.new("password") }
let(:file_5BAA6) { File.new('./spec/fixtures/5BAA6.txt') }
let(:file_37D5B) { File.new('./spec/fixtures/37D5B.txt') }

it "initializes with a password" do
expect(password.password).to eq("password")
Expand All @@ -23,11 +21,7 @@
expect(password.hashed_password).to eq("5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8")
end

describe "when pwned" do
before(:example) do
@stub = stub_request(:get, "https://api.pwnedpasswords.com/range/5BAA6").to_return(body: file_5BAA6)
end

describe "when pwned", pwned_range: "5BAA6" do
it "reports it is pwned" do
expect(password.pwned?).to be true
expect(@stub).to have_been_requested
Expand All @@ -45,14 +39,9 @@
end
end

describe "when not pwned" do
describe "when not pwned", pwned_range: "37D5B" do
let(:password) { Pwned::Password.new("t3hb3stpa55w0rd") }

before(:example) do
file = File.new('./spec/fixtures/37D5B.txt')
@stub = stub_request(:get, "https://api.pwnedpasswords.com/range/37D5B").to_return(body: file_37D5B)
end

it "reports it is not pwned" do
expect(password.pwned?).to be false
expect(@stub).to have_been_requested
Expand Down Expand Up @@ -125,11 +114,7 @@ def verify_not_found_error(error)
end
end

describe "advanced requests" do
before(:example) do
stub = stub_request(:get, "https://api.pwnedpasswords.com/range/5BAA6").to_return(body: file_5BAA6)
end

describe "advanced requests", pwned_range: "5BAA6" do
it "sends a user agent with the current version" do
password.pwned?

Expand Down
108 changes: 108 additions & 0 deletions spec/pwned/pwned_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
RSpec.describe PwnedValidator do
class Model
include ActiveModel::Validations

attr_accessor :password
end

after(:example) do
Model.clear_validators!
end

describe "when pwned", pwned_range: "5BAA6" do
it "marks the model as invalid" do
Model.validates :password, pwned: true
model = create_model('password')

expect(model).to_not be_valid
expect(model.errors[:password].size).to eq(1)
expect(model.errors[:password].first).to eq('has previously appeared in a data breach and should not be used')
end

it "allows to change the error message" do
Model.validates :password, pwned: { message: "has been pwned %{count} times" }
model = create_model('password')

expect(model).to_not be_valid
expect(model.errors[:password].size).to eq(1)
expect(model.errors[:password].first).to eq('has been pwned 3303003 times')
end

it "allows the user agent to be set" do
Model.validates :password, pwned: {
request_options: { "User-Agent" => "Super fun user agent" }
}
model = create_model('password')

expect(model).to_not be_valid
expect(a_request(:get, "https://api.pwnedpasswords.com/range/5BAA6").
with(headers: { "User-Agent" => "Super fun user agent" })).
to have_been_made.once
end
end

describe "when not pwned", pwned_range: "37D5B" do
it "reports the model as valid" do
Model.validates :password, pwned: true
model = create_model('t3hb3stpa55w0rd')

expect(model).to be_valid
end
end

describe "when the API times out" do
before(:example) do
@stub = stub_request(:get, "https://api.pwnedpasswords.com/range/5BAA6").to_timeout
end

it "marks the model as valid when not error handling configured" do
Model.validates :password, pwned: true
model = create_model('password')

expect(model).to be_valid
end

it "raises a custom error when error handling configured to :raise_error" do
Model.validates :password, pwned: { on_error: :raise_error }
model = create_model('password')

expect { model.valid? }.to raise_error(Pwned::TimeoutError, /execution expired/)
end

it "marks the model as invalid when error handling configured to :invalid" do
Model.validates :password, pwned: { on_error: :invalid }
model = create_model('password')

expect(model).to_not be_valid
expect(model.errors[:password].size).to eq(1)
expect(model.errors[:password].first).to eq("could not be verified against the past data breaches")
end

it "marks the model as invalid with a custom error message when error handling configured to :invalid" do
Model.validates :password, pwned: { on_error: :invalid, error_message: "might be pwned" }
model = create_model('password')

expect(model).to_not be_valid
expect(model.errors[:password].size).to eq(1)
expect(model.errors[:password].first).to eq("might be pwned")
end

it "marks the model as valid when error handling configured to :valid" do
Model.validates :password, pwned: { on_error: :valid }
model = create_model('password')

expect(model).to be_valid
end

it "calls a proc configured for error handling" do
Model.validates :password, pwned: { on_error: ->(record, error) { raise RuntimeError, "custom proc" } }
model = create_model('password')

expect { model.valid? }.to raise_error(RuntimeError, "custom proc")
end
end

def create_model(password)
Model.new.tap { |model| model.password = password }
end
end
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
require "webmock/rspec"
require "pwned"

# Easily stub pwned password hash range API requests
require_relative "support/stub_pwned_range"

# No network requests in specs
WebMock.disable_net_connect!

RSpec.configure do |config|
Expand Down
10 changes: 10 additions & 0 deletions spec/support/stub_pwned_range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
RSpec.configure do |config|
config.around :example, :pwned_range do |example|
pwned_range = example.metadata[:pwned_range]
File.open(File.expand_path("../fixtures/#{pwned_range}.txt", __dir__)) do |body|
uri = "https://api.pwnedpasswords.com/range/#{pwned_range}"
@stub = stub_request(:get, uri).to_return(body: body)
example.run
end
end
end

0 comments on commit df81220

Please sign in to comment.