From d9e6c1d4dd693143bae3bf9eed6e63ff469586f3 Mon Sep 17 00:00:00 2001 From: Adrian Turjak Date: Wed, 17 Oct 2018 16:59:46 +1300 Subject: [PATCH] Implement auth receipts spec Adds a new model and provider for receipts which are very similar to tokens (fernet based), and share the same fernet mechanisms. Adds changes to the auth layer to handle the creation, validation, and consumptions of receipts as part of the auth process. Change-Id: Iccb6e6fc7aee57c58a53f90c1d671402b8efcdbb bp: mfa-auth-receipt --- keystone/api/_shared/authentication.py | 13 +- keystone/cmd/cli.py | 102 +++- keystone/common/authorization.py | 5 + keystone/conf/__init__.py | 10 +- keystone/conf/fernet_receipts.py | 71 +++ keystone/conf/receipt.py | 86 ++++ keystone/exception.py | 16 +- keystone/models/receipt_model.py | 150 ++++++ keystone/receipt/__init__.py | 20 + keystone/receipt/handlers.py | 74 +++ keystone/receipt/provider.py | 176 +++++++ keystone/receipt/providers/__init__.py | 0 keystone/receipt/providers/base.py | 54 ++ keystone/receipt/providers/fernet/__init__.py | 20 + keystone/receipt/providers/fernet/core.py | 66 +++ keystone/receipt/receipt_formatters.py | 303 +++++++++++ keystone/server/backends.py | 5 +- keystone/server/flask/application.py | 6 + keystone/tests/unit/base_classes.py | 8 + keystone/tests/unit/core.py | 8 + keystone/tests/unit/receipt/__init__.py | 0 .../unit/receipt/test_fernet_provider.py | 471 ++++++++++++++++++ .../receipt/test_receipt_serialization.py | 61 +++ keystone/tests/unit/test_receipt_provider.py | 82 +++ keystone/tests/unit/test_v3_auth.py | 214 ++++++++ .../bp-mfa-auth-receipt-8b459431c1f360ce.yaml | 18 + setup.cfg | 3 + 27 files changed, 2032 insertions(+), 10 deletions(-) create mode 100644 keystone/conf/fernet_receipts.py create mode 100644 keystone/conf/receipt.py create mode 100644 keystone/models/receipt_model.py create mode 100644 keystone/receipt/__init__.py create mode 100644 keystone/receipt/handlers.py create mode 100644 keystone/receipt/provider.py create mode 100644 keystone/receipt/providers/__init__.py create mode 100644 keystone/receipt/providers/base.py create mode 100644 keystone/receipt/providers/fernet/__init__.py create mode 100644 keystone/receipt/providers/fernet/core.py create mode 100644 keystone/receipt/receipt_formatters.py create mode 100644 keystone/tests/unit/receipt/__init__.py create mode 100644 keystone/tests/unit/receipt/test_fernet_provider.py create mode 100644 keystone/tests/unit/receipt/test_receipt_serialization.py create mode 100644 keystone/tests/unit/test_receipt_provider.py create mode 100644 releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml diff --git a/keystone/api/_shared/authentication.py b/keystone/api/_shared/authentication.py index 10370fcc18..b669adc0cc 100644 --- a/keystone/api/_shared/authentication.py +++ b/keystone/api/_shared/authentication.py @@ -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') diff --git a/keystone/cmd/cli.py b/keystone/cmd/cli.py index 2e7afcfc84..418406b5a8 100644 --- a/keystone/cmd/cli.py +++ b/keystone/cmd/cli.py @@ -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. diff --git a/keystone/common/authorization.py b/keystone/common/authorization.py index 1afe47f700..5c4581b963 100644 --- a/keystone/common/authorization.py +++ b/keystone/common/authorization.py @@ -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' diff --git a/keystone/conf/__init__.py b/keystone/conf/__init__.py index f0f2dbce20..c40284025a 100644 --- a/keystone/conf/__init__.py +++ b/keystone/conf/__init__.py @@ -30,6 +30,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 @@ -37,6 +38,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 @@ -65,6 +67,7 @@ conf_modules = [ endpoint_policy, eventlet_server, federation, + fernet_receipts, fernet_tokens, identity, identity_mapping, @@ -72,6 +75,7 @@ conf_modules = [ memcache, oauth1, policy, + receipt, resource, revoke, role, @@ -151,10 +155,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', diff --git a/keystone/conf/fernet_receipts.py b/keystone/conf/fernet_receipts.py new file mode 100644 index 0000000000..4ba077a43e --- /dev/null +++ b/keystone/conf/fernet_receipts.py @@ -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} diff --git a/keystone/conf/receipt.py b/keystone/conf/receipt.py new file mode 100644 index 0000000000..e8d0357e91 --- /dev/null +++ b/keystone/conf/receipt.py @@ -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} diff --git a/keystone/exception.py b/keystone/exception.py index 94f159d50c..20f314be13 100644 --- a/keystone/exception.py +++ b/keystone/exception.py @@ -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 " diff --git a/keystone/models/receipt_model.py b/keystone/models/receipt_model.py new file mode 100644 index 0000000000..8b4a2a132c --- /dev/null +++ b/keystone/models/receipt_model.py @@ -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) diff --git a/keystone/receipt/__init__.py b/keystone/receipt/__init__.py new file mode 100644 index 0000000000..a9dfa8b47e --- /dev/null +++ b/keystone/receipt/__init__.py @@ -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", +] diff --git a/keystone/receipt/handlers.py b/keystone/receipt/handlers.py new file mode 100644 index 0000000000..db9192674f --- /dev/null +++ b/keystone/receipt/handlers.py @@ -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 diff --git a/keystone/receipt/provider.py b/keystone/receipt/provider.py new file mode 100644 index 0000000000..6f30434d8f --- /dev/null +++ b/keystone/receipt/provider.py @@ -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 diff --git a/keystone/receipt/providers/__init__.py b/keystone/receipt/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/receipt/providers/base.py b/keystone/receipt/providers/base.py new file mode 100644 index 0000000000..15b96bcd05 --- /dev/null +++ b/keystone/receipt/providers/base.py @@ -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 diff --git a/keystone/receipt/providers/fernet/__init__.py b/keystone/receipt/providers/fernet/__init__.py new file mode 100644 index 0000000000..b3f7d2ac9c --- /dev/null +++ b/keystone/receipt/providers/fernet/__init__.py @@ -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", +] diff --git a/keystone/receipt/providers/fernet/core.py b/keystone/receipt/providers/fernet/core.py new file mode 100644 index 0000000000..fb75f875e5 --- /dev/null +++ b/keystone/receipt/providers/fernet/core.py @@ -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 diff --git a/keystone/receipt/receipt_formatters.py b/keystone/receipt/receipt_formatters.py new file mode 100644 index 0000000000..6684d9f84b --- /dev/null +++ b/keystone/receipt/receipt_formatters.py @@ -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 + '==') diff --git a/keystone/server/backends.py b/keystone/server/backends.py index 9e5a380e0a..04acfa540d 100644 --- a/keystone/server/backends.py +++ b/keystone/server/backends.py @@ -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} diff --git a/keystone/server/flask/application.py b/keystone/server/flask/application.py index be7c60d926..004dbfadf7 100644 --- a/keystone/server/flask/application.py +++ b/keystone/server/flask/application.py @@ -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( diff --git a/keystone/tests/unit/base_classes.py b/keystone/tests/unit/base_classes.py index fb6e3af9b2..bcf6ec4e6b 100644 --- a/keystone/tests/unit/base_classes.py +++ b/keystone/tests/unit/base_classes.py @@ -49,6 +49,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) diff --git a/keystone/tests/unit/core.py b/keystone/tests/unit/core.py index 2a904eb4ff..8b1420625e 100644 --- a/keystone/tests/unit/core.py +++ b/keystone/tests/unit/core.py @@ -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 diff --git a/keystone/tests/unit/receipt/__init__.py b/keystone/tests/unit/receipt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/tests/unit/receipt/test_fernet_provider.py b/keystone/tests/unit/receipt/test_fernet_provider.py new file mode 100644 index 0000000000..d00604e143 --- /dev/null +++ b/keystone/tests/unit/receipt/test_fernet_provider.py @@ -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) diff --git a/keystone/tests/unit/receipt/test_receipt_serialization.py b/keystone/tests/unit/receipt/test_receipt_serialization.py new file mode 100644 index 0000000000..61e95ebe41 --- /dev/null +++ b/keystone/tests/unit/receipt/test_receipt_serialization.py @@ -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 + ) diff --git a/keystone/tests/unit/test_receipt_provider.py b/keystone/tests/unit/test_receipt_provider.py new file mode 100644 index 0000000000..c304687bbe --- /dev/null +++ b/keystone/tests/unit/test_receipt_provider.py @@ -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) diff --git a/keystone/tests/unit/test_v3_auth.py b/keystone/tests/unit/test_v3_auth.py index cdcd654bb3..90f06d3d73 100644 --- a/keystone/tests/unit/test_v3_auth.py +++ b/keystone/tests/unit/test_v3_auth.py @@ -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): diff --git a/releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml b/releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml new file mode 100644 index 0000000000..45f52d796d --- /dev/null +++ b/releasenotes/notes/bp-mfa-auth-receipt-8b459431c1f360ce.yaml @@ -0,0 +1,18 @@ +--- +features: + - | + [`blueprint 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 `_] + 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. diff --git a/setup.cfg b/setup.cfg index b21d86d8e1..40949299de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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