diff --git a/README.md b/README.md index e71da6e8e6..3756b96711 100644 --- a/README.md +++ b/README.md @@ -703,6 +703,37 @@ def send_devise_notification(notification, *args) end ``` +## Security Settings + +### Modification of the password length and complexity +Devise allows you to also set more complex requirements such as: + +* Minimum password length – Longer passwords are generally more secure. +* Presence of lowercase characters (a-z) +* Presence of uppercase characters (A-Z) +* Presence of a number (0-9) +* Presence of special characters (configurable, but defaults to any character outside a-z, A-Z, 0-9, such as @, !, ‡, $, , etc.) + +And these are available as devise config: +```ruby +config.password_complexity = { + require_upper: true, + require_lower: true, + require_digit: true, + require_special_character: true, + allowed_special_characters: nil + } +``` + +To allow for non-breaking updates to devise, these complexity settings are false by default, but can be enforced by adding the above snippet to your devise config. + +It is worth stressing that **enforcing complexity requirements does not guarantee strong passwords**. Strength of a password is a combination of: +* Length +* Unpredictability/Entropy +* Uniqueness + +Adding the above configuration as well setting a high minimum length (>12) is an attempt to address the first two of these factors of password strength. + ### Password reset tokens and Rails logs If you enable the [Recoverable](http://rubydoc.info/github/heartcombo/devise/main/Devise/Models/Recoverable) module, note that a stolen password reset token could give an attacker access to your application. Devise takes effort to generate random, secure tokens, and stores only token digests in the database, never plaintext. However the default logging behavior in Rails can cause plaintext tokens to leak into log files: diff --git a/config/locales/en.yml b/config/locales/en.yml index 260e1c4ba6..a1dfe7170c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -63,3 +63,9 @@ en: not_saved: one: "1 error prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" + must_contain_lowercase: "must include at least one lowercase letter" + must_contain_uppercase: "must include at least one uppercase letter" + must_contain_digit: "must include at least one number" + must_contain_special_character: "must include at least one special character" + must_contain_special_character_from_list: "must include at least one special character from the list: %{special_characters}" + diff --git a/lib/devise.rb b/lib/devise.rb index 5b7417ed6c..466209a972 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -120,6 +120,16 @@ module Test mattr_accessor :password_length @@password_length = 6..128 + # Password complexity configuration + mattr_accessor :password_complexity + @@password_complexity = { + require_upper: false, + require_lower: false, + require_digit: false, + require_special_character: false, + allowed_special_characters: nil + } + # The time the user will be remembered without asking for credentials again. mattr_accessor :remember_for @@remember_for = 2.weeks diff --git a/lib/devise/models/validatable.rb b/lib/devise/models/validatable.rb index 62486cfbe0..db53f6c4ee 100644 --- a/lib/devise/models/validatable.rb +++ b/lib/devise/models/validatable.rb @@ -13,13 +13,20 @@ module Models # # * +email_regexp+: the regular expression used to validate e-mails; # * +password_length+: a range expressing password length. Defaults to 6..128. + # * +password_complexity+: a hash with the password complexity requirements, with the following keys: + # - +require_lower+: a boolean to require a lower case letter in the password. Defaults to false. + # - +require_upper+: a boolean to require an upper case letter in the password. Defaults to false. + # - +require_digit+: a boolean to require a digit in the password. Defaults to false. + # - +require_special_character+: a boolean to require a special character in the password. Defaults to false. + # - +allowed_special_characters+: a string with the special characters allowed in the password. Defaults to nil. # # Since +password_length+ is applied in a proc within `validates_length_of` it can be overridden # at runtime. + module Validatable # All validations used by this module. VALIDATIONS = [:validates_presence_of, :validates_uniqueness_of, :validates_format_of, - :validates_confirmation_of, :validates_length_of].freeze + :validates_confirmation_of, :validates_length_of, :validate].freeze def self.required_fields(klass) [] @@ -37,6 +44,13 @@ def self.included(base) validates_presence_of :password, if: :password_required? validates_confirmation_of :password, if: :password_required? validates_length_of :password, minimum: proc { password_length.min }, maximum: proc { password_length.max }, allow_blank: true + + validates_format_of :password, with: /\p{Lower}/, if: -> { password_requires_lowercase }, message: :must_contain_lowercase + validates_format_of :password, with: /\p{Upper}/, if: -> { password_requires_uppercase }, message: :must_contain_uppercase + validates_format_of :password, with: /\d/, if: -> { password_requires_digit }, message: :must_contain_digit + + # Run as special character check as a custom validation to ensure allowed_special_characters is evaluated at runtime + validate :password_contains_special_character, if: -> { password_requires_special_character } end end @@ -62,8 +76,53 @@ def email_required? true end + # Make these instance methods so the default Devise.password_requires_<> + #can be overridden + def password_complexity + self.class.password_complexity + end + + def password_requires_lowercase + password_complexity[:require_lower] + end + + def password_requires_uppercase + password_complexity[:require_upper] + end + + def password_requires_digit + password_complexity[:require_digit] + end + + def password_requires_special_character + password_complexity[:require_special_character] + end + + def allowed_special_characters + password_complexity[:allowed_special_characters] + end + + def password_contains_special_character + if allowed_special_characters + special_character_regex = /[#{Regexp.escape(allowed_special_characters)}]/ + error_message = I18n.t('errors.messages.must_contain_special_character_from_list', special_characters: allowed_special_characters) + else + special_character_regex = /\W/ + error_message = :must_contain_special_character + end + + unless password =~ special_character_regex + errors.add(:password, error_message) + end + end + module ClassMethods - Devise::Models.config(self, :email_regexp, :password_length) + Devise::Models.config( + self, + :email_regexp, + :password_length, + :password_complexity + ) end end end diff --git a/lib/generators/templates/devise.rb b/lib/generators/templates/devise.rb index 9e6744bd7d..8b3a90882c 100644 --- a/lib/generators/templates/devise.rb +++ b/lib/generators/templates/devise.rb @@ -185,6 +185,15 @@ # to give user feedback and not to assert the e-mail validity. config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + # Password complexity configuration + # config.password_complexity = { + # require_upper: false, + # require_lower: false, + # require_digit: false, + # requre_special_character: false, + # allowed_special_characters: nil + # } + # ==> Configuration for :timeoutable # The time you want to timeout the user session without activity. After this # time the user will be asked for credentials again. Default is 30 minutes. diff --git a/test/models/validatable_test.rb b/test/models/validatable_test.rb index e8858de7e3..a821af182b 100644 --- a/test/models/validatable_test.rb +++ b/test/models/validatable_test.rb @@ -1,6 +1,5 @@ # encoding: UTF-8 # frozen_string_literal: true - require 'test_helper' class ValidatableTest < ActiveSupport::TestCase @@ -121,4 +120,100 @@ class ValidatableTest < ActiveSupport::TestCase test 'required_fields should be an empty array' do assert_equal [], Devise::Models::Validatable.required_fields(User) end + + + test "password must require a lower case letter if require_lower is true" do + with_password_requirement(:require_lower, false) do + user = new_user(password: 'PASSWORD', password_confirmation: 'PASSWORD') + assert user.valid? + end + + with_password_requirement(:require_lower, true) do + user = new_user(password: 'PASSWORD', password_confirmation: 'PASSWORD') + assert user.invalid? + assert_equal 'must include at least one lowercase letter', user.errors[:password].join + + user = new_user(password: 'PASSWORDx', password_confirmation: 'PASSWORDx') + assert user.valid? + end + end + + test "password must require an upper case letter if require_upper is true" do + with_password_requirement(:require_upper, false) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.valid? + end + + with_password_requirement(:require_upper, true) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.invalid? + assert_equal 'must include at least one uppercase letter', user.errors[:password].join + + user = new_user(password: 'passwordX', password_confirmation: 'passwordX') + assert user.valid? + end + end + + test "password must require an upper case letter if require_digit is true" do + with_password_requirement(:require_digit, false) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.valid? + end + + with_password_requirement(:require_digit, true) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.invalid? + assert_equal 'must include at least one number', user.errors[:password].join + + user = new_user(password: 'password1', password_confirmation: 'password1') + assert user.valid? + end + end + + test "password must require a special character if require_digit is true" do + with_password_requirement(:require_special_character, false) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.valid? + end + + with_password_requirement(:require_special_character, true) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.invalid? + assert_equal 'must include at least one special character', user.errors[:password].join + + user = new_user(password: 'password', password_confirmation: 'password') + assert user.valid? + end + end + + test "password must require a special character from the supplied list if require_digit is true and the allowed_special_characters is provided" do + with_password_requirement(:allowed_special_characters, "!") do + with_password_requirement(:require_special_character, false) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.valid? + end + + with_password_requirement(:require_special_character, true) do + user = new_user(password: 'password', password_confirmation: 'password') + assert user.invalid? + assert_equal 'must include at least one special character from the list: !', user.errors[:password].join + + user = new_user(password: 'password!', password_confirmation: 'password!') + assert user.valid? + end + end + end + + def with_password_requirement(requirement, value) + # Change the password requirement and restore it after the block is executed + original_password_complexity= User.public_send("password_complexity") + + updated_password_complexity = original_password_complexity.dup + updated_password_complexity[requirement] = value + + User.public_send("password_complexity=", updated_password_complexity) + yield + ensure + User.public_send("password_complexity=", original_password_complexity) + end end diff --git a/test/rails_app/config/initializers/devise.rb b/test/rails_app/config/initializers/devise.rb index a3a339edc1..ac1d4150ce 100644 --- a/test/rails_app/config/initializers/devise.rb +++ b/test/rails_app/config/initializers/devise.rb @@ -100,6 +100,15 @@ # Regex to use to validate the email address # config.email_regexp = /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i + + # Password complexity configuration + # config.password_complexity = { + # require_upper: false, + # require_lower: false, + # require_digit: false, + # require_special_character: false + # allowed_special_characters: %w( ! @ # $ % ^ & * ) + # } # ==> Configuration for :timeoutable # The time you want to timeout the user session without activity. After this