Browse Source

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 years ago
parent
commit
bb08159

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

@@ -18,11 +18,51 @@
 Configuration options for the "user admin" application
 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(
 DEFAULT_USER_GROUP = Config(
     key="default_user_group",
     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,
     type=str,
     default='default')
     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 GroupPermission, HuePermission
 from useradmin.models import get_default_user_group
 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(),),
       regex='^%s$' % (get_username_re_rule(),),
       help_text = _t("Required. 30 characters or fewer. No whitespaces or colons."),
       help_text = _t("Required. 30 characters or fewer. No whitespaces or colons."),
       error_messages = {'invalid': _t("Whitespaces and ':' not allowed") })
       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)
   password_old = forms.CharField(label=_t("Previous Password"), widget=forms.PasswordInput, required=False)
   ensure_home_directory = forms.BooleanField(label=_t("Create home directory"),
   ensure_home_directory = forms.BooleanField(label=_t("Create home directory"),
                                             help_text=_t("Create home directory if one doesn't already exist."),
                                             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()
       self.save_m2m()
     return user
     return user
 
 
+
 class SuperUserChangeForm(UserChangeForm):
 class SuperUserChangeForm(UserChangeForm):
   class Meta(UserChangeForm.Meta):
   class Meta(UserChangeForm.Meta):
     fields = ["username", "is_active"] + UserChangeForm.Meta.fields + ["is_superuser", "groups"]
     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 desktop.views import commonheader, commonfooter
 from django.utils.translation import ugettext as _
 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" />
 <%namespace name="layout" file="layout.mako" />
@@ -55,6 +56,11 @@ ${ layout.menubar(section='users') }
         ${layout.render_field(form["username"], extra_attrs={'validate':'true'})}
         ${layout.render_field(form["username"], extra_attrs={'validate':'true'})}
         % if "password1" in form.fields:
         % if "password1" in form.fields:
           ${layout.render_field(form["password1"], extra_attrs=username is None and {'validate':'true'} or {})}
           ${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 {})}
           ${layout.render_field(form["password2"], extra_attrs=username is None and {'validate':'true'} or {})}
           % if username:
           % if username:
             ${layout.render_field(form["password_old"], extra_attrs=username is None and {'validate':'true'} or {})}
             ${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.contrib.auth.models import User, Group
 from django.utils.encoding import smart_unicode
 from django.utils.encoding import smart_unicode
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.test.client import Client
 
 
 from useradmin.models import HuePermission, GroupPermission, UserProfile
 from useradmin.models import HuePermission, GroupPermission, UserProfile
 from useradmin.models import get_profile, get_default_user_group
 from useradmin.models import get_profile, get_default_user_group
 
 
 import useradmin.conf
 import useradmin.conf
 from hadoop import pseudo_hdfs4
 from hadoop import pseudo_hdfs4
+from useradmin.password_policy import reset_password_policy
 
 
 
 
 def reset_all_users():
 def reset_all_users():
@@ -360,6 +362,125 @@ def test_group_admin():
   response = c.post('/useradmin/groups/new', dict(name="with space"))
   response = c.post('/useradmin/groups/new', dict(name="with space"))
   assert_equal(len(Group.objects.all()), group_count + 1)
   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():
 def test_user_admin():
   FUNNY_NAME = '~`!@#$%^&*()_-+={}[]|\;"<>?/,.'
   FUNNY_NAME = '~`!@#$%^&*()_-+={}[]|\;"<>?/,.'
   FUNNY_NAME_QUOTED = urllib.quote(FUNNY_NAME)
   FUNNY_NAME_QUOTED = urllib.quote(FUNNY_NAME)
@@ -368,6 +489,9 @@ def test_user_admin():
   reset_all_groups()
   reset_all_groups()
   useradmin.conf.DEFAULT_USER_GROUP.set_for_testing('test_default')
   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)
   c = make_logged_in_client('test', is_superuser=True)
   user = User.objects.get(username='test')
   user = User.objects.get(username='test')
 
 
@@ -525,6 +649,9 @@ def test_ensure_home_directory():
   reset_all_users()
   reset_all_users()
   reset_all_groups()
   reset_all_groups()
 
 
+  useradmin.conf.PASSWORD_POLICY.IS_ENABLED.set_for_testing(False)
+  reset_password_policy()
+
   # Cluster and client for home directory creation
   # Cluster and client for home directory creation
   cluster = pseudo_hdfs4.shared_cluster()
   cluster = pseudo_hdfs4.shared_cluster()
   c = make_logged_in_client(cluster.superuser, is_superuser=True, groupname='test1')
   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
   # The name of the default user group that users will be a member of
   ## default_user_group=default
   ## 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
 # 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
   # The name of the default user group that users will be a member of
   ## default_user_group=default
   ## 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
 # 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 django.utils.translation import ugettext_lazy as _t, ugettext as _
 
 
 from desktop import conf
 from desktop import conf
+from useradmin.password_policy import get_password_validators
 
 
 
 
 def get_server_choices():
 def get_server_choices():
@@ -111,7 +112,9 @@ class UserCreationForm(AuthUserCreationForm):
   password fields with the value of that password field
   password fields with the value of that password field
   Adds appropriate classes to authentication form.
   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):
   def __init__(self, data=None, *args, **kwargs):
     if data and 'password' in data:
     if data and 'password' in data:

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

@@ -18,6 +18,7 @@
   from desktop import conf
   from desktop import conf
   from django.utils.translation import ugettext as _
   from django.utils.translation import ugettext as _
   from desktop.views import commonheader, commonfooter
   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 }
 ${ 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:
         %if first_login_ever:
           <div class="alert alert-block">
           <div class="alert alert-block">
             ${_('Since this is your first time logging in, pick any username and password. Be sure to remember these, as')}
             ${_('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>
           </div>
         %endif
         %endif