From: Nizamudeen A Date: Thu, 26 Nov 2020 10:25:00 +0000 (+0530) Subject: mgr/dashboard: Temporary User Lockout if 10 Invalid Login attempts X-Git-Tag: v16.1.0~245^2~1 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=41941f0d28f51cb391ff7bacae84a5d511bafb36;p=ceph.git mgr/dashboard: Temporary User Lockout if 10 Invalid Login attempts 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 --- diff --git a/qa/tasks/mgr/dashboard/test_auth.py b/qa/tasks/mgr/dashboard/test_auth.py index 45d2900f1e51..890ea05d3b10 100644 --- a/qa/tasks/mgr/dashboard/test_auth.py +++ b/qa/tasks/mgr/dashboard/test_auth.py @@ -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) diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index 7ce5f3ff1071..264204d557a5 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -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 diff --git a/src/pybind/mgr/dashboard/exceptions.py b/src/pybind/mgr/dashboard/exceptions.py index d537efb6e11c..8857c423cc4e 100644 --- a/src/pybind/mgr/dashboard/exceptions.py +++ b/src/pybind/mgr/dashboard/exceptions.py @@ -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): diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py index a4fcd3778854..233f74998125 100644 --- a/src/pybind/mgr/dashboard/services/access_control.py +++ b/src/pybind/mgr/dashboard/services/access_control.py @@ -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()), '' diff --git a/src/pybind/mgr/dashboard/settings.py b/src/pybind/mgr/dashboard/settings.py index 9b0cdf972ecd..45cae2965a80 100644 --- a/src/pybind/mgr/dashboard/settings.py +++ b/src/pybind/mgr/dashboard/settings.py @@ -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)