# vim: tabstop=4 shiftwidth=4 softtabstop=4

# Copyright 2010-2012 OpenStack LLC
#
# 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.

"""
TOKEN-BASED AUTH MIDDLEWARE

This WSGI component:

* Verifies that incoming client requests have valid tokens by validating
  tokens with the auth service.
* Rejects unauthenticated requests UNLESS it is in '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

Refer to: http://keystone.openstack.org/middleware_architecture.html

HEADERS
-------

* Headers starting with HTTP\_ is a standard http header
* Headers starting with HTTP_X is an extended http header

Coming in from initial call from client or customer
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

HTTP_X_AUTH_TOKEN
    The client token being passed in.

HTTP_X_STORAGE_TOKEN
    The client token being passed in (legacy Rackspace use) to support
    swift/cloud files

Used for communication between components
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

WWW-Authenticate
    HTTP header returned to a user indicating which endpoint to use
    to retrieve a new token

What we add to the request for use by the OpenStack service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

HTTP_X_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

HTTP_X_TENANT_ID
    Identity service managed unique identifier, string

HTTP_X_TENANT_NAME
    Unique tenant identifier, string

HTTP_X_USER_ID
    Identity-service managed unique identifier, string

HTTP_X_USER_NAME
    Unique user identifier, string

HTTP_X_ROLES
    Comma delimited list of case-sensitive Roles

HTTP_X_TENANT
    *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME
    Keystone-assigned unique identifier, deprecated

HTTP_X_USER
    *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME
    Unique user name, string

HTTP_X_ROLE
    *Deprecated* in favor of HTTP_X_ROLES
    This is being renamed, and the new header contains the same data.

"""

import httplib
import json
import logging
import time

import webob
import webob.exc


LOG = logging.getLogger(__name__)


class InvalidUserToken(Exception):
    pass


class ServiceError(Exception):
    pass


