# -*- coding: utf-8 -*-

from __future__ import absolute_import
from __future__ import division

import base64
from datetime import timedelta, datetime

import falcon

try:
    # This is an optional dependency. To use JWTAuthBackend be sure to add
    # [backend-jwt] to your falcon-auth requirement.
    # See https://www.python.org/dev/peps/pep-0508/#extras
    import jwt
except ImportError:
    pass

try:
    # This is an optional dependency. To use AuthBackend be sure to add
    # [backend-hawk] to your falcon-auth requirement.
    # See https://www.python.org/dev/peps/pep-0508/#extras
    import mohawk
except ImportError:
    pass

from falcon_auth.serializer import ExtendedJSONEncoder


class AuthBackend(object):
    """
    Base Class for all authentication backends. If successfully authenticated must
    return the authenticated `user` object. In case authorization header is
    not set properly or there is a credential mismatch, results in an
    `falcon.HTTPUnauthorized exception` with proper description of the issue

    Args:
        user_loader(function, required): A callback function that is called with the
            decoded `token` extracted from the `Authorization`
            header. Returns an `authenticated user` if user exists matching the
            credentials or return `None` to indicate if no user found or credentials
            mismatch.

        auth_header_prefix(string, optional): A prefix that is used with the
            bases64 encoded credentials in the `Authorization` header.

    """

    def __init__(self, user_loader, auth_header_prefix='basic'):
        raise NotImplementedError("Must be overridden")

    def parse_auth_token_from_request(self, auth_header):
        """
        Parses and returns Auth token from the request header. Raises
        `falcon.HTTPUnauthoried exception` with proper error message
        """
        if not auth_header:
            raise falcon.HTTPUnauthorized(
                description='Missing Authorization Header')

        parts = auth_header.split()

        if parts[0].lower() != self.auth_header_prefix.lower():
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: '
                            'Must start with {0}'.format(self.auth_header_prefix))

        elif len(parts) == 1:
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: Token Missing')
        elif len(parts) > 2:
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: Contains extra content')

        return parts[1]

    def authenticate(self, req, resp, resource):
        """
        Authenticate the request and return the authenticated user. Must return
        `None` if authentication fails, or raise an exception

        """
        raise NotImplementedError(".authenticate() must be overridden.")

    def get_auth_token(self, user_payload):
        """
        Returns a authentication token created using the provided user details

        Args:
            user_payload(dict, required): A `dict` containing required information
                to create authentication token
        """
        raise NotImplementedError("Must be overridden")

    def get_auth_header(self, user_payload):
        """
        Returns the value for authorization header
        Args:
            user_payload(dict, required): A `dict` containing required information
                to create authentication token
        """
        auth_token = self.get_auth_token(user_payload)
        return '{auth_header_prefix} {auth_token}'.format(
            auth_header_prefix=self.auth_header_prefix, auth_token=auth_token
        )


