from base64 import b64encode, b64decode
from hashlib import sha1
import hmac
from random import choice
import string
from urllib import urlencode
from urllib2 import urlopen


class YubiClient10(object):
    """
    Client for the Yubico validation service, version 1.0.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV10

    :param int api_id: Your API id.
    :param str api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/verify'``.
    """
    _NONCE_CHARS = string.ascii_letters + string.digits

    def __init__(self, api_id=1, api_key=None, ssl=False):
        self.api_id = api_id
        self.api_key = api_key
        self.ssl = ssl

    def verify(self, token):
        """
        Verify a single Yubikey OTP against the validation service.

        :param str token: A modhex-encoded YubiKey OTP, as generated by a YubiKey
            device.

        :returns: A response from the validation service.
        :rtype: :class:`YubiResponse`
        """
        nonce = self.nonce()

        url = self.url(token, nonce)
        stream = urlopen(url)
        response = YubiResponse(stream.read(), self.api_key, token, nonce)
        stream.close()

        return response

    def url(self, token, nonce=None):
        """
        Generates the validation URL without sending a request.

        :param str token: A modhex-encoded YubiKey OTP, as generated by a
            YubiKey.
        :param str nonce: A nonce string, or ``None`` to generate a random one.

        :returns: The URL that we would use to validate the token.
        :rtype: str
        """
        if nonce is None:
            nonce = self.nonce()

        return '{0}?{1}'.format(self.base_url, self.param_string(token, nonce))

    _base_url = None

    @property
    def base_url(self):
        if self._base_url is None:
            self._base_url = self.default_base_url()

        return self._base_url

    @base_url.setter
    def base_url(self, url):
        self._base_url = url

    @base_url.deleter
    def base_url(self):
        delattr(self, '_base_url')

    def default_base_url(self):
        if self.ssl:
            return 'https://api.yubico.com/wsapi/verify'
        else:
            return 'http://api.yubico.com/wsapi/verify'

    def nonce(self):
        return ''.join(choice(self._NONCE_CHARS) for i in xrange(32))

    def param_string(self, token, nonce):
        params = self.params(token, nonce)

        if self.api_key is not None:
            signature = param_signature(params, self.api_key)
            params.append(('h', b64encode(signature)))

        return urlencode(params)

    def params(self, token, nonce):
        return [
            ('id', self.api_id),
            ('otp', token),
        ]


class YubiClient11(YubiClient10):
    """
    Client for the Yubico validation service, version 1.1.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV11

    :param int api_id: Your API id.
    :param str api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.
    :param bool timestamp: ``True`` if we want the server to include timestamp
        and counter information in the response.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/verify'``.
    """
    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False):
        super(YubiClient11, self).__init__(api_id, api_key, ssl)

        self.timestamp = timestamp

    def params(self, token, nonce):
        params = super(YubiClient11, self).params(token, nonce)

        if self.timestamp:
            params.append(('timestamp', '1'))

        return params


class YubiClient20(YubiClient11):
    """
    Client for the Yubico validation service, version 2.0.

    http://code.google.com/p/yubikey-val-server-php/wiki/ValidationProtocolV20

    :param int api_id: Your API id.
    :param str api_key: Your base64-encoded API key.
    :param bool ssl: ``True`` if we should use https URLs by default.
    :param bool timestamp: ``True`` if we want the server to include timestamp
        and counter information in the response.
    :param sl: See protocol spec.
    :param timeout: See protocol spec.

    .. attribute:: base_url

        The base URL of the validation service. Set this if you want to use a
        custom validation service. Defaults to
        ``'http[s]://api.yubico.com/wsapi/2.0/verify'``.
    """
    def __init__(self, api_id=1, api_key=None, ssl=False, timestamp=False, sl=None, timeout=None):
        super(YubiClient20, self).__init__(api_id, api_key, ssl, timestamp)

        self.sl = sl
        self.timeout = timeout

    def default_base_url(self):
        if self.ssl:
            return 'https://api.yubico.com/wsapi/2.0/verify'
        else:
            return 'http://api.yubico.com/wsapi/2.0/verify'

    def params(self, token, nonce):
        params = super(YubiClient20, self).params(token, nonce)

        params.append(('nonce', nonce))

        if self.sl is not None:
            params.append(('sl', self.sl))

        if self.timeout is not None:
            params.append(('timeout', self.timeout))

        return params


