]> source.dussan.org Git - redmine.git/commitdiff
Force passwords to contain specified character classes (#4221).
authorGo MAEDA <maeda@farend.jp>
Thu, 29 Aug 2019 01:35:09 +0000 (01:35 +0000)
committerGo MAEDA <maeda@farend.jp>
Thu, 29 Aug 2019 01:35:09 +0000 (01:35 +0000)
Patch by Takenori TAKAKI.

git-svn-id: http://svn.redmine.org/redmine/trunk@18411 e93f8b46-1217-0410-a6f0-8f06a7374b81

app/models/setting.rb
app/models/user.rb
app/views/account/password_recovery.html.erb
app/views/account/register.html.erb
app/views/my/password.html.erb
app/views/settings/_authentication.html.erb
app/views/users/_form.html.erb
config/locales/en.yml
config/settings.yml
test/unit/user_test.rb

index b5c8ca2e5febd40e5c2ce1ec112f30d58d3074d0..384b6e866536608fe3d8a27df3831c6b8cb25b6f 100644 (file)
 
 class Setting < ActiveRecord::Base
 
+  PASSWORD_CHAR_CLASSES = {
+        'uppercase'     => /[A-Z]/,
+        'lowercase'     => /[a-z]/,
+        'digits'        => /[0-9]/,
+        'special_chars' => /[[:ascii:]&&[:graph:]&&[:^alnum:]]/
+    }
+
   DATE_FORMATS = [
         '%Y-%m-%d',
         '%d/%m/%Y',
index 9003b253f2d16d4f2f3441b6849e5905119d8391..a8a80678ad998e6d9b602ede01a6c2a06b2c5e39 100644 (file)
@@ -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_CHAR_CLASSES.each do |k, v|
+    validates_format_of :password, :with => v, :message => :"must_contain_#{k}", :allow_blank => true, :if => Proc.new {Setting.password_required_char_classes.include?(k)}
+  end
   validate :validate_password_length
   validate do
     if password_confirmation && password != password_confirmation
@@ -366,10 +369,22 @@ 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]
+    # auto-generated passwords contain special characters only when admins
+    # require users to use passwords which contains special characters
+    if Setting.password_required_char_classes.include?('special_chars')
+      chars_list << ("\x20".."\x7e").to_a.select {|c| c =~ Setting::PASSWORD_CHAR_CLASSES['special_chars']}
+    end
+    chars_list.each {|v| v.reject! {|c| %(0O1l|'"`*).include?(c)}}
+
     password = +''
-    length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
+    chars_list.each do |chars|
+      password << chars[SecureRandom.random_number(chars.size)]
+      length -= 1
+    end
+    chars = chars_list.flatten
+    length.times { password << chars[SecureRandom.random_number(chars.size)] }
+    password = password.split('').shuffle(random: SecureRandom).join
     self.password = password
     self.password_confirmation = password
     self
index 24da8223b5cb8ae97d464323c402b29cd85ee76b..0c275b476c9a2fbd089615c1b26ad6735751aefb 100644 (file)
@@ -9,6 +9,9 @@
       <label for="new_password"><%=l(:field_new_password)%> <span class="required">*</span></label>
       <%= password_field_tag 'new_password', nil, :size => 25 %>
       <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em>
+      <% if Setting.password_required_char_classes.any? %>
+        <em class="info"><%= l(:text_characters_must_contain, :character_classes => Setting.password_required_char_classes.collect{|c| l("label_password_char_class_#{c}")}.join(", ")) %></em>
+    <% end %>
     </p>
 
     <p>
index ade00adfc6c3ad1fb4dd486e8fab113be0d025d0..f35e0e0cc6f5f89b555c40a897668af5e816661e 100644 (file)
@@ -8,8 +8,11 @@
   <p><%= f.text_field :login, :size => 25, :required => true %></p>
 
   <p><%= f.password_field :password, :size => 25, :required => true %>
-  <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em></p>
-
+  <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em>
+  <% if Setting.password_required_char_classes.any? %>
+    <em class="info"><%= l(:text_characters_must_contain, :character_classes => Setting.password_required_char_classes.collect{|c| l("label_password_char_class_#{c}")}.join(", ")) %></em>
+  <% end %>
+  </p>
   <p><%= f.password_field :password_confirmation, :size => 25, :required => true %></p>
 <% end %>
 
index 7a411e51a24f14a9b023e9b0322c3e2e96df5cb4..4e123db48dd1f6e38ad0537738ca14ba17df95b2 100644 (file)
@@ -9,7 +9,11 @@
 
 <p><label for="new_password"><%=l(:field_new_password)%> <span class="required">*</span></label>
 <%= password_field_tag 'new_password', nil, :size => 25 %>
-<em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em></p>
+  <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em>
+  <% if Setting.password_required_char_classes.any? %>
+    <em class="info"><%= l(:text_characters_must_contain, :character_classes => Setting.password_required_char_classes.collect{|c| l("label_password_char_class_#{c}")}.join(", ")) %></em>
+  <% end %>
+</p>
 
 <p><label for="new_password_confirmation"><%=l(:field_password_confirmation)%> <span class="required">*</span></label>
 <%= password_field_tag 'new_password_confirmation', nil, :size => 25 %></p>
index 4bf890d3f4bf21758141670c58b56e81c50c400e..9a39497b8027f397919ac5897e118df9be6efea0 100644 (file)
@@ -20,6 +20,8 @@
 
 <p><%= setting_text_field :password_min_length, :size => 6 %></p>
 
+<p><%= setting_multiselect :password_required_char_classes, Setting::PASSWORD_CHAR_CLASSES.keys.collect {|c| [l("label_password_char_class_#{c}"), c]} , :inline => true %></p>
+
 <p>
   <%= 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]} %>
 </p>
index ce5b1f6c7b860057a2b52b6af1b10ba6d7db0029..bb20a4f9d653658effb79642d53795f77a2a9be6 100644 (file)
   <p><%= 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();}" %></p>
   <% end %>
   <div id="password_fields" style="<%= 'display:none;' if @user.auth_source %>">
-  <p><%= f.password_field :password, :required => true, :size => 25  %>
-  <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em></p>
+  <p>
+    <%= f.password_field :password, :required => true, :size => 25  %>
+    <em class="info"><%= l(:text_caracters_minimum, :count => Setting.password_min_length) %></em>
+    <% if Setting.password_required_char_classes.any? %>
+      <em class="info"><%= l(:text_characters_must_contain, :character_classes => Setting.password_required_char_classes.collect{|c| l("label_password_char_class_#{c}")}.join(", ")) %></em>
+    <% end %>
+  </p>
   <p><%= f.password_field :password_confirmation, :required => true, :size => 25  %></p>
   <p><%= f.check_box :generate_password %></p>
   <p><%= f.check_box :must_change_passwd %></p>
index b4f37f0b791c611b86f5e25262a197702fc7a9d2..d0c72ef694f5322c7120ed026d81c2b8e012b7e9 100644 (file)
@@ -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_contain_uppercase: "must contain uppercase letters (A-Z)"
+        must_contain_lowercase: "must contain lowercase letters (a-z)"
+        must_contain_digits: "must contain digits (0-9)"
+        must_contain_special_chars: "must contain special characters (!, $, %, ...)"
 
   actionview_instancetag_blank_option: Please select
 
@@ -437,6 +441,7 @@ 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_char_classes : Required character classes for passwords
   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
@@ -1061,6 +1066,10 @@ en:
   label_issue_history_properties: Property changes
   label_issue_history_notes: Notes
   label_last_tab_visited: Last visited tab
+  label_password_char_class_uppercase: uppercase letters
+  label_password_char_class_lowercase: lowercase letters
+  label_password_char_class_digits: digits
+  label_password_char_class_special_chars: special characters
 
   button_login: Login
   button_submit: Submit
@@ -1152,6 +1161,7 @@ en:
   text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
   text_caracters_maximum: "%{count} characters maximum."
   text_caracters_minimum: "Must be at least %{count} characters long."
+  text_characters_must_contain: "Must contain %{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
index 6acfd9769629c6bf902902ec7b5dd36532311a6b..96b6eca7f7e938a4e0a98429cc5629d492795786 100644 (file)
@@ -36,6 +36,9 @@ lost_password:
   security_notifications: 1
 unsubscribe:
   default: 1
+password_required_char_classes:
+  serialized: true
+  default: []
 password_min_length:
   format: int
   default: 8
index e03528809f700d5641f7b4abeedb7ba1c41490f9..519de8b042db95d37c0b1c6eb415600a6cf358af 100644 (file)
@@ -539,6 +539,18 @@ class UserTest < ActiveSupport::TestCase
     end
   end
 
+  def test_validate_password_format
+    Setting::PASSWORD_CHAR_CLASSES.each do |key, regexp|
+      with_settings :password_required_char_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_char_classes => Setting::PASSWORD_CHAR_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?