diff --git a/keystone/conf/security_compliance.py b/keystone/conf/security_compliance.py index ee692217dd..3390a61f9d 100644 --- a/keystone/conf/security_compliance.py +++ b/keystone/conf/security_compliance.py @@ -130,6 +130,19 @@ configuration variable will be returned to users to explain why their requested password was insufficient. """)) +change_password_upon_first_use = cfg.BoolOpt( + 'change_password_upon_first_use', + default=False, + help=utils.fmt(""" +Enabling this option requires users to change their password when the user is +created, or upon administrative reset. Before accessing any services, affected +users will have to change their password. To ignore this requirement for +specific users, such as service users, set the `options` attribute +`ignore_change_password_upon_first_use` to `True` for the desired user via the +update user API. This feature is disabled by default. This feature is only +applicable with the `sql` backend for the `[identity] driver`. +""")) + GROUP_NAME = __name__.split('.')[-1] ALL_OPTS = [ @@ -143,6 +156,7 @@ ALL_OPTS = [ minimum_password_age, password_regex, password_regex_description, + change_password_upon_first_use ] diff --git a/keystone/identity/backends/resource_options.py b/keystone/identity/backends/resource_options.py index 87283f77b7..7f2bba1106 100644 --- a/keystone/identity/backends/resource_options.py +++ b/keystone/identity/backends/resource_options.py @@ -14,20 +14,16 @@ from keystone.common import resource_options USER_OPTIONS_REGISTRY = resource_options.ResourceOptionRegistry('USER') - -# NOTE(notmorgan): This placeholder options can be removed once more -# options are populated. This forces iteration on possible options for -# complete test purposes in unit/functional/gate tests outside of the -# explicit test cases that test resource options. This option is never -# expected to be set. -_placeholder_opt = resource_options.ResourceOption('_TST', '__PLACEHOLDER__') +IGNORE_CHANGE_PASSWORD_OPT = ( + resource_options.ResourceOption('1000', + 'ignore_change_password_upon_first_use')) # NOTE(notmorgan): wrap this in a function for testing purposes. # This is called on import by design. def register_user_options(): for opt in [ - _placeholder_opt, + IGNORE_CHANGE_PASSWORD_OPT, ]: USER_OPTIONS_REGISTRY.register_option(opt) diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index 5685b077ad..54f8ebd796 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -25,6 +25,7 @@ import keystone.conf from keystone import exception from keystone.i18n import _ from keystone.identity.backends import base +from keystone.identity.backends import resource_options as options from keystone.identity.backends import sql_model as model @@ -124,6 +125,8 @@ class Identity(base.IdentityDriverBase): user = utils.hash_user_password(user) with sql.session_for_write() as session: user_ref = model.User.from_dict(user) + if self._change_password_required(user_ref): + user_ref.password_ref.expires_at = datetime.datetime.utcnow() user_ref.created_at = datetime.datetime.utcnow() session.add(user_ref) # Set resource options passed on creation @@ -131,6 +134,13 @@ class Identity(base.IdentityDriverBase): user_ref, model.UserOption) return base.filter_user(user_ref.to_dict()) + def _change_password_required(self, user): + if not CONF.security_compliance.change_password_upon_first_use: + return False + ignore_option = user.get_resource_option( + options.IGNORE_CHANGE_PASSWORD_OPT.option_id) + return not (ignore_option and ignore_option.option_value is True) + def _create_password_expires_query(self, session, query, hints): for filter_ in hints.filters: if 'password_expires_at' == filter_['name']: @@ -202,6 +212,9 @@ class Identity(base.IdentityDriverBase): resource_options.resource_options_ref_to_mapper( user_ref, model.UserOption) + if 'password' in user and self._change_password_required(user_ref): + user_ref.password_ref.expires_at = datetime.datetime.utcnow() + user_ref.extra = new_user.extra return base.filter_user( user_ref.to_dict(include_extra_dict=True)) diff --git a/keystone/identity/schema.py b/keystone/identity/schema.py index 260af4778c..5b5365546c 100644 --- a/keystone/identity/schema.py +++ b/keystone/identity/schema.py @@ -12,6 +12,7 @@ from keystone.common import validation from keystone.common.validation import parameter_types +from keystone.identity.backends import resource_options as ro # NOTE(lhcheng): the max length is not applicable since it is specific @@ -62,6 +63,14 @@ user_update_v2 = { # Schema for Identity v3 API +_user_options = { + 'type': 'object', + 'properties': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: parameter_types.boolean, + }, + 'additionalProperties': False +} + _user_properties = { 'default_project_id': validation.nullable(parameter_types.id_string), 'description': validation.nullable(parameter_types.description), @@ -70,7 +79,8 @@ _user_properties = { 'name': _identity_name, 'password': { 'type': ['string', 'null'] - } + }, + 'options': _user_options } # TODO(notmorgan): Provide a mechanism for options to supply real jsonschema diff --git a/keystone/tests/unit/identity/test_backend_sql.py b/keystone/tests/unit/identity/test_backend_sql.py index 4c4194417c..f7468e43c1 100644 --- a/keystone/tests/unit/identity/test_backend_sql.py +++ b/keystone/tests/unit/identity/test_backend_sql.py @@ -806,3 +806,97 @@ class MinimumPasswordAgeTests(test_backend_sql.SqlTests): for password_ref in user_ref.local_user.passwords: password_ref.created_at = password_create_at - slightly_less latest_password.created_at = password_create_at + + +class ChangePasswordRequiredAfterFirstUse(test_backend_sql.SqlTests): + def _create_user(self, password, change_password_upon_first_use): + self.config_fixture.config( + group='security_compliance', + change_password_upon_first_use=change_password_upon_first_use) + user_dict = { + 'name': uuid.uuid4().hex, + 'domain_id': CONF.identity.default_domain_id, + 'enabled': True, + 'password': password + } + return self.identity_api.create_user(user_dict) + + def assertPasswordIsExpired(self, user_id, password): + self.assertRaises(exception.PasswordExpired, + self.identity_api.authenticate, + self.make_request(), + user_id=user_id, + password=password) + + def assertPasswordIsNotExpired(self, user_id, password): + self.identity_api.authenticate(self.make_request(), + user_id=user_id, + password=password) + + def test_password_expired_after_create(self): + # create user, password expired + initial_password = uuid.uuid4().hex + user = self._create_user(initial_password, True) + self.assertPasswordIsExpired(user['id'], initial_password) + # change password (self-service), password not expired + new_password = uuid.uuid4().hex + self.identity_api.change_password(self.make_request(), + user['id'], + initial_password, + new_password) + self.assertPasswordIsNotExpired(user['id'], new_password) + + def test_password_expired_after_reset(self): + # create user with feature disabled, password not expired + initial_password = uuid.uuid4().hex + user = self._create_user(initial_password, False) + self.assertPasswordIsNotExpired(user['id'], initial_password) + # enable change_password_upon_first_use + self.config_fixture.config( + group='security_compliance', + change_password_upon_first_use=True) + # admin reset, password expired + admin_password = uuid.uuid4().hex + user['password'] = admin_password + self.identity_api.update_user(user['id'], user) + self.assertPasswordIsExpired(user['id'], admin_password) + # change password (self-service), password not expired + new_password = uuid.uuid4().hex + self.identity_api.change_password(self.make_request(), + user['id'], + admin_password, + new_password) + self.assertPasswordIsNotExpired(user['id'], new_password) + + def test_password_not_expired_when_feature_disabled(self): + # create user with feature disabled + initial_password = uuid.uuid4().hex + user = self._create_user(initial_password, False) + self.assertPasswordIsNotExpired(user['id'], initial_password) + # admin reset + admin_password = uuid.uuid4().hex + user['password'] = admin_password + self.identity_api.update_user(user['id'], user) + self.assertPasswordIsNotExpired(user['id'], admin_password) + + def test_password_not_expired_for_ignore_user(self): + # create user with feature disabled, password not expired + initial_password = uuid.uuid4().hex + user = self._create_user(initial_password, False) + self.assertPasswordIsNotExpired(user['id'], initial_password) + # enable change_password_upon_first_use + self.config_fixture.config( + group='security_compliance', + change_password_upon_first_use=True) + # ignore user and reset password, password not expired + user['options'][iro.IGNORE_CHANGE_PASSWORD_OPT.option_name] = True + admin_password = uuid.uuid4().hex + user['password'] = admin_password + self.identity_api.update_user(user['id'], user) + self.assertPasswordIsNotExpired(user['id'], admin_password) + # set ignore user to false and reset password, password is expired + user['options'][iro.IGNORE_CHANGE_PASSWORD_OPT.option_name] = False + admin_password = uuid.uuid4().hex + user['password'] = admin_password + self.identity_api.update_user(user['id'], user) + self.assertPasswordIsExpired(user['id'], admin_password) diff --git a/keystone/tests/unit/test_v3_identity.py b/keystone/tests/unit/test_v3_identity.py index c5c3bb73bf..13a7dad963 100644 --- a/keystone/tests/unit/test_v3_identity.py +++ b/keystone/tests/unit/test_v3_identity.py @@ -29,6 +29,7 @@ import keystone.conf from keystone.credential.providers import fernet as credential_fernet from keystone import exception from keystone.identity.backends import base as identity_base +from keystone.identity.backends import resource_options as options from keystone.identity.backends import sql_model as model from keystone.tests import unit from keystone.tests.unit import ksfixtures @@ -975,6 +976,64 @@ class UserSelfServiceChangingPasswordsTestCase(ChangePasswordTestCase): original_password=password, expected_status=http_client.UNAUTHORIZED) + def test_change_password_required_upon_first_use_for_create(self): + self.config_fixture.config(group='security_compliance', + change_password_upon_first_use=True) + + # create user + self.user_ref = unit.create_user(self.identity_api, + domain_id=self.domain['id']) + + # attempt to authenticate with create user password + self.get_request_token(self.user_ref['password'], + expected_status=http_client.UNAUTHORIZED) + + # self-service change password + new_password = uuid.uuid4().hex + self.change_password(password=new_password, + original_password=self.user_ref['password'], + expected_status=http_client.NO_CONTENT) + + # authenticate with the new password + self.token = self.get_request_token(new_password, http_client.CREATED) + + def test_change_password_required_upon_first_use_for_admin_reset(self): + self.config_fixture.config(group='security_compliance', + change_password_upon_first_use=True) + + # admin reset + reset_password = uuid.uuid4().hex + user_password = {'password': reset_password} + self.identity_api.update_user(self.user_ref['id'], user_password) + + # attempt to authenticate with admin reset password + self.get_request_token(reset_password, + expected_status=http_client.UNAUTHORIZED) + + # self-service change password + new_password = uuid.uuid4().hex + self.change_password(password=new_password, + original_password=reset_password, + expected_status=http_client.NO_CONTENT) + + # authenticate with the new password + self.token = self.get_request_token(new_password, http_client.CREATED) + + def test_change_password_required_upon_first_use_ignore_user(self): + self.config_fixture.config(group='security_compliance', + change_password_upon_first_use=True) + + # ignore user and reset password + reset_password = uuid.uuid4().hex + self.user_ref['password'] = reset_password + ignore_opt_name = options.IGNORE_CHANGE_PASSWORD_OPT.option_name + self.user_ref['options'][ignore_opt_name] = True + self.identity_api.update_user(self.user_ref['id'], self.user_ref) + + # authenticate with the reset password + self.token = self.get_request_token(reset_password, + http_client.CREATED) + class PasswordValidationTestCase(ChangePasswordTestCase): diff --git a/keystone/tests/unit/test_validation.py b/keystone/tests/unit/test_validation.py index 189b12712f..bc3188b52f 100644 --- a/keystone/tests/unit/test_validation.py +++ b/keystone/tests/unit/test_validation.py @@ -23,6 +23,7 @@ from keystone.common.validation import validators from keystone.credential import schema as credential_schema from keystone import exception from keystone.federation import schema as federation_schema +from keystone.identity.backends import resource_options as ro from keystone.identity import schema as identity_schema from keystone.oauth1 import schema as oauth1_schema from keystone.policy import schema as policy_schema @@ -1781,6 +1782,63 @@ class UserValidationTestCase(unit.BaseTestCase): self.update_user_validator.validate, request_to_validate) + def test_user_create_succeeds_with_empty_options(self): + request_to_validate = { + 'name': self.user_name, + 'options': {} + } + self.create_user_validator.validate(request_to_validate) + + def test_user_create_options_fails_invalid_option(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + 'whatever': True + } + } + self.assertRaises(exception.SchemaValidationError, + self.create_user_validator.validate, + request_to_validate) + + def test_user_create_with_options_change_password_required(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: True + } + } + self.create_user_validator.validate(request_to_validate) + + def test_user_create_options_change_password_required_wrong_type(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: 'whatever' + } + } + self.assertRaises(exception.SchemaValidationError, + self.create_user_validator.validate, + request_to_validate) + + def test_user_create_options_change_password_required_none(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: None + } + } + self.assertRaises(exception.SchemaValidationError, + self.create_user_validator.validate, + request_to_validate) + + def test_user_update_with_options_change_password_required(self): + request_to_validate = { + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: False + } + } + self.update_user_validator.validate(request_to_validate) + class GroupValidationTestCase(unit.BaseTestCase): """Test for V3 Group API validation.""" diff --git a/releasenotes/notes/bug-1645487-ca22c216ec26cc9b.yaml b/releasenotes/notes/bug-1645487-ca22c216ec26cc9b.yaml new file mode 100644 index 0000000000..eec424afe7 --- /dev/null +++ b/releasenotes/notes/bug-1645487-ca22c216ec26cc9b.yaml @@ -0,0 +1,8 @@ +--- +features: + - > + [`Bug 1645487 `_] + Added a new PCI-DSS feature that will require users to immediately change + their password upon first use for new users and after an administrative + password reset. The new feature can be enabled by setting + [security_compliance] ``change_password_upon_first_use`` to ``True``.