Merge "Implement auth receipts spec"
This commit is contained in:
commit
c785729efe
@ -26,6 +26,7 @@ from keystone.common import provider_api
|
||||
from keystone import exception
|
||||
from keystone.federation import constants
|
||||
from keystone.i18n import _
|
||||
from keystone.receipt import handlers as receipt_handlers
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
@ -191,11 +192,19 @@ def authenticate_for_token(auth=None):
|
||||
)
|
||||
trust_id = trust.get('id') if trust else None
|
||||
|
||||
receipt = receipt_handlers.extract_receipt(auth_context)
|
||||
|
||||
# NOTE(notmorgan): only methods that actually run and succeed will
|
||||
# be in the auth_context['method_names'] list. Do not blindly take
|
||||
# the values from auth_info, look at the authoritative values. Make
|
||||
# sure the set is unique.
|
||||
method_names_set = set(auth_context.get('method_names', []))
|
||||
# NOTE(adriant): The set of methods will also include any methods from
|
||||
# the given receipt.
|
||||
if receipt:
|
||||
method_names_set = set(
|
||||
auth_context.get('method_names', []) + receipt.methods)
|
||||
else:
|
||||
method_names_set = set(auth_context.get('method_names', []))
|
||||
method_names = list(method_names_set)
|
||||
|
||||
app_cred_id = None
|
||||
@ -208,7 +217,7 @@ def authenticate_for_token(auth=None):
|
||||
auth_context['user_id'], method_names_set):
|
||||
raise exception.InsufficientAuthMethods(
|
||||
user_id=auth_context['user_id'],
|
||||
methods='[%s]' % ','.join(auth_info.get_method_names()))
|
||||
methods=method_names)
|
||||
|
||||
expires_at = auth_context.get('expires_at')
|
||||
token_audit_id = auth_context.get('audit_id')
|
||||
|
@ -385,11 +385,11 @@ class BasePermissionsSetup(BaseApp):
|
||||
|
||||
|
||||
class FernetSetup(BasePermissionsSetup):
|
||||
"""Setup a key repository for Fernet tokens.
|
||||
"""Setup key repositories for Fernet tokens and auth receipts.
|
||||
|
||||
This also creates a primary key used for both creating and validating
|
||||
Fernet tokens. To improve security, you should rotate your keys (using
|
||||
keystone-manage fernet_rotate, for example).
|
||||
Fernet tokens and auth receipts. To improve security, you should rotate
|
||||
your keys (using keystone-manage fernet_rotate, for example).
|
||||
|
||||
"""
|
||||
|
||||
@ -409,6 +409,32 @@ class FernetSetup(BasePermissionsSetup):
|
||||
futils.initialize_key_repository(
|
||||
keystone_user_id, keystone_group_id)
|
||||
|
||||
if (CONF.fernet_tokens.key_repository !=
|
||||
CONF.fernet_receipts.key_repository):
|
||||
futils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
|
||||
futils.create_key_directory(keystone_user_id, keystone_group_id)
|
||||
if futils.validate_key_repository(requires_write=True):
|
||||
futils.initialize_key_repository(
|
||||
keystone_user_id, keystone_group_id)
|
||||
elif(CONF.fernet_tokens.max_active_keys !=
|
||||
CONF.fernet_receipts.max_active_keys):
|
||||
# WARNING(adriant): If the directories are the same,
|
||||
# 'max_active_keys' is ignored from fernet_receipts in favor of
|
||||
# fernet_tokens to avoid a potential mismatch. Only if the
|
||||
# directories are different do we create a different one for
|
||||
# receipts, and then respect 'max_active_keys' for receipts.
|
||||
LOG.warning(
|
||||
"Receipt and Token fernet key directories are the same "
|
||||
"but `max_active_keys` is different. Receipt "
|
||||
"`max_active_keys` will be ignored in favor of Token "
|
||||
"`max_active_keys`."
|
||||
)
|
||||
|
||||
|
||||
class FernetRotate(BasePermissionsSetup):
|
||||
"""Rotate Fernet encryption keys.
|
||||
@ -442,6 +468,17 @@ class FernetRotate(BasePermissionsSetup):
|
||||
if futils.validate_key_repository(requires_write=True):
|
||||
futils.rotate_keys(keystone_user_id, keystone_group_id)
|
||||
|
||||
if (CONF.fernet_tokens.key_repository !=
|
||||
CONF.fernet_receipts.key_repository):
|
||||
futils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
|
||||
if futils.validate_key_repository(requires_write=True):
|
||||
futils.rotate_keys(keystone_user_id, keystone_group_id)
|
||||
|
||||
|
||||
class TokenSetup(BasePermissionsSetup):
|
||||
"""Setup a key repository for tokens.
|
||||
@ -504,6 +541,65 @@ class TokenRotate(BasePermissionsSetup):
|
||||
futils.rotate_keys(keystone_user_id, keystone_group_id)
|
||||
|
||||
|
||||
class ReceiptSetup(BasePermissionsSetup):
|
||||
"""Setup a key repository for auth receipts.
|
||||
|
||||
This also creates a primary key used for both creating and validating
|
||||
receipts. To improve security, you should rotate your keys (using
|
||||
keystone-manage receipt_rotate, for example).
|
||||
|
||||
"""
|
||||
|
||||
name = 'receipt_setup'
|
||||
|
||||
@classmethod
|
||||
def main(cls):
|
||||
futils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
|
||||
keystone_user_id, keystone_group_id = cls.get_user_group()
|
||||
futils.create_key_directory(keystone_user_id, keystone_group_id)
|
||||
if futils.validate_key_repository(requires_write=True):
|
||||
futils.initialize_key_repository(
|
||||
keystone_user_id, keystone_group_id)
|
||||
|
||||
|
||||
class ReceiptRotate(BasePermissionsSetup):
|
||||
"""Rotate auth receipts encryption keys.
|
||||
|
||||
This assumes you have already run keystone-manage receipt_setup.
|
||||
|
||||
A new primary key is placed into rotation, which is used for new receipts.
|
||||
The old primary key is demoted to secondary, which can then still be used
|
||||
for validating receipts. Excess secondary keys (beyond [receipt]
|
||||
max_active_keys) are revoked. Revoked keys are permanently deleted. A new
|
||||
staged key will be created and used to validate receipts. The next time key
|
||||
rotation takes place, the staged key will be put into rotation as the
|
||||
primary key.
|
||||
|
||||
Rotating keys too frequently, or with [receipt] max_active_keys set
|
||||
too low, will cause receipts to become invalid prior to their expiration.
|
||||
|
||||
"""
|
||||
|
||||
name = 'receipt_rotate'
|
||||
|
||||
@classmethod
|
||||
def main(cls):
|
||||
futils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
|
||||
keystone_user_id, keystone_group_id = cls.get_user_group()
|
||||
if futils.validate_key_repository(requires_write=True):
|
||||
futils.rotate_keys(keystone_user_id, keystone_group_id)
|
||||
|
||||
|
||||
class CredentialSetup(BasePermissionsSetup):
|
||||
"""Setup a Fernet key repository for credential encryption.
|
||||
|
||||
|
@ -21,6 +21,11 @@
|
||||
# Header used to transmit the auth token
|
||||
AUTH_TOKEN_HEADER = 'X-Auth-Token'
|
||||
|
||||
|
||||
# Header used to transmit the auth receipt
|
||||
AUTH_RECEIPT_HEADER = 'Openstack-Auth-Receipt'
|
||||
|
||||
|
||||
# Header used to transmit the subject token
|
||||
SUBJECT_TOKEN_HEADER = 'X-Subject-Token'
|
||||
|
||||
|
@ -31,6 +31,7 @@ from keystone.conf import endpoint_filter
|
||||
from keystone.conf import endpoint_policy
|
||||
from keystone.conf import eventlet_server
|
||||
from keystone.conf import federation
|
||||
from keystone.conf import fernet_receipts
|
||||
from keystone.conf import fernet_tokens
|
||||
from keystone.conf import identity
|
||||
from keystone.conf import identity_mapping
|
||||
@ -38,6 +39,7 @@ from keystone.conf import ldap
|
||||
from keystone.conf import memcache
|
||||
from keystone.conf import oauth1
|
||||
from keystone.conf import policy
|
||||
from keystone.conf import receipt
|
||||
from keystone.conf import resource
|
||||
from keystone.conf import revoke
|
||||
from keystone.conf import role
|
||||
@ -66,6 +68,7 @@ conf_modules = [
|
||||
endpoint_policy,
|
||||
eventlet_server,
|
||||
federation,
|
||||
fernet_receipts,
|
||||
fernet_tokens,
|
||||
identity,
|
||||
identity_mapping,
|
||||
@ -73,6 +76,7 @@ conf_modules = [
|
||||
memcache,
|
||||
oauth1,
|
||||
policy,
|
||||
receipt,
|
||||
resource,
|
||||
revoke,
|
||||
role,
|
||||
@ -163,10 +167,12 @@ def set_external_opts_defaults():
|
||||
'X-Project-Domain-Id',
|
||||
'X-Project-Domain-Name',
|
||||
'X-Domain-Id',
|
||||
'X-Domain-Name'],
|
||||
'X-Domain-Name',
|
||||
'Openstack-Auth-Receipt'],
|
||||
expose_headers=['X-Auth-Token',
|
||||
'X-Openstack-Request-Id',
|
||||
'X-Subject-Token'],
|
||||
'X-Subject-Token',
|
||||
'Openstack-Auth-Receipt'],
|
||||
allow_methods=['GET',
|
||||
'PUT',
|
||||
'POST',
|
||||
|
71
keystone/conf/fernet_receipts.py
Normal file
71
keystone/conf/fernet_receipts.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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.
|
||||
|
||||
from oslo_config import cfg
|
||||
|
||||
from keystone.conf import utils
|
||||
|
||||
|
||||
key_repository = cfg.StrOpt(
|
||||
'key_repository',
|
||||
default='/etc/keystone/fernet-keys/',
|
||||
help=utils.fmt("""
|
||||
Directory containing Fernet receipt keys. This directory must exist before
|
||||
using `keystone-manage fernet_setup` for the first time, must be writable by
|
||||
the user running `keystone-manage fernet_setup` or `keystone-manage
|
||||
fernet_rotate`, and of course must be readable by keystone's server process.
|
||||
The repository may contain keys in one of three states: a single staged key
|
||||
(always index 0) used for receipt validation, a single primary key (always the
|
||||
highest index) used for receipt creation and validation, and any number of
|
||||
secondary keys (all other index values) used for receipt validation. With
|
||||
multiple keystone nodes, each node must share the same key repository contents,
|
||||
with the exception of the staged key (index 0). It is safe to run
|
||||
`keystone-manage fernet_rotate` once on any one node to promote a staged key
|
||||
(index 0) to be the new primary (incremented from the previous highest index),
|
||||
and produce a new staged key (a new key with index 0); the resulting repository
|
||||
can then be atomically replicated to other nodes without any risk of race
|
||||
conditions (for example, it is safe to run `keystone-manage fernet_rotate` on
|
||||
host A, wait any amount of time, create a tarball of the directory on host A,
|
||||
unpack it on host B to a temporary location, and atomically move (`mv`) the
|
||||
directory into place on host B). Running `keystone-manage fernet_rotate`
|
||||
*twice* on a key repository without syncing other nodes will result in receipts
|
||||
that can not be validated by all nodes.
|
||||
"""))
|
||||
|
||||
max_active_keys = cfg.IntOpt(
|
||||
'max_active_keys',
|
||||
default=3,
|
||||
min=1,
|
||||
help=utils.fmt("""
|
||||
This controls how many keys are held in rotation by `keystone-manage
|
||||
fernet_rotate` before they are discarded. The default value of 3 means that
|
||||
keystone will maintain one staged key (always index 0), one primary key (the
|
||||
highest numerical index), and one secondary key (every other index). Increasing
|
||||
this value means that additional secondary keys will be kept in the rotation.
|
||||
"""))
|
||||
|
||||
|
||||
GROUP_NAME = __name__.split('.')[-1]
|
||||
ALL_OPTS = [
|
||||
key_repository,
|
||||
max_active_keys,
|
||||
]
|
||||
|
||||
|
||||
def register_opts(conf):
|
||||
conf.register_opts(ALL_OPTS, group=GROUP_NAME)
|
||||
|
||||
|
||||
def list_opts():
|
||||
return {GROUP_NAME: ALL_OPTS}
|
86
keystone/conf/receipt.py
Normal file
86
keystone/conf/receipt.py
Normal file
@ -0,0 +1,86 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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.
|
||||
|
||||
from oslo_config import cfg
|
||||
|
||||
from keystone.conf import utils
|
||||
|
||||
|
||||
expiration = cfg.IntOpt(
|
||||
'expiration',
|
||||
default=300,
|
||||
min=0,
|
||||
max=86400,
|
||||
help=utils.fmt("""
|
||||
The amount of time that a receipt should remain valid (in seconds). This value
|
||||
should always be very short, as it represents how long a user has to reattempt
|
||||
auth with the missing auth methods.
|
||||
"""))
|
||||
|
||||
provider = cfg.StrOpt(
|
||||
'provider',
|
||||
default='fernet',
|
||||
help=utils.fmt("""
|
||||
Entry point for the receipt provider in the `keystone.receipt.provider`
|
||||
namespace. The receipt provider controls the receipt construction and
|
||||
validation operations. Keystone includes just the `fernet` receipt provider for
|
||||
now. `fernet` receipts do not need to be persisted at all, but require that you
|
||||
run `keystone-manage fernet_setup` (also see the `keystone-manage
|
||||
fernet_rotate` command).
|
||||
"""))
|
||||
|
||||
caching = cfg.BoolOpt(
|
||||
'caching',
|
||||
default=True,
|
||||
help=utils.fmt("""
|
||||
Toggle for caching receipt creation and validation data. This has no effect
|
||||
unless global caching is enabled, or if cache_on_issue is disabled as we only
|
||||
cache receipts on issue.
|
||||
"""))
|
||||
|
||||
cache_time = cfg.IntOpt(
|
||||
'cache_time',
|
||||
default=300,
|
||||
min=0,
|
||||
help=utils.fmt("""
|
||||
The number of seconds to cache receipt creation and validation data. This has
|
||||
no effect unless both global and `[receipt] caching` are enabled.
|
||||
"""))
|
||||
|
||||
cache_on_issue = cfg.BoolOpt(
|
||||
'cache_on_issue',
|
||||
default=True,
|
||||
help=utils.fmt("""
|
||||
Enable storing issued receipt data to receipt validation cache so that first
|
||||
receipt validation doesn't actually cause full validation cycle. This option
|
||||
has no effect unless global caching and receipt caching are enabled.
|
||||
"""))
|
||||
|
||||
|
||||
GROUP_NAME = __name__.split('.')[-1]
|
||||
ALL_OPTS = [
|
||||
expiration,
|
||||
provider,
|
||||
caching,
|
||||
cache_time,
|
||||
cache_on_issue,
|
||||
]
|
||||
|
||||
|
||||
def register_opts(conf):
|
||||
conf.register_opts(ALL_OPTS, group=GROUP_NAME)
|
||||
|
||||
|
||||
def list_opts():
|
||||
return {GROUP_NAME: ALL_OPTS}
|
@ -284,13 +284,25 @@ class Unauthorized(SecurityError):
|
||||
|
||||
|
||||
class InsufficientAuthMethods(Error):
|
||||
# NOTE(notmorgan): This is not a security error, this is meant to
|
||||
# communicate real information back to the user.
|
||||
# NOTE(adriant): This is an internal only error that is built into
|
||||
# an auth receipt response.
|
||||
message_format = _("Insufficient auth methods received for %(user_id)s. "
|
||||
"Auth Methods Provided: %(methods)s.")
|
||||
code = 401
|
||||
title = 'Unauthorized'
|
||||
|
||||
def __init__(self, message=None, user_id=None, methods=None):
|
||||
methods_str = '[%s]' % ','.join(methods)
|
||||
super(InsufficientAuthMethods, self).__init__(
|
||||
message, user_id=user_id, methods=methods_str)
|
||||
|
||||
self.user_id = user_id
|
||||
self.methods = methods
|
||||
|
||||
|
||||
class ReceiptNotFound(Unauthorized):
|
||||
message_format = _("Could not find auth receipt: %(receipt_id)s.")
|
||||
|
||||
|
||||
class PasswordExpired(Unauthorized):
|
||||
message_format = _("The password is expired and needs to be changed for "
|
||||
|
150
keystone/models/receipt_model.py
Normal file
150
keystone/models/receipt_model.py
Normal file
@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
|
||||
"""Unified in-memory receipt model."""
|
||||
|
||||
from oslo_log import log
|
||||
from oslo_serialization import msgpackutils
|
||||
from oslo_utils import reflection
|
||||
import six
|
||||
|
||||
from keystone.auth import core
|
||||
from keystone.common import cache
|
||||
from keystone.common import provider_api
|
||||
from keystone import exception
|
||||
from keystone.identity.backends import resource_options as ro
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
PROVIDERS = provider_api.ProviderAPIs
|
||||
|
||||
|
||||
class ReceiptModel(object):
|
||||
"""An object that represents a receipt emitted by keystone.
|
||||
|
||||
This is a queryable object that other parts of keystone can use to reason
|
||||
about a user's receipt.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.user_id = None
|
||||
self.__user = None
|
||||
self.__user_domain = None
|
||||
|
||||
self.methods = None
|
||||
self.__required_methods = None
|
||||
|
||||
self.__expires_at = None
|
||||
self.__issued_at = None
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation of KeystoneReceipt."""
|
||||
desc = ('<%(type)s at %(loc)s>')
|
||||
self_cls_name = reflection.get_class_name(self, fully_qualified=False)
|
||||
return desc % {'type': self_cls_name, 'loc': hex(id(self))}
|
||||
|
||||
@property
|
||||
def expires_at(self):
|
||||
return self.__expires_at
|
||||
|
||||
@expires_at.setter
|
||||
def expires_at(self, value):
|
||||
if not isinstance(value, six.string_types):
|
||||
raise ValueError('expires_at must be a string.')
|
||||
self.__expires_at = value
|
||||
|
||||
@property
|
||||
def issued_at(self):
|
||||
return self.__issued_at
|
||||
|
||||
@issued_at.setter
|
||||
def issued_at(self, value):
|
||||
if not isinstance(value, six.string_types):
|
||||
raise ValueError('issued_at must be a string.')
|
||||
self.__issued_at = value
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
if not self.__user:
|
||||
if self.user_id:
|
||||
self.__user = PROVIDERS.identity_api.get_user(self.user_id)
|
||||
return self.__user
|
||||
|
||||
@property
|
||||
def user_domain(self):
|
||||
if not self.__user_domain:
|
||||
if self.user:
|
||||
self.__user_domain = PROVIDERS.resource_api.get_domain(
|
||||
self.user['domain_id']
|
||||
)
|
||||
return self.__user_domain
|
||||
|
||||
@property
|
||||
def required_methods(self):
|
||||
if not self.__required_methods:
|
||||
mfa_rules = self.user['options'].get(
|
||||
ro.MFA_RULES_OPT.option_name, [])
|
||||
rules = core.UserMFARulesValidator._parse_rule_structure(
|
||||
mfa_rules, self.user_id)
|
||||
methods = set(self.methods)
|
||||
active_methods = set(core.AUTH_METHODS.keys())
|
||||
|
||||
required_auth_methods = []
|
||||
for r in rules:
|
||||
r_set = set(r).intersection(active_methods)
|
||||
if r_set.intersection(methods):
|
||||
required_auth_methods.append(list(r_set))
|
||||
|
||||
self.__required_methods = required_auth_methods
|
||||
|
||||
return self.__required_methods
|
||||
|
||||
def mint(self, receipt_id, issued_at):
|
||||
"""Set the ``id`` and ``issued_at`` attributes of a receipt.
|
||||
|
||||
The process of building a Receipt requires setting attributes about the
|
||||
partial authentication context, like ``user_id`` and ``methods`` for
|
||||
example. Once a Receipt object accurately represents this information
|
||||
it should be "minted". Receipt are minted when they get an ``id``
|
||||
attribute and their creation time is recorded.
|
||||
"""
|
||||
self.id = receipt_id
|
||||
self.issued_at = issued_at
|
||||
|
||||
|
||||
class _ReceiptModelHandler(object):
|
||||
identity = 125
|
||||
handles = (ReceiptModel,)
|
||||
|
||||
def __init__(self, registry):
|
||||
self._registry = registry
|
||||
|
||||
def serialize(self, obj):
|
||||
serialized = msgpackutils.dumps(obj.__dict__, registry=self._registry)
|
||||
return serialized
|
||||
|
||||
def deserialize(self, data):
|
||||
receipt_data = msgpackutils.loads(data, registry=self._registry)
|
||||
try:
|
||||
receipt_model = ReceiptModel()
|
||||
for k, v in iter(receipt_data.items()):
|
||||
setattr(receipt_model, k, v)
|
||||
except Exception:
|
||||
LOG.debug(
|
||||
"Failed to deserialize ReceiptModel. Data is %s", receipt_data
|
||||
)
|
||||
raise exception.CacheDeserializationError(
|
||||
ReceiptModel.__name__, receipt_data
|
||||
)
|
||||
return receipt_model
|
||||
|
||||
|
||||
cache.register_model_handler(_ReceiptModelHandler)
|
20
keystone/receipt/__init__.py
Normal file
20
keystone/receipt/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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.
|
||||
|
||||
from keystone.receipt import provider
|
||||
|
||||
|
||||
__all__ = [
|
||||
"provider",
|
||||
]
|
74
keystone/receipt/handlers.py
Normal file
74
keystone/receipt/handlers.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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 flask
|
||||
from oslo_serialization import jsonutils
|
||||
from six.moves import http_client
|
||||
|
||||
from keystone.common import authorization
|
||||
from keystone.common import provider_api
|
||||
from keystone import exception
|
||||
|
||||
|
||||
PROVIDERS = provider_api.ProviderAPIs
|
||||
|
||||
|
||||
def extract_receipt(auth_context):
|
||||
receipt_id = flask.request.headers.get(
|
||||
authorization.AUTH_RECEIPT_HEADER, None)
|
||||
if receipt_id:
|
||||
receipt = PROVIDERS.receipt_provider_api.validate_receipt(
|
||||
receipt_id)
|
||||
|
||||
if auth_context['user_id'] != receipt.user_id:
|
||||
raise exception.ReceiptNotFound(
|
||||
"AuthContext user_id: %s does not match "
|
||||
"user_id for supplied auth receipt: %s" %
|
||||
(auth_context['user_id'], receipt.user_id),
|
||||
receipt_id=receipt_id
|
||||
)
|
||||
else:
|
||||
receipt = None
|
||||
return receipt
|
||||
|
||||
|
||||
def _render_receipt_response_from_model(receipt):
|
||||
receipt_reference = {
|
||||
'receipt': {
|
||||
'methods': receipt.methods,
|
||||
'user': {
|
||||
'id': receipt.user['id'],
|
||||
'name': receipt.user['name'],
|
||||
'domain': {
|
||||
'id': receipt.user_domain['id'],
|
||||
'name': receipt.user_domain['name'],
|
||||
}
|
||||
},
|
||||
'expires_at': receipt.expires_at,
|
||||
'issued_at': receipt.issued_at,
|
||||
},
|
||||
'required_auth_methods': receipt.required_methods,
|
||||
}
|
||||
return receipt_reference
|
||||
|
||||
|
||||
def build_receipt(mfa_error):
|
||||
receipt = PROVIDERS.receipt_provider_api. \
|
||||
issue_receipt(mfa_error.user_id, mfa_error.methods)
|
||||
resp_data = _render_receipt_response_from_model(receipt)
|
||||
resp_body = jsonutils.dumps(resp_data)
|
||||
response = flask.make_response(resp_body, http_client.UNAUTHORIZED)
|
||||
response.headers[authorization.AUTH_RECEIPT_HEADER] = receipt.id
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
176
keystone/receipt/provider.py
Normal file
176
keystone/receipt/provider.py
Normal file
@ -0,0 +1,176 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Receipt provider interface."""
|
||||
|
||||
import datetime
|
||||
|
||||
from oslo_log import log
|
||||
from oslo_utils import timeutils
|
||||
import six
|
||||
|
||||
from keystone.common import cache
|
||||
from keystone.common import manager
|
||||
from keystone.common import provider_api
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
from keystone.models import receipt_model
|
||||
from keystone import notifications
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
LOG = log.getLogger(__name__)
|
||||
PROVIDERS = provider_api.ProviderAPIs
|
||||
|
||||
RECEIPTS_REGION = cache.create_region(name='receipts')
|
||||
MEMOIZE_RECEIPTS = cache.get_memoization_decorator(
|
||||
group='receipt',
|
||||
region=RECEIPTS_REGION)
|
||||
|
||||
|
||||
def default_expire_time():
|
||||
"""Determine when a fresh receipt should expire.
|
||||
|
||||
Expiration time varies based on configuration (see
|
||||
``[receipt] expiration``).
|
||||
|
||||
:returns: a naive UTC datetime.datetime object
|
||||
|
||||
"""
|
||||
expire_delta = datetime.timedelta(seconds=CONF.receipt.expiration)
|
||||
expires_at = timeutils.utcnow() + expire_delta
|
||||
return expires_at.replace(microsecond=0)
|
||||
|
||||
|
||||
class Manager(manager.Manager):
|
||||
"""Default pivot point for the receipt provider backend.
|
||||
|
||||
See :mod:`keystone.common.manager.Manager` for more details on how this
|
||||
dynamically calls the backend.
|
||||
|
||||
"""
|
||||
|
||||
driver_namespace = 'keystone.receipt.provider'
|
||||
_provides_api = 'receipt_provider_api'
|
||||
|
||||
def __init__(self):
|
||||
super(Manager, self).__init__(CONF.receipt.provider)
|
||||
self._register_callback_listeners()
|
||||
|
||||
def _register_callback_listeners(self):
|
||||
callbacks = {
|
||||
notifications.ACTIONS.deleted: [
|
||||
['OS-TRUST:trust', self._drop_receipt_cache],
|
||||
['user', self._drop_receipt_cache],
|
||||
['domain', self._drop_receipt_cache],
|
||||
],
|
||||
notifications.ACTIONS.disabled: [
|
||||
['user', self._drop_receipt_cache],
|
||||
['domain', self._drop_receipt_cache],
|
||||
['project', self._drop_receipt_cache],
|
||||
],
|
||||
notifications.ACTIONS.internal: [
|
||||
[notifications.INVALIDATE_TOKEN_CACHE,
|
||||
self._drop_receipt_cache],
|
||||
]
|
||||
}
|
||||
|
||||
for event, cb_info in callbacks.items():
|
||||
for resource_type, callback_fns in cb_info:
|
||||
notifications.register_event_callback(event, resource_type,
|
||||
callback_fns)
|
||||
|
||||
def _drop_receipt_cache(self, service, resource_type, operation, payload):
|
||||
"""Invalidate the entire receipt cache.
|
||||
|
||||
This is a handy private utility method that should be used when
|
||||
consuming notifications that signal invalidating the receipt cache.
|
||||
|
||||
"""
|
||||
if CONF.receipt.cache_on_issue:
|
||||
RECEIPTS_REGION.invalidate()
|
||||
|
||||
def validate_receipt(self, receipt_id, window_seconds=0):
|
||||
if not receipt_id:
|
||||
raise exception.ReceiptNotFound(
|
||||
_('No receipt in the request'), receipt_id=receipt_id)
|
||||
|
||||
try:
|
||||
receipt = self._validate_receipt(receipt_id)
|
||||
self._is_valid_receipt(receipt, window_seconds=window_seconds)
|
||||
return receipt
|
||||
except exception.Unauthorized as e:
|
||||
LOG.debug('Unable to validate receipt: %s', e)
|
||||
raise exception.ReceiptNotFound(receipt_id=receipt_id)
|
||||
|
||||
@MEMOIZE_RECEIPTS
|
||||
def _validate_receipt(self, receipt_id):
|
||||
(user_id, methods, issued_at,
|
||||
expires_at) = self.driver.validate_receipt(receipt_id)
|
||||
|
||||
receipt = receipt_model.ReceiptModel()
|
||||
receipt.user_id = user_id
|
||||
receipt.methods = methods
|
||||
receipt.expires_at = expires_at
|
||||
receipt.mint(receipt_id, issued_at)
|
||||
return receipt
|
||||
|
||||
def _is_valid_receipt(self, receipt, window_seconds=0):
|
||||
"""Verify the receipt is valid format and has not expired."""
|
||||
current_time = timeutils.normalize_time(timeutils.utcnow())
|
||||
|
||||
try:
|
||||
expiry = timeutils.parse_isotime(receipt.expires_at)
|
||||
expiry = timeutils.normalize_time(expiry)
|
||||
|
||||
# add a window in which you can fetch a receipt beyond expiry
|
||||
expiry += datetime.timedelta(seconds=window_seconds)
|
||||
|
||||
except Exception:
|
||||
LOG.exception('Unexpected error or malformed receipt '
|
||||
'determining receipt expiry: %s', receipt)
|
||||
raise exception.ReceiptNotFound(
|
||||
_('Failed to validate receipt'), receipt_id=receipt.id)
|
||||
|
||||
if current_time < expiry:
|
||||
return None
|
||||
else:
|
||||
raise exception.ReceiptNotFound(
|
||||
_('Failed to validate receipt'), receipt_id=receipt.id)
|
||||
|
||||
def issue_receipt(self, user_id, method_names, expires_at=None):
|
||||
|
||||
receipt = receipt_model.ReceiptModel()
|
||||
receipt.user_id = user_id
|
||||
receipt.methods = method_names
|
||||
|
||||
if isinstance(expires_at, datetime.datetime):
|
||||
receipt.expires_at = utils.isotime(expires_at, subsecond=True)
|
||||
if isinstance(expires_at, six.string_types):
|
||||
receipt.expires_at = expires_at
|
||||
elif not expires_at:
|
||||
receipt.expires_at = utils.isotime(
|
||||
default_expire_time(), subsecond=True
|
||||
)
|
||||
|
||||
receipt_id, issued_at = self.driver.generate_id_and_issued_at(receipt)
|
||||
receipt.mint(receipt_id, issued_at)
|
||||
|
||||
if CONF.receipt.cache_on_issue:
|
||||
self._validate_receipt.set(
|
||||
receipt, RECEIPTS_REGION, receipt_id)
|
||||
|
||||
return receipt
|
0
keystone/receipt/providers/__init__.py
Normal file
0
keystone/receipt/providers/__init__.py
Normal file
54
keystone/receipt/providers/base.py
Normal file
54
keystone/receipt/providers/base.py
Normal file
@ -0,0 +1,54 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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 abc
|
||||
|
||||
import six
|
||||
|
||||
from keystone import exception
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class Provider(object):
|
||||
"""Interface description for a Receipt provider."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate_receipt(self, receipt_id):
|
||||
"""Validate a given receipt by its ID and return the receipt_data.
|
||||
|
||||
:param receipt_id: the unique ID of the receipt
|
||||
:type receipt_id: str
|
||||
:returns: receipt data as a tuple in the form of:
|
||||
|
||||
(user_id, methods, issued_at, expires_at)
|
||||
|
||||
``user_id`` is the unique ID of the user as a string
|
||||
``methods`` a list of authentication methods used to obtain the receipt
|
||||
``issued_at`` a datetime object of when the receipt was minted
|
||||
``expires_at`` a datetime object of when the receipt expires
|
||||
|
||||
:raises keystone.exception.ReceiptNotFound: when receipt doesn't exist.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def generate_id_and_issued_at(self, receipt):
|
||||
"""Generate a receipt based on the information provided.
|
||||
|
||||
:param receipt: A receipt object containing information about the
|
||||
authorization context of the request.
|
||||
:type receipt: `keystone.models.receipt.ReceiptModel`
|
||||
:returns: tuple containing an ID for the receipt and the issued at time
|
||||
of the receipt (receipt_id, issued_at).
|
||||
"""
|
||||
raise exception.NotImplemented() # pragma: no cover
|
20
keystone/receipt/providers/fernet/__init__.py
Normal file
20
keystone/receipt/providers/fernet/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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.
|
||||
|
||||
from keystone.receipt.providers.fernet.core import Provider
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
]
|
66
keystone/receipt/providers/fernet/core.py
Normal file
66
keystone/receipt/providers/fernet/core.py
Normal file
@ -0,0 +1,66 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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 os
|
||||
|
||||
|
||||
from keystone.common import utils as ks_utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
from keystone.receipt.providers import base
|
||||
from keystone.receipt import receipt_formatters as tf
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
|
||||
|
||||
class Provider(base.Provider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Provider, self).__init__(*args, **kwargs)
|
||||
|
||||
# NOTE(lbragstad): We add these checks here because if the fernet
|
||||
# provider is going to be used and either the `key_repository` is empty
|
||||
# or doesn't exist we should fail, hard. It doesn't make sense to start
|
||||
# keystone and just 500 because we can't do anything with an empty or
|
||||
# non-existant key repository.
|
||||
if not os.path.exists(CONF.fernet_receipts.key_repository):
|
||||
subs = {'key_repo': CONF.fernet_receipts.key_repository}
|
||||
raise SystemExit(_('%(key_repo)s does not exist') % subs)
|
||||
if not os.listdir(CONF.fernet_receipts.key_repository):
|
||||
subs = {'key_repo': CONF.fernet_receipts.key_repository}
|
||||
raise SystemExit(_('%(key_repo)s does not contain keys, use '
|
||||
'keystone-manage fernet_setup to create '
|
||||
'Fernet keys.') % subs)
|
||||
|
||||
self.receipt_formatter = tf.ReceiptFormatter()
|
||||
|
||||
def validate_receipt(self, receipt_id):
|
||||
try:
|
||||
return self.receipt_formatter.validate_receipt(receipt_id)
|
||||
except exception.ValidationError:
|
||||
raise exception.ReceiptNotFound(receipt_id=receipt_id)
|
||||
|
||||
def generate_id_and_issued_at(self, receipt):
|
||||
receipt_id = self.receipt_formatter.create_receipt(
|
||||
receipt.user_id,
|
||||
receipt.methods,
|
||||
receipt.expires_at,
|
||||
)
|
||||
creation_datetime_obj = self.receipt_formatter.creation_time(
|
||||
receipt_id)
|
||||
issued_at = ks_utils.isotime(
|
||||
at=creation_datetime_obj, subsecond=True
|
||||
)
|
||||
return receipt_id, issued_at
|
303
keystone/receipt/receipt_formatters.py
Normal file
303
keystone/receipt/receipt_formatters.py
Normal file
@ -0,0 +1,303 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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 base64
|
||||
import datetime
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
from cryptography import fernet
|
||||
import msgpack
|
||||
from oslo_log import log
|
||||
from oslo_utils import timeutils
|
||||
|
||||
from keystone.auth import plugins as auth_plugins
|
||||
from keystone.common import fernet_utils as utils
|
||||
from keystone.common import utils as ks_utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.i18n import _
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
# Fernet byte indexes as computed by pypi/keyless_fernet and defined in
|
||||
# https://github.com/fernet/spec
|
||||
TIMESTAMP_START = 1
|
||||
TIMESTAMP_END = 9
|
||||
|
||||
|
||||
class ReceiptFormatter(object):
|
||||
"""Packs and unpacks payloads into receipts for transport."""
|
||||
|
||||
@property
|
||||
def crypto(self):
|
||||
"""Return a cryptography instance.
|
||||
|
||||
You can extend this class with a custom crypto @property to provide
|
||||
your own receipt encoding / decoding. For example, using a different
|
||||
cryptography library (e.g. ``python-keyczar``) or to meet arbitrary
|
||||
security requirements.
|
||||
|
||||
This @property just needs to return an object that implements
|
||||
``encrypt(plaintext)`` and ``decrypt(ciphertext)``.
|
||||
|
||||
"""
|
||||
fernet_utils = utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
keys = fernet_utils.load_keys()
|
||||
|
||||
if not keys:
|
||||
raise exception.KeysNotFound()
|
||||
|
||||
fernet_instances = [fernet.Fernet(key) for key in keys]
|
||||
return fernet.MultiFernet(fernet_instances)
|
||||
|
||||
def pack(self, payload):
|
||||
"""Pack a payload for transport as a receipt.
|
||||
|
||||
:type payload: six.binary_type
|
||||
:rtype: six.text_type
|
||||
|
||||
"""
|
||||
# base64 padding (if any) is not URL-safe
|
||||
return self.crypto.encrypt(payload).rstrip(b'=').decode('utf-8')
|
||||
|
||||
def unpack(self, receipt):
|
||||
"""Unpack a receipt, and validate the payload.
|
||||
|
||||
:type receipt: six.text_type
|
||||
:rtype: six.binary_type
|
||||
|
||||
"""
|
||||
receipt = ReceiptFormatter.restore_padding(receipt)
|
||||
|
||||
try:
|
||||
return self.crypto.decrypt(receipt.encode('utf-8'))
|
||||
except fernet.InvalidToken:
|
||||
raise exception.ValidationError(
|
||||
_('This is not a recognized Fernet receipt %s') % receipt)
|
||||
|
||||
@classmethod
|
||||
def restore_padding(cls, receipt):
|
||||
"""Restore padding based on receipt size.
|
||||
|
||||
:param receipt: receipt to restore padding on
|
||||
:type receipt: six.text_type
|
||||
:returns: receipt with correct padding
|
||||
|
||||
"""
|
||||
# Re-inflate the padding
|
||||
mod_returned = len(receipt) % 4
|
||||
if mod_returned:
|
||||
missing_padding = 4 - mod_returned
|
||||
receipt += '=' * missing_padding
|
||||
return receipt
|
||||
|
||||
@classmethod
|
||||
def creation_time(cls, fernet_receipt):
|
||||
"""Return the creation time of a valid Fernet receipt.
|
||||
|
||||
:type fernet_receipt: six.text_type
|
||||
|
||||
"""
|
||||
fernet_receipt = ReceiptFormatter.restore_padding(fernet_receipt)
|
||||
# fernet_receipt is six.text_type
|
||||
|
||||
# Fernet receipts are base64 encoded, so we need to unpack them first
|
||||
# urlsafe_b64decode() requires six.binary_type
|
||||
receipt_bytes = base64.urlsafe_b64decode(
|
||||
fernet_receipt.encode('utf-8'))
|
||||
|
||||
# slice into the byte array to get just the timestamp
|
||||
timestamp_bytes = receipt_bytes[TIMESTAMP_START:TIMESTAMP_END]
|
||||
|
||||
# convert those bytes to an integer
|
||||
# (it's a 64-bit "unsigned long long int" in C)
|
||||
timestamp_int = struct.unpack(">Q", timestamp_bytes)[0]
|
||||
|
||||
# and with an integer, it's trivial to produce a datetime object
|
||||
issued_at = datetime.datetime.utcfromtimestamp(timestamp_int)
|
||||
|
||||
return issued_at
|
||||
|
||||
def create_receipt(self, user_id, methods, expires_at):
|
||||
"""Given a set of payload attributes, generate a Fernet receipt."""
|
||||
payload = ReceiptPayload.assemble(user_id, methods, expires_at)
|
||||
|
||||
serialized_payload = msgpack.packb(payload)
|
||||
receipt = self.pack(serialized_payload)
|
||||
|
||||
# NOTE(lbragstad): We should warn against Fernet receipts that are over
|
||||
# 255 characters in length. This is mostly due to persisting the
|
||||
# receipts in a backend store of some kind that might have a limit of
|
||||
# 255 characters. Even though Keystone isn't storing a Fernet receipt
|
||||
# anywhere, we can't say it isn't being stored somewhere else with
|
||||
# those kind of backend constraints.
|
||||
if len(receipt) > 255:
|
||||
LOG.info('Fernet receipt created with length of %d '
|
||||
'characters, which exceeds 255 characters',
|
||||
len(receipt))
|
||||
|
||||
return receipt
|
||||
|
||||
def validate_receipt(self, receipt):
|
||||
"""Validate a Fernet receipt and returns the payload attributes.
|
||||
|
||||
:type receipt: six.text_type
|
||||
|
||||
"""
|
||||
serialized_payload = self.unpack(receipt)
|
||||
payload = msgpack.unpackb(serialized_payload)
|
||||
|
||||
(user_id, methods, expires_at) = ReceiptPayload.disassemble(payload)
|
||||
|
||||
# rather than appearing in the payload, the creation time is encoded
|
||||
# into the receipt format itself
|
||||
issued_at = ReceiptFormatter.creation_time(receipt)
|
||||
issued_at = ks_utils.isotime(at=issued_at, subsecond=True)
|
||||
expires_at = timeutils.parse_isotime(expires_at)
|
||||
expires_at = ks_utils.isotime(at=expires_at, subsecond=True)
|
||||
|
||||
return (user_id, methods, issued_at, expires_at)
|
||||
|
||||
|
||||
class ReceiptPayload(object):
|
||||
|
||||
@classmethod
|
||||
def assemble(cls, user_id, methods, expires_at):
|
||||
"""Assemble the payload of a receipt.
|
||||
|
||||
:param user_id: identifier of the user in the receipt request
|
||||
:param methods: list of authentication methods used
|
||||
:param expires_at: datetime of the receipt's expiration
|
||||
:returns: the payload of a receipt
|
||||
|
||||
"""
|
||||
b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id)
|
||||
methods = auth_plugins.convert_method_list_to_integer(methods)
|
||||
expires_at_int = cls._convert_time_string_to_float(expires_at)
|
||||
return (b_user_id, methods, expires_at_int)
|
||||
|
||||
@classmethod
|
||||
def disassemble(cls, payload):
|
||||
"""Disassemble a payload into the component data.
|
||||
|
||||
The tuple consists of::
|
||||
|
||||
(user_id, methods, expires_at_str)
|
||||
|
||||
* ``methods`` are the auth methods.
|
||||
|
||||
:param payload: this variant of payload
|
||||
:returns: a tuple of the payloads component data
|
||||
|
||||
"""
|
||||
(is_stored_as_bytes, user_id) = payload[0]
|
||||
if is_stored_as_bytes:
|
||||
user_id = cls.convert_uuid_bytes_to_hex(user_id)
|
||||
methods = auth_plugins.convert_integer_to_method_list(payload[1])
|
||||
expires_at_str = cls._convert_float_to_time_string(payload[2])
|
||||
return (user_id, methods, expires_at_str)
|
||||
|
||||
@classmethod
|
||||
def convert_uuid_hex_to_bytes(cls, uuid_string):
|
||||
"""Compress UUID formatted strings to bytes.
|
||||
|
||||
:param uuid_string: uuid string to compress to bytes
|
||||
:returns: a byte representation of the uuid
|
||||
|
||||
"""
|
||||
uuid_obj = uuid.UUID(uuid_string)
|
||||
return uuid_obj.bytes
|
||||
|
||||
@classmethod
|
||||
def convert_uuid_bytes_to_hex(cls, uuid_byte_string):
|
||||
"""Generate uuid.hex format based on byte string.
|
||||
|
||||
:param uuid_byte_string: uuid string to generate from
|
||||
:returns: uuid hex formatted string
|
||||
|
||||
"""
|
||||
uuid_obj = uuid.UUID(bytes=uuid_byte_string)
|
||||
return uuid_obj.hex
|
||||
|
||||
@classmethod
|
||||
def _convert_time_string_to_float(cls, time_string):
|
||||
"""Convert a time formatted string to a float.
|
||||
|
||||
:param time_string: time formatted string
|
||||
:returns: a timestamp as a float
|
||||
|
||||
"""
|
||||
time_object = timeutils.parse_isotime(time_string)
|
||||
return (timeutils.normalize_time(time_object) -
|
||||
datetime.datetime.utcfromtimestamp(0)).total_seconds()
|
||||
|
||||
@classmethod
|
||||
def _convert_float_to_time_string(cls, time_float):
|
||||
"""Convert a floating point timestamp to a string.
|
||||
|
||||
:param time_float: integer representing timestamp
|
||||
:returns: a time formatted strings
|
||||
|
||||
"""
|
||||
time_object = datetime.datetime.utcfromtimestamp(time_float)
|
||||
return ks_utils.isotime(time_object, subsecond=True)
|
||||
|
||||
@classmethod
|
||||
def attempt_convert_uuid_hex_to_bytes(cls, value):
|
||||
"""Attempt to convert value to bytes or return value.
|
||||
|
||||
:param value: value to attempt to convert to bytes
|
||||
:returns: tuple containing boolean indicating whether user_id was
|
||||
stored as bytes and uuid value as bytes or the original value
|
||||
|
||||
"""
|
||||
try:
|
||||
return (True, cls.convert_uuid_hex_to_bytes(value))
|
||||
except ValueError:
|
||||
# this might not be a UUID, depending on the situation (i.e.
|
||||
# federation)
|
||||
return (False, value)
|
||||
|
||||
@classmethod
|
||||
def base64_encode(cls, s):
|
||||
"""Encode a URL-safe string.
|
||||
|
||||
:type s: six.text_type
|
||||
:rtype: six.text_type
|
||||
|
||||
"""
|
||||
# urlsafe_b64encode() returns six.binary_type so need to convert to
|
||||
# six.text_type, might as well do it before stripping.
|
||||
return base64.urlsafe_b64encode(s).decode('utf-8').rstrip('=')
|
||||
|
||||
@classmethod
|
||||
def random_urlsafe_str_to_bytes(cls, s):
|
||||
"""Convert a string from :func:`random_urlsafe_str()` to six.binary_type.
|
||||
|
||||
:type s: six.text_type
|
||||
:rtype: six.binary_type
|
||||
|
||||
"""
|
||||
# urlsafe_b64decode() requires str, unicode isn't accepted.
|
||||
s = str(s)
|
||||
|
||||
# restore the padding (==) at the end of the string
|
||||
return base64.urlsafe_b64decode(s + '==')
|
@ -27,6 +27,7 @@ from keystone import identity
|
||||
from keystone import limit
|
||||
from keystone import oauth1
|
||||
from keystone import policy
|
||||
from keystone import receipt
|
||||
from keystone import resource
|
||||
from keystone import revoke
|
||||
from keystone import token
|
||||
@ -43,6 +44,7 @@ def load_backends():
|
||||
cache.configure_cache(region=assignment.COMPUTED_ASSIGNMENTS_REGION)
|
||||
cache.configure_cache(region=revoke.REVOKE_REGION)
|
||||
cache.configure_cache(region=token.provider.TOKENS_REGION)
|
||||
cache.configure_cache(region=receipt.provider.RECEIPTS_REGION)
|
||||
cache.configure_cache(region=identity.ID_MAPPING_REGION)
|
||||
cache.configure_invalidation_region()
|
||||
|
||||
@ -54,7 +56,8 @@ def load_backends():
|
||||
identity.Manager, identity.ShadowUsersManager,
|
||||
limit.Manager, oauth1.Manager, policy.Manager,
|
||||
resource.Manager, revoke.Manager, assignment.RoleManager,
|
||||
trust.Manager, token.provider.Manager]
|
||||
receipt.provider.Manager, trust.Manager,
|
||||
token.provider.Manager]
|
||||
|
||||
drivers = {d._provides_api: d() for d in managers}
|
||||
|
||||
|
@ -28,6 +28,8 @@ from keystone.server.flask import common as ks_flask
|
||||
from keystone.server.flask.request_processing import json_body
|
||||
from keystone.server.flask.request_processing import req_logging
|
||||
|
||||
from keystone.receipt import handlers as receipt_handlers
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
@ -67,6 +69,10 @@ def _best_match_language():
|
||||
|
||||
|
||||
def _handle_keystone_exception(error):
|
||||
# TODO(adriant): register this with its own specific handler:
|
||||
if isinstance(error, exception.InsufficientAuthMethods):
|
||||
return receipt_handlers.build_receipt(error)
|
||||
|
||||
# Handle logging
|
||||
if isinstance(error, exception.Unauthorized):
|
||||
LOG.warning(
|
||||
|
@ -50,6 +50,14 @@ class TestCaseWithBootstrap(core.BaseTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
'fernet_receipts',
|
||||
CONF.fernet_receipts.max_active_keys
|
||||
)
|
||||
)
|
||||
|
||||
self.bootstrapper = bootstrap.Bootstrapper()
|
||||
self.addCleanup(provider_api.ProviderAPIs._clear_registry_instances)
|
||||
self.addCleanup(self.clean_default_domain)
|
||||
|
@ -789,6 +789,14 @@ class TestCase(BaseTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
'fernet_receipts',
|
||||
CONF.fernet_receipts.max_active_keys
|
||||
)
|
||||
)
|
||||
|
||||
def _assert_config_overrides_called(self):
|
||||
assert self.__config_overrides_called is True
|
||||
|
||||
|
0
keystone/tests/unit/receipt/__init__.py
Normal file
0
keystone/tests/unit/receipt/__init__.py
Normal file
471
keystone/tests/unit/receipt/test_fernet_provider.py
Normal file
471
keystone/tests/unit/receipt/test_fernet_provider.py
Normal file
@ -0,0 +1,471 @@
|
||||
# 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 base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import mock
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from oslo_utils import timeutils
|
||||
import six
|
||||
|
||||
from keystone.common import fernet_utils
|
||||
from keystone.common import provider_api
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.identity.backends import resource_options as ro
|
||||
from keystone.receipt.providers import fernet
|
||||
from keystone.receipt import receipt_formatters
|
||||
from keystone.tests import unit
|
||||
from keystone.tests.unit import default_fixtures
|
||||
from keystone.tests.unit import ksfixtures
|
||||
from keystone.tests.unit.ksfixtures import database
|
||||
from keystone.token import provider as token_provider
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
PROVIDERS = provider_api.ProviderAPIs
|
||||
|
||||
|
||||
class TestFernetReceiptProvider(unit.TestCase):
|
||||
def setUp(self):
|
||||
super(TestFernetReceiptProvider, self).setUp()
|
||||
self.provider = fernet.Provider()
|
||||
|
||||
def test_invalid_receipt_raises_receipt_not_found(self):
|
||||
receipt_id = uuid.uuid4().hex
|
||||
e = self.assertRaises(
|
||||
exception.ReceiptNotFound,
|
||||
self.provider.validate_receipt,
|
||||
receipt_id)
|
||||
self.assertIn(receipt_id, u'%s' % e)
|
||||
|
||||
|
||||
class TestValidate(unit.TestCase):
|
||||
def setUp(self):
|
||||
super(TestValidate, self).setUp()
|
||||
self.useFixture(database.Database())
|
||||
self.useFixture(
|
||||
ksfixtures.ConfigAuthPlugins(
|
||||
self.config_fixture,
|
||||
['totp', 'token', 'password']))
|
||||
self.load_backends()
|
||||
PROVIDERS.resource_api.create_domain(
|
||||
default_fixtures.ROOT_DOMAIN['id'], default_fixtures.ROOT_DOMAIN)
|
||||
|
||||
def config_overrides(self):
|
||||
super(TestValidate, self).config_overrides()
|
||||
self.config_fixture.config(group='receipt', provider='fernet')
|
||||
|
||||
def test_validate_v3_receipt_simple(self):
|
||||
# Check the fields in the receipt result when use validate_v3_receipt
|
||||
# with a simple receipt.
|
||||
|
||||
domain_ref = unit.new_domain_ref()
|
||||
domain_ref = PROVIDERS.resource_api.create_domain(
|
||||
domain_ref['id'], domain_ref
|
||||
)
|
||||
|
||||
rule_list = [
|
||||
['password', 'totp'],
|
||||
['password', 'totp', 'token'],
|
||||
]
|
||||
|
||||
user_ref = unit.new_user_ref(domain_ref['id'])
|
||||
user_ref = PROVIDERS.identity_api.create_user(user_ref)
|
||||
user_ref['options'][ro.MFA_RULES_OPT.option_name] = rule_list
|
||||
user_ref['options'][ro.MFA_ENABLED_OPT.option_name] = True
|
||||
PROVIDERS.identity_api.update_user(user_ref['id'], user_ref)
|
||||
|
||||
method_names = ['password']
|
||||
receipt = PROVIDERS.receipt_provider_api.\
|
||||
issue_receipt(user_ref['id'], method_names)
|
||||
|
||||
receipt = PROVIDERS.receipt_provider_api.validate_receipt(
|
||||
receipt.id)
|
||||
self.assertIsInstance(receipt.expires_at, str)
|
||||
self.assertIsInstance(receipt.issued_at, str)
|
||||
self.assertEqual(set(method_names), set(receipt.methods))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in rule_list),
|
||||
set(frozenset(r) for r in
|
||||
receipt.required_methods))
|
||||
self.assertEqual(user_ref['id'], receipt.user_id)
|
||||
|
||||
def test_validate_v3_receipt_validation_error_exc(self):
|
||||
# When the receipt format isn't recognized, ReceiptNotFound is raised.
|
||||
|
||||
# A uuid string isn't a valid Fernet receipt.
|
||||
receipt_id = uuid.uuid4().hex
|
||||
self.assertRaises(
|
||||
exception.ReceiptNotFound,
|
||||
PROVIDERS.receipt_provider_api.validate_receipt,
|
||||
receipt_id
|
||||
)
|
||||
|
||||
|
||||
class TestReceiptFormatter(unit.TestCase):
|
||||
def test_restore_padding(self):
|
||||
# 'a' will result in '==' padding, 'aa' will result in '=' padding, and
|
||||
# 'aaa' will result in no padding.
|
||||
binary_to_test = [b'a', b'aa', b'aaa']
|
||||
|
||||
for binary in binary_to_test:
|
||||
# base64.urlsafe_b64encode takes six.binary_type and returns
|
||||
# six.binary_type.
|
||||
encoded_string = base64.urlsafe_b64encode(binary)
|
||||
encoded_string = encoded_string.decode('utf-8')
|
||||
# encoded_string is now six.text_type.
|
||||
encoded_str_without_padding = encoded_string.rstrip('=')
|
||||
self.assertFalse(encoded_str_without_padding.endswith('='))
|
||||
encoded_str_with_padding_restored = (
|
||||
receipt_formatters.ReceiptFormatter.restore_padding(
|
||||
encoded_str_without_padding)
|
||||
)
|
||||
self.assertEqual(encoded_string, encoded_str_with_padding_restored)
|
||||
|
||||
|
||||
class TestPayloads(unit.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestPayloads, self).setUp()
|
||||
self.useFixture(
|
||||
ksfixtures.ConfigAuthPlugins(
|
||||
self.config_fixture, ['totp', 'token', 'password']))
|
||||
|
||||
def assertTimestampsEqual(self, expected, actual):
|
||||
# The timestamp that we get back when parsing the payload may not
|
||||
# exactly match the timestamp that was put in the payload due to
|
||||
# conversion to and from a float.
|
||||
|
||||
exp_time = timeutils.parse_isotime(expected)
|
||||
actual_time = timeutils.parse_isotime(actual)
|
||||
|
||||
# the granularity of timestamp string is microseconds and it's only the
|
||||
# last digit in the representation that's different, so use a delta
|
||||
# just above nanoseconds.
|
||||
return self.assertCloseEnoughForGovernmentWork(exp_time, actual_time,
|
||||
delta=1e-05)
|
||||
|
||||
def test_strings_can_be_converted_to_bytes(self):
|
||||
s = token_provider.random_urlsafe_str()
|
||||
self.assertIsInstance(s, six.text_type)
|
||||
|
||||
b = receipt_formatters.ReceiptPayload.random_urlsafe_str_to_bytes(s)
|
||||
self.assertIsInstance(b, six.binary_type)
|
||||
|
||||
def test_uuid_hex_to_byte_conversions(self):
|
||||
payload_cls = receipt_formatters.ReceiptPayload
|
||||
|
||||
expected_hex_uuid = uuid.uuid4().hex
|
||||
uuid_obj = uuid.UUID(expected_hex_uuid)
|
||||
expected_uuid_in_bytes = uuid_obj.bytes
|
||||
actual_uuid_in_bytes = payload_cls.convert_uuid_hex_to_bytes(
|
||||
expected_hex_uuid)
|
||||
self.assertEqual(expected_uuid_in_bytes, actual_uuid_in_bytes)
|
||||
actual_hex_uuid = payload_cls.convert_uuid_bytes_to_hex(
|
||||
expected_uuid_in_bytes)
|
||||
self.assertEqual(expected_hex_uuid, actual_hex_uuid)
|
||||
|
||||
def test_time_string_to_float_conversions(self):
|
||||
payload_cls = receipt_formatters.ReceiptPayload
|
||||
|
||||
original_time_str = utils.isotime(subsecond=True)
|
||||
time_obj = timeutils.parse_isotime(original_time_str)
|
||||
expected_time_float = (
|
||||
(timeutils.normalize_time(time_obj) -
|
||||
datetime.datetime.utcfromtimestamp(0)).total_seconds())
|
||||
|
||||
# NOTE(lbragstad): The receipt expiration time for Fernet receipts is
|
||||
# passed in the payload of the receipt. This is different from the
|
||||
# receipt creation time, which is handled by Fernet and doesn't support
|
||||
# subsecond precision because it is a timestamp integer.
|
||||
self.assertIsInstance(expected_time_float, float)
|
||||
|
||||
actual_time_float = payload_cls._convert_time_string_to_float(
|
||||
original_time_str)
|
||||
self.assertIsInstance(actual_time_float, float)
|
||||
self.assertEqual(expected_time_float, actual_time_float)
|
||||
|
||||
# Generate expected_time_str using the same time float. Using
|
||||
# original_time_str from utils.isotime will occasionally fail due to
|
||||
# floating point rounding differences.
|
||||
time_object = datetime.datetime.utcfromtimestamp(actual_time_float)
|
||||
expected_time_str = utils.isotime(time_object, subsecond=True)
|
||||
|
||||
actual_time_str = payload_cls._convert_float_to_time_string(
|
||||
actual_time_float)
|
||||
self.assertEqual(expected_time_str, actual_time_str)
|
||||
|
||||
def _test_payload(self, payload_class, exp_user_id=None, exp_methods=None):
|
||||
exp_user_id = exp_user_id or uuid.uuid4().hex
|
||||
exp_methods = exp_methods or ['password']
|
||||
exp_expires_at = utils.isotime(timeutils.utcnow(), subsecond=True)
|
||||
|
||||
payload = payload_class.assemble(
|
||||
exp_user_id, exp_methods, exp_expires_at)
|
||||
|
||||
(user_id, methods, expires_at) = payload_class.disassemble(payload)
|
||||
|
||||
self.assertEqual(exp_user_id, user_id)
|
||||
self.assertEqual(exp_methods, methods)
|
||||
self.assertTimestampsEqual(exp_expires_at, expires_at)
|
||||
|
||||
def test_payload(self):
|
||||
self._test_payload(receipt_formatters.ReceiptPayload)
|
||||
|
||||
def test_payload_multiple_methods(self):
|
||||
self._test_payload(
|
||||
receipt_formatters.ReceiptPayload,
|
||||
exp_methods=['password', 'totp'])
|
||||
|
||||
|
||||
class TestFernetKeyRotation(unit.TestCase):
|
||||
def setUp(self):
|
||||
super(TestFernetKeyRotation, self).setUp()
|
||||
|
||||
# A collection of all previously-seen signatures of the key
|
||||
# repository's contents.
|
||||
self.key_repo_signatures = set()
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
"""Key files converted to numbers."""
|
||||
return sorted(
|
||||
int(x) for x in os.listdir(CONF.fernet_receipts.key_repository))
|
||||
|
||||
@property
|
||||
def key_repository_size(self):
|
||||
"""The number of keys in the key repository."""
|
||||
return len(self.keys)
|
||||
|
||||
@property
|
||||
def key_repository_signature(self):
|
||||
"""Create a "thumbprint" of the current key repository.
|
||||
|
||||
Because key files are renamed, this produces a hash of the contents of
|
||||
the key files, ignoring their filenames.
|
||||
|
||||
The resulting signature can be used, for example, to ensure that you
|
||||
have a unique set of keys after you perform a key rotation (taking a
|
||||
static set of keys, and simply shuffling them, would fail such a test).
|
||||
|
||||
"""
|
||||
# Load the keys into a list, keys is list of six.text_type.
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
keys = key_utils.load_keys()
|
||||
|
||||
# Sort the list of keys by the keys themselves (they were previously
|
||||
# sorted by filename).
|
||||
keys.sort()
|
||||
|
||||
# Create the thumbprint using all keys in the repository.
|
||||
signature = hashlib.sha1()
|
||||
for key in keys:
|
||||
# Need to convert key to six.binary_type for update.
|
||||
signature.update(key.encode('utf-8'))
|
||||
return signature.hexdigest()
|
||||
|
||||
def assertRepositoryState(self, expected_size):
|
||||
"""Validate the state of the key repository."""
|
||||
self.assertEqual(expected_size, self.key_repository_size)
|
||||
self.assertUniqueRepositoryState()
|
||||
|
||||
def assertUniqueRepositoryState(self):
|
||||
"""Ensure that the current key repo state has not been seen before."""
|
||||
# This is assigned to a variable because it takes some work to
|
||||
# calculate.
|
||||
signature = self.key_repository_signature
|
||||
|
||||
# Ensure the signature is not in the set of previously seen signatures.
|
||||
self.assertNotIn(signature, self.key_repo_signatures)
|
||||
|
||||
# Add the signature to the set of repository signatures to validate
|
||||
# that we don't see it again later.
|
||||
self.key_repo_signatures.add(signature)
|
||||
|
||||
def test_rotation(self):
|
||||
# Initializing a key repository results in this many keys. We don't
|
||||
# support max_active_keys being set any lower.
|
||||
min_active_keys = 2
|
||||
|
||||
# Simulate every rotation strategy up to "rotating once a week while
|
||||
# maintaining a year's worth of keys."
|
||||
for max_active_keys in range(min_active_keys, 52 + 1):
|
||||
self.config_fixture.config(group='fernet_receipts',
|
||||
max_active_keys=max_active_keys)
|
||||
|
||||
# Ensure that resetting the key repository always results in 2
|
||||
# active keys.
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
'fernet_receipts',
|
||||
CONF.fernet_receipts.max_active_keys
|
||||
)
|
||||
)
|
||||
|
||||
# Validate the initial repository state.
|
||||
self.assertRepositoryState(expected_size=min_active_keys)
|
||||
|
||||
# The repository should be initialized with a staged key (0) and a
|
||||
# primary key (1). The next key is just auto-incremented.
|
||||
exp_keys = [0, 1]
|
||||
next_key_number = exp_keys[-1] + 1 # keep track of next key
|
||||
self.assertEqual(exp_keys, self.keys)
|
||||
|
||||
# Rotate the keys just enough times to fully populate the key
|
||||
# repository.
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
for rotation in range(max_active_keys - min_active_keys):
|
||||
key_utils.rotate_keys()
|
||||
self.assertRepositoryState(expected_size=rotation + 3)
|
||||
|
||||
exp_keys.append(next_key_number)
|
||||
next_key_number += 1
|
||||
self.assertEqual(exp_keys, self.keys)
|
||||
|
||||
# We should have a fully populated key repository now.
|
||||
self.assertEqual(max_active_keys, self.key_repository_size)
|
||||
|
||||
# Rotate an additional number of times to ensure that we maintain
|
||||
# the desired number of active keys.
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
for rotation in range(10):
|
||||
key_utils.rotate_keys()
|
||||
self.assertRepositoryState(expected_size=max_active_keys)
|
||||
|
||||
exp_keys.pop(1)
|
||||
exp_keys.append(next_key_number)
|
||||
next_key_number += 1
|
||||
self.assertEqual(exp_keys, self.keys)
|
||||
|
||||
def test_rotation_disk_write_fail(self):
|
||||
# Make sure that the init key repository contains 2 keys
|
||||
self.assertRepositoryState(expected_size=2)
|
||||
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
|
||||
# Simulate the disk full situation
|
||||
mock_open = mock.mock_open()
|
||||
file_handle = mock_open()
|
||||
file_handle.flush.side_effect = IOError('disk full')
|
||||
|
||||
with mock.patch('keystone.common.fernet_utils.open', mock_open):
|
||||
self.assertRaises(IOError, key_utils.rotate_keys)
|
||||
|
||||
# Assert that the key repository is unchanged
|
||||
self.assertEqual(self.key_repository_size, 2)
|
||||
|
||||
with mock.patch('keystone.common.fernet_utils.open', mock_open):
|
||||
self.assertRaises(IOError, key_utils.rotate_keys)
|
||||
|
||||
# Assert that the key repository is still unchanged, even after
|
||||
# repeated rotation attempts.
|
||||
self.assertEqual(self.key_repository_size, 2)
|
||||
|
||||
# Rotate the keys normally, without any mocking, to show that the
|
||||
# system can recover.
|
||||
key_utils.rotate_keys()
|
||||
|
||||
# Assert that the key repository is now expanded.
|
||||
self.assertEqual(self.key_repository_size, 3)
|
||||
|
||||
def test_rotation_empty_file(self):
|
||||
active_keys = 2
|
||||
self.assertRepositoryState(expected_size=active_keys)
|
||||
empty_file = os.path.join(CONF.fernet_receipts.key_repository, '2')
|
||||
with open(empty_file, 'w'):
|
||||
pass
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
# Rotate the keys to overwrite the empty file
|
||||
key_utils.rotate_keys()
|
||||
self.assertTrue(os.path.isfile(empty_file))
|
||||
keys = key_utils.load_keys()
|
||||
self.assertEqual(3, len(keys))
|
||||
self.assertTrue(os.path.getsize(empty_file) > 0)
|
||||
|
||||
def test_non_numeric_files(self):
|
||||
evil_file = os.path.join(CONF.fernet_receipts.key_repository, '99.bak')
|
||||
with open(evil_file, 'w'):
|
||||
pass
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
key_utils.rotate_keys()
|
||||
self.assertTrue(os.path.isfile(evil_file))
|
||||
keys = 0
|
||||
for x in os.listdir(CONF.fernet_receipts.key_repository):
|
||||
if x == '99.bak':
|
||||
continue
|
||||
keys += 1
|
||||
self.assertEqual(3, keys)
|
||||
|
||||
|
||||
class TestLoadKeys(unit.TestCase):
|
||||
|
||||
def assertValidFernetKeys(self, keys):
|
||||
# Make sure each key is a non-empty string
|
||||
for key in keys:
|
||||
self.assertGreater(len(key), 0)
|
||||
self.assertIsInstance(key, str)
|
||||
|
||||
def test_non_numeric_files(self):
|
||||
evil_file = os.path.join(CONF.fernet_receipts.key_repository, '~1')
|
||||
with open(evil_file, 'w'):
|
||||
pass
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
keys = key_utils.load_keys()
|
||||
self.assertEqual(2, len(keys))
|
||||
self.assertValidFernetKeys(keys)
|
||||
|
||||
def test_empty_files(self):
|
||||
empty_file = os.path.join(CONF.fernet_receipts.key_repository, '2')
|
||||
with open(empty_file, 'w'):
|
||||
pass
|
||||
key_utils = fernet_utils.FernetUtils(
|
||||
CONF.fernet_receipts.key_repository,
|
||||
CONF.fernet_receipts.max_active_keys,
|
||||
'fernet_receipts'
|
||||
)
|
||||
keys = key_utils.load_keys()
|
||||
self.assertEqual(2, len(keys))
|
||||
self.assertValidFernetKeys(keys)
|
61
keystone/tests/unit/receipt/test_receipt_serialization.py
Normal file
61
keystone/tests/unit/receipt/test_receipt_serialization.py
Normal file
@ -0,0 +1,61 @@
|
||||
# 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 datetime
|
||||
import uuid
|
||||
|
||||
import mock
|
||||
|
||||
from keystone.common.cache import _context_cache
|
||||
from keystone.common import utils as ks_utils
|
||||
from keystone import exception
|
||||
from keystone.models import receipt_model
|
||||
from keystone.tests.unit import base_classes
|
||||
|
||||
|
||||
class TestReceiptSerialization(base_classes.TestCaseWithBootstrap):
|
||||
|
||||
def setUp(self):
|
||||
super(TestReceiptSerialization, self).setUp()
|
||||
self.admin_user_id = self.bootstrapper.admin_user_id
|
||||
|
||||
self.receipt_id = uuid.uuid4().hex
|
||||
issued_at = datetime.datetime.utcnow()
|
||||
self.issued_at = ks_utils.isotime(at=issued_at, subsecond=True)
|
||||
|
||||
# Reach into the cache registry and pull out an instance of the
|
||||
# _ReceiptModelHandler so that we can interact and test it directly (as
|
||||
# opposed to using PROVIDERS or managers to invoke it).
|
||||
receipt_handler_id = receipt_model._ReceiptModelHandler.identity
|
||||
self.receipt_handler = _context_cache._registry.get(receipt_handler_id)
|
||||
|
||||
self.exp_receipt = receipt_model.ReceiptModel()
|
||||
self.exp_receipt.user_id = self.admin_user_id
|
||||
self.exp_receipt.mint(self.receipt_id, self.issued_at)
|
||||
|
||||
def test_serialize_and_deserialize_receipt_model(self):
|
||||
serialized = self.receipt_handler.serialize(self.exp_receipt)
|
||||
receipt = self.receipt_handler.deserialize(serialized)
|
||||
|
||||
self.assertEqual(self.exp_receipt.user_id, receipt.user_id)
|
||||
self.assertEqual(self.exp_receipt.id, receipt.id)
|
||||
self.assertEqual(self.exp_receipt.issued_at, receipt.issued_at)
|
||||
|
||||
@mock.patch.object(
|
||||
receipt_model.ReceiptModel, '__init__', side_effect=Exception)
|
||||
def test_error_handling_in_deserialize(self, handler_mock):
|
||||
serialized = self.receipt_handler.serialize(self.exp_receipt)
|
||||
self.assertRaises(
|
||||
exception.CacheDeserializationError,
|
||||
self.receipt_handler.deserialize,
|
||||
serialized
|
||||
)
|
82
keystone/tests/unit/test_receipt_provider.py
Normal file
82
keystone/tests/unit/test_receipt_provider.py
Normal file
@ -0,0 +1,82 @@
|
||||
# Copyright 2018 Catalyst Cloud Ltd
|
||||
#
|
||||
# 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 datetime
|
||||
import uuid
|
||||
|
||||
from oslo_utils import timeutils
|
||||
|
||||
import freezegun
|
||||
from keystone.common import provider_api
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.models import receipt_model
|
||||
from keystone import receipt
|
||||
from keystone.tests import unit
|
||||
from keystone.tests.unit import ksfixtures
|
||||
from keystone.tests.unit.ksfixtures import database
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
PROVIDERS = provider_api.ProviderAPIs
|
||||
|
||||
DELTA = datetime.timedelta(seconds=CONF.receipt.expiration)
|
||||
CURRENT_DATE = timeutils.utcnow()
|
||||
|
||||
|
||||
class TestReceiptProvider(unit.TestCase):
|
||||
def setUp(self):
|
||||
super(TestReceiptProvider, self).setUp()
|
||||
self.useFixture(database.Database())
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
'fernet_receipts',
|
||||
CONF.fernet_receipts.max_active_keys
|
||||
)
|
||||
)
|
||||
self.load_backends()
|
||||
|
||||
def test_unsupported_receipt_provider(self):
|
||||
self.config_fixture.config(group='receipt',
|
||||
provider='MyProvider')
|
||||
self.assertRaises(ImportError,
|
||||
receipt.provider.Manager)
|
||||
|
||||
def test_provider_receipt_expiration_validation(self):
|
||||
receipt = receipt_model.ReceiptModel()
|
||||
receipt.issued_at = utils.isotime(CURRENT_DATE)
|
||||
receipt.expires_at = utils.isotime(CURRENT_DATE - DELTA)
|
||||
receipt.id = uuid.uuid4().hex
|
||||
with freezegun.freeze_time(CURRENT_DATE):
|
||||
self.assertRaises(exception.ReceiptNotFound,
|
||||
PROVIDERS.receipt_provider_api._is_valid_receipt,
|
||||
receipt)
|
||||
|
||||
# confirm a non-expired receipt doesn't throw errors.
|
||||
# returning None, rather than throwing an error is correct.
|
||||
receipt = receipt_model.ReceiptModel()
|
||||
receipt.issued_at = utils.isotime(CURRENT_DATE)
|
||||
receipt.expires_at = utils.isotime(CURRENT_DATE + DELTA)
|
||||
receipt.id = uuid.uuid4().hex
|
||||
with freezegun.freeze_time(CURRENT_DATE):
|
||||
self.assertIsNone(
|
||||
PROVIDERS.receipt_provider_api._is_valid_receipt(receipt))
|
||||
|
||||
def test_validate_v3_none_receipt_raises_receipt_not_found(self):
|
||||
self.assertRaises(
|
||||
exception.ReceiptNotFound,
|
||||
PROVIDERS.receipt_provider_api.validate_receipt,
|
||||
None)
|
@ -34,6 +34,7 @@ from testtools import testcase
|
||||
|
||||
from keystone import auth
|
||||
from keystone.auth.plugins import totp
|
||||
from keystone.common import authorization
|
||||
from keystone.common import provider_api
|
||||
from keystone.common.rbac_enforcer import policy
|
||||
from keystone.common import utils
|
||||
@ -54,6 +55,7 @@ PROVIDERS = provider_api.ProviderAPIs
|
||||
class TestMFARules(test_v3.RestfulTestCase):
|
||||
def config_overrides(self):
|
||||
super(TestMFARules, self).config_overrides()
|
||||
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
@ -70,6 +72,18 @@ class TestMFARules(test_v3.RestfulTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def assertValidErrorResponse(self, r):
|
||||
resp = r.result
|
||||
if r.headers.get(authorization.AUTH_RECEIPT_HEADER):
|
||||
self.assertIsNotNone(resp.get('receipt'))
|
||||
self.assertIsNotNone(resp.get('receipt').get('methods'))
|
||||
else:
|
||||
self.assertIsNotNone(resp.get('error'))
|
||||
self.assertIsNotNone(resp['error'].get('code'))
|
||||
self.assertIsNotNone(resp['error'].get('title'))
|
||||
self.assertIsNotNone(resp['error'].get('message'))
|
||||
self.assertEqual(int(resp['error']['code']), r.status_code)
|
||||
|
||||
def _create_totp_cred(self):
|
||||
totp_cred = unit.new_totp_credential(self.user_id, self.project_id)
|
||||
PROVIDERS.credential_api.create_credential(uuid.uuid4().hex, totp_cred)
|
||||
@ -247,6 +261,206 @@ class TestMFARules(test_v3.RestfulTestCase):
|
||||
project_id=self.project_id)
|
||||
self.v3_create_token(auth_data)
|
||||
|
||||
def test_MFA_requirements_makes_correct_receipt_for_password(self):
|
||||
# if multiple rules are specified and only one is passed,
|
||||
# unauthorized is expected
|
||||
rule_list = [['password', 'totp']]
|
||||
self._update_user_with_MFA_rules(rule_list=rule_list)
|
||||
# NOTE(notmorgan): Step forward in time to ensure we're not causing
|
||||
# issues with revocation events that occur at the same time as the
|
||||
# token issuance. This is a bug with the limited resolution that
|
||||
# tokens and revocation events have.
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
password=self.user['password'],
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
|
||||
self.assertIsNotNone(
|
||||
response.headers.get(authorization.AUTH_RECEIPT_HEADER))
|
||||
resp_data = response.result
|
||||
# NOTE(adriant): We convert to sets to avoid any potential sorting
|
||||
# related failures since order isn't important, just content.
|
||||
self.assertEqual(
|
||||
{'password'}, set(resp_data.get('receipt').get('methods')))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in rule_list),
|
||||
set(frozenset(r) for r in resp_data.get('required_auth_methods')))
|
||||
|
||||
def test_MFA_requirements_makes_correct_receipt_for_totp(self):
|
||||
# if multiple rules are specified and only one is passed,
|
||||
# unauthorized is expected
|
||||
totp_cred = self._create_totp_cred()
|
||||
rule_list = [['password', 'totp']]
|
||||
self._update_user_with_MFA_rules(rule_list=rule_list)
|
||||
# NOTE(notmorgan): Step forward in time to ensure we're not causing
|
||||
# issues with revocation events that occur at the same time as the
|
||||
# token issuance. This is a bug with the limited resolution that
|
||||
# tokens and revocation events have.
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id,
|
||||
passcode=totp._generate_totp_passcode(totp_cred['blob'])),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
|
||||
self.assertIsNotNone(
|
||||
response.headers.get(authorization.AUTH_RECEIPT_HEADER))
|
||||
resp_data = response.result
|
||||
# NOTE(adriant): We convert to sets to avoid any potential sorting
|
||||
# related failures since order isn't important, just content.
|
||||
self.assertEqual(
|
||||
{'totp'}, set(resp_data.get('receipt').get('methods')))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in rule_list),
|
||||
set(frozenset(r) for r in resp_data.get('required_auth_methods')))
|
||||
|
||||
def test_MFA_requirements_makes_correct_receipt_for_pass_and_totp(self):
|
||||
# if multiple rules are specified and only one is passed,
|
||||
# unauthorized is expected
|
||||
totp_cred = self._create_totp_cred()
|
||||
rule_list = [['password', 'totp', 'token']]
|
||||
self._update_user_with_MFA_rules(rule_list=rule_list)
|
||||
# NOTE(notmorgan): Step forward in time to ensure we're not causing
|
||||
# issues with revocation events that occur at the same time as the
|
||||
# token issuance. This is a bug with the limited resolution that
|
||||
# tokens and revocation events have.
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
password=self.user['password'],
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id,
|
||||
passcode=totp._generate_totp_passcode(totp_cred['blob'])),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
|
||||
self.assertIsNotNone(
|
||||
response.headers.get(authorization.AUTH_RECEIPT_HEADER))
|
||||
resp_data = response.result
|
||||
# NOTE(adriant): We convert to sets to avoid any potential sorting
|
||||
# related failures since order isn't important, just content.
|
||||
self.assertEqual(
|
||||
{'password', 'totp'}, set(resp_data.get('receipt').get('methods')))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in rule_list),
|
||||
set(frozenset(r) for r in resp_data.get('required_auth_methods')))
|
||||
|
||||
def test_MFA_requirements_returns_correct_required_auth_methods(self):
|
||||
# if multiple rules are specified and only one is passed,
|
||||
# unauthorized is expected
|
||||
rule_list = [
|
||||
['password', 'totp', 'token'],
|
||||
['password', 'totp'],
|
||||
['token', 'totp'],
|
||||
['BoGusAuThMeTh0dHandl3r']
|
||||
]
|
||||
expect_rule_list = rule_list = [
|
||||
['password', 'totp', 'token'],
|
||||
['password', 'totp'],
|
||||
]
|
||||
self._update_user_with_MFA_rules(rule_list=rule_list)
|
||||
# NOTE(notmorgan): Step forward in time to ensure we're not causing
|
||||
# issues with revocation events that occur at the same time as the
|
||||
# token issuance. This is a bug with the limited resolution that
|
||||
# tokens and revocation events have.
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
password=self.user['password'],
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
|
||||
self.assertIsNotNone(
|
||||
response.headers.get(authorization.AUTH_RECEIPT_HEADER))
|
||||
resp_data = response.result
|
||||
# NOTE(adriant): We convert to sets to avoid any potential sorting
|
||||
# related failures since order isn't important, just content.
|
||||
self.assertEqual(
|
||||
{'password'}, set(resp_data.get('receipt').get('methods')))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in expect_rule_list),
|
||||
set(frozenset(r) for r in resp_data.get('required_auth_methods')))
|
||||
|
||||
def test_MFA_consuming_receipt_with_totp(self):
|
||||
# if multiple rules are specified and only one is passed,
|
||||
# unauthorized is expected
|
||||
totp_cred = self._create_totp_cred()
|
||||
rule_list = [['password', 'totp']]
|
||||
self._update_user_with_MFA_rules(rule_list=rule_list)
|
||||
# NOTE(notmorgan): Step forward in time to ensure we're not causing
|
||||
# issues with revocation events that occur at the same time as the
|
||||
# token issuance. This is a bug with the limited resolution that
|
||||
# tokens and revocation events have.
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
password=self.user['password'],
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
|
||||
self.assertIsNotNone(
|
||||
response.headers.get(authorization.AUTH_RECEIPT_HEADER))
|
||||
receipt = response.headers.get(authorization.AUTH_RECEIPT_HEADER)
|
||||
resp_data = response.result
|
||||
# NOTE(adriant): We convert to sets to avoid any potential sorting
|
||||
# related failures since order isn't important, just content.
|
||||
self.assertEqual(
|
||||
{'password'}, set(resp_data.get('receipt').get('methods')))
|
||||
self.assertEqual(
|
||||
set(frozenset(r) for r in rule_list),
|
||||
set(frozenset(r) for r in resp_data.get('required_auth_methods')))
|
||||
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
headers={authorization.AUTH_RECEIPT_HEADER: receipt},
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id,
|
||||
passcode=totp._generate_totp_passcode(totp_cred['blob'])))
|
||||
|
||||
def test_MFA_consuming_receipt_not_found(self):
|
||||
time = datetime.datetime.utcnow() + datetime.timedelta(seconds=5)
|
||||
with freezegun.freeze_time(time):
|
||||
response = self.admin_request(
|
||||
method='POST',
|
||||
path='/v3/auth/tokens',
|
||||
headers={authorization.AUTH_RECEIPT_HEADER: "bogus-receipt"},
|
||||
body=self.build_authentication_request(
|
||||
user_id=self.user_id,
|
||||
user_domain_id=self.domain_id,
|
||||
project_id=self.project_id),
|
||||
expected_status=http_client.UNAUTHORIZED)
|
||||
self.assertEqual(401, response.result['error']['code'])
|
||||
|
||||
|
||||
class TestAuthInfo(common_auth.AuthTestMixin, testcase.TestCase):
|
||||
def setUp(self):
|
||||
|
18
releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml
Normal file
18
releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
[`blueprint mfa-auth-receipt <https://blueprints.launchpad.net/keystone/+spec/mfa-auth-receipt>`_]
|
||||
Added support for auth receipts. Allows multi-step authentication for users
|
||||
with configured MFA Rules. Partial authentication with successful auth
|
||||
methods will return an auth receipt that can be consumed in subsequent auth
|
||||
attempts along with the missing auth methods to complete auth and be
|
||||
provided with a valid token.
|
||||
upgrade:
|
||||
- |
|
||||
[`blueprint mfa-auth-receipt <https://blueprints.launchpad.net/keystone/+spec/mfa-auth-receipt>`_]
|
||||
Auth receipts share the same fernet mechanism as tokens and by default
|
||||
will share keys with tokens and work out of the box. If your fernet key
|
||||
directory is not the default, you will need to also configure the receipt
|
||||
key directory, but they can both point to the same location allowing key
|
||||
rotations to affect both safely. It is possible to split receipt and token
|
||||
keys and run rotatations separately for both if needed.
|
Loading…
x
Reference in New Issue
Block a user