Skip to content
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

Introduce config to allow for password complexity #5727

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,8 @@ 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"

9 changes: 9 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ 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,
special_characters: ""
}

# The time the user will be remembered without asking for credentials again.
mattr_accessor :remember_for
@@remember_for = 2.weeks
Expand Down
51 changes: 49 additions & 2 deletions lib/devise/models/validatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ 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.
# - +special_characters+: a string with the special characters that are allowed in the password. Defaults to empty string.
#
# 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 +42,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 password_special_characters is evaluated at runtime
validate :password_must_contain_special_character, if: -> { password_special_characters.present? }
end
end

Expand All @@ -62,8 +74,43 @@ 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_special_characters
password_complexity[:special_characters]
end

def password_must_contain_special_character
special_character_regex = /[#{Regexp.escape(password_special_characters)}]/

unless password =~ special_character_regex
errors.add(:password, :must_contain_special_character)
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
8 changes: 8 additions & 0 deletions lib/generators/templates/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
# 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,
# special_characters: "!?@#$%^&*()_+-=[]{}|:;<>,./"
# }

# ==> 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
65 changes: 64 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,68 @@ 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
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
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
end
end

test "special character must be within defined special character set if it is custom" do
with_password_requirement(:special_characters, '!') do
user = new_user(password: 'password!', password_confirmation: 'password!')
assert user.valid?

user = new_user(password: 'password?', password_confirmation: 'password?')
assert user.invalid?
assert_equal 'must include at least one special character', user.errors[:password].join
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
8 changes: 8 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,14 @@

# 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,
# special_characters: "!?@#$%^&*()_+-=[]{}|:;<>,./"
# }

# ==> Configuration for :timeoutable
# The time you want to timeout the user session without activity. After this
Expand Down