Skip to content

Commit

Permalink
Introduce config to allow for password complexity
Browse files Browse the repository at this point in the history
to be validated in :validatable with lower case,
upper case, numbers, and configurable special character
presence to be validated on.
  • Loading branch information
kykyi committed Nov 22, 2024
1 parent 0f514f1 commit a6301cc
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 6 deletions.
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_number: "must include at least one number"
must_contain_special_character: "must include at least one special character"

20 changes: 20 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ module Test
mattr_accessor :password_length
@@password_length = 6..128

# Validate presence of lower case letter in password
mattr_accessor :password_requires_lowercase
@@password_requires_lowercase = false

# Validate presence of upper case letter in password
mattr_accessor :password_requires_uppercase
@@password_requires_uppercase = false

# Validate presence of special character in password
mattr_accessor :password_requires_special_character
@@password_requires_special_character = false

# Special character options
mattr_accessor :password_special_characters
@@password_special_characters = "!?@#$%^&*()_+-=[]{}|:;<>,./"

# Validate presence of a number in password
mattr_accessor :password_requires_number
@@password_requires_number = false

# The time the user will be remembered without asking for credentials again.
mattr_accessor :remember_for
@@remember_for = 2.weeks
Expand Down
57 changes: 54 additions & 3 deletions lib/devise/models/validatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ module Models
#
# * +email_regexp+: the regular expression used to validate e-mails;
# * +password_length+: a range expressing password length. Defaults to 6..128.
#
# * +password_requires_lowercase+: a boolean to require a lower case letter in the password. Defaults to false.
# * +password_requires_uppercase+: a boolean to require an upper case letter in the password. Defaults to false.
# * +password_requires_special_character+: a boolean to require a special character in the password. Defaults to false.
# * +password_requires_number+: a boolean to require a number in the password. Defaults to false.
# * +password_special_characters+: a string with the special characters that are allowed in the password. Defaults to nil.

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 @@ -35,6 +40,13 @@ def self.included(base)
validates_presence_of :password, if: :password_required?
validates_confirmation_of :password, if: :password_required?
validates_length_of :password, within: password_length, 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_number }, message: :must_contain_number

# 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_requires_special_character }
end
end

Expand All @@ -60,8 +72,47 @@ def email_required?
true
end

# Make these instance methods so the default Devise.password_requires_<>
#can be overridden
def password_requires_lowercase
self.class.password_requires_lowercase
end

def password_requires_uppercase
self.class.password_requires_uppercase
end

def password_requires_number
self.class.password_requires_number
end

def password_requires_special_character
self.class.password_requires_special_character
end

def password_special_characters
self.class.password_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_requires_lowercase,
:password_requires_uppercase,
:password_requires_special_character,
:password_requires_number,
:password_special_characters
)
end
end
end
Expand Down
15 changes: 15 additions & 0 deletions lib/generators/templates/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@
# to give user feedback and not to assert the e-mail validity.
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/

# Password requires a lower case letter
# config.password_requires_lowercase = false

# Password requires an upper case letter
# config.password_requires_uppercase = false

# Password requires a number
# config.password_requires_number = false

# Password requires a special character
# config.password_requires_special_character = false

# Special characters that are allowed in the password
# config.password_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
81 changes: 78 additions & 3 deletions test/models/validatable_test.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# encoding: UTF-8
# frozen_string_literal: true

require 'test_helper'
require "test_helper"

class ValidatableTest < ActiveSupport::TestCase
test 'should require email to be set' do
test 'should require email to be set' do
user = new_user(email: nil)
assert user.invalid?
assert user.errors[:email]
Expand Down Expand Up @@ -121,4 +120,80 @@ 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 password_requires_lowercase_letter is true" do
with_password_requirement(:password_requires_lowercase, false) do
user = new_user(password: 'PASSWORD', password_confirmation: 'PASSWORD')
assert user.valid?
end

with_password_requirement(:password_requires_lowercase, 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 password_requires_uppercase_letter is true" do
with_password_requirement(:password_requires_uppercase, false) do
user = new_user(password: 'password', password_confirmation: 'password')
assert user.valid?
end

with_password_requirement(:password_requires_uppercase, 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 password_requires_number is true" do
with_password_requirement(:password_requires_number, false) do
user = new_user(password: 'password', password_confirmation: 'password')
assert user.valid?
end

with_password_requirement(:password_requires_number, 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 "password must require special character if password_requires_special_character is true" do
with_password_requirement(:password_requires_special_character, false) do
user = new_user(password: 'password', password_confirmation: 'password')
assert user.valid?
end

with_password_requirement(:password_requires_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
end
end


test "special character must be within defined special character set if it is custom" do
with_password_requirement(:password_requires_special_character, true) do
with_password_requirement(:password_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
end

def with_password_requirement(requirement, value)
# Change the password requirement and restore it after the block is executed
original_value = User.public_send(requirement)
User.public_send("#{requirement}=", value)
yield
ensure
User.public_send("#{requirement}=", original_value)
end
end
15 changes: 15 additions & 0 deletions test/rails_app/config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@
# Regex to use to validate the email address
# config.email_regexp = /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i

# Password requires a lower case letter
# config.password_requires_lowercase = false

# Password requires an upper case letter
# config.password_requires_uppercase = false

# Password requires a number
# config.password_requires_number = false

# Password requires a special character
# config.password_requires_special_character = false

# Special characters that are allowed in the password
# config.password_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

0 comments on commit a6301cc

Please sign in to comment.