Repository URL to install this package:
|
Version:
0.2.2.post1 ▾
|
YubiOTP
/
client.py
|
|---|
from base64 import b64decode, b64encode
from hashlib import sha1
import hmac
from random import choice
import string
from six.moves import xrange
from six.moves.urllib.parse import urlencode
from six.moves.urllib.request 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 bytes 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().decode('utf-8'), 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 bytes 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 bytes 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'].encode())
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'``
"""
status = self.fields.get('status')
if status == 'BAD_SIGNATURE' or self.is_valid(strict=False):
return 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 bytes api_key: The Yubico API key (raw, not base64-encoded).
:returns: The parameter signature (raw, not base64-encoded).
:rtype: bytes
"""
param_string = '&'.join('{0}={1}'.format(k, v) for k, v in sorted(params))
signature = hmac.new(api_key, param_string.encode('utf-8'), sha1).digest()
return signature