Support new hashing algorithms for securely storing password hashes
Support bcrypt, pbkdf2_sha512, or scrypt in password hashing for passwords managed within keystone. sha512_crypt is insufficient to hash passwords in a secure way for storage in the DB. Keystone defaults now to using bcrypt but can handle scrypt and pbkdf2_sha512 with a number of tuning options if desired. Closes-bug: #1543048 Closes-bug: #1668503 Change-Id: Id05026720839d94de26d0e44631deb34bcc0e610
This commit is contained in:
parent
5f31868c0b
commit
8ad765e023
130
keystone/common/password_hashing.py
Normal file
130
keystone/common/password_hashing.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Copyright 2017 Red Hat
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import itertools
|
||||
|
||||
from oslo_log import log
|
||||
import passlib.hash
|
||||
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
SUPPORTED_HASHERS = frozenset([passlib.hash.bcrypt,
|
||||
passlib.hash.scrypt,
|
||||
passlib.hash.pbkdf2_sha512,
|
||||
passlib.hash.sha512_crypt])
|
||||
|
||||
_HASHER_NAME_MAP = {hasher.name: hasher for hasher in SUPPORTED_HASHERS}
|
||||
|
||||
|
||||
# NOTE(notmorgan): Build the list of prefixes. This comprehension builds
|
||||
# a dictionary where the keys are the prefix (all hashedpasswords are
|
||||
# '$<ident>$<metadata>$<hash>') so we can do a fast-lookup on the hasher to
|
||||
# use. If has hasher has multiple ident options it is encoded in the
|
||||
# .ident_values attribute whereas hashers that have a single option
|
||||
# (sha512_crypt) only has the .ident attribute.
|
||||
_HASHER_IDENT_MAP = {
|
||||
prefix: module for module, prefix in itertools.chain(
|
||||
*[zip([mod] * len(getattr(mod, 'ident_values', (mod.ident,))),
|
||||
getattr(mod, 'ident_values', (mod.ident,)))
|
||||
for mod in SUPPORTED_HASHERS])}
|
||||
|
||||
|
||||
def _get_hasher_from_ident(hashed):
|
||||
try:
|
||||
return _HASHER_IDENT_MAP[hashed[0:hashed.index('$', 1) + 1]]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
_('Unsupported password hashing algorithm ident: %s') %
|
||||
hashed[0:hashed.index('$', 1) + 1])
|
||||
|
||||
|
||||
def verify_length_and_trunc_password(password):
|
||||
"""Verify and truncate the provided password to the max_password_length."""
|
||||
max_length = CONF.identity.max_password_length
|
||||
try:
|
||||
if len(password) > max_length:
|
||||
if CONF.strict_password_check:
|
||||
raise exception.PasswordVerificationError(size=max_length)
|
||||
else:
|
||||
msg = "Truncating user password to %d characters."
|
||||
LOG.warning(msg, max_length)
|
||||
return password[:max_length]
|
||||
else:
|
||||
return password
|
||||
except TypeError:
|
||||
raise exception.ValidationError(attribute='string', target='password')
|
||||
|
||||
|
||||
def check_password(password, hashed):
|
||||
"""Check that a plaintext password matches hashed.
|
||||
|
||||
hashpw returns the salt value concatenated with the actual hash value.
|
||||
It extracts the actual salt if this value is then passed as the salt.
|
||||
|
||||
"""
|
||||
if password is None or hashed is None:
|
||||
return False
|
||||
password_utf8 = verify_length_and_trunc_password(password).encode('utf-8')
|
||||
hasher = _get_hasher_from_ident(hashed)
|
||||
return hasher.verify(password_utf8, hashed)
|
||||
|
||||
|
||||
def hash_user_password(user):
|
||||
"""Hash a user dict's password without modifying the passed-in dict."""
|
||||
password = user.get('password')
|
||||
if password is None:
|
||||
return user
|
||||
|
||||
return dict(user, password=hash_password(password))
|
||||
|
||||
|
||||
def hash_password_compat(password):
|
||||
password_utf8 = verify_length_and_trunc_password(password).encode('utf-8')
|
||||
return passlib.hash.sha512_crypt.using(rounds=CONF.crypt_strength).hash(
|
||||
password_utf8)
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password. Harder."""
|
||||
params = {}
|
||||
password_utf8 = verify_length_and_trunc_password(password).encode('utf-8')
|
||||
conf_hasher = CONF.identity.password_hash_algorithm
|
||||
hasher = _HASHER_NAME_MAP.get(conf_hasher)
|
||||
|
||||
if hasher is None:
|
||||
raise RuntimeError(
|
||||
_('Password Hash Algorithm %s not found') %
|
||||
CONF.identity.password_hash_algorithm)
|
||||
|
||||
if CONF.identity.password_hash_rounds:
|
||||
params['rounds'] = CONF.identity.password_hash_rounds
|
||||
if hasher is passlib.hash.scrypt:
|
||||
if CONF.identity.scrypt_block_size:
|
||||
params['block_size'] = CONF.identity.scrypt_block_size
|
||||
if CONF.identity.scrypt_parallelism:
|
||||
params['parallelism'] = CONF.identity.scrypt_parallelism
|
||||
if CONF.identity.salt_bytesize:
|
||||
params['salt_size'] = CONF.identity.salt_bytesize
|
||||
if hasher is passlib.hash.pbkdf2_sha512:
|
||||
if CONF.identity.salt_bytesize:
|
||||
params['salt_size'] = CONF.identity.salt_bytesize
|
||||
|
||||
return hasher.using(**params).hash(password_utf8)
|
@ -0,0 +1,15 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
pass
|
@ -0,0 +1,15 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
pass
|
@ -0,0 +1,25 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import sqlalchemy as sql
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
meta = sql.MetaData()
|
||||
meta.bind = migrate_engine
|
||||
|
||||
# NOTE(notmorgan): To support the full range of scrypt and pbkfd password
|
||||
# hash lengths, this should be closer to varchar(1500) instead of
|
||||
# varchar(255).
|
||||
password_hash = sql.Column('password_hash', sql.String(255), nullable=True)
|
||||
password_table = sql.Table('password', meta, autoload=True)
|
||||
password_table.create_column(password_hash)
|
@ -30,10 +30,10 @@ from oslo_serialization import jsonutils
|
||||
from oslo_utils import reflection
|
||||
from oslo_utils import strutils
|
||||
from oslo_utils import timeutils
|
||||
import passlib.hash
|
||||
import six
|
||||
from six import moves
|
||||
|
||||
from keystone.common import password_hashing
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
@ -53,6 +53,12 @@ WHITELISTED_PROPERTIES = [
|
||||
# used for ID transformation.
|
||||
RESOURCE_ID_NAMESPACE = uuid.UUID('4332ecab-770b-4288-a680-b9aca3b1b153')
|
||||
|
||||
# Compatibilty for password hashing functions.
|
||||
verify_length_and_trunc_password = password_hashing.verify_length_and_trunc_password # noqa
|
||||
hash_password = password_hashing.hash_password
|
||||
hash_user_password = password_hashing.hash_user_password
|
||||
check_password = password_hashing.check_password
|
||||
|
||||
|
||||
def resource_uuid(value):
|
||||
"""Convert input to valid UUID hex digits."""
|
||||
@ -94,23 +100,6 @@ class SmarterEncoder(jsonutils.json.JSONEncoder):
|
||||
return super(SmarterEncoder, self).default(obj)
|
||||
|
||||
|
||||
def verify_length_and_trunc_password(password):
|
||||
"""Verify and truncate the provided password to the max_password_length."""
|
||||
max_length = CONF.identity.max_password_length
|
||||
try:
|
||||
if len(password) > max_length:
|
||||
if CONF.strict_password_check:
|
||||
raise exception.PasswordVerificationError(size=max_length)
|
||||
else:
|
||||
msg = "Truncating user password to %d characters."
|
||||
LOG.warning(msg, max_length)
|
||||
return password[:max_length]
|
||||
else:
|
||||
return password
|
||||
except TypeError:
|
||||
raise exception.ValidationError(attribute='string', target='password')
|
||||
|
||||
|
||||
def hash_access_key(access):
|
||||
hash_ = hashlib.sha256()
|
||||
if not isinstance(access, six.binary_type):
|
||||
@ -119,35 +108,6 @@ def hash_access_key(access):
|
||||
return hash_.hexdigest()
|
||||
|
||||
|
||||
def hash_user_password(user):
|
||||
"""Hash a user dict's password without modifying the passed-in dict."""
|
||||
password = user.get('password')
|
||||
if password is None:
|
||||
return user
|
||||
|
||||
return dict(user, password=hash_password(password))
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password. Hard."""
|
||||
password_utf8 = verify_length_and_trunc_password(password).encode('utf-8')
|
||||
return passlib.hash.sha512_crypt.hash(
|
||||
password_utf8, rounds=CONF.crypt_strength)
|
||||
|
||||
|
||||
def check_password(password, hashed):
|
||||
"""Check that a plaintext password matches hashed.
|
||||
|
||||
hashpw returns the salt value concatenated with the actual hash value.
|
||||
It extracts the actual salt if this value is then passed as the salt.
|
||||
|
||||
"""
|
||||
if password is None or hashed is None:
|
||||
return False
|
||||
password_utf8 = verify_length_and_trunc_password(password).encode('utf-8')
|
||||
return passlib.hash.sha512_crypt.verify(password_utf8, hashed)
|
||||
|
||||
|
||||
def attr_as_boolean(val_attr):
|
||||
"""Return the boolean value, decoded from a string.
|
||||
|
||||
|
@ -121,6 +121,13 @@ crypt_strength = cfg.IntOpt(
|
||||
default=10000,
|
||||
min=1000,
|
||||
max=100000,
|
||||
deprecated_since=versionutils.deprecated.PIKE,
|
||||
deprecated_reason=utils.fmt("""
|
||||
sha512_crypt is insufficient for password hashes, use of bcrypt, pbkfd2_sha512
|
||||
and scrypt are now supported. Options are located in the [identity] config
|
||||
block. This option is still used for rolling upgrade compatibility password
|
||||
hashing.
|
||||
"""),
|
||||
help=utils.fmt("""
|
||||
The value passed as the keyword "rounds" to passlib's encrypt method. This
|
||||
option represents a trade off between security and performance. Higher values
|
||||
|
@ -11,6 +11,7 @@
|
||||
# under the License.
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_log import versionutils
|
||||
import passlib.utils
|
||||
|
||||
from keystone.conf import utils
|
||||
@ -108,6 +109,81 @@ list_limit = cfg.IntOpt(
|
||||
Maximum number of entities that will be returned in an identity collection.
|
||||
"""))
|
||||
|
||||
password_hash_algorithm = cfg.StrOpt(
|
||||
'password_hash_algorithm',
|
||||
choices=['bcrypt', 'scrypt', 'pbkdf2_sha512'],
|
||||
default='bcrypt',
|
||||
help=utils.fmt("""
|
||||
The password hashing algorithm to use for passwords stored within keystone.
|
||||
"""))
|
||||
|
||||
password_hash_rounds = cfg.IntOpt(
|
||||
'password_hash_rounds',
|
||||
help=utils.fmt("""
|
||||
This option represents a trade off between security and performance. Higher
|
||||
values lead to slower performance, but higher security. Changing this option
|
||||
will only affect newly created passwords as existing password hashes already
|
||||
have a fixed number of rounds applied, so it is safe to tune this option in a
|
||||
running cluster.
|
||||
|
||||
The default for bcrypt is 12, must be between 4 and 31, inclusive.
|
||||
|
||||
The default for scrypt is 16, must be within `range(1,32)`.
|
||||
|
||||
The default for pbkdf_sha512 is 60000, must be within `range(1,1<<32)`
|
||||
|
||||
WARNING: If using scrypt, increasing this value increases BOTH time AND
|
||||
memory requirements to hash a password.
|
||||
"""))
|
||||
|
||||
salt_bytesize = cfg.IntOpt(
|
||||
'salt_bytesize',
|
||||
min=0,
|
||||
max=96,
|
||||
help=utils.fmt("""
|
||||
Number of bytes to use in scrypt and pbkfd2_sha512 hashing salt.
|
||||
|
||||
Default for scrypt is 16 bytes.
|
||||
Default for pbkfd2_sha512 is 16 bytes.
|
||||
|
||||
Limited to a maximum of 96 bytes due to the size of the column used to store
|
||||
password hashes.
|
||||
"""))
|
||||
|
||||
scrypt_block_size = cfg.IntOpt(
|
||||
'scrypt_block_size',
|
||||
help=utils.fmt("""
|
||||
Optional block size to pass to scrypt hash function (the `r` parameter).
|
||||
Useful for tuning scrypt to optimal performance for your CPU architecture.
|
||||
This option is only used when the `password_hash_algorithm` option is set
|
||||
to `scrypt`. Defaults to 8.
|
||||
"""))
|
||||
|
||||
scrypt_paralellism = cfg.IntOpt(
|
||||
'scrypt_parallelism',
|
||||
help=utils.fmt("""
|
||||
Optional parallelism to pass to scrypt hash function (the `p` parameter).
|
||||
This option is only used when the `password_hash_algorithm` option is set
|
||||
to `scrypt`. Defaults to 1.
|
||||
"""))
|
||||
|
||||
# TODO(notmorgan): remove this option in Q release.
|
||||
rolling_upgrade_password_hash_compat = cfg.BoolOpt(
|
||||
'rolling_upgrade_password_hash_compat',
|
||||
default=False,
|
||||
deprecated_since=versionutils.deprecated.PIKE,
|
||||
deprecated_reason='Only used for rolling-upgrade between Ocata and Pike',
|
||||
help=utils.fmt("""
|
||||
This option tells keystone to continue to hash passwords with the sha512_crypt
|
||||
algorithm for supporting rolling upgrades. sha512_crypt is typically more
|
||||
insecure than bcrypt, pbkdf2, and scrypt. This option should be set to
|
||||
`False` except in the case of performing a rolling upgrade where some
|
||||
Keystone servers may not know how to verify non-sha512_crypt based password
|
||||
hashes.
|
||||
|
||||
This option will be removed in the Queens release and is only to support
|
||||
rolling upgrades from Ocata release to Pike release.
|
||||
"""))
|
||||
|
||||
GROUP_NAME = __name__.split('.')[-1]
|
||||
ALL_OPTS = [
|
||||
@ -120,6 +196,12 @@ ALL_OPTS = [
|
||||
cache_time,
|
||||
max_password_length,
|
||||
list_limit,
|
||||
password_hash_algorithm,
|
||||
password_hash_rounds,
|
||||
scrypt_block_size,
|
||||
scrypt_paralellism,
|
||||
salt_bytesize,
|
||||
rolling_upgrade_password_hash_compat,
|
||||
]
|
||||
|
||||
|
||||
|
@ -18,9 +18,9 @@ from oslo_db import api as oslo_db_api
|
||||
import sqlalchemy
|
||||
|
||||
from keystone.common import driver_hints
|
||||
from keystone.common import password_hashing
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
@ -52,7 +52,7 @@ class Identity(base.IdentityDriverBase):
|
||||
https://blueprints.launchpad.net/keystone/+spec/sql-identiy-pam
|
||||
|
||||
"""
|
||||
return utils.check_password(password, user_ref.password)
|
||||
return password_hashing.check_password(password, user_ref.password)
|
||||
|
||||
# Identity interface
|
||||
def authenticate(self, user_id, password):
|
||||
@ -125,7 +125,6 @@ class Identity(base.IdentityDriverBase):
|
||||
|
||||
@sql.handle_conflicts(conflict_type='user')
|
||||
def create_user(self, user_id, user):
|
||||
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):
|
||||
@ -206,7 +205,6 @@ class Identity(base.IdentityDriverBase):
|
||||
with sql.session_for_write() as session:
|
||||
user_ref = self._get_user(session, user_id)
|
||||
old_user_dict = user_ref.to_dict()
|
||||
user = utils.hash_user_password(user)
|
||||
for k in user:
|
||||
old_user_dict[k] = user[k]
|
||||
new_user = model.User.from_dict(old_user_dict)
|
||||
@ -223,8 +221,11 @@ 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()
|
||||
if 'password' in user:
|
||||
user_ref.password = user['password']
|
||||
if self._change_password_required(user_ref):
|
||||
expires_now = datetime.datetime.utcnow()
|
||||
user_ref.password_ref.expires_at = expires_now
|
||||
|
||||
user_ref.extra = new_user.extra
|
||||
return base.filter_user(
|
||||
@ -238,7 +239,9 @@ class Identity(base.IdentityDriverBase):
|
||||
# Validate the new password against the remaining passwords.
|
||||
if unique_cnt > 1:
|
||||
for password_ref in user_ref.local_user.passwords:
|
||||
if utils.check_password(password, password_ref.password):
|
||||
if password_hashing.check_password(
|
||||
password,
|
||||
password_ref.password_hash or password_ref.password):
|
||||
raise exception.PasswordHistoryValidationError(
|
||||
unique_count=unique_cnt)
|
||||
|
||||
@ -248,7 +251,7 @@ class Identity(base.IdentityDriverBase):
|
||||
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)
|
||||
user_ref.password = utils.hash_password(new_password)
|
||||
user_ref.password = new_password
|
||||
user_ref.password_ref.self_service = True
|
||||
|
||||
def _validate_minimum_password_age(self, user_ref):
|
||||
|
@ -19,6 +19,7 @@ from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.orm import collections
|
||||
|
||||
from keystone.common import password_hashing
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
import keystone.conf
|
||||
@ -32,7 +33,7 @@ class User(sql.ModelBase, sql.ModelDictMixinWithExtras):
|
||||
__tablename__ = 'user'
|
||||
attributes = ['id', 'name', 'domain_id', 'password', 'enabled',
|
||||
'default_project_id', 'password_expires_at']
|
||||
readonly_attributes = ['id', 'password_expires_at']
|
||||
readonly_attributes = ['id', 'password_expires_at', 'password']
|
||||
resource_options_registry = iro.USER_OPTIONS_REGISTRY
|
||||
id = sql.Column(sql.String(64), primary_key=True)
|
||||
domain_id = sql.Column(sql.String(64), nullable=False)
|
||||
@ -104,7 +105,10 @@ class User(sql.ModelBase, sql.ModelDictMixinWithExtras):
|
||||
def password(self):
|
||||
"""Return the current password."""
|
||||
if self.password_ref:
|
||||
return self.password_ref.password
|
||||
if self.password_ref.password_hash is not None:
|
||||
return self.password_ref.password_hash
|
||||
else:
|
||||
return self.password_ref.password
|
||||
return None
|
||||
|
||||
@property
|
||||
@ -124,7 +128,7 @@ class User(sql.ModelBase, sql.ModelDictMixinWithExtras):
|
||||
@property
|
||||
def password_is_expired(self):
|
||||
"""Return whether password is expired or not."""
|
||||
if self.password_expires_at:
|
||||
if self.password_expires_at and not self._password_expiry_exempt():
|
||||
return datetime.datetime.utcnow() >= self.password_expires_at
|
||||
return False
|
||||
|
||||
@ -142,23 +146,44 @@ class User(sql.ModelBase, sql.ModelDictMixinWithExtras):
|
||||
if not ref.expires_at or ref.expires_at > now:
|
||||
ref.expires_at = now
|
||||
new_password_ref = Password()
|
||||
new_password_ref.password = value
|
||||
|
||||
hashed_passwd = None
|
||||
hashed_compat = None
|
||||
if value is not None:
|
||||
# NOTE(notmorgan): hash the passwords, never directly bind the
|
||||
# "value" in the unhashed form to hashed_passwd or hashed_compat
|
||||
# to ensure the unhashed password cannot end up in the db. If an
|
||||
# unhashed password ends up in the DB, it cannot be used for auth,
|
||||
# it is however incorrect and could leak user credentials (due to
|
||||
# users doing insecure things such as sharing passwords across
|
||||
# different systems) to unauthorized parties.
|
||||
hashed_passwd = password_hashing.hash_password(value)
|
||||
|
||||
# TODO(notmorgan): Remove this compat code in Q release.
|
||||
if CONF.identity.rolling_upgrade_password_hash_compat:
|
||||
hashed_compat = password_hashing.hash_password_compat(value)
|
||||
|
||||
new_password_ref.password_hash = hashed_passwd
|
||||
new_password_ref.password = hashed_compat
|
||||
new_password_ref.created_at = now
|
||||
new_password_ref.expires_at = self._get_password_expires_at(now)
|
||||
self.local_user.passwords.append(new_password_ref)
|
||||
|
||||
def _get_password_expires_at(self, created_at):
|
||||
expires_days = CONF.security_compliance.password_expires_days
|
||||
def _password_expiry_exempt(self):
|
||||
# Get the IGNORE_PASSWORD_EXPIRY_OPT value from the user's
|
||||
# option_mapper.
|
||||
ignore_pw_expiry = getattr(
|
||||
return getattr(
|
||||
self.get_resource_option(iro.IGNORE_PASSWORD_EXPIRY_OPT.option_id),
|
||||
'option_value',
|
||||
False)
|
||||
if not ignore_pw_expiry and expires_days:
|
||||
expired_date = (created_at +
|
||||
datetime.timedelta(days=expires_days))
|
||||
return expired_date.replace(microsecond=0)
|
||||
|
||||
def _get_password_expires_at(self, created_at):
|
||||
expires_days = CONF.security_compliance.password_expires_days
|
||||
if not self._password_expiry_exempt():
|
||||
if expires_days:
|
||||
expired_date = (created_at +
|
||||
datetime.timedelta(days=expires_days))
|
||||
return expired_date.replace(microsecond=0)
|
||||
return None
|
||||
|
||||
@password.expression
|
||||
@ -267,12 +292,17 @@ class LocalUser(sql.ModelBase, sql.ModelDictMixin):
|
||||
|
||||
class Password(sql.ModelBase, sql.ModelDictMixin):
|
||||
__tablename__ = 'password'
|
||||
attributes = ['id', 'local_user_id', 'password', 'created_at',
|
||||
'expires_at']
|
||||
attributes = ['id', 'local_user_id', 'password', 'password_hash',
|
||||
'created_at', 'expires_at']
|
||||
id = sql.Column(sql.Integer, primary_key=True)
|
||||
local_user_id = sql.Column(sql.Integer, sql.ForeignKey('local_user.id',
|
||||
ondelete='CASCADE'))
|
||||
# TODO(notmorgan): in the Q release the "password" field can be dropped as
|
||||
# long as data migration exists to move the hashes over to the
|
||||
# password_hash column if no value is in the password_hash column.
|
||||
password = sql.Column(sql.String(128), nullable=True)
|
||||
password_hash = sql.Column(sql.String(255), nullable=True)
|
||||
|
||||
# created_at default set here to safe guard in case it gets missed
|
||||
created_at = sql.Column(sql.DateTime, nullable=False,
|
||||
default=datetime.datetime.utcnow)
|
||||
|
@ -620,6 +620,14 @@ class TestCase(BaseTestCase):
|
||||
'keystone.notifications=INFO',
|
||||
'keystone.identity.backends.ldap.common=INFO',
|
||||
])
|
||||
# NOTE(notmorgan): Set password rounds low here to ensure speedy
|
||||
# tests. This is explicitly set because the tests here are not testing
|
||||
# the integrity of the password hashing, just that the correct form
|
||||
# of hashing has been used. Note that 4 is the lowest for bcrypt
|
||||
# allowed in the `[identity] password_hash_rounds` setting
|
||||
self.config_fixture.config(group='identity', password_hash_rounds=4)
|
||||
self.config_fixture.config(crypt_strength=1000)
|
||||
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
|
@ -14,11 +14,12 @@ import datetime
|
||||
import uuid
|
||||
|
||||
import freezegun
|
||||
import passlib.hash
|
||||
|
||||
from keystone.common import controller
|
||||
from keystone.common import password_hashing
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.identity.backends import base
|
||||
@ -30,6 +31,71 @@ from keystone.tests.unit import test_backend_sql
|
||||
CONF = keystone.conf.CONF
|
||||
|
||||
|
||||
class UserPasswordHashingTestsNoCompat(test_backend_sql.SqlTests):
|
||||
def config_overrides(self):
|
||||
super(UserPasswordHashingTestsNoCompat, self).config_overrides()
|
||||
self.config_fixture.config(group='identity',
|
||||
password_hash_algorithm='scrypt')
|
||||
|
||||
def test_password_hashing_compat_not_set_used(self):
|
||||
with sql.session_for_read() as session:
|
||||
user_ref = self.identity_api._get_user(session,
|
||||
self.user_foo['id'])
|
||||
self.assertIsNone(user_ref.password_ref.password)
|
||||
self.assertIsNotNone(user_ref.password_ref.password_hash)
|
||||
self.assertEqual(user_ref.password,
|
||||
user_ref.password_ref.password_hash)
|
||||
self.assertTrue(password_hashing.check_password(
|
||||
self.user_foo['password'], user_ref.password))
|
||||
|
||||
def test_configured_algorithm_used(self):
|
||||
with sql.session_for_read() as session:
|
||||
user_ref = self.identity_api._get_user(session,
|
||||
self.user_foo['id'])
|
||||
self.assertEqual(
|
||||
passlib.hash.scrypt,
|
||||
password_hashing._get_hasher_from_ident(user_ref.password))
|
||||
|
||||
|
||||
class UserPasswordHashingTestsWithCompat(test_backend_sql.SqlTests):
|
||||
def config_overrides(self):
|
||||
super(UserPasswordHashingTestsWithCompat, self).config_overrides()
|
||||
self.config_fixture.config(
|
||||
group='identity',
|
||||
rolling_upgrade_password_hash_compat=True)
|
||||
|
||||
def test_compat_password_hashing(self):
|
||||
with sql.session_for_read() as session:
|
||||
user_ref = self.identity_api._get_user(session,
|
||||
self.user_foo['id'])
|
||||
self.assertIsNotNone(user_ref.password_ref.password)
|
||||
self.assertIsNotNone(user_ref.password_ref.password_hash)
|
||||
self.assertEqual(user_ref.password,
|
||||
user_ref.password_ref.password_hash)
|
||||
self.assertNotEqual(user_ref.password,
|
||||
user_ref.password_ref.password)
|
||||
self.assertTrue(password_hashing.check_password(
|
||||
self.user_foo['password'], user_ref.password))
|
||||
self.assertTrue(password_hashing.check_password(
|
||||
self.user_foo['password'], user_ref.password_ref.password))
|
||||
|
||||
def test_user_with_compat_password_hash_only(self):
|
||||
with sql.session_for_write() as session:
|
||||
user_ref = self.identity_api._get_user(session,
|
||||
self.user_foo['id'])
|
||||
user_ref.password_ref.password_hash = None
|
||||
|
||||
with sql.session_for_read() as session:
|
||||
user_ref = self.identity_api._get_user(session,
|
||||
self.user_foo['id'])
|
||||
|
||||
self.assertIsNone(user_ref.password_ref.password_hash)
|
||||
self.assertIsNotNone(user_ref.password)
|
||||
self.assertEqual(user_ref.password, user_ref.password_ref.password)
|
||||
self.assertTrue(password_hashing.check_password(
|
||||
self.user_foo['password'], user_ref.password))
|
||||
|
||||
|
||||
class UserResourceOptionTests(test_backend_sql.SqlTests):
|
||||
def setUp(self):
|
||||
super(UserResourceOptionTests, self).setUp()
|
||||
@ -188,7 +254,6 @@ class UserResourceOptionTests(test_backend_sql.SqlTests):
|
||||
|
||||
def _create_user(self, user_dict):
|
||||
user_dict['id'] = uuid.uuid4().hex
|
||||
user_dict = utils.hash_user_password(user_dict)
|
||||
with sql.session_for_write() as session:
|
||||
user_ref = model.User.from_dict(user_dict)
|
||||
session.add(user_ref)
|
||||
@ -316,7 +381,6 @@ class DisableInactiveUserTests(test_backend_sql.SqlTests):
|
||||
|
||||
def _create_user(self, user_dict, last_active_at):
|
||||
user_dict['id'] = uuid.uuid4().hex
|
||||
user_dict = utils.hash_user_password(user_dict)
|
||||
with sql.session_for_write() as session:
|
||||
user_ref = model.User.from_dict(user_dict)
|
||||
user_ref.last_active_at = last_active_at
|
||||
|
@ -154,6 +154,7 @@ class SqlModels(SqlTests):
|
||||
cols = (('id', sql.Integer, None),
|
||||
('local_user_id', sql.Integer, None),
|
||||
('password', sql.String, 128),
|
||||
('password_hash', sql.String, 255),
|
||||
('created_at', sql.DateTime, None),
|
||||
('expires_at', sql.DateTime, None),
|
||||
('self_service', sql.Boolean, False))
|
||||
|
@ -0,0 +1,43 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
* [`bug 1543048 <https://bugs.launchpad.net/keystone/+bug/1543048>`_]
|
||||
[`bug 1668503 <https://bugs.launchpad.net/keystone/+bug/1668503>`_]
|
||||
Keystone now supports multiple forms of password hashing. Notably bcrypt,
|
||||
scrypt, and pbkdf2_sha512. The options are now located in the
|
||||
`[identity]` section of the configuration file. To set the algorithm
|
||||
use `[identity] password_hash_algorithm`. To set the number of rounds
|
||||
(time-complexity, and memory-use in the case of scrypt) use
|
||||
`[identity] password_hash_rounds`. `scrypt` and `pbkdf2_sha512` have
|
||||
further tuning options available. Keystone now defaults to using
|
||||
`bcrypt` as the hashing algorithm. All passwords will continue to
|
||||
function with the old sha512_crypt hash, but new password hashes
|
||||
will be bcrypt.
|
||||
upgrade:
|
||||
- |
|
||||
* If performing rolling upgrades, set
|
||||
`[identity] rolling_upgrade_password_hash_compat` to `True`. This will
|
||||
instruct keystone to continue to hash passwords in a manner that older
|
||||
(pre Pike release) keystones can still verify passwords. Once all
|
||||
upgrades are complete, ensure this option is set back to `False`.
|
||||
deprecations:
|
||||
- |
|
||||
* `[DEFAULT] crypt_strength` is deprecated in favor of
|
||||
`[identity] password_hash_rounds`. Note that `[DEFAULT] crypt_strength`
|
||||
is still used when `[identity] rolling_upgrade_password_hash_compat` is
|
||||
set to `True`.
|
||||
security:
|
||||
- |
|
||||
* The use of `sha512_crypt` is considered inadequate for password hashing
|
||||
in an application like Keystone. The use of bcrypt or scrypt is
|
||||
recommended to ensure protection against password cracking utilities if
|
||||
the hashes are exposed. This is due to Time-Complexity requirements for
|
||||
computing the hashes in light of modern hardware (CPU, GPU, ASIC, FPGA,
|
||||
etc). Keystone has moved to bcrypt as a default and no longer hashes
|
||||
new passwords (and password changes) with sha512_crypt. It is
|
||||
recommended passwords be changed after upgrade to Pike. The risk of
|
||||
password hash exposure is limited, but for the best possible
|
||||
protection against cracking the hash it is recommended passwords be
|
||||
changed after upgrade. The password change will then result in a more
|
||||
secure hash (bcrypt by default) being used to store the password in the
|
||||
DB.
|
@ -20,6 +20,8 @@ stevedore>=1.20.0 # Apache-2.0
|
||||
passlib>=1.7.0 # BSD
|
||||
python-keystoneclient>=3.8.0 # Apache-2.0
|
||||
keystonemiddleware>=4.12.0 # Apache-2.0
|
||||
bcrypt>=3.1.3 # Apache-2.0
|
||||
scrypt>=0.8.0 # BSD
|
||||
oslo.cache>=1.5.0 # Apache-2.0
|
||||
oslo.concurrency>=3.8.0 # Apache-2.0
|
||||
oslo.config>=3.22.0 # Apache-2.0
|
||||
|
Loading…
Reference in New Issue
Block a user