Merge "Implement auth receipts spec"

This commit is contained in:
Zuul 2018-11-02 18:30:44 +00:00 committed by Gerrit Code Review
commit c785729efe
27 changed files with 2032 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View 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
View 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}

View File

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

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

View 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",
]

View 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

View 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

View File

View 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

View 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",
]

View 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

View 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 + '==')

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

@ -145,6 +145,9 @@ keystone.role =
keystone.token.provider =
fernet = keystone.token.providers.fernet:Provider
keystone.receipt.provider =
fernet = keystone.receipt.providers.fernet:Provider
keystone.trust =
sql = keystone.trust.backends.sql:Trust