]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Temporary User Lockout if 10 Invalid Login attempts
authorNizamudeen A <nia@redhat.com>
Thu, 26 Nov 2020 10:25:00 +0000 (15:55 +0530)
committerNizamudeen A <nia@redhat.com>
Wed, 16 Dec 2020 10:44:47 +0000 (16:14 +0530)
Implemented a user lockout mechanism if the user enters 10 invalid attempts. The attempt count gets resetted to 0 once the user succesfully logins before getting disabled. Once the user gets disabled administrator has to manually enable the user which will also resets the number of attempts.

Fixes: https://tracker.ceph.com/issues/40914
Signed-off-by: Nizamudeen A <nia@redhat.com>
qa/tasks/mgr/dashboard/test_auth.py
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/exceptions.py
src/pybind/mgr/dashboard/services/access_control.py
src/pybind/mgr/dashboard/settings.py

index 45d2900f1e514b3eba7f344e489d255149a61bb6..890ea05d3b10228edbc93f983bc100d94d3693d7 100644 (file)
@@ -71,6 +71,31 @@ class AuthTest(DashboardTestCase):
         })
         self.delete_user('admin2')
 
+    def test_lockout_user(self):
+        self._ceph_cmd(['dashboard', 'set-account-lockout-attempts', '3'])
+        for _ in range(3):
+            self._post("/api/auth", {'username': 'admin', 'password': 'inval'})
+        self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
+        self.assertStatus(400)
+        self.assertJsonBody({
+            "component": "auth",
+            "code": "invalid_credentials",
+            "detail": "Invalid credentials"
+        })
+        self._ceph_cmd(['dashboard', 'ac-user-enable', 'admin'])
+        self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
+        self.assertStatus(201)
+        data = self.jsonBody()
+        self.assertSchema(data, JObj(sub_elems={
+            'token': JLeaf(str),
+            'username': JLeaf(str),
+            'permissions': JObj(sub_elems={}, allow_unknown=True),
+            'sso': JLeaf(bool),
+            'pwdExpirationDate': JLeaf(int, none=True),
+            'pwdUpdateRequired': JLeaf(bool)
+        }, allow_unknown=False))
+        self._validate_jwt_token(data['token'], "admin", data['permissions'])
+
     def test_logout(self):
         self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
         self.assertStatus(201)
index 7ce5f3ff1071ff9249e5781b31cbc1c987d855b1..264204d557a5d112bd7e492f21ebea5dfbb686df 100644 (file)
@@ -6,8 +6,9 @@ import logging
 import cherrypy
 
 from .. import mgr
-from ..exceptions import DashboardException
+from ..exceptions import InvalidCredentialsError, UserDoesNotExist
 from ..services.auth import AuthManager, JwtManager
+from ..settings import Settings
 from . import ApiController, ControllerDoc, EndpointDoc, RESTController, allow_empty_body
 
 logger = logging.getLogger('controllers.auth')
@@ -28,33 +29,48 @@ class Auth(RESTController):
     """
     Provide authenticates and returns JWT token.
     """
-
     def create(self, username, password):
         user_data = AuthManager.authenticate(username, password)
         user_perms, pwd_expiration_date, pwd_update_required = None, None, None
-        if user_data:
-            user_perms = user_data.get('permissions')
-            pwd_expiration_date = user_data.get('pwdExpirationDate', None)
-            pwd_update_required = user_data.get('pwdUpdateRequired', False)
-
-        if user_perms is not None:
-            logger.debug('Login successful')
-            token = JwtManager.gen_token(username)
-            token = token.decode('utf-8')
-            cherrypy.response.headers['Authorization'] = "Bearer: {}".format(token)
-            return {
-                'token': token,
-                'username': username,
-                'permissions': user_perms,
-                'pwdExpirationDate': pwd_expiration_date,
-                'sso': mgr.SSO_DB.protocol == 'saml2',
-                'pwdUpdateRequired': pwd_update_required
-            }
+        max_attempt = Settings.ACCOUNT_LOCKOUT_ATTEMPTS
+        if max_attempt == 0 or mgr.ACCESS_CTRL_DB.get_attempt(username) < max_attempt:
+            if user_data:
+                user_perms = user_data.get('permissions')
+                pwd_expiration_date = user_data.get('pwdExpirationDate', None)
+                pwd_update_required = user_data.get('pwdUpdateRequired', False)
 
