Skip to content

Introduce config to allow for password complexity #5727

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

10 changes: 10 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 61 additions & 2 deletions lib/devise/models/validatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
[]
Expand All @@ -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

Expand All @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@salzig @fthobe this allows both approaches: \W and check based on configured special chars 👍

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
Expand Down
9 changes: 9 additions & 0 deletions lib/generators/templates/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 96 additions & 1 deletion test/models/validatable_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# encoding: UTF-8
# frozen_string_literal: true

require 'test_helper'

class ValidatableTest < ActiveSupport::TestCase
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions test/rails_app/config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down