PCI-DSS Force users to change password upon first use
"PCI-DSS 8.2.6 Set passwords/passphrases for first-time use and upon reset to a unique value for each user, and change immediately after the first use" [1]. I'll update the docs in a subsequent patch. [1] https://www.pcisecuritystandards.org/documents/PCI_DSS_v3-1.pdf Closes-Bug: #1645487 Change-Id: I5575dbd6d63d41014a7468acd6bdf0175d791618
This commit is contained in:
parent
85e8a7bc5e
commit
0b3e59e041
@ -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
|
||||
]
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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."""
|
||||
|
8
releasenotes/notes/bug-1645487-ca22c216ec26cc9b.yaml
Normal file
8
releasenotes/notes/bug-1645487-ca22c216ec26cc9b.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
features:
|
||||
- >
|
||||
[`Bug 1645487 <https://bugs.launchpad.net/keystone/+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``.
|
Loading…
Reference in New Issue
Block a user