-        logger.debug('Login failed')
-        raise DashboardException(msg='Invalid credentials',
-                                 code='invalid_credentials',
-                                 component='auth')
+            if user_perms is not None:
+                logger.info('Login successful: %s', username)
+                mgr.ACCESS_CTRL_DB.reset_attempt(username)
+                mgr.ACCESS_CTRL_DB.save()
+                token = JwtManager.gen_token(username)
+                token = token.decode('utf-8')
+                cherrypy.response.headers['Authorization'] = "Bearer: {}".format(token)
+                return {
+                    'token': token,
+                    'username': username,
+                    'permissions': user_perms,
+                    'pwdExpirationDate': pwd_expiration_date,
+                    'sso': mgr.SSO_DB.protocol == 'saml2',
+                    'pwdUpdateRequired': pwd_update_required
+                }
+            mgr.ACCESS_CTRL_DB.increment_attempt(username)
+            mgr.ACCESS_CTRL_DB.save()
+        else:
+            try:
+                user = mgr.ACCESS_CTRL_DB.get_user(username)
+                user.enabled = False
+                mgr.ACCESS_CTRL_DB.save()
+                logging.warning('Maximum number of unsuccessful log-in attempts '
+                                '(%d) reached for '
+                                'username "%s" so the account was blocked. '
+                                'An administrator will need to re-enable the account',
+                                max_attempt, username)
+                raise InvalidCredentialsError
+            except UserDoesNotExist:
+                raise InvalidCredentialsError
+        logger.info('Login failed: %s', username)
+        raise InvalidCredentialsError
 
     @RESTController.Collection('POST')
     @allow_empty_body
index d537efb6e11c8db29eccc173157a419308aed0ff..8857c423cc4ecbb2c52858cdee7f6a92dedf56e4 100644 (file)
@@ -45,6 +45,13 @@ class DashboardException(Exception):
         return str(abs(self.errno)) if self.errno is not None else 'Error'
 
 
+class InvalidCredentialsError(DashboardException):
+    def __init__(self):
+        super().__init__(msg='Invalid credentials',
+                         code='invalid_credentials',
+                         component='auth')
+
+
 # access control module exceptions
 class RoleAlreadyExists(Exception):
     def __init__(self, name):
index a4fcd377885456396f816b0d09b23741e1b30730..233f749981254ed11db46aaaa5cd6dc6a5648560 100644 (file)
@@ -290,6 +290,7 @@ class User(object):
         self.password = password
         self.name = name
         self.email = email
+        self.invalid_auth_attempt = 0
         if roles is None:
             self.roles = set()
         else:
@@ -328,6 +329,7 @@ class User(object):
         self.set_password_hash(password_hash(password))
 
     def set_password_hash(self, hashed_password):
+        self.invalid_auth_attempt = 0
         self.password = hashed_password
         self.refresh_last_update()
         self.refresh_pwd_expiration_date()
@@ -432,6 +434,23 @@ class AccessControlDB(object):
                 raise RoleDoesNotExist(name)
             return self.roles[name]
 
+    def increment_attempt(self, username):
+        with self.lock:
+            if username in self.users:
+                self.users[username].invalid_auth_attempt += 1
+
+    def reset_attempt(self, username):
+        with self.lock:
+            if username in self.users:
+                self.users[username].invalid_auth_attempt = 0
+
+    def get_attempt(self, username):
+        with self.lock:
+            try:
+                return self.users[username].invalid_auth_attempt
+            except KeyError:
+                return 0
+
     def delete_role(self, name):
         with self.lock:
             if name not in self.roles:
@@ -738,6 +757,7 @@ def ac_user_enable(_, username):
     try:
         user = mgr.ACCESS_CTRL_DB.get_user(username)
         user.enabled = True
+        mgr.ACCESS_CTRL_DB.reset_attempt(username)
 
         mgr.ACCESS_CTRL_DB.save()
         return 0, json.dumps(user.to_dict()), ''
index 9b0cdf972ecd0abf8649406a083abe9336a573a5..45cae2965a80077c5829ac0597fdde41d4bf69f0 100644 (file)
@@ -20,6 +20,9 @@ class Options(object):
     ENABLE_BROWSABLE_API = (True, bool)
     REST_REQUESTS_TIMEOUT = (45, int)
 
+    # AUTHENTICATION ATTEMPTS
+    ACCOUNT_LOCKOUT_ATTEMPTS = (10, int)
+
     # API auditing
     AUDIT_API_ENABLED = (False, bool)
     AUDIT_API_LOG_PAYLOAD = (True, bool)