class AuthProtocol(object):
    """Auth Middleware that handles authenticating client calls."""

    def __init__(self, app, conf):
        LOG.info('Starting keystone auth_token middleware')
        self.conf = conf
        self.app = app

        # delay_auth_decision means we still allow unauthenticated requests
        # through and we let the downstream service make the final decision
        self.delay_auth_decision = int(conf.get('delay_auth_decision', 0))

        # where to find the auth service (we use this to validate tokens)
        self.auth_host = conf.get('auth_host')
        self.auth_port = int(conf.get('auth_port', 35357))
        auth_protocol = conf.get('auth_protocol', 'https')
        if auth_protocol == 'http':
            self.http_client_class = httplib.HTTPConnection
        else:
            self.http_client_class = httplib.HTTPSConnection

        default_auth_uri = '%s://%s:%s' % (auth_protocol,
                                           self.auth_host,
                                           self.auth_port)
        self.auth_uri = conf.get('auth_uri', default_auth_uri)

        # Credentials used to verify this component with the Auth service since
        # validating tokens is a privileged call
        self.admin_token = conf.get('admin_token')
        self.admin_user = conf.get('admin_user')
        self.admin_password = conf.get('admin_password')
        self.admin_tenant_name = conf.get('admin_tenant_name', 'admin')

        # Token caching via memcache
        self._cache = None
        self._iso8601 = None
        memcache_servers = conf.get('memcache_servers')
        # By default the token will be cached for 5 minutes
        self.token_cache_time = conf.get('token_cache_time', 300)
        if memcache_servers:
            try:
                import memcache
                import iso8601
                LOG.info('Using memcache for caching token')
                self._cache = memcache.Client(memcache_servers.split(','))
                self._iso8601 = iso8601
            except NameError as e:
                LOG.warn('disabled caching due to missing libraries %s', e)

    def __call__(self, env, start_response):
        """Handle incoming request.

        Authenticate send downstream on success. Reject request if
        we can't authenticate.

        """
        LOG.debug('Authenticating user token')
        try:
            self._remove_auth_headers(env)
            user_token = self._get_user_token_from_header(env)
            token_info = self._validate_user_token(user_token)
            user_headers = self._build_user_headers(token_info)
            self._add_headers(env, user_headers)
            return self.app(env, start_response)

        except InvalidUserToken:
            if self.delay_auth_decision:
                LOG.info('Invalid user token - deferring reject downstream')
                self._add_headers(env, {'X-Identity-Status': 'Invalid'})
                return self.app(env, start_response)
            else:
                LOG.info('Invalid user token - rejecting request')
                return self._reject_request(env, start_response)

        except ServiceError, e:
            LOG.critical('Unable to obtain admin token: %s' % e)
            resp = webob.exc.HTTPServiceUnavailable()
            return resp(env, start_response)

    def _remove_auth_headers(self, env):
        """Remove headers so a user can't fake authentication.

        :param env: wsgi request environment

        """
        auth_headers = (
            'X-Identity-Status',
            'X-Tenant-Id',
            'X-Tenant-Name',
            'X-User-Id',
            'X-User-Name',
            'X-Roles',
            # Deprecated
            'X-User',
            'X-Tenant',
            'X-Role',
        )
        LOG.debug('Removing headers from request environment: %s' %
                     ','.join(auth_headers))
        self._remove_headers(env, auth_headers)

    def _get_user_token_from_header(self, env):
        """Get token id from request.

        :param env: wsgi request environment
        :return token id
        :raises InvalidUserToken if no token is provided in request

        """
        token = self._get_header(env, 'X-Auth-Token',
                                 self._get_header(env, 'X-Storage-Token'))
        if token:
            return token
        else:
            LOG.warn("Unable to find authentication token in headers: %s", env)
            raise InvalidUserToken('Unable to find token in headers')

    def _reject_request(self, env, start_response):
        """Redirect client to auth server.

        :param env: wsgi request environment
        :param start_response: wsgi response callback
        :returns HTTPUnauthorized http response

        """
        headers = [('WWW-Authenticate', 'Keystone uri=\'%s\'' % self.auth_uri)]
        resp = webob.exc.HTTPUnauthorized('Authentication required', headers)
        return resp(env, start_response)

    def get_admin_token(self):
        """Return admin token, possibly fetching a new one.

        :return admin token id
        :raise ServiceError when unable to retrieve token from keystone

        """
        if not self.admin_token:
            self.admin_token = self._request_admin_token()

        return self.admin_token

    def _get_http_connection(self):
        return self.http_client_class(self.auth_host, self.auth_port)

    def _json_request(self, method, path, body=None, additional_headers=None):
        """HTTP request helper used to make json requests.

        :param method: http method
        :param path: relative request url
        :param body: dict to encode to json as request body. Optional.
        :param additional_headers: dict of additional headers to send with
                                   http request. Optional.
        :return (http response object, response body parsed as json)
        :raise ServerError when unable to communicate with keystone

        """
        conn = self._get_http_connection()

        kwargs = {
            'headers': {
                'Content-type': 'application/json',
                'Accept': 'application/json',
            },
        }

        if additional_headers:
            kwargs['headers'].update(additional_headers)

        if body:
            kwargs['body'] = json.dumps(body)

        try:
            conn.request(method, path, **kwargs)
            response = conn.getresponse()
            body = response.read()
        except Exception, e:
            LOG.error('HTTP connection exception: %s' % e)
            raise ServiceError('Unable to communicate with keystone')
        finally:
            conn.close()

        try:
            data = json.loads(body)
        except ValueError:
            LOG.debug('Keystone did not return json-encoded body')
            data = {}

        return response, data

    def _request_admin_token(self):
        """Retrieve new token as admin user from keystone.

        :return token id upon success
        :raises ServerError when unable to communicate with keystone

        """
        params = {
            'auth': {
                'passwordCredentials': {
                    'username': self.admin_user,
                    'password': self.admin_password,
                },
                'tenantName': self.admin_tenant_name,
            }
        }

        response, data = self._json_request('POST',
                                            '/v2.0/tokens',
                                            body=params)

        try:
            token = data['access']['token']['id']
            assert token
            return token
        except (AssertionError, KeyError):
            LOG.warn("Unexpected response from keystone service: %s", data)
            raise ServiceError('invalid json response')

    def _validate_user_token(self, user_token, retry=True):
        """Authenticate user token with keystone.

        :param user_token: user's token id
        :param retry: flag that forces the middleware to retry
                      user authentication when an indeterminate
                      response is received. Optional.
        :return token object received from keystone on success
        :raise InvalidUserToken if token is rejected
        :raise ServiceError if unable to authenticate token

        """
        cached = self._cache_get(user_token)
        if cached:
            return cached

        headers = {'X-Auth-Token': self.get_admin_token()}
        response, data = self._json_request('GET',
                                            '/v2.0/tokens/%s' % user_token,
                                            additional_headers=headers)

        if response.status == 200:
            self._cache_put(user_token, data)
            return data
        if response.status == 404:
            # FIXME(ja): I'm assuming the 404 status means that user_token is
            #            invalid - not that the admin_token is invalid
            self._cache_store_invalid(user_token)
            LOG.warn("Authorization failed for token %s", user_token)
            raise InvalidUserToken('Token authorization failed')
        if response.status == 401:
            LOG.info('Keystone rejected admin token %s, resetting', headers)
            self.admin_token = None
        else:
            LOG.error('Bad response code while validating token: %s' %
                         response.status)
        if retry:
            LOG.info('Retrying validation')
            return self._validate_user_token(user_token, False)
        else:
            LOG.warn("Invalid user token: %s. Keystone response: %s.",
                     user_token, data)

            raise InvalidUserToken()

    def _build_user_headers(self, token_info):
        """Convert token object into headers.

        Build headers that represent authenticated user:
         * X_IDENTITY_STATUS: Confirmed or Invalid
         * X_TENANT_ID: id of tenant if tenant is present
         * X_TENANT_NAME: name of tenant if tenant is present
         * X_USER_ID: id of user
         * X_USER_NAME: name of user
         * X_ROLES: list of roles

        Additional (deprecated) headers include:
         * X_USER: name of user
         * X_TENANT: For legacy compatibility before we had ID and Name
         * X_ROLE: list of roles

        :param token_info: token object returned by keystone on authentication
        :raise InvalidUserToken when unable to parse token object

        """
        user = token_info['access']['user']
        token = token_info['access']['token']
        roles = ','.join([role['name'] for role in user.get('roles', [])])

        def get_tenant_info():
            """Returns a (tenant_id, tenant_name) tuple from context."""
            def essex():
                """Essex puts the tenant ID and name on the token."""
                return (token['tenant']['id'], token['tenant']['name'])

            def pre_diablo():
                """Pre-diablo, Keystone only provided tenantId."""
                return (token['tenantId'], token['tenantId'])

            def default_tenant():
                """Assume the user's default tenant."""
                return (user['tenantId'], user['tenantName'])

            for method in [essex, pre_diablo, default_tenant]:
                try:
                    return method()
                except KeyError:
                    pass

            raise InvalidUserToken('Unable to determine tenancy.')

        tenant_id, tenant_name = get_tenant_info()

        user_id = user['id']
        user_name = user['name']

        return {
            'X-Identity-Status': 'Confirmed',
            'X-Tenant-Id': tenant_id,
            'X-Tenant-Name': tenant_name,
            'X-User-Id': user_id,
            'X-User-Name': user_name,
            'X-Roles': roles,
            # Deprecated
            'X-User': user_name,
            'X-Tenant': tenant_name,
            'X-Role': roles,
        }

    def _header_to_env_var(self, key):
        """Convert header to wsgi env variable.

        :param key: http header name (ex. 'X-Auth-Token')
        :return wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN')

        """
        return  'HTTP_%s' % key.replace('-', '_').upper()

    def _add_headers(self, env, headers):
        """Add http headers to environment."""
        for (k, v) in headers.iteritems():
            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

    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)

    def _cache_get(self, token):
        """Return token information from cache.

        If token is invalid raise InvalidUserToken
        return token only if fresh (not expired).
        """
        if self._cache and token:
            key = 'tokens/%s' % token
            cached = self._cache.get(key)
            if cached == 'invalid':
                LOG.debug('Cached Token %s is marked unauthorized', token)
                raise InvalidUserToken('Token authorization failed')
            if cached:
                data, expires = cached
                if time.time() < float(expires):
                    LOG.debug('Returning cached token %s', token)
                    return data
                else:
                    LOG.debug('Cached Token %s seems expired', token)

    def _cache_put(self, token, data):
        """Put token data into the cache.

        Stores the parsed expire date in cache allowing
        quick check of token freshness on retrieval.
        """
        if self._cache and data:
            key = 'tokens/%s' % token
            if 'token' in data.get('access', {}):
                timestamp = data['access']['token']['expires']
                expires = self._iso8601.parse_date(timestamp).strftime('%s')
            else:
                LOG.error('invalid token format')
                return
            LOG.debug('Storing %s token in memcache', token)
            self._cache.set(key,
                            (data, expires),
                            time=self.token_cache_time)

    def _cache_store_invalid(self, token):
        """Store invalid token in cache."""
        if self._cache:
            key = 'tokens/%s' % token
            LOG.debug('Marking token %s as unauthorized in memcache', token)
            self._cache.set(key,
                            'invalid',
                            time=self.token_cache_time)


def filter_factory(global_conf, **local_conf):
    """Returns a WSGI filter app for use with paste.deploy."""
    conf = global_conf.copy()
    conf.update(local_conf)

    def auth_filter(app):
        return AuthProtocol(app, conf)
    return auth_filter


def app_factory(global_conf, **local_conf):
    conf = global_conf.copy()
    conf.update(local_conf)
    return AuthProtocol(None, conf)