class YubiResponse(object):
    """
    A response from the Yubico validation service.

    .. attribute:: fields

        A dictionary of the response fields (excluding 'h').
    """
    def __init__(self, raw, api_key, token, nonce):
        self.raw = raw
        self.api_key = api_key
        self.token = token
        self.nonce = nonce

        self.fields = {}
        self.signature = None

        self._parse_response()

    def _parse_response(self):
        self.fields = dict(tuple(line.split('=', 1)) for line in self.raw.splitlines() if '=' in line)

        if 'h' in self.fields:
            self.signature = b64decode(self.fields['h'])
            del self.fields['h']

    def is_ok(self):
        """
        Returns true if all validation checks pass and the status is 'OK'.

        :rtype: bool
        """
        return self.is_valid() and (self.fields.get('status') == 'OK')

    def status(self):
        """
        If the response is valid, this returns the value of the status field.
        Otherwise, it returns the special status ``'BAD_RESPONSE'``
        """
        if self.is_valid(strict=False):
            return self.fields.get('status')
        else:
            return 'BAD_RESPONSE'

    def is_valid(self, strict=True):
        """
        Performs all validity checks (signature, token, and nonce).

        :param bool strict: If ``True``, all validity checks must pass
            unambiguously. Otherwise, this only requires that no validity check
            fails.
        :returns: ``True`` if none of the validity checks fail.
        :rtype: bool
        """
        results = [
            self.is_signature_valid(),
            self.is_token_valid(),
            self.is_nonce_valid(),
        ]

        if strict:
            is_valid = all(results)
        else:
            is_valid = False not in results

        return is_valid

    def is_signature_valid(self):
        """
        Validates the response signature.

        :returns: ``True`` if the signature is valid or if we did not sign the
            request. ``False`` if the signature is invalid.
        :rtype: bool
        """
        if self.api_key is not None:
            signature = param_signature(self.fields.items(), self.api_key)
            is_valid = (signature == self.signature)
        else:
            is_valid = True

        return is_valid

    def is_token_valid(self):
        """
        Validates the otp token sent in the response.

        :returns: ``True`` if the token in the response is the same as the one
            in the request; ``False`` if not; ``None`` if the response does not
            contain a token.
        :rtype: bool for a positive result or ``None`` for an ambiguous result.
        """
        if 'otp' in self.fields:
            is_valid = (self.fields['otp'] == self.token)
        else:
            is_valid = None

        return is_valid

    def is_nonce_valid(self):
        """
        Validates the nonce value sent in the response.

        :returns: ``True`` if the nonce in the response matches the one we sent
            (or didn't send). ``False`` if the two do not match. ``None`` if we
            sent a nonce and did not receive one in the response: this is often
            true of error responses.
        :rtype: bool for a positive result or ``None`` for an ambiguous result.
        """
        reply = self.fields.get('nonce')

        if (self.nonce is not None) and (reply is None):
            is_valid = None
        else:
            is_valid = (reply == self.nonce)

        return is_valid

    @property
    def public_id(self):
        """
        Returns the public id of the response token as a modhex string.

        :rtype: str or ``None``.
        """
        try:
            public_id = self.fields['otp'][:-32]
        except KeyError:
            public_id = None

        return public_id


def param_signature(params, api_key):
    """
    Returns the signature over a list of Yubico validation service parameters.
    Note that the signature algorithm packs the paramters into a form similar
    to URL parameters, but without any escaping.

    :param params: An association list of parameters, such as you would give to
        urllib.urlencode.
    :type params: list of 2-tuples

    :param str api_key: The Yubico API key (raw, not base64-encoded).

    :returns: The parameter signature (raw, not base64-encoded).
    :rtype: str
    """
    param_string = '&'.join('{0}={1}'.format(k,v) for k,v in sorted(params))
    signature = hmac.new(api_key, param_string, sha1).digest()

    return signature
