Переглянути джерело

HUE-2405 [useradmin] Add enhanced user password policy

If enabled, the default password policy requires a password that:
1. Must be at least 8 characters long;
2. Must contain uppercase and lowercase letters;
3. Must contain at least one number;
4. Must contain at least one special character.

This security fix applies in the following use cases:
1. Create the superuser's password on login page.
2. Edit superuser's profile.
3. Create/Edit a user's password.

Add test_user_admin_password_policy in useradmin.tests
Shuo Diao 11 роки тому
батько
коміт
bb08159b2f

+ 43 - 3
apps/useradmin/src/useradmin/conf.py

@@ -18,11 +18,51 @@
 Configuration options for the "user admin" application
 """
 
-from desktop.lib.conf import Config
+from desktop.lib.conf import Config, ConfigSection, coerce_bool
+from django.utils.translation import ugettext_lazy as _
+
 
 DEFAULT_USER_GROUP = Config(
     key="default_user_group",
-    help="The name of a default group for users at creation time, or at first login "
-         "if the server is configured to authenticate against an external source.",
+    help=_("The name of a default group for users at creation time, or at first login "
+           "if the server is configured to authenticate against an external source."),
     type=str,
     default='default')
+
+PASSWORD_POLICY = ConfigSection(
+  key="password_policy",
+  help=_("Configuration options for user password policy"),
+  members=dict(
+    IS_ENABLED = Config(
+      key="is_enabled",
+      help=_("Enable user password policy."),
+      type=coerce_bool,
+      default=False),
+
+    PWD_RULE = Config(
+      key="pwd_regex",
+      help=_("The regular expression of password rule. The default rule requires that "
+             "a password  must be at least 8 characters long, and must contain both "
+             "uppercase and lowercase letters, at least one number, and at least one "
+             "special character."),
+      type=str,
+      default="^(?=.*?[A-Z])(?=(.*[a-z]){1,})(?=(.*[\d]){1,})(?=(.*[\W_]){1,}).{8,}$"),
+
+    PWD_HINT = Config(
+      key="pwd_hint",
+      help=_("Message about the password rule defined in pwd_regex"),
+      type=str,
+      default="The password must be at least 8 characters long, and must contain both " + \
+              "uppercase and lowercase letters, at least one number, and at least " + \
+              "one special character."),
+
+    PWD_ERROR_MESSAGE = Config(
+      key="pwd_error_message",
+      help=_("The error message displayed if the provided password does not "
+             "meet the enhanced password rule"),
+      type=str,
+      default="The password must be at least 8 characters long, and must contain both " + \
+               "uppercase and lowercase letters, at least one number, and at least " + \
+               "one special character.")
+    )
+  )

+ 12 - 2
apps/useradmin/src/useradmin/forms.py

@@ -29,6 +29,7 @@ from desktop.lib.django_util import get_username_re_rule, get_groupname_re_rule
 
 from useradmin.models import GroupPermission, HuePermission
 from useradmin.models import get_default_user_group
+from useradmin.password_policy import get_password_validators
 
 
 
@@ -70,8 +71,16 @@ class UserChangeForm(django.contrib.auth.forms.UserChangeForm):
       regex='^%s$' % (get_username_re_rule(),),
       help_text = _t("Required. 30 characters or fewer. No whitespaces or colons."),
       error_messages = {'invalid': _t("Whitespaces and ':' not allowed") })
-  password1 = forms.CharField(label=_t("Password"), widget=forms.PasswordInput, required=False)
-  password2 = forms.CharField(label=_t("Password confirmation"), widget=forms.PasswordInput, required=False)
+
+  password1 = forms.CharField(label=_t("Password"),
+                              widget=forms.
+                              PasswordInput,
+                              required=False,
+                              validators=get_password_validators())
+  password2 = forms.CharField(label=_t("Password confirmation"),
+                              widget=forms.PasswordInput,
+                              required=False,
+                              validators=get_password_validators())
   password_old = forms.CharField(label=_t("Previous Password"), widget=forms.PasswordInput, required=False)
   ensure_home_directory = forms.BooleanField(label=_t("Create home directory"),
                                             help_text=_t("Create home directory if one doesn't already exist."),
@@ -124,6 +133,7 @@ class UserChangeForm(django.contrib.auth.forms.UserChangeForm):
       self.save_m2m()
     return user
 
+
 class SuperUserChangeForm(UserChangeForm):
   class Meta(UserChangeForm.Meta):
     fields = ["username", "is_active"] + UserChangeForm.Meta.fields + ["is_superuser", "groups"]

+ 80 - 0
apps/useradmin/src/useradmin/password_policy.py

@@ -0,0 +1,80 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+from useradmin.conf import PASSWORD_POLICY
+
+import re
+
+
+_PASSWORD_POLICY = None
+
+
+class PasswordPolicy(object):
+
+  def __init__(self, is_enabled, rule, hint, error_message):
+
+    self._is_enabled = is_enabled
+    self._rule = rule
+    self._hint = hint
+    self._message = error_message
+    self._valid_pattern = re.compile(self._rule)
+
+  def validate_password(self, password):
+    if self._is_enabled and not self._valid_pattern.match(password):
+      raise ValidationError(_(self._message))
+
+  @property
+  def password_hint(self):
+    return _(self._hint)
+
+  @property
+  def is_enabled(self):
+    return self._is_enabled
+
+
+def get_password_policy():
+  global _PASSWORD_POLICY
+  if _PASSWORD_POLICY is None:
+    _PASSWORD_POLICY = PasswordPolicy(is_enabled=PASSWORD_POLICY.IS_ENABLED.get(),
+                                      rule=PASSWORD_POLICY.PWD_RULE.get(),
+                                      hint=PASSWORD_POLICY.PWD_HINT.get(),
+                                      error_message=PASSWORD_POLICY.PWD_ERROR_MESSAGE.get())
+
+  return _PASSWORD_POLICY
+
+
+def reset_password_policy():
+  global _PASSWORD_POLICY
+  _PASSWORD_POLICY = None
+
+
+def get_password_validators():
+  def validate_against_policy(password):
+    get_password_policy().validate_password(password)
+
+  return [validate_against_policy]
+
+
+def is_password_policy_enabled():
+  return get_password_policy().is_enabled
+
+
+def get_password_hint():
+  return  get_password_policy().password_hint

+ 6 - 0
apps/useradmin/src/useradmin/templates/edit_user.mako

@@ -16,6 +16,7 @@
 <%!
 from desktop.views import commonheader, commonfooter
 from django.utils.translation import ugettext as _
+from useradmin.password_policy import is_password_policy_enabled, get_password_hint
 %>
 
 <%namespace name="layout" file="layout.mako" />
@@ -55,6 +56,11 @@ ${ layout.menubar(section='users') }
         ${layout.render_field(form["username"], extra_attrs={'validate':'true'})}
         % if "password1" in form.fields:
           ${layout.render_field(form["password1"], extra_attrs=username is None and {'validate':'true'} or {})}
+          % if is_password_policy_enabled():
+            <div class="password_rule" style="margin-left:180px; width:500px;">
+              <p>${get_password_hint()}</p>
+            </div>
+          % endif
           ${layout.render_field(form["password2"], extra_attrs=username is None and {'validate':'true'} or {})}
           % if username:
             ${layout.render_field(form["password_old"], extra_attrs=username is None and {'validate':'true'} or {})}

+ 127 - 0
apps/useradmin/src/useradmin/tests.py

@@ -28,12 +28,14 @@ from desktop.lib.django_test_util import make_logged_in_client
 from django.contrib.auth.models import User, Group
 from django.utils.encoding import smart_unicode
 from django.core.urlresolvers import reverse
+from django.test.client import Client
 
 from useradmin.models import HuePermission, GroupPermission, UserProfile
 from useradmin.models import get_profile, get_default_user_group
 
 import useradmin.conf
 from hadoop import pseudo_hdfs4
+from useradmin.password_policy import reset_password_policy
 
 
 def reset_all_users():
@@ -360,6 +362,125 @@ def test_group_admin():
   response = c.post('/useradmin/groups/new', dict(name="with space"))
   assert_equal(len(Group.objects.all()), group_count + 1)
 
+def test_user_admin_password_policy():
+  reset_all_users()
+  reset_all_groups()
+
+  # Set up password policy
+  password_hint = password_error_msg = ("The password must be at least 8 characters long, "
+                                        "and must contain both uppercase and lowercase letters, "
+                                        "at least one number, and at least one special character.")
+  password_rule = "^(?=.*?[A-Z])(?=(.*[a-z]){1,})(?=(.*[\d]){1,})(?=(.*[\W_]){1,}).{8,}$"
+
+  useradmin.conf.PASSWORD_POLICY.IS_ENABLED.set_for_testing(True)
+  useradmin.conf.PASSWORD_POLICY.PWD_RULE.set_for_testing(password_rule)
+  useradmin.conf.PASSWORD_POLICY.PWD_HINT.set_for_testing(password_hint)
+  useradmin.conf.PASSWORD_POLICY.PWD_ERROR_MESSAGE.set_for_testing(password_error_msg)
+  reset_password_policy()
+
+  # Test first-ever login with password policy enabled
+  c = Client()
+
+  response = c.get('/accounts/login/')
+  assert_equal(200, response.status_code)
+  assert_true(response.context['first_login_ever'])
+
+  response = c.post('/accounts/login/', dict(username="test_first_login", password="foo"))
+  assert_true(response.context['first_login_ever'])
+  assert_equal([password_error_msg], response.context["form"]["password"].errors)
+
+  response = c.post('/accounts/login/', dict(username="test_first_login", password="foobarTest1["), follow=True)
+  assert_equal(200, response.status_code)
+  assert_true(User.objects.get(username="test_first_login").is_superuser)
+  assert_true(User.objects.get(username="test_first_login").check_password("foobarTest1["))
+
+  c.get('/accounts/logout')
+
+  # Test changing a user's password
+  c = make_logged_in_client('superuser', is_superuser=True)
+
+  # Test password hint is displayed
+  response = c.get('/useradmin/users/edit/superuser')
+  assert_true(password_hint in response.content)
+
+  # Password is less than 8 characters
+  response = c.post('/useradmin/users/edit/superuser',
+                    dict(username="superuser",
+                         is_superuser=True,
+                         password1="foo",
+                         password2="foo"))
+  assert_equal([password_error_msg], response.context["form"]["password1"].errors)
+
+  # Password is more than 8 characters long but does not have a special character
+  response = c.post('/useradmin/users/edit/superuser',
+                    dict(username="superuser",
+                         is_superuser=True,
+                         password1="foobarTest1",
+                         password2="foobarTest1"))
+  assert_equal([password_error_msg], response.context["form"]["password1"].errors)
+
+  # Password1 and Password2 are valid but they do not match
+  response = c.post('/useradmin/users/edit/superuser',
+                    dict(username="superuser",
+                         is_superuser=True,
+                         password1="foobarTest1??",
+                         password2="foobarTest1?",
+                         password_old="foobarTest1[",
+                         is_active=True))
+  assert_equal(["Passwords do not match."], response.context["form"]["password2"].errors)
+
+  # Password is valid now
+  c.post('/useradmin/users/edit/superuser',
+         dict(username="superuser",
+              is_superuser=True,
+              password1="foobarTest1[",
+              password2="foobarTest1[",
+              password_old="test",
+              is_active=True))
+  assert_true(User.objects.get(username="superuser").is_superuser)
+  assert_true(User.objects.get(username="superuser").check_password("foobarTest1["))
+
+  # Test creating a new user
+  response = c.get('/useradmin/users/new')
+  assert_true(password_hint in response.content)
+
+  # Password is more than 8 characters long but does not have a special character
+  response = c.post('/useradmin/users/new',
+                    dict(username="test_user",
+                         is_superuser=False,
+                         password1="foo",
+                         password2="foo"))
+  assert_equal({'password1': [password_error_msg], 'password2': [password_error_msg]},
+               response.context["form"].errors)
+
+  # Password is more than 8 characters long but does not have a special character
+  response = c.post('/useradmin/users/new',
+                    dict(username="test_user",
+                         is_superuser=False,
+                         password1="foobarTest1",
+                         password2="foobarTest1"))
+
+  assert_equal({'password1': [password_error_msg], 'password2': [password_error_msg]},
+               response.context["form"].errors)
+
+  # Password1 and Password2 are valid but they do not match
+  response = c.post('/useradmin/users/new',
+                    dict(username="test_user",
+                         is_superuser=False,
+                         password1="foobarTest1[",
+                         password2="foobarTest1?"))
+  assert_equal({'password2': ["Passwords do not match."]}, response.context["form"].errors)
+
+  # Password is valid now
+  c.post('/useradmin/users/new',
+         dict(username="test_user",
+              is_superuser=False,
+              password1="foobarTest1[",
+              password2="foobarTest1[", is_active=True))
+  assert_false(User.objects.get(username="test_user").is_superuser)
+  assert_true(User.objects.get(username="test_user").check_password("foobarTest1["))
+
+
 def test_user_admin():
   FUNNY_NAME = '~`!@#$%^&*()_-+={}[]|\;"<>?/,.'
   FUNNY_NAME_QUOTED = urllib.quote(FUNNY_NAME)
@@ -368,6 +489,9 @@ def test_user_admin():
   reset_all_groups()
   useradmin.conf.DEFAULT_USER_GROUP.set_for_testing('test_default')
 
+  useradmin.conf.PASSWORD_POLICY.IS_ENABLED.set_for_testing(False)
+  reset_password_policy()
+
   c = make_logged_in_client('test', is_superuser=True)
   user = User.objects.get(username='test')
 
@@ -525,6 +649,9 @@ def test_ensure_home_directory():
   reset_all_users()
   reset_all_groups()
 
+  useradmin.conf.PASSWORD_POLICY.IS_ENABLED.set_for_testing(False)
+  reset_password_policy()
+
   # Cluster and client for home directory creation
   cluster = pseudo_hdfs4.shared_cluster()
   c = make_logged_in_client(cluster.superuser, is_superuser=True, groupname='test1')

+ 8 - 0
desktop/conf.dist/hue.ini

@@ -940,6 +940,14 @@
   # The name of the default user group that users will be a member of
   ## default_user_group=default
 
+  [[password_policy]]
+    # Set password policy to all users. The default policy requires password to be at least 8 characters long,
+    # and contain both uppercase and lowercase letters, numbers, and special characters.
+
+    ## is_enabled=false
+    ## pwd_regex="^(?=.*?[A-Z])(?=(.*[a-z]){1,})(?=(.*[\d]){1,})(?=(.*[\W_]){1,}).{8,}$"
+    ## pwd_hint="The password must be at least 8 characters long, and must contain both uppercase and lowercase letters, at least one number, and at least one special character."
+    ## pwd_error_message="The password must be at least 8 characters long, and must contain both uppercase and lowercase letters, at least one number, and at least one special character."
 
 ###########################################################################
 # Settings for the Sentry lib

+ 9 - 0
desktop/conf/pseudo-distributed.ini.tmpl

@@ -947,6 +947,15 @@
   # The name of the default user group that users will be a member of
   ## default_user_group=default
 
+  [[password_policy]]
+    # Set password policy to all users. The default policy requires password to be at least 8 characters long,
+    # and contain both uppercase and lowercase letters, numbers, and special characters.
+
+    ## is_enabled=false
+    ## pwd_regex="^(?=.*?[A-Z])(?=(.*[a-z]){1,})(?=(.*[\d]){1,})(?=(.*[\W_]){1,}).{8,}$"
+    ## pwd_hint="The password must be at least 8 characters long, and must contain both uppercase and lowercase letters, at least one number, and at least one special character."
+    ## pwd_error_message="The password must be at least 8 characters long, and must contain both uppercase and lowercase letters, at least one number, and at least one special character."
+
 
 ###########################################################################
 # Settings for the Sentry lib

+ 4 - 1
desktop/core/src/desktop/auth/forms.py

@@ -26,6 +26,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _t, ugettext as _
 
 from desktop import conf
+from useradmin.password_policy import get_password_validators
 
 
 def get_server_choices():
@@ -111,7 +112,9 @@ class UserCreationForm(AuthUserCreationForm):
   password fields with the value of that password field
   Adds appropriate classes to authentication form.
   """