class JWTAuthBackend(AuthBackend):
    """
    Token based authentication using the `JSON Web Token standard <https://jwt.io/introduction/>`__
    Clients should authenticate by passing the token key in the `Authorization`
    HTTP header, prepended with the string specified in the setting
    `auth_header_prefix`. For example:

        Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj

    Args:
        user_loader(function, required): A callback function that is called with the
            decoded `jwt payload` extracted from the `Authorization`
            header. Returns an `authenticated user` if user exists matching the
            credentials or return `None` to indicate if no user found or credentials
            mismatch.

        secrey_key(string, required): A secure key that was used to encode and
            create the `jwt token` from a dictionary payload

        algorithm(string, optional): Specifies the algorithm that was used
            to for cryptographic signing. Default is ``HS256`` which stands for
            HMAC using SHA-256 hash algorithm. Other supported algorithms can be
            found `here <http://pyjwt.readthedocs.io/en/latest/algorithms.html>`__

        auth_header_prefix(string, optional): A prefix that is used with the
            bases64 encoded credentials in the `Authorization` header. Default is
            ``jwt``

        leeway(int, optional): Specifies the timedelta in seconds that is allowed
            as leeway while validating `expiration time` / `nbf(not before) claim`
            /`iat (issued at) claim` which is in past but not very
            far. For example, if you have a JWT payload with an expiration time
            set to 30 seconds after creation but you know that sometimes you will
            process it after 30 seconds, you can set a leeway of 10 seconds in
            order to have some margin. Default is ``0 seconds``

        expiration_delta(int, optional): Specifies the timedelta in seconds that
            will be added to current time to set the expiration for the token.
            Default is ``1 day(24 * 60 * 60 seconds)``

        audience(string, optional): Specifies the string that will be specified
            as value of ``aud`` field in the jwt payload. It will also be checked
            against the ``aud`` field while decoding.

        issuer(string, optional): Specifies the string that will be specified
            as value of ``iss`` field in the jwt payload. It will also be checked
            against the ``iss`` field while decoding.

    """

    def __init__(self, user_loader, secret_key,
                 algorithm='HS256', auth_header_prefix='jwt',
                 leeway=0, expiration_delta=24 * 60 * 60,
                 audience=None, issuer=None,
                 verify_claims=None, required_claims=None):

        try:
            jwt
        except NameError:
            raise ImportError('Optional dependency falcon-auth[backend-jwt] not installed')

        self.user_loader = user_loader
        self.secret_key = secret_key
        self.algorithm = algorithm
        self.leeway = timedelta(seconds=leeway)
        self.auth_header_prefix = auth_header_prefix
        self.expiration_delta = timedelta(seconds=expiration_delta)
        self.audience = audience
        self.issuer = issuer
        self.verify_claims = verify_claims or ['signature', 'exp', 'nbf', 'iat']
        self.required_claims = required_claims or ['exp', 'iat', 'nbf']

        if 'aud' in self.verify_claims and not audience:
            raise ValueError('Audience parameter must be provided if '
                             '`aud` claim needs to be verified')

        if 'iss' in self.verify_claims and not issuer:
            raise ValueError('Issuer parameter must be provided if '
                             '`iss` claim needs to be verified')

    def _decode_jwt_token(self, req):

        # Decodes the jwt token into a payload
        auth_header = req.get_header('Authorization')
        token = self.parse_auth_token_from_request(auth_header=auth_header)

        options = dict(('verify_' + claim, True) for claim in self.verify_claims)

        options.update(
            dict(('require_' + claim, True) for claim in self.required_claims)
        )

        try:
            payload = jwt.decode(jwt=token, key=self.secret_key,
                                 options=options,
                                 algorithms=[self.algorithm],
                                 issuer=self.issuer,
                                 audience=self.audience,
                                 leeway=self.leeway)
        except jwt.InvalidTokenError as ex:
            raise falcon.HTTPUnauthorized(
                description=str(ex))

        return payload

    def authenticate(self, req, resp, resource):
        """
        Extract auth token from request `authorization` header, decode jwt token,
        verify configured claims and return either a ``user``
        object if successful else raise an `falcon.HTTPUnauthorized exception`
        """
        payload = self._decode_jwt_token(req)
        user = self.user_loader(payload)
        if not user:
            raise falcon.HTTPUnauthorized(
                description='Invalid JWT Credentials')

        return user

    def get_auth_token(self, user_payload):
        """
        Create a JWT authentication token from ``user_payload``

        Args:
            user_payload(dict, required): A `dict` containing required information
                to create authentication token
        """
        now = datetime.utcnow()
        payload = {
            'user': user_payload
        }
        if 'iat' in self.verify_claims:
            payload['iat'] = now

        if 'nbf' in self.verify_claims:
            payload['nbf'] = now + self.leeway

        if 'exp' in self.verify_claims:
            payload['exp'] = now + self.expiration_delta

        if self.audience is not None:
            payload['aud'] = self.audience

        if self.issuer is not None:
            payload['iss'] = self.issuer

        return jwt.encode(
            payload,
            self.secret_key,
            algorithm=self.algorithm,
            json_encoder=ExtendedJSONEncoder).decode('utf-8')


