Răsfoiți Sursa

HUE-2010 [core] Configure Hue to terminate users who has not logged in X days

Added logic to AuthenticationForm.
Made it so that all backends return a User object even if it is inactive.
This make it easier to see the correct error message.
Superuser accounts are subject to this policy when EXPIRE_SUPERUSERS is set to true.
Abraham Elmahrek 11 ani în urmă
părinte
comite
912c2f8

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

@@ -148,6 +148,13 @@
     # Only supported in remoteUserDjangoBackend.
     ## force_username_lowercase=false
 
+    # Users will expire after they have not logged in for 'n' amount of seconds.
+    # A negative number means that users will never expire.
+    ## expires_after=-1
+
+    # Apply 'expires_after' to superusers.
+    ## expire_superusers=true
+
   # Configuration options for connecting to LDAP and Active Directory
   # -------------------------------------------------------------------
   [[ldap]]

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

@@ -153,6 +153,13 @@
     # Only supported in remoteUserDjangoBackend.
     ## force_username_lowercase=false
 
+    # Users will expire after they have not logged in for 'n' amount of seconds.
+    # A negative number means that users will never expire.
+    ## expires_after=-1
+
+    # Apply 'expires_after' to superusers.
+    ## expire_superusers=true
+
   # Configuration options for connecting to LDAP and Active Directory
   # -------------------------------------------------------------------
   [[ldap]]

+ 2 - 2
desktop/core/src/desktop/auth/backend.py

@@ -154,7 +154,7 @@ class AllowFirstUserDjangoBackend(django.contrib.auth.backends.ModelBackend):
       if user.is_active:
         user = rewrite_user(user)
         return user
-      return None
+      return user
 
     if self.is_first_login_ever():
       user = find_or_create_user(username, password)
@@ -412,7 +412,7 @@ class LdapBackend(object):
         self.import_groups(user)
       return user
 
-    return None
+    return user
 
   def get_user(self, user_id):
     user = self._backend.get_user(user_id)

+ 40 - 2
desktop/core/src/desktop/auth/forms.py

@@ -15,19 +15,57 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import datetime
+
+from django.conf import settings
+from django.contrib.auth.models import User
 from django.contrib.auth.forms import AuthenticationForm as AuthAuthenticationForm, UserCreationForm as AuthUserCreationForm
-from django.forms import CharField, TextInput, PasswordInput
-from django.utils.translation import ugettext_lazy as _t
+from django.forms import CharField, TextInput, PasswordInput, ValidationError
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _t, ugettext as _
 
+from desktop import conf
 
 
 class AuthenticationForm(AuthAuthenticationForm):
   """
   Adds appropriate classes to authentication form
   """
+  error_messages = {
+    'invalid_login': _t("Invalid username or password."),
+    'inactive': _t("Account deactivated. Please contact an administrator."),
+  }
+
   username = CharField(label=_t("Username"), max_length=30, widget=TextInput(attrs={'maxlength': 30, 'placeholder': _t("Username"), "autofocus": "autofocus"}))
   password = CharField(label=_t("Password"), widget=PasswordInput(attrs={'placeholder': _t("Password")}))
 