-  password = CharField(label=_t("Password"), widget=PasswordInput(attrs={'class': 'input-large'}))
+  password = CharField(label=_t("Password"),
+                       widget=PasswordInput(attrs={'class': 'input-large'}),
+                       validators=get_password_validators())
 
   def __init__(self, data=None, *args, **kwargs):
     if data and 'password' in data:

+ 5 - 1
desktop/core/src/desktop/templates/login.mako

@@ -18,6 +18,7 @@
   from desktop import conf
   from django.utils.translation import ugettext as _
   from desktop.views import commonheader, commonfooter
+  from useradmin.password_policy import is_password_policy_enabled, get_password_hint
 %>
 
 ${ commonheader("Welcome to Hue", "login", user, "50px") | n,unicode }
@@ -201,7 +202,10 @@ ${ commonheader("Welcome to Hue", "login", user, "50px") | n,unicode }
         %if first_login_ever:
           <div class="alert alert-block">
             ${_('Since this is your first time logging in, pick any username and password. Be sure to remember these, as')}
-            <strong>${_('they will become your Hue superuser credentials.')}</strong>.
+            <strong>${_('they will become your Hue superuser credentials.')}</strong>
+            % if is_password_policy_enabled():
+	      <p>${get_password_hint()}</p>
+            % endif
           </div>
         %endif