diff --git a/app/models/setting.rb b/app/models/setting.rb
index b18f8ed89..cfc01f18d 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -19,6 +19,13 @@
class Setting < ActiveRecord::Base
+ PASSWORD_REQUIRED_CHARACTER_CLASSES = {
+ 'uppercase' => /[A-Z]/,
+ 'lowercase' => /[a-z]/,
+ 'digits' => /[0-9]/,
+ 'special_characters' => /[!@#$%]/
+ }
+
DATE_FORMATS = [
'%Y-%m-%d',
'%d/%m/%Y',
diff --git a/app/models/user.rb b/app/models/user.rb
index 096adc4a9..b9634a24c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -112,6 +112,9 @@ class User < Principal
validates_length_of :firstname, :lastname, :maximum => 30
validates_length_of :identity_url, maximum: 255
validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
+ Setting::PASSWORD_REQUIRED_CHARACTER_CLASSES.each do |k, v|
+ validates_format_of :password, :with => v, :message => :"must_include_#{k}", :allow_blank => true, :if => Proc.new { Setting.password_required_character_classes.include?(k) }
+ end
validate :validate_password_length
validate do
if password_confirmation && password != password_confirmation
@@ -367,10 +370,12 @@ class User < Principal
# Generate and set a random password on given length
def random_password(length=40)
- chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
- chars -= %w(0 O 1 l)
+ chars_list = [("a".."z").to_a, ("A".."Z").to_a, ("0".."9").to_a, '!@#$%'.split('')]
+ chars_list = chars_list.collect {|chars| chars -= %w(0 O 1 l) }
password = +''
- length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
+ chars_list.each { |chars| password << chars[SecureRandom.random_number(chars.size)]; length -= 1 }
+ length.times { password << chars_list.flatten[SecureRandom.random_number(chars_list.flatten.size)] }
+ password = password.split('').shuffle.join
self.password = password
self.password_confirmation = password
self
diff --git a/app/views/settings/_authentication.html.erb b/app/views/settings/_authentication.html.erb
index 4bf890d3f..d6ed57f68 100644
--- a/app/views/settings/_authentication.html.erb
+++ b/app/views/settings/_authentication.html.erb
@@ -20,6 +20,8 @@
<%= setting_text_field :password_min_length, :size => 6 %>
+<%= setting_multiselect :password_required_character_classes, Setting::PASSWORD_REQUIRED_CHARACTER_CLASSES.keys.collect {|c| [l("setting_password_required_character_class_#{c}"), c]} , :inline => true %>
+
<%= setting_select :password_max_age, [[l(:label_disabled), 0]] + [7, 30, 60, 90, 180, 365].collect{|days| [l('datetime.distance_in_words.x_days', :count => days), days.to_s]} %>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb
index ce5b1f6c7..377de5107 100644
--- a/app/views/users/_form.html.erb
+++ b/app/views/users/_form.html.erb
@@ -31,8 +31,13 @@
<%= f.select :auth_source_id, ([[l(:label_internal), ""]] + @auth_sources.collect { |a| [a.name, a.id] }), {}, :onchange => "if (this.value=='') {$('#password_fields').show();} else {$('#password_fields').hide();}" %>
<% end %>
-
<%= f.password_field :password, :required => true, :size => 25 %>
- <%= l(:text_caracters_minimum, :count => Setting.password_min_length) %>
+
+ <%= f.password_field :password, :required => true, :size => 25 %>
+ <%= l(:text_caracters_minimum, :count => Setting.password_min_length) %>
+ <% if Setting.password_required_character_classes.any? %>
+ <%= l(:text_caracters_must_include, :character_classes => Setting.password_required_character_classes.collect{|c| l("setting_password_required_character_class_#{c}")}.join(", ")) %>
+ <% end %>
+
<%= f.password_field :password_confirmation, :required => true, :size => 25 %>
<%= f.check_box :generate_password %>
<%= f.check_box :must_change_passwd %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f2833e652..874eb2c17 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -132,6 +132,10 @@ en:
earlier_than_minimum_start_date: "cannot be earlier than %{date} because of preceding issues"
not_a_regexp: "is not a valid regular expression"
open_issue_with_closed_parent: "An open issue cannot be attached to a closed parent task"
+ must_include_uppercase: "must include Uppercase"
+ must_include_lowercase: "must include Lowercase"
+ must_include_digits: "must include Digits"
+ must_include_special_characters: "must include Special Characters"
actionview_instancetag_blank_option: Please select
@@ -437,6 +441,11 @@ en:
setting_openid: Allow OpenID login and registration
setting_password_max_age: Require password change after
setting_password_min_length: Minimum password length
+ setting_password_required_character_classes : Required character classes for passwords
+ setting_password_required_character_class_uppercase: Uppercase
+ setting_password_required_character_class_lowercase: Lowercase
+ setting_password_required_character_class_digits: Digits
+ setting_password_required_character_class_special_characters: Special Characters
setting_lost_password: Allow password reset via email
setting_new_project_user_role_id: Role given to a non-admin user who creates a project
setting_default_projects_modules: Default enabled modules for new projects
@@ -1152,6 +1161,7 @@ en:
text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.
Once saved, the identifier cannot be changed.'
text_caracters_maximum: "%{count} characters maximum."
text_caracters_minimum: "Must be at least %{count} characters long."
+ text_caracters_must_include: "Must include %{character_classes}."
text_length_between: "Length between %{min} and %{max} characters."
text_tracker_no_workflow: No workflow defined for this tracker
text_role_no_workflow: No workflow defined for this role
diff --git a/config/settings.yml b/config/settings.yml
index 6acfd9769..49e947e96 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -36,6 +36,9 @@ lost_password:
security_notifications: 1
unsubscribe:
default: 1
+password_required_character_classes:
+ serialized: true
+ default: []
password_min_length:
format: int
default: 8
diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb
index e03528809..c5fb328d0 100644
--- a/test/unit/user_test.rb
+++ b/test/unit/user_test.rb
@@ -539,6 +539,18 @@ class UserTest < ActiveSupport::TestCase
end
end
+ def test_validate_password_format
+ Setting::PASSWORD_REQUIRED_CHARACTER_CLASSES.each do |key, regexp|
+ with_settings :password_required_character_classes => key do
+ user = User.new(:firstname => "new", :lastname => "user", :login => "random", :mail => "random@somnet.foo")
+ p = 'PASSWDpasswd01234!@#$%'.gsub(regexp, '')
+ user.password, user.password_confirmation = p, p
+ assert !user.save
+ assert_equal 1, user.errors.count
+ end
+ end
+ end
+
def test_name_format
assert_equal 'John S.', @jsmith.name(:firstname_lastinitial)
assert_equal 'Smith, John', @jsmith.name(:lastname_comma_firstname)
@@ -1058,6 +1070,14 @@ class UserTest < ActiveSupport::TestCase
assert !u.password_confirmation.blank?
end
+ def test_random_password_include_required_characters
+ with_settings :password_required_character_classes => Setting::PASSWORD_REQUIRED_CHARACTER_CLASSES do
+ u = User.new(:firstname => "new", :lastname => "user", :login => "random", :mail => "random@somnet.foo")
+ u.random_password
+ assert u.valid?
+ end
+ end
+
test "#change_password_allowed? should be allowed if no auth source is set" do
user = User.generate!
assert user.change_password_allowed?