diff options
Diffstat (limited to 'keystonemiddleware-moon/keystonemiddleware/auth_token')
6 files changed, 848 insertions, 668 deletions
diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/__init__.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/__init__.py index 80539714..8987e0ea 100644 --- a/keystonemiddleware-moon/keystonemiddleware/auth_token/__init__.py +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/__init__.py @@ -21,27 +21,15 @@ This WSGI component: * Verifies that incoming client requests have valid tokens by validating tokens with the auth service. * Rejects unauthenticated requests unless the auth_token middleware is in - 'delay_auth_decision' mode, which means the final decision is delegated to + ``delay_auth_decision`` mode, which means the final decision is delegated to the downstream WSGI component (usually the OpenStack service). * Collects and forwards identity information based on a valid token - such as user name, tenant, etc + such as user name, domain, project, etc. Refer to: http://docs.openstack.org/developer/keystonemiddleware/\ middlewarearchitecture.html -Echo test server ----------------- - -Run this module directly to start a protected echo service on port 8000:: - - $ python -m keystonemiddleware.auth_token - -When the ``auth_token`` module authenticates a request, the echo service -will respond with all the environment variables presented to it by this -module. - - Headers ------- @@ -62,7 +50,7 @@ Used for communication between components WWW-Authenticate HTTP header returned to a user indicating which endpoint to use - to retrieve a new token + to retrieve a new token. What auth_token adds to the request for use by the OpenStack service ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -70,34 +58,35 @@ What auth_token adds to the request for use by the OpenStack service When using composite authentication (a user and service token are present) additional service headers relating to the service user will be added. They take the same form as the standard headers but add -'_SERVICE_'. These headers will not exist in the environment if no +``_SERVICE_``. These headers will not exist in the environment if no service token is present. HTTP_X_IDENTITY_STATUS, HTTP_X_SERVICE_IDENTITY_STATUS - 'Confirmed' or 'Invalid' - The underlying service will only see a value of 'Invalid' if the Middleware - is configured to run in 'delay_auth_decision' mode. As with all such - headers, HTTP_X_SERVICE_IDENTITY_STATUS will only exist in the + Will be set to either ``Confirmed`` or ``Invalid``. + + The underlying service will only see a value of 'Invalid' if the middleware + is configured to run in ``delay_auth_decision`` mode. As with all such + headers, ``HTTP_X_SERVICE_IDENTITY_STATUS`` will only exist in the environment if a service token is presented. This is different than - HTTP_X_IDENTITY_STATUS which is always set even if no user token is + ``HTTP_X_IDENTITY_STATUS`` which is always set even if no user token is presented. This allows the underlying service to determine if a - denial should use 401 or 403. + denial should use ``401 Unauthenticated`` or ``403 Forbidden``. HTTP_X_DOMAIN_ID, HTTP_X_SERVICE_DOMAIN_ID Identity service managed unique identifier, string. Only present if - this is a domain-scoped v3 token. + this is a domain-scoped token. HTTP_X_DOMAIN_NAME, HTTP_X_SERVICE_DOMAIN_NAME Unique domain name, string. Only present if this is a domain-scoped - v3 token. + token. HTTP_X_PROJECT_ID, HTTP_X_SERVICE_PROJECT_ID Identity service managed unique identifier, string. Only present if - this is a project-scoped v3 token, or a tenant-scoped v2 token. + this is a project-scoped token. HTTP_X_PROJECT_NAME, HTTP_X_SERVICE_PROJECT_NAME Project name, unique within owning domain, string. Only present if - this is a project-scoped v3 token, or a tenant-scoped v2 token. + this is a project-scoped token. HTTP_X_PROJECT_DOMAIN_ID, HTTP_X_SERVICE_PROJECT_DOMAIN_ID Identity service managed unique identifier of owning domain of @@ -111,10 +100,10 @@ HTTP_X_PROJECT_DOMAIN_NAME, HTTP_X_SERVICE_PROJECT_DOMAIN_NAME the PROJECT_NAME can only be assumed to be unique within this domain. HTTP_X_USER_ID, HTTP_X_SERVICE_USER_ID - Identity-service managed unique identifier, string + Identity-service managed unique identifier, string. HTTP_X_USER_NAME, HTTP_X_SERVICE_USER_NAME - User identifier, unique within owning domain, string + User identifier, unique within owning domain, string. HTTP_X_USER_DOMAIN_ID, HTTP_X_SERVICE_USER_DOMAIN_ID Identity service managed unique identifier of owning domain of @@ -127,39 +116,45 @@ HTTP_X_USER_DOMAIN_NAME, HTTP_X_SERVICE_USER_DOMAIN_NAME this domain. HTTP_X_ROLES, HTTP_X_SERVICE_ROLES - Comma delimited list of case-sensitive role names + Comma delimited list of case-sensitive role names. HTTP_X_SERVICE_CATALOG - json encoded service catalog (optional). + service catalog (optional, JSON string). + For compatibility reasons this catalog will always be in the V2 catalog format even if it is a v3 token. - Note: This is an exception in that it contains 'SERVICE' but relates to a - user token, not a service token. The existing user's - catalog can be very large; it was decided not to present a catalog - relating to the service token to avoid using more HTTP header space. + .. note:: This is an exception in that it contains 'SERVICE' but relates to + a user token, not a service token. The existing user's catalog can be + very large; it was decided not to present a catalog relating to the + service token to avoid using more HTTP header space. HTTP_X_TENANT_ID - *Deprecated* in favor of HTTP_X_PROJECT_ID + *Deprecated* in favor of HTTP_X_PROJECT_ID. + Identity service managed unique identifier, string. For v3 tokens, this - will be set to the same value as HTTP_X_PROJECT_ID + will be set to the same value as HTTP_X_PROJECT_ID. HTTP_X_TENANT_NAME - *Deprecated* in favor of HTTP_X_PROJECT_NAME + *Deprecated* in favor of HTTP_X_PROJECT_NAME. + Project identifier, unique within owning domain, string. For v3 tokens, - this will be set to the same value as HTTP_X_PROJECT_NAME + this will be set to the same value as HTTP_X_PROJECT_NAME. HTTP_X_TENANT - *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME - identity server-assigned unique identifier, string. For v3 tokens, this - will be set to the same value as HTTP_X_PROJECT_ID + *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME. + + Identity server-assigned unique identifier, string. For v3 tokens, this + will be set to the same value as HTTP_X_PROJECT_ID. HTTP_X_USER - *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME - User name, unique within owning domain, string + *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME. + + User name, unique within owning domain, string. HTTP_X_ROLE - *Deprecated* in favor of HTTP_X_ROLES + *Deprecated* in favor of HTTP_X_ROLES. + Will contain the same values as HTTP_X_ROLES. Environment Variables @@ -171,7 +166,7 @@ WSGI component. keystone.token_info Information about the token discovered in the process of validation. This may include extended information returned by the token validation call, as - well as basic information about the tenant and user. + well as basic information about the project and user. keystone.token_auth A keystoneclient auth plugin that may be used with a @@ -182,8 +177,8 @@ keystone.token_auth Configuration ------------- -Middleware configuration can be in the main application's configuration file, -e.g. in ``nova.conf``: +auth_token middleware configuration can be in the main application's +configuration file, e.g. in ``nova.conf``: .. code-block:: ini @@ -202,12 +197,12 @@ but this is discouraged. Swift ----- -When deploy Keystone auth_token middleware with Swift, user may elect to use -Swift memcache instead of the local auth_token memcache. Swift memcache is -passed in from the request environment and it's identified by the -``swift.cache`` key. However it could be different, depending on deployment. To -use Swift memcache, you must set the ``cache`` option to the environment key -where the Swift cache object is stored. +When deploy auth_token middleware with Swift, user may elect to use Swift +memcache instead of the local auth_token memcache. Swift memcache is passed in +from the request environment and it's identified by the ``swift.cache`` key. +However it could be different, depending on deployment. To use Swift memcache, +you must set the ``cache`` option to the environment key where the Swift cache +object is stored. """ @@ -223,18 +218,19 @@ from keystoneclient import exceptions from keystoneclient import session from oslo_config import cfg from oslo_serialization import jsonutils -from oslo_utils import timeutils +import pkg_resources import six +import webob.dec from keystonemiddleware.auth_token import _auth from keystonemiddleware.auth_token import _base from keystonemiddleware.auth_token import _cache from keystonemiddleware.auth_token import _exceptions as exc from keystonemiddleware.auth_token import _identity +from keystonemiddleware.auth_token import _request from keystonemiddleware.auth_token import _revocations from keystonemiddleware.auth_token import _signing_dir from keystonemiddleware.auth_token import _user_plugin -from keystonemiddleware.auth_token import _utils from keystonemiddleware.i18n import _, _LC, _LE, _LI, _LW @@ -281,6 +277,8 @@ _OPTS = [ help='A PEM encoded Certificate Authority to use when ' 'verifying HTTPs connections. Defaults to system CAs.'), cfg.BoolOpt('insecure', default=False, help='Verify HTTPS connections.'), + cfg.StrOpt('region_name', default=None, + help='The region in which the identity server can be found.'), cfg.StrOpt('signing_dir', help='Directory used to cache files related to PKI tokens.'), cfg.ListOpt('memcached_servers', @@ -325,7 +323,7 @@ _OPTS = [ cfg.IntOpt('memcache_pool_socket_timeout', default=3, help='(Optional) Socket timeout in seconds for communicating ' - 'with a memcache server.'), + 'with a memcached server.'), cfg.IntOpt('memcache_pool_unused_timeout', default=60, help='(Optional) Number of seconds a connection to memcached' @@ -333,10 +331,10 @@ _OPTS = [ cfg.IntOpt('memcache_pool_conn_get_timeout', default=10, help='(Optional) Number of seconds that an operation will wait ' - 'to get a memcache client connection from the pool.'), + 'to get a memcached client connection from the pool.'), cfg.BoolOpt('memcache_use_advanced_pool', default=False, - help='(Optional) Use the advanced (eventlet safe) memcache ' + help='(Optional) Use the advanced (eventlet safe) memcached ' 'client pool. The advanced pool will only work under ' 'python 2.x.'), cfg.BoolOpt('include_service_catalog', @@ -376,26 +374,6 @@ CONF.register_opts(_OPTS, group=_base.AUTHTOKEN_GROUP) _LOG = logging.getLogger(__name__) -_HEADER_TEMPLATE = { - 'X%s-Domain-Id': 'domain_id', - 'X%s-Domain-Name': 'domain_name', - 'X%s-Project-Id': 'project_id', - 'X%s-Project-Name': 'project_name', - 'X%s-Project-Domain-Id': 'project_domain_id', - 'X%s-Project-Domain-Name': 'project_domain_name', - 'X%s-User-Id': 'user_id', - 'X%s-User-Name': 'username', - 'X%s-User-Domain-Id': 'user_domain_id', - 'X%s-User-Domain-Name': 'user_domain_name', -} - -_DEPRECATED_HEADER_TEMPLATE = { - 'X-User': 'username', - 'X-Tenant-Id': 'project_id', - 'X-Tenant-Name': 'project_name', - 'X-Tenant': 'project_name', -} - class _BIND_MODE(object): DISABLED = 'disabled' @@ -413,61 +391,6 @@ def _token_is_v3(token_info): return ('token' in token_info) -def _get_token_expiration(data): - if not data: - raise exc.InvalidToken(_('Token authorization failed')) - if _token_is_v2(data): - return data['access']['token']['expires'] - elif _token_is_v3(data): - return data['token']['expires_at'] - else: - raise exc.InvalidToken(_('Token authorization failed')) - - -def _confirm_token_not_expired(expires): - expires = timeutils.parse_isotime(expires) - expires = timeutils.normalize_time(expires) - utcnow = timeutils.utcnow() - if utcnow >= expires: - raise exc.InvalidToken(_('Token authorization failed')) - - -def _v3_to_v2_catalog(catalog): - """Convert a catalog to v2 format. - - X_SERVICE_CATALOG must be specified in v2 format. If you get a token - that is in v3 convert it. - """ - v2_services = [] - for v3_service in catalog: - # first copy over the entries we allow for the service - v2_service = {'type': v3_service['type']} - try: - v2_service['name'] = v3_service['name'] - except KeyError: - pass - - # now convert the endpoints. Because in v3 we specify region per - # URL not per group we have to collect all the entries of the same - # region together before adding it to the new service. - regions = {} - for v3_endpoint in v3_service.get('endpoints', []): - region_name = v3_endpoint.get('region') - try: - region = regions[region_name] - except KeyError: - region = {'region': region_name} if region_name else {} - regions[region_name] = region - - interface_name = v3_endpoint['interface'].lower() + 'URL' - region[interface_name] = v3_endpoint['url'] - - v2_service['endpoints'] = list(regions.values()) - v2_services.append(v2_service) - - return v2_services - - def _conf_values_type_convert(conf): """Convert conf values into correct type.""" if not conf: @@ -499,32 +422,263 @@ def _conf_values_type_convert(conf): return opts -class AuthProtocol(object): +def _get_project_version(project): + return pkg_resources.get_distribution(project).version + + +class _BaseAuthProtocol(object): + """A base class for AuthProtocol token checking implementations. + + :param Callable app: The next application to call after middleware. + :param logging.Logger log: The logging object to use for output. By default + it will use a logger in the + keystonemiddleware.auth_token namespace. + :param str enforce_token_bind: The style of token binding enforcement to + perform. + """ + + def __init__(self, + app, + log=_LOG, + enforce_token_bind=_BIND_MODE.PERMISSIVE): + self.log = log + self._app = app + self._enforce_token_bind = enforce_token_bind + + @webob.dec.wsgify(RequestClass=_request._AuthTokenRequest) + def __call__(self, req): + """Handle incoming request.""" + response = self.process_request(req) + if response: + return response + response = req.get_response(self._app) + return self.process_response(response) + + def process_request(self, request): + """Process request. + + If this method returns a value then that value will be used as the + response. The next application down the stack will not be executed and + process_response will not be called. + + Otherwise, the next application down the stack will be executed and + process_response will be called with the generated response. + + By default this method does not return a value. + + :param request: Incoming request + :type request: _request.AuthTokenRequest + + """ + request.remove_auth_headers() + + user_auth_ref = None + serv_auth_ref = None + + if request.user_token: + self.log.debug('Authenticating user token') + try: + data, user_auth_ref = self._do_fetch_token(request.user_token) + self._validate_token(user_auth_ref) + self._confirm_token_bind(user_auth_ref, request) + except exc.InvalidToken: + self.log.info(_LI('Invalid user token')) + request.user_token_valid = False + else: + request.user_token_valid = True + request.environ['keystone.token_info'] = data + + if request.service_token: + self.log.debug('Authenticating service token') + try: + _, serv_auth_ref = self._do_fetch_token(request.service_token) + self._validate_token(serv_auth_ref) + self._confirm_token_bind(serv_auth_ref, request) + except exc.InvalidToken: + self.log.info(_LI('Invalid service token')) + request.service_token_valid = False + else: + request.service_token_valid = True + + p = _user_plugin.UserAuthPlugin(user_auth_ref, serv_auth_ref) + request.environ['keystone.token_auth'] = p + + def _validate_token(self, auth_ref): + """Perform the validation steps on the token. + + :param auth_ref: The token data + :type auth_ref: keystoneclient.access.AccessInfo + + :raises exc.InvalidToken: if token is rejected + """ + # 0 seconds of validity means it is invalid right now + if auth_ref.will_expire_soon(stale_duration=0): + raise exc.InvalidToken(_('Token authorization failed')) + + def _do_fetch_token(self, token): + """Helper method to fetch a token and convert it into an AccessInfo""" + data = self._fetch_token(token) + + try: + return data, access.AccessInfo.factory(body=data, auth_token=token) + except Exception: + self.log.warning(_LW('Invalid token contents.'), exc_info=True) + raise exc.InvalidToken(_('Token authorization failed')) + + def _fetch_token(self, token): + """Fetch the token data based on the value in the header. + + Retrieve the data associated with the token value that was in the + header. This can be from PKI, contacting the identity server or + whatever is required. + + :param str token: The token present in the request header. + + :raises exc.InvalidToken: if token is invalid. + + :returns: The token data + :rtype: dict + """ + raise NotImplemented() + + def process_response(self, response): + """Do whatever you'd like to the response. + + By default the response is returned unmodified. + + :param response: Response object + :type response: ._request._AuthTokenResponse + """ + return response + + def _invalid_user_token(self, msg=False): + # NOTE(jamielennox): use False as the default so that None is valid + if msg is False: + msg = _('Token authorization failed') + + raise exc.InvalidToken(msg) + + def _confirm_token_bind(self, auth_ref, req): + if self._enforce_token_bind == _BIND_MODE.DISABLED: + return + + try: + if auth_ref.version == 'v2.0': + bind = auth_ref['token']['bind'] + elif auth_ref.version == 'v3': + bind = auth_ref['bind'] + else: + self._invalid_user_token() + except KeyError: + bind = {} + + # permissive and strict modes don't require there to be a bind + permissive = self._enforce_token_bind in (_BIND_MODE.PERMISSIVE, + _BIND_MODE.STRICT) + + if not bind: + if permissive: + # no bind provided and none required + return + else: + self.log.info(_LI('No bind information present in token.')) + self._invalid_user_token() + + # get the named mode if bind_mode is not one of the predefined + if permissive or self._enforce_token_bind == _BIND_MODE.REQUIRED: + name = None + else: + name = self._enforce_token_bind + + if name and name not in bind: + self.log.info(_LI('Named bind mode %s not in bind information'), + name) + self._invalid_user_token() + + for bind_type, identifier in six.iteritems(bind): + if bind_type == _BIND_MODE.KERBEROS: + if req.auth_type != 'negotiate': + self.log.info(_LI('Kerberos credentials required and ' + 'not present.')) + self._invalid_user_token() + + if req.remote_user != identifier: + self.log.info(_LI('Kerberos credentials do not match ' + 'those in bind.')) + self._invalid_user_token() + + self.log.debug('Kerberos bind authentication successful.') + + elif self._enforce_token_bind == _BIND_MODE.PERMISSIVE: + self.log.debug('Ignoring Unknown bind for permissive mode: ' + '%(bind_type)s: %(identifier)s.', + {'bind_type': bind_type, + 'identifier': identifier}) + + else: + self.log.info( + _LI('Couldn`t verify unknown bind: %(bind_type)s: ' + '%(identifier)s.'), + {'bind_type': bind_type, 'identifier': identifier}) + self._invalid_user_token() + + +class AuthProtocol(_BaseAuthProtocol): """Middleware that handles authenticating client calls.""" _SIGNING_CERT_FILE_NAME = 'signing_cert.pem' _SIGNING_CA_FILE_NAME = 'cacert.pem' def __init__(self, app, conf): - self._LOG = logging.getLogger(conf.get('log_name', __name__)) - self._LOG.info(_LI('Starting Keystone auth_token middleware')) + log = logging.getLogger(conf.get('log_name', __name__)) + log.info(_LI('Starting Keystone auth_token middleware')) + # NOTE(wanghong): If options are set in paste file, all the option # values passed into conf are string type. So, we should convert the # conf value into correct type. self._conf = _conf_values_type_convert(conf) - self._app = app + + # NOTE(sileht): If we don't want to use oslo.config global object + # we can set the paste "oslo_config_project" and the middleware + # will load the configuration with a local oslo.config object. + self._local_oslo_config = None + if 'oslo_config_project' in conf: + if 'oslo_config_file' in conf: + default_config_files = [conf['oslo_config_file']] + else: + default_config_files = None + + # For unit tests, support passing in a ConfigOpts in + # oslo_config_config. + self._local_oslo_config = conf.get('oslo_config_config', + cfg.ConfigOpts()) + self._local_oslo_config( + {}, project=conf['oslo_config_project'], + default_config_files=default_config_files, + validate_default_values=True) + + self._local_oslo_config.register_opts( + _OPTS, group=_base.AUTHTOKEN_GROUP) + auth.register_conf_options(self._local_oslo_config, + group=_base.AUTHTOKEN_GROUP) + + super(AuthProtocol, self).__init__( + app, + log=log, + enforce_token_bind=self._conf_get('enforce_token_bind')) # delay_auth_decision means we still allow unauthenticated requests # through and we let the downstream service make the final decision self._delay_auth_decision = self._conf_get('delay_auth_decision') self._include_service_catalog = self._conf_get( 'include_service_catalog') + self._hash_algorithms = self._conf_get('hash_algorithms') self._identity_server = self._create_identity_server() self._auth_uri = self._conf_get('auth_uri') if not self._auth_uri: - self._LOG.warning( + self.log.warning( _LW('Configuring auth_uri to point to the public identity ' 'endpoint is required; clients may not be able to ' 'authenticate against an admin endpoint')) @@ -535,7 +689,7 @@ class AuthProtocol(object): self._auth_uri = self._identity_server.auth_uri self._signing_directory = _signing_dir.SigningDirectory( - directory_name=self._conf_get('signing_dir'), log=self._LOG) + directory_name=self._conf_get('signing_dir'), log=self.log) self._token_cache = self._token_cache_factory() @@ -545,184 +699,82 @@ class AuthProtocol(object): self._signing_directory, self._identity_server, self._cms_verify, - self._LOG) + self.log) self._check_revocations_for_cached = self._conf_get( 'check_revocations_for_cached') - self._init_auth_headers() def _conf_get(self, name, group=_base.AUTHTOKEN_GROUP): # try config from paste-deploy first if name in self._conf: return self._conf[name] + elif self._local_oslo_config: + return self._local_oslo_config[group][name] else: return CONF[group][name] - def _call_app(self, env, start_response): - # NOTE(jamielennox): We wrap the given start response so that if an - # application with a 'delay_auth_decision' setting fails, or otherwise - # raises Unauthorized that we include the Authentication URL headers. - def _fake_start_response(status, response_headers, exc_info=None): - if status.startswith('401'): - response_headers.extend(self._reject_auth_headers) - - return start_response(status, response_headers, exc_info) - - return self._app(env, _fake_start_response) - - def __call__(self, env, start_response): - """Handle incoming request. - - Authenticate send downstream on success. Reject request if - we can't authenticate. + def process_request(self, request): + """Process request. + Evaluate the headers in a request and attempt to authenticate the + request. If authenticated then additional headers are added to the + request for use by applications. If not authenticated the request will + be rejected or marked unauthenticated depending on configuration. """ - def _fmt_msg(env): - msg = ('user: user_id %s, project_id %s, roles %s ' - 'service: user_id %s, project_id %s, roles %s' % ( - env.get('HTTP_X_USER_ID'), env.get('HTTP_X_PROJECT_ID'), - env.get('HTTP_X_ROLES'), - env.get('HTTP_X_SERVICE_USER_ID'), - env.get('HTTP_X_SERVICE_PROJECT_ID'), - env.get('HTTP_X_SERVICE_ROLES'))) - return msg - - self._token_cache.initialize(env) - self._remove_auth_headers(env) - - try: - user_auth_ref = None - serv_auth_ref = None - - try: - self._LOG.debug('Authenticating user token') - user_token = self._get_user_token_from_header(env) - user_token_info = self._validate_token(user_token, env) - user_auth_ref = access.AccessInfo.factory( - body=user_token_info, - auth_token=user_token) - env['keystone.token_info'] = user_token_info - user_headers = self._build_user_headers(user_auth_ref, - user_token_info) - self._add_headers(env, user_headers) - except exc.InvalidToken: - if self._delay_auth_decision: - self._LOG.info( - _LI('Invalid user token - deferring reject ' - 'downstream')) - self._add_headers(env, {'X-Identity-Status': 'Invalid'}) - else: - self._LOG.info( - _LI('Invalid user token - rejecting request')) - return self._reject_request(env, start_response) - - try: - self._LOG.debug('Authenticating service token') - serv_token = self._get_service_token_from_header(env) - if serv_token is not None: - serv_token_info = self._validate_token( - serv_token, env) - serv_auth_ref = access.AccessInfo.factory( - body=serv_token_info, - auth_token=serv_token) - serv_headers = self._build_service_headers(serv_token_info) - self._add_headers(env, serv_headers) - except exc.InvalidToken: - if self._delay_auth_decision: - self._LOG.info( - _LI('Invalid service token - deferring reject ' - 'downstream')) - self._add_headers(env, - {'X-Service-Identity-Status': 'Invalid'}) - else: - self._LOG.info( - _LI('Invalid service token - rejecting request')) - return self._reject_request(env, start_response) - - env['keystone.token_auth'] = _user_plugin.UserAuthPlugin( - user_auth_ref, serv_auth_ref) - - except exc.ServiceError as e: - self._LOG.critical(_LC('Unable to obtain admin token: %s'), e) - return self._do_503_error(env, start_response) - - self._LOG.debug("Received request from %s", _fmt_msg(env)) - - return self._call_app(env, start_response) - - def _do_503_error(self, env, start_response): - resp = _utils.MiniResp('Service unavailable', env) - start_response('503 Service Unavailable', resp.headers) - return resp.body - - def _init_auth_headers(self): - """Initialize auth header list. - - Both user and service token headers are generated. - """ - auth_headers = ['X-Service-Catalog', - 'X-Identity-Status', - 'X-Service-Identity-Status', - 'X-Roles', - 'X-Service-Roles'] - for key in six.iterkeys(_HEADER_TEMPLATE): - auth_headers.append(key % '') - # Service headers - auth_headers.append(key % '-Service') - - # Deprecated headers - auth_headers.append('X-Role') - for key in six.iterkeys(_DEPRECATED_HEADER_TEMPLATE): - auth_headers.append(key) - - self._auth_headers = auth_headers - - def _remove_auth_headers(self, env): - """Remove headers so a user can't fake authentication. + self._token_cache.initialize(request.environ) + + resp = super(AuthProtocol, self).process_request(request) + if resp: + return resp + + if not request.user_token: + # if no user token is present then that's an invalid request + request.user_token_valid = False + + # NOTE(jamielennox): The service status is allowed to be missing if a + # service token is not passed. If the service status is missing that's + # a valid request. We should find a better way to expose this from the + # request object. + user_status = request.user_token and request.user_token_valid + service_status = request.headers.get('X-Service-Identity-Status', + 'Confirmed') + + if not (user_status and service_status == 'Confirmed'): + if self._delay_auth_decision: + self.log.info(_LI('Deferring reject downstream')) + else: + self.log.info(_LI('Rejecting request')) + self._reject_request() - Both user and service token headers are removed. + if request.user_token_valid: + request.set_user_headers(request.token_auth._user_auth_ref, + self._include_service_catalog) - :param env: wsgi request environment + if request.service_token and request.service_token_valid: + request.set_service_headers(request.token_auth._serv_auth_ref) - """ - self._LOG.debug('Removing headers from request environment: %s', - ','.join(self._auth_headers)) - self._remove_headers(env, self._auth_headers) + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug('Received request from %s', + request.token_auth._log_format) - def _get_user_token_from_header(self, env): - """Get token id from request. - - :param env: wsgi request environment - :returns: token id - :raises exc.InvalidToken: if no token is provided in request + def process_response(self, response): + """Process Response. + Add ``WWW-Authenticate`` headers to requests that failed with + ``401 Unauthenticated`` so users know where to authenticate for future + requests. """ - token = self._get_header(env, 'X-Auth-Token', - self._get_header(env, 'X-Storage-Token')) - if token: - return token - else: - if not self._delay_auth_decision: - self._LOG.warn(_LW('Unable to find authentication token' - ' in headers')) - self._LOG.debug('Headers: %s', env) - raise exc.InvalidToken(_('Unable to find token in headers')) + if response.status_int == 401: + response.headers.extend(self._reject_auth_headers) - def _get_service_token_from_header(self, env): - """Get service token id from request. - - :param env: wsgi request environment - :returns: service token id or None if not present - - """ - return self._get_header(env, 'X-Service-Token') + return response @property def _reject_auth_headers(self): header_val = 'Keystone uri=\'%s\'' % self._auth_uri return [('WWW-Authenticate', header_val)] - def _reject_request(self, env, start_response): + def _reject_request(self): """Redirect client to auth server. :param env: wsgi request environment @@ -730,246 +782,113 @@ class AuthProtocol(object): :returns: HTTPUnauthorized http response """ - resp = _utils.MiniResp('Authentication required', - env, self._reject_auth_headers) - start_response('401 Unauthorized', resp.headers) - return resp.body - - def _validate_token(self, token, env, retry=True): - """Authenticate user token - - :param token: token id - :param env: wsgi environment - :param retry: Ignored, as it is not longer relevant - :returns: uncrypted body of the token if the token is valid - :raises exc.InvalidToken: if token is rejected - - """ - token_id = None + raise webob.exc.HTTPUnauthorized(body='Authentication required', + headers=self._reject_auth_headers) - try: - token_ids, cached = self._token_cache.get(token) - token_id = token_ids[0] - if cached: - # Token was retrieved from the cache. In this case, there's no - # need to check that the token is expired because the cache - # fetch fails for an expired token. Also, there's no need to - # put the token in the cache because it's already in the cache. - - data = cached - - if self._check_revocations_for_cached: - # A token stored in Memcached might have been revoked - # regardless of initial mechanism used to validate it, - # and needs to be checked. - self._revocations.check(token_ids) - self._confirm_token_bind(data, env) - else: - verified = None - # Token wasn't cached. In this case, the token needs to be - # checked that it's not expired, and also put in the cache. - try: - if cms.is_pkiz(token): - verified = self._verify_pkiz_token(token, token_ids) - elif cms.is_asn1_token(token): - verified = self._verify_signed_token(token, token_ids) - except exceptions.CertificateConfigError: - self._LOG.warn(_LW('Fetch certificate config failed, ' - 'fallback to online validation.')) - except exc.RevocationListError: - self._LOG.warn(_LW('Fetch revocation list failed, ' - 'fallback to online validation.')) - - if verified is not None: - data = jsonutils.loads(verified) - expires = _get_token_expiration(data) - _confirm_token_not_expired(expires) - else: - data = self._identity_server.verify_token(token, retry) - # No need to confirm token expiration here since - # verify_token fails for expired tokens. - expires = _get_token_expiration(data) - self._confirm_token_bind(data, env) - self._token_cache.store(token_id, data, expires) - return data - except (exceptions.ConnectionRefused, exceptions.RequestTimeout): - self._LOG.debug('Token validation failure.', exc_info=True) - self._LOG.warn(_LW('Authorization failed for token')) - raise exc.InvalidToken(_('Token authorization failed')) - except exc.ServiceError: - raise - except Exception: - self._LOG.debug('Token validation failure.', exc_info=True) - if token_id: - self._token_cache.store_invalid(token_id) - self._LOG.warn(_LW('Authorization failed for token')) - raise exc.InvalidToken(_('Token authorization failed')) + def _token_hashes(self, token): + """Generate a list of hashes that the current token may be cached as. - def _build_user_headers(self, auth_ref, token_info): - """Convert token object into headers. + With PKI tokens we have multiple hashing algorithms that we test with + revocations. This generates that whole list. - Build headers that represent authenticated user - see main - doc info at start of file for details of headers to be defined. + The first element of this list is the preferred algorithm and is what + new cache values should be saved as. - :param token_info: token object returned by identity - server on authentication - :raises exc.InvalidToken: when unable to parse token object + :param str token: The token being presented by a user. + :returns: list of str token hashes. """ - roles = ','.join(auth_ref.role_names) - - if _token_is_v2(token_info) and not auth_ref.project_id: - raise exc.InvalidToken(_('Unable to determine tenancy.')) - - rval = { - 'X-Identity-Status': 'Confirmed', - 'X-Roles': roles, - } - - for header_tmplt, attr in six.iteritems(_HEADER_TEMPLATE): - rval[header_tmplt % ''] = getattr(auth_ref, attr) - - # Deprecated headers - rval['X-Role'] = roles - for header_tmplt, attr in six.iteritems(_DEPRECATED_HEADER_TEMPLATE): - rval[header_tmplt] = getattr(auth_ref, attr) - - if self._include_service_catalog and auth_ref.has_service_catalog(): - catalog = auth_ref.service_catalog.get_data() - if _token_is_v3(token_info): - catalog = _v3_to_v2_catalog(catalog) - rval['X-Service-Catalog'] = jsonutils.dumps(catalog) - - return rval - - def _build_service_headers(self, token_info): - """Convert token object into service headers. + if cms.is_asn1_token(token) or cms.is_pkiz(token): + return list(cms.cms_hash_token(token, mode=algo) + for algo in self._hash_algorithms) + else: + return [token] - Build headers that represent authenticated user - see main - doc info at start of file for details of headers to be defined. + def _cache_get_hashes(self, token_hashes): + """Check if the token is cached already. - :param token_info: token object returned by identity - server on authentication - :raises exc.InvalidToken: when unable to parse token object + Functions takes a list of hashes that might be in the cache and matches + the first one that is present. If nothing is found in the cache it + returns None. + :returns: token data if found else None. """ - auth_ref = access.AccessInfo.factory(body=token_info) - if _token_is_v2(token_info) and not auth_ref.project_id: - raise exc.InvalidToken(_('Unable to determine service tenancy.')) + for token in token_hashes: + cached = self._token_cache.get(token) - roles = ','.join(auth_ref.role_names) - rval = { - 'X-Service-Identity-Status': 'Confirmed', - 'X-Service-Roles': roles, - } - - header_type = '-Service' - for header_tmplt, attr in six.iteritems(_HEADER_TEMPLATE): - rval[header_tmplt % header_type] = getattr(auth_ref, attr) - - return rval + if cached: + return cached - def _header_to_env_var(self, key): - """Convert header to wsgi env variable. + def _fetch_token(self, token): + """Retrieve a token from either a PKI bundle or the identity server. - :param key: http header name (ex. 'X-Auth-Token') - :returns: wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN') + :param str token: token id + :raises exc.InvalidToken: if token is rejected """ - return 'HTTP_%s' % key.replace('-', '_').upper() - - def _add_headers(self, env, headers): - """Add http headers to environment.""" - for (k, v) in six.iteritems(headers): - env_key = self._header_to_env_var(k) - env[env_key] = v - - def _remove_headers(self, env, keys): - """Remove http headers from environment.""" - for k in keys: - env_key = self._header_to_env_var(k) - try: - del env[env_key] - except KeyError: - pass + data = None + token_hashes = None - def _get_header(self, env, key, default=None): - """Get http header from environment.""" - env_key = self._header_to_env_var(key) - return env.get(env_key, default) + try: + token_hashes = self._token_hashes(token) + cached = self._cache_get_hashes(token_hashes) - def _invalid_user_token(self, msg=False): - # NOTE(jamielennox): use False as the default so that None is valid - if msg is False: - msg = _('Token authorization failed') + if cached: + data = cached - raise exc.InvalidToken(msg) + if self._check_revocations_for_cached: + # A token might have been revoked, regardless of initial + # mechanism used to validate it, and needs to be checked. + self._revocations.check(token_hashes) + else: + data = self._validate_offline(token, token_hashes) + if not data: + data = self._identity_server.verify_token(token) - def _confirm_token_bind(self, data, env): - bind_mode = self._conf_get('enforce_token_bind') + self._token_cache.store(token_hashes[0], data) - if bind_mode == _BIND_MODE.DISABLED: - return + except (exceptions.ConnectionRefused, exceptions.RequestTimeout): + self.log.debug('Token validation failure.', exc_info=True) + self.log.warning(_LW('Authorization failed for token')) + raise exc.InvalidToken(_('Token authorization failed')) + except exc.ServiceError as e: + self.log.critical(_LC('Unable to obtain admin token: %s'), e) + raise webob.exc.HTTPServiceUnavailable() + except Exception: + self.log.debug('Token validation failure.', exc_info=True) + if token_hashes: + self._token_cache.store_invalid(token_hashes[0]) + self.log.warning(_LW('Authorization failed for token')) + raise exc.InvalidToken(_('Token authorization failed')) + return data + + def _validate_offline(self, token, token_hashes): try: - if _token_is_v2(data): - bind = data['access']['token']['bind'] - elif _token_is_v3(data): - bind = data['token']['bind'] + if cms.is_pkiz(token): + verified = self._verify_pkiz_token(token, token_hashes) + elif cms.is_asn1_token(token): + verified = self._verify_signed_token(token, token_hashes) else: - self._invalid_user_token() - except KeyError: - bind = {} - - # permissive and strict modes don't require there to be a bind - permissive = bind_mode in (_BIND_MODE.PERMISSIVE, _BIND_MODE.STRICT) - - if not bind: - if permissive: - # no bind provided and none required + # Can't do offline validation for this type of token. return - else: - self._LOG.info(_LI('No bind information present in token.')) - self._invalid_user_token() - - # get the named mode if bind_mode is not one of the predefined - if permissive or bind_mode == _BIND_MODE.REQUIRED: - name = None + except exceptions.CertificateConfigError: + self.log.warning(_LW('Fetch certificate config failed, ' + 'fallback to online validation.')) + except exc.RevocationListError: + self.log.warning(_LW('Fetch revocation list failed, ' + 'fallback to online validation.')) else: - name = bind_mode - - if name and name not in bind: - self._LOG.info(_LI('Named bind mode %s not in bind information'), - name) - self._invalid_user_token() - - for bind_type, identifier in six.iteritems(bind): - if bind_type == _BIND_MODE.KERBEROS: - if not env.get('AUTH_TYPE', '').lower() == 'negotiate': - self._LOG.info(_LI('Kerberos credentials required and ' - 'not present.')) - self._invalid_user_token() - - if not env.get('REMOTE_USER') == identifier: - self._LOG.info(_LI('Kerberos credentials do not match ' - 'those in bind.')) - self._invalid_user_token() - - self._LOG.debug('Kerberos bind authentication successful.') + data = jsonutils.loads(verified) + return data - elif bind_mode == _BIND_MODE.PERMISSIVE: - self._LOG.debug('Ignoring Unknown bind for permissive mode: ' - '%(bind_type)s: %(identifier)s.', - {'bind_type': bind_type, - 'identifier': identifier}) + def _validate_token(self, auth_ref): + super(AuthProtocol, self)._validate_token(auth_ref) - else: - self._LOG.info( - _LI('Couldn`t verify unknown bind: %(bind_type)s: ' - '%(identifier)s.'), - {'bind_type': bind_type, 'identifier': identifier}) - self._invalid_user_token() + if auth_ref.version == 'v2.0' and not auth_ref.project_id: + msg = _('Unable to determine service tenancy.') + raise exc.InvalidToken(msg) def _cms_verify(self, data, inform=cms.PKI_ASN1_FORM): """Verifies the signature of the provided data's IAW CMS syntax. @@ -987,7 +906,7 @@ class AuthProtocol(object): signing_ca_path, inform=inform).decode('utf-8') except cms.subprocess.CalledProcessError as err: - self._LOG.warning(_LW('Verify error: %s'), err) + self.log.warning(_LW('Verify error: %s'), err) raise try: @@ -1003,7 +922,7 @@ class AuthProtocol(object): except exceptions.CertificateConfigError as err: # if this is still occurring, something else is wrong and we # need err.output to identify the problem - self._LOG.error(_LE('CMS Verify output: %s'), err.output) + self.log.error(_LE('CMS Verify output: %s'), err.output) raise def _verify_signed_token(self, signed_text, token_ids): @@ -1056,10 +975,11 @@ class AuthProtocol(object): else: plugin_class = _auth.AuthTokenPlugin # logger object is a required parameter of the default plugin - plugin_kwargs['log'] = self._LOG + plugin_kwargs['log'] = self.log plugin_opts = plugin_class.get_options() - CONF.register_opts(plugin_opts, group=group) + (self._local_oslo_config or CONF).register_opts(plugin_opts, + group=group) for opt in plugin_opts: val = self._conf_get(opt.dest, group=group) @@ -1069,6 +989,45 @@ class AuthProtocol(object): return plugin_class.load_from_options(**plugin_kwargs) + def _determine_project(self): + """Determine a project name from all available config sources. + + The sources are checked in the following order: + + 1. The paste-deploy config for auth_token middleware + 2. The keystone_authtoken in the project's config + 3. The oslo.config CONF.project property + + """ + try: + return self._conf_get('project') + except cfg.NoSuchOptError: + # Prefer local oslo config object + if self._local_oslo_config: + return self._local_oslo_config.project + try: + # CONF.project will exist only if the service uses + # oslo.config. It will only be set when the project + # calls CONF(...) and when not set oslo.config oddly + # raises a NoSuchOptError exception. + return CONF.project + except cfg.NoSuchOptError: + return '' + + def _build_useragent_string(self): + project = self._determine_project() + if project: + project_version = _get_project_version(project) + project = '{project}/{project_version} '.format( + project=project, + project_version=project_version) + + ua_template = ('{project}' + 'keystonemiddleware.auth_token/{ksm_version}') + return ua_template.format( + project=project, + ksm_version=_get_project_version('keystonemiddleware')) + def _create_identity_server(self): # NOTE(jamielennox): Loading Session here should be exactly the # same as calling Session.load_from_conf_options(CONF, GROUP) @@ -1079,7 +1038,8 @@ class AuthProtocol(object): key=self._conf_get('keyfile'), cacert=self._conf_get('cafile'), insecure=self._conf_get('insecure'), - timeout=self._conf_get('http_connect_timeout') + timeout=self._conf_get('http_connect_timeout'), + user_agent=self._build_useragent_string() )) auth_plugin = self._get_auth_plugin() @@ -1089,13 +1049,14 @@ class AuthProtocol(object): auth=auth_plugin, service_type='identity', interface='admin', + region_name=self._conf_get('region_name'), connect_retries=self._conf_get('http_request_max_retries')) auth_version = self._conf_get('auth_version') if auth_version is not None: auth_version = discover.normalize_version_number(auth_version) return _identity.IdentityServer( - self._LOG, + self.log, adap, include_service_catalog=self._include_service_catalog, requested_auth_version=auth_version) @@ -1105,7 +1066,6 @@ class AuthProtocol(object): cache_kwargs = dict( cache_time=int(self._conf_get('token_cache_time')), - hash_algorithms=self._conf_get('hash_algorithms'), env_cache_name=self._conf_get('cache'), memcached_servers=self._conf_get('memcached_servers'), use_advanced_pool=self._conf_get('memcache_use_advanced_pool'), @@ -1122,12 +1082,12 @@ class AuthProtocol(object): if security_strategy: secret_key = self._conf_get('memcache_secret_key') - return _cache.SecureTokenCache(self._LOG, + return _cache.SecureTokenCache(self.log, security_strategy, secret_key, **cache_kwargs) else: - return _cache.TokenCache(self._LOG, **cache_kwargs) + return _cache.TokenCache(self.log, **cache_kwargs) def filter_factory(global_conf, **local_conf): @@ -1146,24 +1106,6 @@ def app_factory(global_conf, **local_conf): return AuthProtocol(None, conf) -if __name__ == '__main__': - def echo_app(environ, start_response): - """A WSGI application that echoes the CGI environment to the user.""" - start_response('200 OK', [('Content-Type', 'application/json')]) - environment = dict((k, v) for k, v in six.iteritems(environ) - if k.startswith('HTTP_X_')) - yield jsonutils.dumps(environment) - - from wsgiref import simple_server - - # hardcode any non-default configuration here - conf = {'auth_protocol': 'http', 'admin_token': 'ADMIN'} - app = AuthProtocol(echo_app, conf) - server = simple_server.make_server('', 8000, app) - print('Serving on port 8000 (Ctrl+C to end)...') - server.serve_forever() - - # NOTE(jamielennox): Maintained here for public API compatibility. InvalidToken = exc.InvalidToken ServiceError = exc.ServiceError diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/_auth.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/_auth.py index acc32ca5..cf7ed84d 100644 --- a/keystonemiddleware-moon/keystonemiddleware/auth_token/_auth.py +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/_auth.py @@ -30,6 +30,14 @@ class AuthTokenPlugin(auth.BaseAuthPlugin): def __init__(self, auth_host, auth_port, auth_protocol, auth_admin_prefix, admin_user, admin_password, admin_tenant_name, admin_token, identity_uri, log): + + log.warning(_LW( + "Use of the auth_admin_prefix, auth_host, auth_port, " + "auth_protocol, identity_uri, admin_token, admin_user, " + "admin_password, and admin_tenant_name configuration options is " + "deprecated in favor of auth_plugin and related options and may " + "be removed in a future release.")) + # NOTE(jamielennox): it does appear here that our default arguments # are backwards. We need to do it this way so that we can handle the # same deprecation strategy for CONF and the conf variable. @@ -88,7 +96,8 @@ class AuthTokenPlugin(auth.BaseAuthPlugin): :param session: The session object that the auth_plugin belongs to. :type session: keystoneclient.session.Session - :param tuple version: The version number required for this endpoint. + :param version: The version number required for this endpoint. + :type version: tuple or str :param str interface: what visibility the endpoint should have. :returns: The base URL that will be used to talk to the required @@ -115,9 +124,13 @@ class AuthTokenPlugin(auth.BaseAuthPlugin): # NOTE(jamielennox): for backwards compatibility here we don't # actually use the URL from discovery we hack it up instead. :( - if version[0] == 2: + # NOTE(blk-u): Normalizing the version is a workaround for bug 1450272. + # This can be removed once that's fixed. Also fix the docstring for the + # version parameter to be just "tuple". + version = discover.normalize_version_number(version) + if discover.version_match((2, 0), version): return '%s/v2.0' % self._identity_uri - elif version[0] == 3: + elif discover.version_match((3, 0), version): return '%s/v3' % self._identity_uri # NOTE(jamielennox): This plugin will only get called from auth_token diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/_cache.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/_cache.py index ae155776..ce5faf66 100644 --- a/keystonemiddleware-moon/keystonemiddleware/auth_token/_cache.py +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/_cache.py @@ -11,10 +11,9 @@ # under the License. import contextlib +import hashlib -from keystoneclient.common import cms from oslo_serialization import jsonutils -from oslo_utils import timeutils import six from keystonemiddleware.auth_token import _exceptions as exc @@ -23,6 +22,22 @@ from keystonemiddleware.i18n import _, _LE from keystonemiddleware.openstack.common import memorycache +def _hash_key(key): + """Turn a set of arguments into a SHA256 hash. + + Using a known-length cache key is important to ensure that memcache + maximum key length is not exceeded causing failures to validate. + """ + if isinstance(key, six.text_type): + # NOTE(morganfainberg): Ensure we are always working with a bytes + # type required for the hasher. In python 2.7 it is possible to + # get a text_type (unicode). In python 3.4 all strings are + # text_type and not bytes by default. This encode coerces the + # text_type to the appropriate bytes values. + key = key.encode('utf-8') + return hashlib.sha256(key).hexdigest() + + class _CachePool(list): """A lazy pool of cache references.""" @@ -57,7 +72,7 @@ class _MemcacheClientPool(object): memcache_pool_conn_get_timeout=None, memcache_pool_socket_timeout=None): # NOTE(morganfainberg): import here to avoid hard dependency on - # python-memcache library. + # python-memcached library. global _memcache_pool from keystonemiddleware.auth_token import _memcache_pool @@ -97,7 +112,7 @@ class TokenCache(object): _CACHE_KEY_TEMPLATE = 'tokens/%s' _INVALID_INDICATOR = 'invalid' - def __init__(self, log, cache_time=None, hash_algorithms=None, + def __init__(self, log, cache_time=None, env_cache_name=None, memcached_servers=None, use_advanced_pool=False, memcache_pool_dead_retry=None, memcache_pool_maxsize=None, memcache_pool_unused_timeout=None, @@ -105,7 +120,6 @@ class TokenCache(object): memcache_pool_socket_timeout=None): self._LOG = log self._cache_time = cache_time - self._hash_algorithms = hash_algorithms self._env_cache_name = env_cache_name self._memcached_servers = memcached_servers self._use_advanced_pool = use_advanced_pool @@ -150,47 +164,11 @@ class TokenCache(object): self._initialized = True - def get(self, user_token): - """Check if the token is cached already. - - Returns a tuple. The first element is a list of token IDs, where the - first one is the preferred hash. - - The second element is the token data from the cache if the token was - cached, otherwise ``None``. - - :raises exc.InvalidToken: if the token is invalid - - """ - - if cms.is_asn1_token(user_token) or cms.is_pkiz(user_token): - # user_token is a PKI token that's not hashed. - - token_hashes = list(cms.cms_hash_token(user_token, mode=algo) - for algo in self._hash_algorithms) - - for token_hash in token_hashes: - cached = self._cache_get(token_hash) - if cached: - return (token_hashes, cached) - - # The token wasn't found using any hash algorithm. - return (token_hashes, None) - - # user_token is either a UUID token or a hashed PKI token. - token_id = user_token - cached = self._cache_get(token_id) - return ([token_id], cached) - - def store(self, token_id, data, expires): + def store(self, token_id, data): """Put token data into the cache. - - Stores the parsed expire date in cache allowing - quick check of token freshness on retrieval. - """ self._LOG.debug('Storing token in cache') - self._cache_store(token_id, (data, expires)) + self._cache_store(token_id, data) def store_invalid(self, token_id): """Store invalid token in cache.""" @@ -217,7 +195,7 @@ class TokenCache(object): # NOTE(jamielennox): in the basic implementation there is no need for # a context so just pass None as it will only get passed back later. unused_context = None - return self._CACHE_KEY_TEMPLATE % token_id, unused_context + return self._CACHE_KEY_TEMPLATE % _hash_key(token_id), unused_context def _deserialize(self, data, context): """Deserialize data from the cache back into python objects. @@ -249,7 +227,7 @@ class TokenCache(object): # memory cache will handle serialization for us return data - def _cache_get(self, token_id): + def get(self, token_id): """Return token information from cache. If token is invalid raise exc.InvalidToken @@ -268,6 +246,8 @@ class TokenCache(object): if serialized is None: return None + if isinstance(serialized, six.text_type): + serialized = serialized.encode('utf8') data = self._deserialize(serialized, context) # Note that _INVALID_INDICATOR and (data, expires) are the only @@ -280,24 +260,15 @@ class TokenCache(object): self._LOG.debug('Cached Token is marked unauthorized') raise exc.InvalidToken(_('Token authorization failed')) - data, expires = cached - + # NOTE(jamielennox): Cached values used to be stored as a tuple of data + # and expiry time. They no longer are but we have to allow some time to + # transition the old format so if it's a tuple just return the data. try: - expires = timeutils.parse_isotime(expires) + data, expires = cached except ValueError: - # Gracefully handle upgrade of expiration times from *nix - # timestamps to ISO 8601 formatted dates by ignoring old cached - # values. - return + data = cached - expires = timeutils.normalize_time(expires) - utcnow = timeutils.utcnow() - if utcnow < expires: - self._LOG.debug('Returning cached token') - return data - else: - self._LOG.debug('Cached Token seems expired') - raise exc.InvalidToken(_('Token authorization failed')) + return data def _cache_store(self, token_id, data): """Store value into memcache. diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/_identity.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/_identity.py index 8acf70d1..98be3b2e 100644 --- a/keystonemiddleware-moon/keystonemiddleware/auth_token/_identity.py +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/_identity.py @@ -10,31 +10,57 @@ # License for the specific language governing permissions and limitations # under the License. +import functools + from keystoneclient import auth from keystoneclient import discover from keystoneclient import exceptions -from oslo_serialization import jsonutils +from keystoneclient.v2_0 import client as v2_client +from keystoneclient.v3 import client as v3_client from six.moves import urllib from keystonemiddleware.auth_token import _auth from keystonemiddleware.auth_token import _exceptions as exc -from keystonemiddleware.auth_token import _utils from keystonemiddleware.i18n import _, _LE, _LI, _LW +def _convert_fetch_cert_exception(fetch_cert): + @functools.wraps(fetch_cert) + def wrapper(self): + try: + text = fetch_cert(self) + except exceptions.HTTPError as e: + raise exceptions.CertificateConfigError(e.details) + return text + + return wrapper + + class _RequestStrategy(object): AUTH_VERSION = None - def __init__(self, json_request, adap, include_service_catalog=None): - self._json_request = json_request - self._adapter = adap + def __init__(self, adap, include_service_catalog=None): self._include_service_catalog = include_service_catalog def verify_token(self, user_token): pass - def fetch_cert_file(self, cert_type): + @_convert_fetch_cert_exception + def fetch_signing_cert(self): + return self._fetch_signing_cert() + + def _fetch_signing_cert(self): + pass + + @_convert_fetch_cert_exception + def fetch_ca_cert(self): + return self._fetch_ca_cert() + + def _fetch_ca_cert(self): + pass + + def fetch_revocation_list(self): pass @@ -42,36 +68,56 @@ class _V2RequestStrategy(_RequestStrategy): AUTH_VERSION = (2, 0) - def verify_token(self, user_token): - return self._json_request('GET', - '/tokens/%s' % user_token, - authenticated=True) + def __init__(self, adap, **kwargs): + super(_V2RequestStrategy, self).__init__(adap, **kwargs) + self._client = v2_client.Client(session=adap) + + def verify_token(self, token): + auth_ref = self._client.tokens.validate_access_info(token) + + if not auth_ref: + msg = _('Failed to fetch token data from identity server') + raise exc.InvalidToken(msg) - def fetch_cert_file(self, cert_type): - return self._adapter.get('/certificates/%s' % cert_type, - authenticated=False) + return {'access': auth_ref} + + def _fetch_signing_cert(self): + return self._client.certificates.get_signing_certificate() + + def _fetch_ca_cert(self): + return self._client.certificates.get_ca_certificate() + + def fetch_revocation_list(self): + return self._client.tokens.get_revoked() class _V3RequestStrategy(_RequestStrategy): AUTH_VERSION = (3, 0) - def verify_token(self, user_token): - path = '/auth/tokens' - if not self._include_service_catalog: - path += '?nocatalog' + def __init__(self, adap, **kwargs): + super(_V3RequestStrategy, self).__init__(adap, **kwargs) + self._client = v3_client.Client(session=adap) - return self._json_request('GET', - path, - authenticated=True, - headers={'X-Subject-Token': user_token}) + def verify_token(self, token): + auth_ref = self._client.tokens.validate( + token, + include_catalog=self._include_service_catalog) - def fetch_cert_file(self, cert_type): - if cert_type == 'signing': - cert_type = 'certificates' + if not auth_ref: + msg = _('Failed to fetch token data from identity server') + raise exc.InvalidToken(msg) - return self._adapter.get('/OS-SIMPLE-CERT/%s' % cert_type, - authenticated=False) + return {'token': auth_ref} + + def _fetch_signing_cert(self): + return self._client.simple_cert.get_certificates() + + def _fetch_ca_cert(self): + return self._client.simple_cert.get_ca_certificates() + + def fetch_revocation_list(self): + return self._client.tokens.get_revoked() _REQUEST_STRATEGIES = [_V3RequestStrategy, _V2RequestStrategy] @@ -120,7 +166,6 @@ class IdentityServer(object): self._adapter.version = strategy_class.AUTH_VERSION self._request_strategy_obj = strategy_class( - self._json_request, self._adapter, include_service_catalog=self._include_service_catalog) @@ -158,15 +203,14 @@ class IdentityServer(object): :param retry: flag that forces the middleware to retry user authentication when an indeterminate response is received. Optional. - :returns: token object received from identity server on success + :returns: access info received from identity server on success + :rtype: :py:class:`keystoneclient.access.AccessInfo` :raises exc.InvalidToken: if token is rejected :raises exc.ServiceError: if unable to authenticate token """ - user_token = _utils.safe_quote(user_token) - try: - response, data = self._request_strategy.verify_token(user_token) + auth_ref = self._request_strategy.verify_token(user_token) except exceptions.NotFound as e: self._LOG.warn(_LW('Authorization failed for token')) self._LOG.warn(_LW('Identity response: %s'), e.response.text) @@ -182,62 +226,24 @@ class IdentityServer(object): e.http_status) self._LOG.warn(_LW('Identity response: %s'), e.response.text) else: - if response.status_code == 200: - return data + return auth_ref - raise exc.InvalidToken() + msg = _('Failed to fetch token data from identity server') + raise exc.InvalidToken(msg) def fetch_revocation_list(self): try: - response, data = self._json_request( - 'GET', '/tokens/revoked', - authenticated=True, - endpoint_filter={'version': (2, 0)}) + data = self._request_strategy.fetch_revocation_list() except exceptions.HTTPError as e: msg = _('Failed to fetch token revocation list: %d') raise exc.RevocationListError(msg % e.http_status) - if response.status_code != 200: - msg = _('Unable to fetch token revocation list.') - raise exc.RevocationListError(msg) if 'signed' not in data: msg = _('Revocation list improperly formatted.') raise exc.RevocationListError(msg) return data['signed'] def fetch_signing_cert(self): - return self._fetch_cert_file('signing') + return self._request_strategy.fetch_signing_cert() def fetch_ca_cert(self): - return self._fetch_cert_file('ca') - - def _json_request(self, method, path, **kwargs): - """HTTP request helper used to make json requests. - - :param method: http method - :param path: relative request url - :param **kwargs: additional parameters used by session or endpoint - :returns: http response object, response body parsed as json - :raises ServerError: when unable to communicate with identity server. - - """ - headers = kwargs.setdefault('headers', {}) - headers['Accept'] = 'application/json' - - response = self._adapter.request(path, method, **kwargs) - - try: - data = jsonutils.loads(response.text) - except ValueError: - self._LOG.debug('Identity server did not return json-encoded body') - data = {} - - return response, data - - def _fetch_cert_file(self, cert_type): - try: - response = self._request_strategy.fetch_cert_file(cert_type) - except exceptions.HTTPError as e: - raise exceptions.CertificateConfigError(e.details) - if response.status_code != 200: - raise exceptions.CertificateConfigError(response.text) - return response.text + return self._request_strategy.fetch_ca_cert() diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/_request.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/_request.py new file mode 100644 index 00000000..72fd5380 --- /dev/null +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/_request.py @@ -0,0 +1,224 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import itertools + +from oslo_serialization import jsonutils +import six +import webob + + +def _v3_to_v2_catalog(catalog): + """Convert a catalog to v2 format. + + X_SERVICE_CATALOG must be specified in v2 format. If you get a token + that is in v3 convert it. + """ + v2_services = [] + for v3_service in catalog: + # first copy over the entries we allow for the service + v2_service = {'type': v3_service['type']} + try: + v2_service['name'] = v3_service['name'] + except KeyError: + pass + + # now convert the endpoints. Because in v3 we specify region per + # URL not per group we have to collect all the entries of the same + # region together before adding it to the new service. + regions = {} + for v3_endpoint in v3_service.get('endpoints', []): + region_name = v3_endpoint.get('region') + try: + region = regions[region_name] + except KeyError: + region = {'region': region_name} if region_name else {} + regions[region_name] = region + + interface_name = v3_endpoint['interface'].lower() + 'URL' + region[interface_name] = v3_endpoint['url'] + + v2_service['endpoints'] = list(regions.values()) + v2_services.append(v2_service) + + return v2_services + + +# NOTE(jamielennox): this should probably be moved into its own file, but at +# the moment there's no real logic here so just keep it locally. +class _AuthTokenResponse(webob.Response): + + default_content_type = None # prevents webob assigning a content type + + +class _AuthTokenRequest(webob.Request): + + ResponseClass = _AuthTokenResponse + + _HEADER_TEMPLATE = { + 'X%s-Domain-Id': 'domain_id', + 'X%s-Domain-Name': 'domain_name', + 'X%s-Project-Id': 'project_id', + 'X%s-Project-Name': 'project_name', + 'X%s-Project-Domain-Id': 'project_domain_id', + 'X%s-Project-Domain-Name': 'project_domain_name', + 'X%s-User-Id': 'user_id', + 'X%s-User-Name': 'username', + 'X%s-User-Domain-Id': 'user_domain_id', + 'X%s-User-Domain-Name': 'user_domain_name', + } + + _ROLES_TEMPLATE = 'X%s-Roles' + + _USER_HEADER_PREFIX = '' + _SERVICE_HEADER_PREFIX = '-Service' + + _USER_STATUS_HEADER = 'X-Identity-Status' + _SERVICE_STATUS_HEADER = 'X-Service-Identity-Status' + + _SERVICE_CATALOG_HEADER = 'X-Service-Catalog' + _TOKEN_AUTH = 'keystone.token_auth' + + _CONFIRMED = 'Confirmed' + _INVALID = 'Invalid' + + # header names that have been deprecated in favour of something else. + _DEPRECATED_HEADER_MAP = { + 'X-Role': 'X-Roles', + 'X-User': 'X-User-Name', + 'X-Tenant-Id': 'X-Project-Id', + 'X-Tenant-Name': 'X-Project-Name', + 'X-Tenant': 'X-Project-Name', + } + + def _confirmed(cls, value): + return cls._CONFIRMED if value else cls._INVALID + + @property + def user_token_valid(self): + """User token is marked as valid. + + :returns: True if the X-Identity-Status header is set to Confirmed. + :rtype: bool + """ + return self.headers[self._USER_STATUS_HEADER] == self._CONFIRMED + + @user_token_valid.setter + def user_token_valid(self, value): + self.headers[self._USER_STATUS_HEADER] = self._confirmed(value) + + @property + def user_token(self): + return self.headers.get('X-Auth-Token', + self.headers.get('X-Storage-Token')) + + @property + def service_token_valid(self): + """Service token is marked as valid. + + :returns: True if the X-Service-Identity-Status header + is set to Confirmed. + :rtype: bool + """ + return self.headers[self._SERVICE_STATUS_HEADER] == self._CONFIRMED + + @service_token_valid.setter + def service_token_valid(self, value): + self.headers[self._SERVICE_STATUS_HEADER] = self._confirmed(value) + + @property + def service_token(self): + return self.headers.get('X-Service-Token') + + def _set_auth_headers(self, auth_ref, prefix): + names = ','.join(auth_ref.role_names) + self.headers[self._ROLES_TEMPLATE % prefix] = names + + for header_tmplt, attr in six.iteritems(self._HEADER_TEMPLATE): + self.headers[header_tmplt % prefix] = getattr(auth_ref, attr) + + def set_user_headers(self, auth_ref, include_service_catalog): + """Convert token object into headers. + + Build headers that represent authenticated user - see main + doc info at start of __init__ file for details of headers to be defined + """ + self._set_auth_headers(auth_ref, self._USER_HEADER_PREFIX) + + for k, v in six.iteritems(self._DEPRECATED_HEADER_MAP): + self.headers[k] = self.headers[v] + + if include_service_catalog and auth_ref.has_service_catalog(): + catalog = auth_ref.service_catalog.get_data() + if auth_ref.version == 'v3': + catalog = _v3_to_v2_catalog(catalog) + + c = jsonutils.dumps(catalog) + self.headers[self._SERVICE_CATALOG_HEADER] = c + + self.user_token_valid = True + + def set_service_headers(self, auth_ref): + """Convert token object into service headers. + + Build headers that represent authenticated user - see main + doc info at start of __init__ file for details of headers to be defined + """ + self._set_auth_headers(auth_ref, self._SERVICE_HEADER_PREFIX) + self.service_token_valid = True + + def _all_auth_headers(self): + """All the authentication headers that can be set on the request""" + yield self._SERVICE_CATALOG_HEADER + yield self._USER_STATUS_HEADER + yield self._SERVICE_STATUS_HEADER + + for header in self._DEPRECATED_HEADER_MAP: + yield header + + prefixes = (self._USER_HEADER_PREFIX, self._SERVICE_HEADER_PREFIX) + + for tmpl, prefix in itertools.product(self._HEADER_TEMPLATE, prefixes): + yield tmpl % prefix + + for prefix in prefixes: + yield self._ROLES_TEMPLATE % prefix + + def remove_auth_headers(self): + """Remove headers so a user can't fake authentication.""" + for header in self._all_auth_headers(): + self.headers.pop(header, None) + + @property + def auth_type(self): + """The authentication type that was performed by the web server. + + The returned string value is always lower case. + + :returns: The AUTH_TYPE environ string or None if not present. + :rtype: str or None + """ + try: + auth_type = self.environ['AUTH_TYPE'] + except KeyError: + return None + else: + return auth_type.lower() + + @property + def token_auth(self): + """The auth plugin that will be associated with this request""" + return self.environ.get(self._TOKEN_AUTH) + + @token_auth.setter + def token_auth(self, v): + self.environ[self._TOKEN_AUTH] = v diff --git a/keystonemiddleware-moon/keystonemiddleware/auth_token/_user_plugin.py b/keystonemiddleware-moon/keystonemiddleware/auth_token/_user_plugin.py index 12a8767c..93075c5c 100644 --- a/keystonemiddleware-moon/keystonemiddleware/auth_token/_user_plugin.py +++ b/keystonemiddleware-moon/keystonemiddleware/auth_token/_user_plugin.py @@ -105,6 +105,13 @@ class _TokenData(object): """ return frozenset(self._stored_auth_ref.role_names or []) + @property + def _log_format(self): + roles = ','.join(self.role_names) + return 'user_id %s, project_id %s, roles %s' % (self.user_id, + self.project_id, + roles) + class UserAuthPlugin(base_identity.BaseIdentityPlugin): """The incoming authentication credentials. @@ -119,8 +126,13 @@ class UserAuthPlugin(base_identity.BaseIdentityPlugin): def __init__(self, user_auth_ref, serv_auth_ref): super(UserAuthPlugin, self).__init__(reauthenticate=False) + + # NOTE(jamielennox): _user_auth_ref and _serv_auth_ref are private + # because this object ends up in the environ that is passed to the + # service, however they are used within auth_token middleware. self._user_auth_ref = user_auth_ref self._serv_auth_ref = serv_auth_ref + self._user_data = None self._serv_data = None @@ -167,3 +179,15 @@ class UserAuthPlugin(base_identity.BaseIdentityPlugin): # calculated by the middleware. reauthenticate=False in __init__ should # ensure that this function is only called on the first access. return self._user_auth_ref + + @property + def _log_format(self): + msg = [] + + if self.has_user_token: + msg.append('user: %s' % self.user._log_format) + + if self.has_service_token: + msg.append('service: %s' % self.service._log_format) + + return ' '.join(msg) |