lib/pwned/not_pwned_validator.rb
# frozen_string_literal: true
##
# An +ActiveModel+ validator to check passwords against the Pwned Passwords API.
#
# @example Validate a password on a +User+ model with the default options.
# class User < ApplicationRecord
# validates :password, not_pwned: true
# end
#
# @example Validate a password on a +User+ model with a custom error message.
# class User < ApplicationRecord
# validates :password, not_pwned: { message: "has been pwned %{count} times" }
# end
#
# @example Validate a password on a +User+ model that allows the password to have been breached once.
# class User < ApplicationRecord
# validates :password, not_pwned: { threshold: 1 }
# end
#
# @example Validate a password on a +User+ model, handling API errors in various ways
# class User < ApplicationRecord
# # The record is marked as invalid on network errors
# # (error message "could not be verified against the past data breaches".)
# validates :password, not_pwned: { on_error: :invalid }
#
# # The record is marked as invalid on network errors with custom error.
# validates :password, not_pwned: { on_error: :invalid, error_message: "might be pwned" }
#
# # An error is raised on network errors.
# # This means that `record.valid?` will raise `Pwned::Error`.
# # Not recommended to use in production.
# validates :password, not_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, not_pwned: {
# on_error: ->(record, error) { Raven.capture_exception(error) }
# }
# end
#
# @since 1.2.0
class NotPwnedValidator < ActiveModel::EachValidator
##
# The default behaviour of this validator in the case of an API failure. The
# default will mean that if the API fails the object will not be marked
# invalid.
DEFAULT_ON_ERROR = :valid
##
# The default threshold for whether a breach is considered pwned. The default
# is 0, so any password that appears in a breach will mark the record as
# invalid.
DEFAULT_THRESHOLD = 0
##
# Validates the +value+ against the Pwned Passwords API. If the +pwned_count+
# is higher than the optional +threshold+ then the record is marked as
# invalid.
#
# In the case of an API error the validator will either mark the
# record as valid or invalid. Alternatively it will run an associated proc or
# re-raise the original error.
#
# The validation will short circuit and return with no errors added if the
# password is blank. The +Pwned::Password+ initializer expects the password to
# be a string and will throw a +TypeError+ if it is +nil+. Also, technically
# the empty string is not a password that is reported to be found in data
# breaches, so returns +false+, short circuiting that using +value.blank?+
# saves us a trip to the API.
#
# @param record [ActiveModel::Validations] The object being validated
# @param attribute [Symbol] The attribute on the record that is currently
# being validated.
# @param value [String] The value of the attribute on the record that is the
# subject of the validation
def validate_each(record, attribute, value)
return if value.blank?
begin
pwned_check = Pwned::Password.new(value, request_options)
if pwned_check.pwned_count > threshold
record.errors.add(attribute, :not_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
private
def on_error
options[:on_error] || DEFAULT_ON_ERROR
end
def request_options
options[:request_options] || {}
end
def threshold
threshold = options[:threshold] || DEFAULT_THRESHOLD
raise TypeError, "#{self.class.to_s} option 'threshold' must be of type Integer" unless threshold.is_a? Integer
threshold
end
end
##
# The version 1.1.0 validator that uses `pwned` in the validate method.
# This has been updated to the above `not_pwned` validator to be clearer what
# is being validated.
#
# This class is being maintained for backwards compatibility but will be
# removed
#
# @example Validate a password on a +User+ model with the default options.
# class User < ApplicationRecord
# validates :password, pwned: true
# end
#
# @deprecated use the +NotPwnedValidator+ instead.
#
# @since 1.1.0
class PwnedValidator < NotPwnedValidator
end