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:
Ronald De Rose 2017-01-26 03:07:44 +00:00
parent 85e8a7bc5e
commit 0b3e59e041
8 changed files with 261 additions and 9 deletions

View File

@ -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
]

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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."""

View 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``.