#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2010-2011 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
    only used if this component is being used remotely

HTTP_AUTHORIZATION
    basic auth password used to validate the connection

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

HTTP_X_AUTHORIZATION
    The client identity being passed in

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
    *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME
    Keystone-assigned unique identifier, deprecated

HTTP_X_TENANT_ID
    Identity service managed unique identifier, string

HTTP_X_TENANT_NAME
    Unique tenant identifier, string

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

HTTP_X_USER_ID
    Identity-service managed unique identifier, string

HTTP_X_USER_NAME
    Unique user identifier, string

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

HTTP_X_ROLES
    Comma delimited list of case-sensitive Roles

"""

from datetime import datetime
import eventlet
from eventlet import wsgi
import json
# memcache is imported in __init__ if memcache caching is configured
import os
from paste.deploy import loadapp
import time
from urlparse import urlparse
from webob.exc import HTTPUnauthorized
from webob.exc import Request, Response
import keystone.tools.tracer  # @UnusedImport # module runs on import
from keystone.common.bufferedhttp import http_connect_raw as http_connect


PROTOCOL_NAME = "Token Authentication"
# The time format of the 'expires' property of a token
EXPIRE_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
MAX_CACHE_TIME = 86400


def get_datetime(time_string):
    """ Gets datetime object with microsecond accuracy from input string.
    Handle strings which don't include microseconds

    :param: time_string: datetime in %Y-%m-%dT%H:%M:%S.%f format (.%f optional)
    """
    result = time_string.split(".", 1)
    datetime_string = result[0]
    microseconds = result[1] if len(result) > 1 else "0"
    d1 = datetime.strptime(datetime_string, "%Y-%m-%dT%H:%M:%S")
    ms = int(microseconds.ljust(6, '0')[:6])
    return d1.replace(microsecond=ms)


class ValidationFailed(Exception):
    pass


class TokenExpired(Exception):
    pass


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

    def _init_protocol_common(self, app, conf):
        """ Common initialization code

        When we eventually superclass this, this will be the superclass
        initialization code that applies to all protocols
        """
        print "Starting the %s component" % PROTOCOL_NAME

        #if app is set, then we are in a WSGI pipeline and requests get passed
        # on to app. If it is not set, this component should forward requests

        # where to find the OpenStack service (if not in local WSGI chain)
        # these settings are only used if this component is acting as a proxy
        # and the OpenSTack service is running remotely
        self.service_protocol = conf.get('service_protocol', 'https')
        self.service_host = conf.get('service_host')
        service_port = conf.get('service_port')
        if service_port:
            self.service_port = int(service_port)
        self.service_url = '%s://%s:%s' % (self.service_protocol,
                                           self.service_host,
                                           self.service_port)
        self.service_timeout = conf.get('service_timeout', 30)
        # used to verify this component with the OpenStack service or PAPIAuth
        self.service_pass = conf.get('service_pass')

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

    def _init_protocol(self, conf):
        """ Protocol specific initialization """

        # 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'))
        self.auth_protocol = conf.get('auth_protocol', 'https')
        self.auth_timeout = conf.get('auth_timeout', 30)

        # where to tell clients to find the auth service (default to url
        # constructed based on endpoint we have for the service to use)
        self.auth_location = conf.get('auth_uri',
                                        "%s://%s:%s" % (self.auth_protocol,
                                        self.auth_host,
                                        self.auth_port))

        # Credentials used to verify this component with the Auth service since
        # validating tokens is a privileged call
        self.admin_token = conf.get('admin_token')
        # Certificate file and key file used to authenticate with Keystone
        # server
        self.cert_file = conf.get('certfile', None)
        self.key_file = conf.get('keyfile', None)
        # Caching
        self.cache = conf.get('cache', None)
        self.memcache_hosts = conf.get('memcache_hosts', None)
        if self.memcache_hosts:
            if self.cache is None:
                self.cache = "keystone.cache"

    def __init__(self, app, conf):
        """ Common initialization code """
        #TODO(ziad): maybe we refactor this into a superclass
        # Defining instance variables here for improving pylint score
        # NOTE(salvatore-orlando): the following vars are assigned values
        # either in init_protocol or init_protocol_common. We should not
        # worry about them being initialized to None
        self.conf = conf
        self.app = app
        self.admin_password = None
        self.admin_token = None
        self.admin_user = None
        self.auth_api_version = None
        self.auth_host = None
        self.auth_location = None
        self.auth_port = None
        self.auth_protocol = None
        self.auth_timeout = None
        self.cert_file = None
        self.key_file = None
        self.delay_auth_decision = None
        self.service_pass = None
        self.service_host = None
        self.service_port = None
        self.service_protocol = None
        self.service_timeout = None
        self.service_url = None
        self.cache = None
        self.memcache_hosts = None
        self._init_protocol_common(app, conf)  # Applies to all protocols
        self._init_protocol(conf)  # Specific to this protocol

    def __call__(self, env, start_response):
        """ Handle incoming request. Authenticate. And send downstream. """
        # Initialize caching client
        if self.memcache_hosts:
            # This will only be used if the configuration calls for memcache
            import memcache

            if env.get(self.cache, None) is None:
                memcache_client = memcache.Client([self.memcache_hosts])
                env[self.cache] = memcache_client

        #Prep headers to forward request to local or remote downstream service
        proxy_headers = env.copy()
        for header in proxy_headers.iterkeys():
            if header[0:5] == 'HTTP_':
                proxy_headers[header[5:]] = proxy_headers[header]
                del proxy_headers[header]

        #Look for authentication claims
        token = self._get_claims(env)
        if not token:
            #No claim(s) provided
            if self.delay_auth_decision:
                #Configured to allow downstream service to make final decision.
                #So mark status as Invalid and forward the request downstream
                self._decorate_request("X_IDENTITY_STATUS",
                    "Invalid", env, proxy_headers)
            else:
                #Respond to client as appropriate for this auth protocol
                return self._reject_request(env, start_response)
        else:
            # this request is presenting claims. Let's validate them
            try:
                claims = self._verify_claims(env, token)
            except (ValidationFailed, TokenExpired):
                # Keystone rejected claim
                if self.delay_auth_decision:
                    # Downstream service will receive call still and decide
                    self._decorate_request("X_IDENTITY_STATUS",
                        "Invalid", env, proxy_headers)
                else:
                    #Respond to client as appropriate for this auth protocol
                    return self._reject_claims(env, start_response)
            else:
                self._decorate_request("X_IDENTITY_STATUS",
                    "Confirmed", env, proxy_headers)

                # Store authentication data
                if claims:
                    self._decorate_request('X_AUTHORIZATION', "Proxy %s" %
                        claims['user']['name'], env, proxy_headers)

                    self._decorate_request('X_TENANT_ID',
                        claims['tenant']['id'], env, proxy_headers)
                    self._decorate_request('X_TENANT_NAME',
                        claims['tenant']['name'], env, proxy_headers)

                    self._decorate_request('X_USER_ID',
                        claims['user']['id'], env, proxy_headers)
                    self._decorate_request('X_USER_NAME',
                        claims['user']['name'], env, proxy_headers)

                    roles = ','.join(claims['roles'])
                    self._decorate_request('X_ROLES',
                        roles, env, proxy_headers)

                    # Deprecated in favor of X_TENANT_ID and _NAME
                    self._decorate_request('X_TENANT',
                        claims['tenant']['id'], env, proxy_headers)

                    # Deprecated in favor of X_USER_ID and _NAME
                    self._decorate_request('X_USER',
                        claims['user']['id'], env, proxy_headers)

                    # Deprecated in favor of X_ROLES
                    self._decorate_request('X_ROLE',
                        roles, env, proxy_headers)

        #Send request downstream
        return self._forward_request(env, start_response, proxy_headers)

    @staticmethod
    def _convert_date(date):
        """ Convert datetime to unix timestamp for caching """
        return time.mktime(datetime.strptime(
                date[:date.rfind(':')].replace('-', ''), "%Y%m%dT%H:%M",
                ).timetuple())

    @staticmethod
    def _protect_claims(token, claims):
        """ encrypt or mac claims if necessary """
        return claims

    @staticmethod
    def _unprotect_claims(token, pclaims):
        """ decrypt or demac claims if necessary """
        return pclaims

    def _cache_put(self, env, token, claims, valid):
        """ Put a claim into the cache """
        cache = self._cache(env)
        if cache and claims:
            key = 'tokens/%s' % (token)
            if "timeout" in cache.set.func_code.co_varnames:
                # swift cache
                expires = self._convert_date(claims['expires'])
                claims = self._protect_claims(token, claims)
                cache.set(key, (claims, expires, valid),
                             timeout=expires - time.time())
            else:
                # normal memcache client
                expires = get_datetime(claims['expires'])
                delta = expires - datetime.now()
                timeout = delta.seconds
                if timeout > MAX_CACHE_TIME or not valid:
                    # Limit cache to one day (and cache bad tokens for a day)
                    timeout = MAX_CACHE_TIME
                claims = self._protect_claims(token, claims)
                cache.set(key, (claims, expires, valid), time=timeout)

    def _cache_get(self, env, token):
        """ Return claim and relevant information (expiration and validity)
        from cache """
        cache = self._cache(env)
        if cache:
            key = 'tokens/%s' % (token)
            cached_claims = cache.get(key)
            if cached_claims:
                claims, expires, valid = cached_claims
                if valid:
                    if "timeout" in cache.set.func_code.co_varnames:
                        if expires > time.time():
                            claims = self._unprotect_claims(token, claims)
                    else:
                        if expires > datetime.now():
                            claims = self._unprotect_claims(token, claims)
                return (claims, expires, valid)
        return None

    def _cache(self, env):
        """ Return a cache to use for token caching, or none """
        if self.cache is not None:
            return env.get(self.cache, None)
        return None

    @staticmethod
    def _get_claims(env):
        """Get claims from request"""
        claims = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN'))
        return claims

    def _reject_request(self, env, start_response):
        """Redirect client to auth server"""
        return HTTPUnauthorized("Authentication required",
                    [("WWW-Authenticate",
                      "Keystone uri='%s'" % self.auth_location)])(env,
                                                        start_response)

    @staticmethod
    def _reject_claims(env, start_response):
        """Client sent bad claims"""
        return HTTPUnauthorized()(env,
            start_response)

    def _verify_claims(self, env, claims):
        """Verify claims and extract identity information, if applicable."""

        cached_claims = self._cache_get(env, claims)
        if cached_claims:
            claims, expires, valid = cached_claims
            if not valid:
                raise ValidationFailed()
            if expires <= datetime.now():
                raise TokenExpired()
            return claims

        # Step 1: We need to auth with the keystone service, so get an
        # admin token
        #TODO(ziad): Need to properly implement this, where to store creds
        # for now using token from ini
        #auth = self.get_admin_auth_token("admin", "secrete", "1")
        #admin_token = json.loads(auth)["auth"]["token"]["id"]

        # Step 2: validate the user's token with the auth service
        # since this is a priviledged op,m we need to auth ourselves
        # by using an admin token
        headers = {"Content-type": "application/json",
                    "Accept": "application/json",
                    "X-Auth-Token": self.admin_token}
                    ##TODO(ziad):we need to figure out how to auth to keystone
                    #since validate_token is a priviledged call
                    #Khaled's version uses creds to get a token
                    # "X-Auth-Token": admin_token}
                    # we're using a test token from the ini file for now
        conn = http_connect(self.auth_host, self.auth_port, 'GET',
                            '/v2.0/tokens/%s' % claims, headers=headers,
                            ssl=(self.auth_protocol == 'https'),
                            key_file=self.key_file, cert_file=self.cert_file,
                            timeout=self.auth_timeout)
        resp = conn.getresponse()
        data = resp.read()

        if not str(resp.status).startswith('20'):
            # Cache it if there is a cache available
            if self.cache:
                self._cache_put(env, claims,
                                claims={'expires':
                                datetime.strftime(datetime.now(),
                                                  EXPIRE_TIME_FORMAT)},
                                valid=False)
            # Keystone rejected claim
            raise ValidationFailed()

        token_info = json.loads(data)

        roles = [role['name'] for role in token_info[
            "access"]["user"]["roles"]]

        # in diablo, there were two ways to get tenant data
        tenant = token_info['access']['token'].get('tenant')
        if tenant:
            # post diablo
            tenant_id = tenant['id']
            tenant_name = tenant['name']
        else:
            # diablo only
            tenant_id = token_info['access']['user'].get('tenantId')
            tenant_name = token_info['access']['user'].get('tenantName')

        verified_claims = {
            'user': {
                'id': token_info['access']['user']['id'],
                'name': token_info['access']['user']['name'],
            },
            'tenant': {
                'id': tenant_id,
                'name': tenant_name
            },
            'roles': roles,
            'expires': token_info['access']['token']['expires']}

        expires = get_datetime(verified_claims['expires'])
        if expires <= datetime.now():
            # Cache it if there is a cache available (we also cached bad
            # claims)
            if self.cache:
                self._cache_put(env, claims, verified_claims, valid=False)
            raise TokenExpired()

        # Cache it if there is a cache available
        if self.cache:
            self._cache_put(env, claims, verified_claims, valid=True)
        return verified_claims

    @staticmethod
    def _decorate_request(index, value, env, proxy_headers):
        """Add headers to request"""
        proxy_headers[index] = value
        env["HTTP_%s" % index] = value

    def _forward_request(self, env, start_response, proxy_headers):
        """Token/Auth processed & claims added to headers"""
        self._decorate_request('AUTHORIZATION',
            "Basic %s" % self.service_pass, env, proxy_headers)
        #now decide how to pass on the call
        if self.app:
            # Pass to downstream WSGI component
            return self.app(env, start_response)
            #.custom_start_response)
        else:
            # We are forwarding to a remote service (no downstream WSGI app)
            req = Request(proxy_headers)
            parsed = urlparse(req.url)

            conn = http_connect(self.service_host,
                                self.service_port,
                                req.method,
                                parsed.path,
                                proxy_headers,
                                ssl=(self.service_protocol == 'https'),
                                timeout=self.service_timeout)
            resp = conn.getresponse()
            data = resp.read()

            #TODO(ziad): use a more sophisticated proxy
            # we are rewriting the headers now

            if resp.status in (401, 305):
                # Add our own headers to the list
                headers = [("WWW_AUTHENTICATE",
                   "Keystone uri='%s'" % self.auth_location)]
                return Response(status=resp.status, body=data,
                            headerlist=headers)(env,
                                                start_response)
            else:
                return Response(status=resp.status, body=data)(env,
                                                start_response)


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(filteredapp):
        return AuthProtocol(filteredapp, conf)
    return auth_filter


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

if __name__ == "__main__":
    wsgiapp = loadapp("config:" + \
        os.path.join(os.path.abspath(os.path.dirname(__file__)),
                     os.pardir,
                     os.pardir,
                    "examples/paste/auth_token.ini"),
                    global_conf={"log_name": "auth_token.log"})
    wsgi.server(eventlet.listen(('', 8090)), wsgiapp)
