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
|
Otherwise, users would not be able to change their passwords before they
|
||||||
expire.
|
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
|
.. _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
|
.. _v3: https://developer.openstack.org/api-ref/identity/v3/index.html#update-user
|
||||||
|
@ -119,6 +119,13 @@ class PasswordAgeValidationError(PasswordValidationError):
|
|||||||
"reset your password.")
|
"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):
|
class SchemaValidationError(ValidationError):
|
||||||
# NOTE(lbragstad): For whole OpenStack message consistency, this error
|
# NOTE(lbragstad): For whole OpenStack message consistency, this error
|
||||||
# message has been written in a format consistent with WSME.
|
# message has been written in a format consistent with WSME.
|
||||||
|
@ -76,6 +76,12 @@ IGNORE_LOCKOUT_ATTEMPT_OPT = (
|
|||||||
option_name='ignore_lockout_failure_attempts',
|
option_name='ignore_lockout_failure_attempts',
|
||||||
validator=resource_options.boolean_validator,
|
validator=resource_options.boolean_validator,
|
||||||
json_schema_validation=parameter_types.boolean))
|
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 = (
|
MFA_RULES_OPT = (
|
||||||
resource_options.ResourceOption(
|
resource_options.ResourceOption(
|
||||||
option_id='MFAR',
|
option_id='MFAR',
|
||||||
@ -112,6 +118,7 @@ def register_user_options():
|
|||||||
IGNORE_CHANGE_PASSWORD_OPT,
|
IGNORE_CHANGE_PASSWORD_OPT,
|
||||||
IGNORE_PASSWORD_EXPIRY_OPT,
|
IGNORE_PASSWORD_EXPIRY_OPT,
|
||||||
IGNORE_LOCKOUT_ATTEMPT_OPT,
|
IGNORE_LOCKOUT_ATTEMPT_OPT,
|
||||||
|
LOCK_PASSWORD_OPT,
|
||||||
MFA_RULES_OPT,
|
MFA_RULES_OPT,
|
||||||
MFA_ENABLED_OPT,
|
MFA_ENABLED_OPT,
|
||||||
]:
|
]:
|
||||||
|
@ -248,6 +248,10 @@ class Identity(base.IdentityDriverBase):
|
|||||||
def change_password(self, user_id, new_password):
|
def change_password(self, user_id, new_password):
|
||||||
with sql.session_for_write() as session:
|
with sql.session_for_write() as session:
|
||||||
user_ref = session.query(model.User).get(user_id)
|
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:
|
if user_ref.password_ref and user_ref.password_ref.self_service:
|
||||||
self._validate_minimum_password_age(user_ref)
|
self._validate_minimum_password_age(user_ref)
|
||||||
self._validate_password_history(new_password, user_ref)
|
self._validate_password_history(new_password, user_ref)
|
||||||
|
@ -459,6 +459,25 @@ class IdentityTestCase(test_v3.RestfulTestCase):
|
|||||||
password=new_password)
|
password=new_password)
|
||||||
self.v3_create_token(new_password_auth)
|
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):
|
def test_update_user_domain_id(self):
|
||||||
"""Call ``PATCH /users/{user_id}`` with domain_id.
|
"""Call ``PATCH /users/{user_id}`` with domain_id.
|
||||||
|
|
||||||
@ -769,6 +788,59 @@ class UserSelfServiceChangingPasswordsTestCase(ChangePasswordTestCase):
|
|||||||
original_password=new_password,
|
original_password=new_password,
|
||||||
expected_status=http_client.NO_CONTENT)
|
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):
|
def test_changing_password_with_missing_original_password_fails(self):
|
||||||
r = self.change_password(password=uuid.uuid4().hex,
|
r = self.change_password(password=uuid.uuid4().hex,
|
||||||
expected_status=http_client.BAD_REQUEST)
|
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