From f17fa57f6ccb3a578507ee494d6d6d9e3680e5e3 Mon Sep 17 00:00:00 2001 From: Morgan Fainberg Date: Fri, 6 Apr 2018 15:15:35 -0700 Subject: [PATCH] Allow blocking users from self-service password change User option ``lock_password`` has been implemented. This option when set to ``True`` will prevent the usage of the self-service password change API. If the ``lock_password`` option is set to ``False`` or ``None`` (to remove the option from the user-data structure) normal password change operations are allowed Closes-Bug: #1755874 Change-Id: Icf1776c5fe625c2e9292bfcf40a8a9f17a002656 --- .../admin/identity-security-compliance.rst | 23 ++++++ keystone/exception.py | 7 ++ .../identity/backends/resource_options.py | 7 ++ keystone/identity/backends/sql.py | 4 ++ keystone/tests/unit/test_v3_identity.py | 72 +++++++++++++++++++ .../notes/bug-1755874-9951f77c6d18431c.yaml | 9 +++ 6 files changed, 122 insertions(+) create mode 100644 releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml diff --git a/doc/source/admin/identity-security-compliance.rst b/doc/source/admin/identity-security-compliance.rst index d46bcdbf0b..6547e6a350 100644 --- a/doc/source/admin/identity-security-compliance.rst +++ b/doc/source/admin/identity-security-compliance.rst @@ -224,6 +224,29 @@ old password. Otherwise, users would not be able to change their passwords before they expire. +Prevent Self-Service Password Changes +------------------------------------- + +If there exists a user who should not be able to change her own password via +the keystone password change API, keystone supports setting that user's option +``lock_password`` to ``True`` via the user update API +(``PATCH /v3/users/{user_id}``): + +.. code-block:: json + + { + "user": { + "options": { + "lock_password": True + } + } + } + +The ``lock_password`` user-option is typically used in the case where passwords +are managed externally to keystone. The ``lock_password`` option can be set to +``True``, ``False``, or ``None``; if the option is set to ``None``, it is +removed from the user's data structure. + .. _Security Hardening PCI-DSS: https://specs.openstack.org/openstack/keystone-specs/specs/keystone/newton/pci-dss.html .. _v3: https://developer.openstack.org/api-ref/identity/v3/index.html#update-user diff --git a/keystone/exception.py b/keystone/exception.py index 0353f3f04a..838ef3c207 100644 --- a/keystone/exception.py +++ b/keystone/exception.py @@ -119,6 +119,13 @@ class PasswordAgeValidationError(PasswordValidationError): "reset your password.") +class PasswordSelfServiceDisabled(PasswordValidationError): + message_format = _("You cannot change your password at this time due " + "to password policy disallowing password changes. " + "Please contact your administrator to reset your " + "password.") + + class SchemaValidationError(ValidationError): # NOTE(lbragstad): For whole OpenStack message consistency, this error # message has been written in a format consistent with WSME. diff --git a/keystone/identity/backends/resource_options.py b/keystone/identity/backends/resource_options.py index 4bdbe8f796..95d85c67e2 100644 --- a/keystone/identity/backends/resource_options.py +++ b/keystone/identity/backends/resource_options.py @@ -76,6 +76,12 @@ IGNORE_LOCKOUT_ATTEMPT_OPT = ( option_name='ignore_lockout_failure_attempts', validator=resource_options.boolean_validator, json_schema_validation=parameter_types.boolean)) +LOCK_PASSWORD_OPT = ( + resource_options.ResourceOption( + option_id='1003', + option_name='lock_password', + validator=resource_options.boolean_validator, + json_schema_validation=parameter_types.boolean)) MFA_RULES_OPT = ( resource_options.ResourceOption( option_id='MFAR', @@ -112,6 +118,7 @@ def register_user_options(): IGNORE_CHANGE_PASSWORD_OPT, IGNORE_PASSWORD_EXPIRY_OPT, IGNORE_LOCKOUT_ATTEMPT_OPT, + LOCK_PASSWORD_OPT, MFA_RULES_OPT, MFA_ENABLED_OPT, ]: diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index ae23ebad76..fefbdf21f0 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -248,6 +248,10 @@ class Identity(base.IdentityDriverBase): def change_password(self, user_id, new_password): with sql.session_for_write() as session: user_ref = session.query(model.User).get(user_id) + lock_pw_opt = user_ref.get_resource_option( + options.LOCK_PASSWORD_OPT.option_id) + if lock_pw_opt is not None and lock_pw_opt.option_value is True: + raise exception.PasswordSelfServiceDisabled() if user_ref.password_ref and user_ref.password_ref.self_service: self._validate_minimum_password_age(user_ref) self._validate_password_history(new_password, user_ref) diff --git a/keystone/tests/unit/test_v3_identity.py b/keystone/tests/unit/test_v3_identity.py index 4fd610024f..c377a96b80 100644 --- a/keystone/tests/unit/test_v3_identity.py +++ b/keystone/tests/unit/test_v3_identity.py @@ -459,6 +459,25 @@ class IdentityTestCase(test_v3.RestfulTestCase): password=new_password) self.v3_create_token(new_password_auth) + def test_admin_password_reset_with_password_lock(self): + # create user + user_ref = unit.create_user(PROVIDERS.identity_api, + domain_id=self.domain['id']) + lock_pw_opt = options.LOCK_PASSWORD_OPT.option_name + update_user_body = {'user': {'options': {lock_pw_opt: True}}} + self.patch('/users/%s' % user_ref['id'], body=update_user_body) + + # administrative password reset + new_password = uuid.uuid4().hex + r = self.patch('/users/%s' % user_ref['id'], + body={'user': {'password': new_password}}) + self.assertValidUserResponse(r, user_ref) + # authenticate with new password + new_password_auth = self.build_authentication_request( + user_id=user_ref['id'], + password=new_password) + self.v3_create_token(new_password_auth) + def test_update_user_domain_id(self): """Call ``PATCH /users/{user_id}`` with domain_id. @@ -769,6 +788,59 @@ class UserSelfServiceChangingPasswordsTestCase(ChangePasswordTestCase): original_password=new_password, expected_status=http_client.NO_CONTENT) + def test_changing_password_with_password_lock(self): + password = uuid.uuid4().hex + ref = unit.new_user_ref(domain_id=self.domain_id, password=password) + response = self.post('/users', body={'user': ref}) + user_id = response.json_body['user']['id'] + + time = datetime.datetime.utcnow() + with freezegun.freeze_time(time) as frozen_datetime: + # Lock the user's password + lock_pw_opt = options.LOCK_PASSWORD_OPT.option_name + user_patch = {'user': {'options': {lock_pw_opt: True}}} + self.patch('/users/%s' % user_id, body=user_patch) + + # Fail, password is locked + new_password = uuid.uuid4().hex + body = { + 'user': { + 'original_password': password, + 'password': new_password + } + } + path = '/users/%s/password' % user_id + self.post(path, body=body, expected_status=http_client.BAD_REQUEST) + + # Unlock the password, and change should work + user_patch['user']['options'][lock_pw_opt] = False + self.patch('/users/%s' % user_id, body=user_patch) + + path = '/users/%s/password' % user_id + self.post(path, body=body, expected_status=http_client.NO_CONTENT) + + frozen_datetime.tick(delta=datetime.timedelta(seconds=1)) + + auth_data = self.build_authentication_request( + user_id=user_id, + password=new_password + ) + self.v3_create_token( + auth_data, expected_status=http_client.CREATED + ) + + path = '/users/%s' % user_id + user = self.get(path).json_body['user'] + self.assertIn(lock_pw_opt, user['options']) + self.assertFalse(user['options'][lock_pw_opt]) + + # Completely unset the option from the user's reference + user_patch['user']['options'][lock_pw_opt] = None + self.patch('/users/%s' % user_id, body=user_patch) + path = '/users/%s' % user_id + user = self.get(path).json_body['user'] + self.assertNotIn(lock_pw_opt, user['options']) + def test_changing_password_with_missing_original_password_fails(self): r = self.change_password(password=uuid.uuid4().hex, expected_status=http_client.BAD_REQUEST) diff --git a/releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml b/releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml new file mode 100644 index 0000000000..87cfbf04cf --- /dev/null +++ b/releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml @@ -0,0 +1,9 @@ +--- +fixes: + - | + [`bug 1755874 `_] + Users now can have the resource option ``lock_password`` set which prevents + the user from utilizing the self-service password change API. Valid + values are ``True``, ``False``, or "None" (where ``None`` clears the + option). +