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 1 commit
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_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

Copy link
Author

@kykyi kykyi Nov 24, 2024

Choose a reason for hiding this comment

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

I wonder if there could be some more general config like:

  @@require_complex_password = false

Which if true would require all of these individual pieces?
So the validations could become:

validates_format_of :password, with: /\p{Upper}/, if: -> { password_requires_uppercase || require_complex_password }, message: :must_contain_uppercase

# 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"
Copy link

Choose a reason for hiding this comment

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

Suggested change
require "test_helper"
require 'test_helper'

Prefer single-quoted strings when you don't need string interpolation or special symbols.
References: rubocop


class ValidatableTest < ActiveSupport::TestCase
test 'should require email to be set' do
test 'should require email to be set' do
Copy link

Choose a reason for hiding this comment

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

Suggested change
test 'should require email to be set' do
test 'should require email to be set' do

remove redundant space

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