+  def clean(self):
+    if conf.AUTH.EXPIRES_AFTER.get() > -1:
+      try:
+        user = User.objects.get(username=self.cleaned_data.get('username'))
+
+        expires_delta = datetime.timedelta(seconds=conf.AUTH.EXPIRES_AFTER.get())
+        if user.is_active and user.last_login + expires_delta < datetime.datetime.now():
+          if user.is_superuser:
+            if conf.AUTH.EXPIRE_SUPERUSERS.get():
+              user.is_active = False
+              user.save()
+          else:
+            user.is_active = False
+            user.save()
+
+        if not user.is_active:
+          if settings.ADMINS:
+            raise ValidationError(mark_safe(_("Account deactivated. Please contact an <a href=\"mailto:%s\">administrator</a>.") % settings.ADMINS[0][1]))
+          else:
+            raise ValidationError(self.error_messages['inactive'])
+      except User.DoesNotExist:
+        # Skip because we couldn't find a user for that username.
+        # This means the user managed to get their username wrong.
+        pass
+
+    return super(AuthenticationForm, self).clean()
+
 
 class UserCreationForm(AuthUserCreationForm):
   """

+ 3 - 1
desktop/core/src/desktop/auth/views.py

@@ -22,6 +22,7 @@ except:
   pass
 
 import cgi
+import datetime
 import logging
 import urllib
 
@@ -40,7 +41,8 @@ from desktop.auth.forms import UserCreationForm, AuthenticationForm
 from desktop.lib.django_util import render
 from desktop.lib.django_util import login_notrequired
 from desktop.log.access import access_warn, last_access_map
-from desktop.conf import OAUTH, DEMO_ENABLED
+from desktop.conf import AUTH, OAUTH, DEMO_ENABLED
+
 
 LOG = logging.getLogger(__name__)
 

+ 60 - 4
desktop/core/src/desktop/auth/views_test.py

@@ -314,13 +314,19 @@ class TestRemoteUserLogin(object):
 
 
 class TestLogin(object):
+  reset = []
+
   def setUp(self):
     # Simulate first login ever
     User.objects.all().delete()
     self.c = Client()
 
+  def tearDown(self):
+    for finish in self.reset:
+      finish()
+
   def test_bad_first_user(self):
-    finish = conf.AUTH.BACKEND.set_for_testing("desktop.auth.backend.AllowFirstUserDjangoBackend")
+    self.reset.append( conf.AUTH.BACKEND.set_for_testing("desktop.auth.backend.AllowFirstUserDjangoBackend") )
 
     response = self.c.get('/accounts/login/')
     assert_equal(200, response.status_code, "Expected ok status.")
@@ -330,15 +336,65 @@ class TestLogin(object):
     assert_equal(200, response.status_code, "Expected ok status.")
     assert_true('This value may contain only letters, numbers and @/./+/-/_ characters.' in response.content, response)
 
-    finish()
-
   def test_non_jframe_login(self):
     client = make_logged_in_client(username="test", password="test")
     # Logout first
     client.get('/accounts/logout')
     # Login
     response = client.post('/accounts/login/', dict(username="test", password="test"), follow=True)
-    assert_true(any(["admin_wizard.mako" in _template.filename for _template in response.template]), response.template) # Go to superuser wizard
+    assert_true(any(["admin_wizard.mako" in _template.filename for _template in response.template]), response.content) # Go to superuser wizard
+
+  def test_login_expiration(self):
+    """ Expiration test without superusers """
+    old_settings = settings.ADMINS
+    self.reset.append( conf.AUTH.BACKEND.set_for_testing("desktop.auth.backend.AllowFirstUserDjangoBackend") )
+    self.reset.append( conf.AUTH.EXPIRES_AFTER.set_for_testing(0) )
+    self.reset.append( conf.AUTH.EXPIRE_SUPERUSERS.set_for_testing(False) )
+
+    client = make_logged_in_client(username="test", password="test")
+    client.get('/accounts/logout')
+    user = User.objects.get(username="test")
+
+    # Login successfully
+    try:
+      user.is_superuser = True
+      user.save()
+      response = client.post('/accounts/login/', dict(username="test", password="test"), follow=True)
+      assert_equal(200, response.status_code, "Expected ok status.")
+
+      client.get('/accounts/logout')
+
+      # Login fail
+      settings.ADMINS = [('test', 'test@test.com')]
+      user.is_superuser = False
+      user.save()
+      response = client.post('/accounts/login/', dict(username="test", password="test"), follow=True)
+      assert_equal(200, response.status_code, "Expected ok status.")
+      assert_true('Account deactivated. Please contact an <a href="mailto:test@test.com">administrator</a>' in response.content, response.content)
+
+      # Failure should report an inactive user without admin link
+      settings.ADMINS = []
+      response = client.post('/accounts/login/', dict(username="test", password="test"), follow=True)
+      assert_equal(200, response.status_code, "Expected ok status.")
+      assert_true("Account deactivated. Please contact an administrator." in response.content, response.content)
+    finally:
+      settings.ADMINS = old_settings
+
+  def test_login_expiration_with_superusers(self):
+    """ Expiration test with superusers """
+    self.reset.append( conf.AUTH.BACKEND.set_for_testing("desktop.auth.backend.AllowFirstUserDjangoBackend") )
+    self.reset.append( conf.AUTH.EXPIRES_AFTER.set_for_testing(0) )
+    self.reset.append( conf.AUTH.EXPIRE_SUPERUSERS.set_for_testing(True) )
+
+    client = make_logged_in_client(username="test", password="test")
+    client.get('/accounts/logout')
+    user = User.objects.get(username="test")
+
+    # Login fail
+    user.is_superuser = True
+    user.save()
+    response = client.post('/accounts/login/', dict(username="test", password="test"), follow=True)
+    assert_equal(200, response.status_code, "Expected unauthorized status.")
 
 
 class MockLdapBackend(object):

+ 10 - 1
desktop/core/src/desktop/conf.py

@@ -405,7 +405,16 @@ AUTH = ConfigSection(
     FORCE_USERNAME_LOWERCASE = Config("force_username_lowercase",
                                       help=_("Force usernames to lowercase when creating new users from LDAP."),
                                       type=coerce_bool,
-                                      default=False)
+                                      default=False),
+    EXPIRES_AFTER = Config("expires_after",
+                            help=_("Users will expire after they have not logged in for 'n' amount of seconds."
+                                   "A negative number means that users will never expire."),
+                            type=int,
+                            default=-1),
+    EXPIRE_SUPERUSERS = Config("expire_superusers",
+                                help=_("Apply 'expires_after' to superusers."),
+                                type=coerce_bool,
+                                default=True)
 ))
 
 LDAP = ConfigSection(

+ 4 - 2
desktop/core/src/desktop/lib/python_util_test.py

@@ -16,9 +16,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import datetime
+
 from nose.tools import assert_true, assert_equal, assert_not_equal
 
-from desktop.lib.python_util import CaseInsensitiveDict
+from desktop.lib.python_util import CaseInsensitiveDict, timedelta_to_string
 
 
 class TestPythonUtil(object):
@@ -30,4 +32,4 @@ class TestPythonUtil(object):
     assert_equal("Test", d['Test'])
     assert_equal("Test", d['test'])
     assert_not_equal("test", d['Test'])
-    assert_not_equal("test", d['test'])
+    assert_not_equal("test", d['test'])

+ 10 - 2
desktop/core/src/desktop/templates/login.mako

@@ -192,8 +192,16 @@ ${ commonheader("Welcome to Hue", "login", user, "50px") | n,unicode }
 
         %if login_errors:
           <div class="alert alert-error" style="text-align: center">
-            <strong><i class="fa fa-exclamation-triangle"></i> ${_('Error!')}
-            </strong> ${_('Invalid username or password.')}
+            <strong><i class="fa fa-exclamation-triangle"></i> ${_('Error!')}</strong>
+            <br />
+            <br />
+            % if form.errors:
+              % for error in form.errors:
+                ${ form.errors[error]|unicode,n }
+              % endfor
+            % else:
+              <strong>${_('Invalid username or password.')}</strong>
+            % endif
           </div>
         %endif
         <hr/>