class BasicAuthBackend(AuthBackend):
    """
    Implements `HTTP Basic Authentication <http://tools.ietf.org/html/rfc2617>`__
    Clients should authenticate by passing the `base64` encoded credentials
    `username:password` in the `Authorization` HTTP header, prepended with the
    string specified in the setting `auth_header_prefix`. For example:

        Authorization: BASIC ZGZkZmY6ZGZkZ2RkZg==

    Args:
        user_loader(function, required): A callback function that is called with the user
            credentials (username and password) extracted from the `Authorization`
            header. Returns an `authenticated user` if user exists matching the
            credentials or return `None` to indicate if no user found or credentials
            mismatch.

        auth_header_prefix(string, optional): A prefix that is used with the
            bases64 encoded credentials in the `Authorization` header. Default is
            ``basic``

    """

    def __init__(self, user_loader,
                 auth_header_prefix='Basic'):

        self.user_loader = user_loader
        self.auth_header_prefix = auth_header_prefix

    def _extract_credentials(self, req):
        auth = req.get_header('Authorization')
        token = self.parse_auth_token_from_request(auth_header=auth)
        try:
            token = base64.b64decode(token).decode('utf-8')

        except Exception:
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: Unable to decode credentials')

        try:
            username, password = token.split(':', 1)
        except ValueError:
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization: Unable to decode credentials')

        return username, password

    def authenticate(self, req, resp, resource):
        """
        Extract basic auth token from request `authorization` header,  decode the
        token, verifies the username/password and return either a ``user``
        object if successful else raise an `falcon.HTTPUnauthorized exception`
        """
        username, password = self._extract_credentials(req)
        user = self.user_loader(username, password)
        if not user:
            raise falcon.HTTPUnauthorized(
                description='Invalid Username/Password')

        return user

    def get_auth_token(self, user_payload):
        """
        Extracts username, password from the `user_payload` and encode the
        credentials `username:password` in `base64` form
        """
        username = user_payload.get('username') or None
        password = user_payload.get('password') or None

        if not username or not password:
            raise ValueError('`user_payload` must contain both username and password')

        token = '{username}:{password}'.format(
            username=username, password=password).encode('utf-8')

        token_b64 = base64.b64encode(token).decode('utf-8', 'ignore')

        return '{auth_header_prefix} {token_b64}'.format(
            auth_header_prefix=self.auth_header_prefix, token_b64=token_b64)


class TokenAuthBackend(BasicAuthBackend):
    """
       Implements Simple Token Based Authentication. Clients should authenticate by passing the token key in the
           "Authorization" HTTP header, prepended with the string "Token ".  For example:

               Authorization: Token 401f7ac837da42b97f613d789819ff93537bee6a

       Args:
           user_loader(function, required): A callback function that is called
               with the token extracted from the `Authorization`
               header. Returns an `authenticated user` if user exists matching the
               credentials or return `None` to indicate if no user found or credentials
               mismatch.

           auth_header_prefix(string, optional): A prefix that is used with the
               token in the `Authorization` header. Default is
               ``basic``

       """

    def __init__(self, user_loader,
                 auth_header_prefix='Token'):

        super(TokenAuthBackend, self).__init__(user_loader, auth_header_prefix)

    def _extract_credentials(self, req):
        auth = req.get_header('Authorization')
        return self.parse_auth_token_from_request(auth_header=auth)

    def authenticate(self, req, resp, resource):
        token = self._extract_credentials(req)
        user = self.user_loader(token)
        if not user:
            raise falcon.HTTPUnauthorized(
                description='Invalid Token')

        return user

    def get_auth_token(self, user_payload):
        """
        Extracts token from the `user_payload`
        """
        token = user_payload.get('token') or None
        if not token:
            raise ValueError('`user_payload` must provide api token')

        return '{auth_header_prefix} {token}'.format(
            auth_header_prefix=self.auth_header_prefix, token=token)


class NoneAuthBackend(AuthBackend):
    """
    Dummy authentication backend.

    This backend does not perform any authentication check. It can be used with the
    MultiAuthBackend in order to provide a fallback for an unauthenticated user.

    Args:
        user_loader(function, required): A callback function that is called
            without any arguments and returns an `unauthenticated user`.

    """

    def __init__(self, user_loader):
        self.user_loader = user_loader

    def authenticate(self, req, resp, resource):
        return self.user_loader()


