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
This commit is contained in:
parent
78adf4b40f
commit
f17fa57f6c
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
]:
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
9
releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml
Normal file
9
releasenotes/notes/bug-1755874-9951f77c6d18431c.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
fixes:
|
||||
- |
|
||||
[`bug 1755874 <https://bugs.launchpad.net/keystone/+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).
|
||||
|
Loading…
Reference in New Issue
Block a user