class HawkAuthBackend(AuthBackend):
    """
    Holder-Of-Key Authentication Scheme defined by `Hawk <https://github.com/hueniverse/hawk>`__
    Clients should authenticate by passing a Hawk-formatted header as the `Authorization`
    HTTP header. For example:

        Authorization: Hawk id="dh37fgj492je", ts="1353832234", nonce="j4h3g2", ext="some-app-ext-data", mac="6R4rV5iE+NPoym+WwjeHzjAGXUtLNIxmo1vpMofpLAE="

    Args:
        user_loader(function, required): A callback function that is called with the `id`
            value extracted from the `Hawk` header. Returns an `authenticated user` if the user
            matching the credentials exists or returns `None` to indicate if no user was found.

        receiver_kwargs(dict, required): A dictionary of arguments to be passed through
            to the Receiver. One must provide the `credentials_map` function for the
            purposes of looking up a user's credentials from their user id (the same value
            passed to `user_loader()`). See the `docs <https://mohawk.readthedocs.io/en/latest/usage.html#receiving-a-request>`__
            for further details.
    """
    def __init__(self, user_loader, receiver_kwargs):
        try:
            mohawk
        except NameError:
            raise ImportError('Optional dependency falcon-auth[backend-hawk] not installed')
        self.user_loader = user_loader
        self.auth_header_prefix = 'Hawk'
        self.receiver_kwargs = receiver_kwargs

        if not callable(self.receiver_kwargs.get('credentials_map')):
            raise ValueError('Required "credentials_map" function not provided in receiver_kwargs')

    def parse_auth_token_from_request(self, auth_header):
        """
        Parses and returns the Hawk Authorization header if it is present and well-formed.
        Raises `falcon.HTTPUnauthorized exception` with proper error message
        """
        if not auth_header:
            raise falcon.HTTPUnauthorized(
                description='Missing Authorization Header')

        try:
            auth_header_prefix, _ = auth_header.split(' ', 1)
        except ValueError:
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: Missing Scheme or Parameters')

        if auth_header_prefix.lower() != self.auth_header_prefix.lower():
            raise falcon.HTTPUnauthorized(
                description='Invalid Authorization Header: '
                            'Must start with {0}'.format(self.auth_header_prefix))

        return auth_header

    def authenticate(self, req, resp, resource):
        request_header = self.parse_auth_token_from_request(req.get_header('Authorization'))

        try:
            # Validate the Authorization header contents and lookup the user's credentials
            # via the provided `credentials_map` function.
            receiver = mohawk.Receiver(
                request_header=request_header,
                method=req.method,
                url=req.forwarded_uri,
                content=req.context.get('body'),
                content_type=req.get_header('Content-Type'),
                **self.receiver_kwargs)
        except mohawk.exc.HawkFail as ex:
            raise falcon.HTTPUnauthorized(
                description='{0}({1!s})'.format(ex.__class__.__name__, ex),
                challenges=(
                    [getattr(ex, 'www_authenticate')]
                    if hasattr(ex, 'www_authenticate')
                    else []))

        # The authentication was successful, get the actual user object now.
        user = self.user_loader(receiver.parsed_header['id'])
        if not user:
            # Should never really happen unless your user objects and their
            # credentials are out of sync.
            raise falcon.HTTPUnauthorized(
                description='Invalid User')

        return user


class MultiAuthBackend(AuthBackend):
    """
    A backend which takes two or more ``AuthBackend`` as inputs and successfully
    authenticates if either of them succeeds else raises `falcon.HTTPUnauthorized exception`

    Args:
        backends(AuthBackend, required): A list of `AuthBackend` to be used in
            order to authenticate the user.

    """

    def __init__(self, *backends):
        if len(backends) <= 1:
            raise ValueError('Invalid authentication backend. Must pass more than one backend')

        for backend in backends:
            if not isinstance(backend, AuthBackend):
                raise ValueError(('Invalid authentication backend {0}.'
                                 'Must inherit `falcon.auth.backends.AuthBackend`')
                                 .format(backend))

        self.backends = backends

    def authenticate(self, req, resp, resource):
        challenges = []
        for backend in self.backends:
            try:
                user = backend.authenticate(req, resp, resource)
                if user:
                    return user
            except falcon.HTTPUnauthorized as ex:
                www_authenticate = ex.headers.get('WWW-Authenticate')
                if www_authenticate:
                    challenges.append(www_authenticate)

        raise falcon.HTTPUnauthorized(
            description='Authorization Failed',
            challenges=challenges)

    def get_auth_token(self, user_payload):
        for backend in self.backends:
            try:
                return backend.get_auth_token(user_payload)
            except Exception:
                pass

        return None