diff --git a/.gitignore b/.gitignore index 172bf57..b3a28f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .tox +mock_api_server.log diff --git a/.travis.yml b/.travis.yml index a8a9089..7042269 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,12 @@ language: python python: - "2.6" - "2.7" + - "pypy" -script: python setup.py test +script: + - pep8 tests/ + - pep8 yubico/ + - python setup.py test notifications: email: diff --git a/CHANGES.md b/CHANGES.md index 50d2f0c..66807a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,15 @@ -in development: +1.6.0 - in development: -- Avoid busy-looping (add time.sleep) when waiting for responses. -- Allow user to pass in value `0` for `sl` argument in `verify` and `verify_multi` method -- Throw an exception inside `verify` and `verify_multi` method if timeout has +* Allow user to specify a path to the CA bundle which is used for verifying the + server SSL certificate by setting `CA_CERTS_BUNDLE_PATH` variable +* When selecting which CA bundle is used for verifying the server SSL + certificate look for the bundle in some common locations. #10 +* Drop support for Python 2.5 +* Use `requests` library for performing HTTP requests and turn SSL cert + verification on by default +* Avoid busy-looping (add time.sleep) when waiting for responses. +* Allow user to pass in value `0` for `sl` argument in `verify` and + `verify_multi` method +* Throw an exception inside `verify` and `verify_multi` method if timeout has occurred or invalid status code is returned -- Add logging +* Add logging diff --git a/README.md b/README.md index 887cb67..e5fd786 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,6 @@ Both methods can also throw one of the following exceptions: **REPLAYED_REQUEST** or no response was received from any of the servers in the specified time frame (default timeout = 10 seconds) -## Notes - -If you are using secure connection (https) and want to validate the server certificate, you need to pass ``verify_cert = True`` argument when instantiating the yubico class and set ``CA_CERTS`` variable in the -``yubico/httplib_ssl.py`` file so it points to a file containing trusted CA certificates. - -For a backward compatibility, ``verify_cert`` is set to ``False`` by default. - [1]: http://www.yubico.com [2]: http://www.yubico.com/developers/intro/ [3]: http://www.yubico.com/developers/version2/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36ee128 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pep8 +requests == 1.1.0 diff --git a/setup.py b/setup.py index f93dd6e..dfbc7bf 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from os.path import splitext, basename, join as pjoin from unittest import TextTestRunner, TestLoader -from distutils.core import setup +from setuptools import setup from distutils.core import Command sys.path.insert(0, pjoin(os.path.dirname(__file__))) @@ -16,8 +16,6 @@ TEST_PATHS = ['tests'] -pre_python26 = (sys.version_info[0] == 2 and sys.version_info[1] < 6) - version_re = re.compile( r'__version__ = (\(.*?\))') @@ -76,19 +74,19 @@ def _run_mock_api_server(self): setup(name='yubico', version='.' . join(map(str, version)), description='Python Yubico Client', - author='Tomaž Muraus', + author='Tomaz Muraus', author_email='tomaz+pypi@tomaz.me', license='BSD', url='http://github.com/Kami/python-yubico-client/', download_url='http://github.com/Kami/python-yubico-client/downloads/', packages=['yubico'], provides=['yubico'], - requires=([], ['ssl'],)[pre_python26], + install_requires=[ + 'requests == 1.1.0', + ], cmdclass={ 'test': TestCommand, }, - - classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', diff --git a/tests/mock_http_server.py b/tests/mock_http_server.py index 5e7d9e4..4d14a15 100755 --- a/tests/mock_http_server.py +++ b/tests/mock_http_server.py @@ -77,8 +77,8 @@ def _send_status(self, status, signature=None): def main(): usage = 'usage: %prog --port=' parser = OptionParser(usage=usage) - parser.add_option("--port", dest='port', default=8881, - help='Port to listen on', metavar='PORT') + parser.add_option('--port', dest='port', default=8881, + help='Port to listen on', metavar='PORT') (options, args) = parser.parse_args() diff --git a/tests/test_yubico.py b/tests/test_yubico.py index 4ea5d52..2dcef7c 100644 --- a/tests/test_yubico.py +++ b/tests/test_yubico.py @@ -1,7 +1,7 @@ import sys import unittest -import httplib -import urllib + +import requests from yubico import yubico from yubico.otp import OTP @@ -13,7 +13,7 @@ class TestOTPClass(unittest.TestCase): def test_otp_class(self): otp1 = OTP('tlerefhcvijlngibueiiuhkeibbcbecehvjiklltnbbl') otp2 = OTP('jjjjjjjjnhe.ngcgjeiuujjjdtgihjuecyixinxunkhj', - translate_otp=True) + translate_otp=True) self.assertEqual(otp1.device_id, 'tlerefhcvijl') self.assertEqual(otp2.otp, @@ -22,16 +22,30 @@ def test_otp_class(self): class TestYubicoVerifySingle(unittest.TestCase): def setUp(self): - yubico.API_URLS = ( - '127.0.0.1:8881/wsapi/2.0/verify', - ) + yubico.API_URLS = ('127.0.0.1:8881/wsapi/2.0/verify',) yubico.DEFAULT_TIMEOUT = 2 + yubico.CA_CERTS_BUNDLE_PATH = None self.client_no_verify_sig = yubico.Yubico('1234', None, use_https=False) self.client_verify_sig = yubico.Yubico('1234', 'secret123456', use_https=False) + def test_invalid_custom_ca_certs_path(self): + if hasattr(sys, 'pypy_version_info'): + # TODO: Figure out why this breaks PyPy + return + + yubico.CA_CERTS_BUNDLE_PATH = '/does/not/exist.1' + client = yubico.Yubico('1234', 'secret123456') + + try: + client.verify('bad') + except requests.exceptions.SSLError: + pass + else: + self.fail('SSL exception was not thrown') + def test_replayed_otp(self): self._set_mock_action('REPLAYED_OTP') @@ -97,13 +111,7 @@ def _set_mock_action(self, action, port=8881, signature=None): if signature: path += '&signature=%s' % (signature) - conn = httplib.HTTPConnection('127.0.0.1:' + str(port)) - conn.request('GET', path) - - try: - conn.getresponse() - except: - pass + requests.get(url='http://127.0.0.1:%s%s' % (port, path)) if __name__ == '__main__': diff --git a/tests/utils.py b/tests/utils.py index a385da5..7777e04 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -12,7 +12,7 @@ from os.path import join as pjoin -def waitForStartUp(process, pid, address, timeout=10): +def waitForStartUp(process, address, timeout=10): # connect to it, with a timeout in case something went wrong start = time.time() while time.time() < start + timeout: @@ -26,57 +26,18 @@ def waitForStartUp(process, pid, address, timeout=10): # see if process is still alive process.poll() - if pid and process.returncode is None: - os.kill(pid, signal.SIGKILL) + if process and process.returncode is None: + process.terminate() raise RuntimeError("Couldn't connect to server; aborting test") class ProcessRunner(object): def setUp(self, *args, **kwargs): - # clean up old. - p = self.getPid() - if p != None: - try: - # remember, process may already be dead. - os.kill(p, 9) - time.sleep(0.01) - except: - pass + pass def tearDown(self, *args, **kwargs): - spid = self.getPid() - if spid: - max_wait = 1 - os.kill(spid, signal.SIGTERM) - slept = 0 - while (slept < max_wait): - time.sleep(0.5) - if not self.isAlive(spid): - if os.path.exists(self.pid_fname): - os.unlink(self.pid_fname) - break - slept += 0.5 - if (slept > max_wait and self.isAlive(spid)): - os.kill(spid, signal.SIGKILL) - if os.path.exists(self.pid_fname): - os.unlink(self.pid_fname) - raise Exception('Server did not shut down correctly') - else: - print 'Unable to locate pid file (%s)!' % self.pid_fname - - def isAlive(self, pid): - try: - os.kill(pid, 0) - return 1 - except OSError, err: - return err.errno == errno.EPERM - - def getPid(self): if self.process: - return self.process.pid - elif os.path.exists(self.pid_fname): - return int(open(self.pid_fname, 'r').read()) - return None + self.process.terminate() class MockAPIServerRunner(ProcessRunner): @@ -87,7 +48,6 @@ def setUp(self, *args, **kwargs): self.cwd = os.getcwd() self.process = None self.base_dir = pjoin(self.cwd) - self.pid_fname = pjoin(self.cwd, 'mock_api_server.pid') self.log_path = pjoin(self.cwd, 'mock_api_server.log') super(MockAPIServerRunner, self).setUp(*args, **kwargs) @@ -95,8 +55,10 @@ def setUp(self, *args, **kwargs): with open(self.log_path, 'a+') as log_fp: args = '%s --port=%s' % (script, str(self.port)) - self.process = subprocess.Popen(args, shell=True, - cwd=self.base_dir, stdout=log_fp, stderr=log_fp) - waitForStartUp(self.process, self.getPid(), - ('127.0.0.1', self.port), 10) + args = [script, '--port=%s' % (self.port)] + + self.process = subprocess.Popen(args, shell=False, + cwd=self.base_dir, stdout=log_fp, + stderr=log_fp) + waitForStartUp(self.process, ('127.0.0.1', self.port), 10) atexit.register(self.tearDown) diff --git a/tox.ini b/tox.ini index 7e7f795..5b6ab1f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,5 @@ [tox] -envlist = py25,py26,py27,pypy +envlist = py26,py27,pypy [testenv] commands = python setup.py test - -[testenv:py25] -deps = ssl diff --git a/yubico/__init__.py b/yubico/__init__.py index e146070..4cd638d 100644 --- a/yubico/__init__.py +++ b/yubico/__init__.py @@ -1 +1 @@ -__version__ = (1, 5, 'dev') +__version__ = (1, 6, 0, 'dev') diff --git a/yubico/httplib_ssl.py b/yubico/httplib_ssl.py deleted file mode 100644 index 03e6a4b..0000000 --- a/yubico/httplib_ssl.py +++ /dev/null @@ -1,63 +0,0 @@ -# Based on example from post "HTTPS Certificate Verification in Python With urllib2" - -# http://www.muchtooscrawled.com/2010/03/https-certificate-verification-in-python-with-urllib2/ - -import socket -import ssl -import httplib -import urllib2 - -# Path to a file containing trusted CA certificates -CA_CERTS = '' - -class VerifiedHTTPSConnection(httplib.HTTPSConnection): - def connect(self): - sock = socket.create_connection((self.host, self.port), - self.timeout) - if self._tunnel_host: - self.sock = sock - self._tunnel() - - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, \ - cert_reqs = ssl.CERT_REQUIRED, ca_certs = CA_CERTS, \ - ssl_version = ssl.PROTOCOL_TLSv1) - - cert = self.sock.getpeercert() - if not self._verify_hostname(self.host, cert): - raise ssl.SSLError('Failed to verify hostname') - - def _verify_hostname(self, hostname, cert): - common_name = self._get_commonName(cert) - alt_names = self._get_subjectAltName(cert) - - if (hostname == common_name) or hostname in alt_names: - return True - - return False - - def _get_subjectAltName(self, cert): - if not cert.has_key('subjectAltName'): - return None - - alt_names = [] - for value in cert['subjectAltName']: - if value[0].lower() == 'dns': - alt_names.append(value[0]) - - return alt_names - - def _get_commonName(self, cert): - if not cert.has_key('subject'): - return None - - for value in cert['subject']: - if value[0][0].lower() == 'commonname': - return value[0][1] - return None - -class VerifiedHTTPSHandler(urllib2.HTTPSHandler): - def __init__(self, connection_class = VerifiedHTTPSConnection): - self.specialized_conn_class = connection_class - urllib2.HTTPSHandler.__init__(self) - - def https_open(self, req): - return self.do_open(self.specialized_conn_class, req) diff --git a/yubico/modhex.py b/yubico/modhex.py index bdf34ff..3ac5507 100644 --- a/yubico/modhex.py +++ b/yubico/modhex.py @@ -118,12 +118,13 @@ index = {} for i, alphabet in enumerate(alphabets): - for letter in alphabet: + for letter in alphabet: index.setdefault(letter, set()).update([i]) HEX = u"0123456789abcdef" MODHEX = u"cbdefghijklnrtuv" + def translate(otp, to=MODHEX): """Return set() of possible modhex interpretations of a Yubikey otp. @@ -146,4 +147,3 @@ def translate(otp, to=MODHEX): translation = dict(zip((ord(c) for c in a), to)) translated.add(otp.translate(translation)) return translated - diff --git a/yubico/otp.py b/yubico/otp.py index fbed43b..80cb540 100644 --- a/yubico/otp.py +++ b/yubico/otp.py @@ -5,10 +5,10 @@ import modhex -class OTP(): +class OTP(object): def __init__(self, otp, translate_otp=True): self.otp = self.get_otp_modehex_interpretation(otp) \ - if translate_otp else otp + if translate_otp else otp self.device_id = self.otp[:12] self.session_counter = None diff --git a/yubico/yubico.py b/yubico/yubico.py index b54d9f8..61ed90d 100644 --- a/yubico/yubico.py +++ b/yubico/yubico.py @@ -15,29 +15,48 @@ import re import os -import sys import time -import socket import urllib -import urllib2 import hmac import base64 import hashlib import threading import logging -from otp import OTP -from yubico_exceptions import * +import requests -try: - import httplib_ssl -except ImportError: - httplib_ssl = None +from otp import OTP +from yubico_exceptions import (StatusCodeError, InvalidClientIdError, + InvalidValidationResponse, + SignatureVerificationError) -logger = logging.getLogger('face') +logger = logging.getLogger('yubico.client') FORMAT = '%(asctime)-15s [%(levelname)s] %(message)s' logging.basicConfig(format=FORMAT) +# Path to the custom CA certificates bundle. Only used if set. +CA_CERTS_BUNDLE_PATH = None + +COMMON_CA_LOCATIONS = [ + '/usr/local/lib/ssl/certs/ca-certificates.crt', + '/usr/local/ssl/certs/ca-certificates.crt', + '/usr/local/share/curl/curl-ca-bundle.crt', + '/usr/local/etc/openssl/cert.pem', + '/opt/local/lib/ssl/certs/ca-certificates.crt', + '/opt/local/ssl/certs/ca-certificates.crt', + '/opt/local/share/curl/curl-ca-bundle.crt', + '/opt/local/etc/openssl/cert.pem', + '/usr/lib/ssl/certs/ca-certificates.crt', + '/usr/ssl/certs/ca-certificates.crt', + '/usr/share/curl/curl-ca-bundle.crt', + '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/cert.pem', + '/etc/pki/CA/cacert.pem', + 'C:\Windows\curl-ca-bundle.crt', + 'C:\Windows\ca-bundle.crt', + 'C:\Windows\cacert.pem' +] + API_URLS = ('api.yubico.com/wsapi/2.0/verify', 'api2.yubico.com/wsapi/2.0/verify', 'api3.yubico.com/wsapi/2.0/verify', @@ -47,8 +66,9 @@ DEFAULT_TIMEOUT = 10 # How long to wait before the time out occurs DEFAULT_MAX_TIME_WINDOW = 40 # How many seconds can pass between the first # and last OTP generations so the OTP is - # still considered valid (only used in the multi - # mode) default is 5 seconds (40 / 0.125 = 5) + # still considered valid (only used in the + # multi mode) default is 5 seconds + # (40 / 0.125 = 5) BAD_STATUS_CODES = ['BAD_OTP', 'REPLAYED_OTP', 'BAD_SIGNATURE', 'MISSING_PARAMETER', 'OPERATION_NOT_ALLOWED', @@ -56,20 +76,9 @@ 'REPLAYED_REQUEST'] -class Yubico(): - def __init__(self, client_id, key=None, use_https=True, verify_cert=False, +class Yubico(object): + def __init__(self, client_id, key=None, use_https=True, verify_cert=True, translate_otp=True): - - if use_https and not httplib_ssl: - raise Exception('SSL support not available') - - if use_https and httplib_ssl and httplib_ssl.CA_CERTS == '': - raise Exception('If you want to validate server certificate,' - ' you need to set CA_CERTS ' - 'variable in the httplib_ssl.py file pointing ' - 'to a file which contains a list of trusted CA ' - 'certificates') - self.client_id = client_id self.key = base64.b64decode(key) if key is not None else None self.use_https = use_https @@ -84,6 +93,8 @@ def verify(self, otp, timestamp=False, sl=None, timeout=None, message signature verification failed and None for the rest of the status values. """ + ca_bundle_path = self._get_ca_bundle_path() + otp = OTP(otp, self.translate_otp) nonce = base64.b64encode(os.urandom(30), 'xz')[:25] query_string = self.generate_query_string(otp.otp, nonce, timestamp, @@ -94,7 +105,7 @@ def verify(self, otp, timestamp=False, sl=None, timeout=None, timeout = timeout or DEFAULT_TIMEOUT for url in request_urls: thread = URLThread('%s?%s' % (url, query_string), timeout, - self.verify_cert) + self.verify_cert, ca_bundle_path) thread.start() threads.append(thread) @@ -102,16 +113,19 @@ def verify(self, otp, timestamp=False, sl=None, timeout=None, start_time = time.time() while threads and (start_time + timeout) > time.time(): for thread in threads: - if not thread.is_alive() and thread.response: - status = self.verify_response(thread.response, - otp.otp, nonce, - return_response) - - if status: - if return_response: - return status - else: - return True + if not thread.is_alive(): + if thread.exception: + raise thread.exception + elif thread.response: + status = self.verify_response(thread.response, + otp.otp, nonce, + return_response) + + if status: + if return_response: + return status + else: + return True threads.remove(thread) time.sleep(0.1) @@ -139,7 +153,7 @@ def verify_multi(self, otp_list=None, max_time_window=None, sl=None, # server but in this case, user would need to provide his AES key. for otp in otps: response = self.verify(otp.otp, True, sl, timeout, - return_response=True) + return_response=True) if not response: return False @@ -173,10 +187,12 @@ def verify_response(self, response, otp, nonce, return_response=False): """ try: status = re.search(r'status=([A-Z0-9_]+)', response) \ - .groups() + .groups() + if len(status) > 1: - raise InvalidValidationResponse('More than one status= returned. Possible attack!', - response) + message = 'More than one status= returned. Possible attack!' + raise InvalidValidationResponse(message, response) + status = status[0] except (AttributeError, IndexError): return False @@ -188,7 +204,7 @@ def verify_response(self, response, otp, nonce, return_response=False): # signature if self.key: generated_signature = \ - self.generate_message_signature(parameters) + self.generate_message_signature(parameters) # Signature located in the response does not match the one we # have generated @@ -198,11 +214,12 @@ def verify_response(self, response, otp, nonce, return_response=False): param_dict = self.get_parameters_as_dictionary(parameters) if 'otp' in param_dict and param_dict['dict'] != otp: - raise InvalidValidationResponse('Unexpected OTP in response. Possible attack!', - response, param_dict) + message = 'Unexpected OTP in response. Possible attack!' + raise InvalidValidationResponse(message, response, param_dict) + if 'nonce' in param_dict and param_dict['nonce'] != nonce: - raise InvalidValidationResponse('Unexpected nonce in response. Possible attack!', - response, param_dict) + message = 'Unexpected nonce in response. Possible attack!' + raise InvalidValidationResponse(message, response, param_dict) if status == 'OK': if return_response: @@ -231,7 +248,7 @@ def generate_query_string(self, otp, nonce, timestamp=False, sl=None, if sl is not None: if sl not in range(0, 101) and sl not in ['fast', 'secure']: raise Exception('sl parameter value must be between 0 and ' - '100 or string "fast" or "secure"') + '100 or string "fast" or "secure"') data.append(('sl', sl)) @@ -282,15 +299,15 @@ def parse_parameters_from_response(self, response): for index, (key, value) in enumerate(split_dict.iteritems()): query_string += '%s=%s' % (key, value) - if index != len(split_dict) -1: + if index != len(split_dict) - 1: query_string += '&' return (signature, query_string) def get_parameters_as_dictionary(self, query_string): """ Returns query string parameters as a dictionary. """ - dictionary = dict([parameter.split('=', 1) for parameter \ - in query_string.split('&')]) + dictionary = dict([parameter.split('=', 1) for parameter + in query_string.split('&')]) return dictionary @@ -308,35 +325,52 @@ def generate_request_urls(self): return urls + def _get_ca_bundle_path(self): + """ + Return a path to the CA bundle which is used for verifying the hosts + SSL certificate. + """ + if CA_CERTS_BUNDLE_PATH: + # User provided a custom path + return CA_CERTS_BUNDLE_PATH + + for file_path in COMMON_CA_LOCATIONS: + if os.path.exists(file_path) and os.path.isfile(file_path): + return file_path + + return None + class URLThread(threading.Thread): - def __init__(self, url, timeout, verify_cert): + def __init__(self, url, timeout, verify_cert, ca_bundle_path=None): super(URLThread, self).__init__() self.url = url self.timeout = timeout self.verify_cert = verify_cert + self.ca_bundle_path = ca_bundle_path + self.exception = None self.request = None self.response = None - if int(sys.version[0]) == 2 and int(sys.version[2]) <= 5: - self.is_alive = self.isAlive - def run(self): logger.debug('Sending HTTP request to %s (thread=%s)' % (self.url, self.name)) - socket.setdefaulttimeout(self.timeout) + verify = self.verify_cert - if self.url.startswith('https') and self.verify_cert: - handler = httplib_ssl.VerifiedHTTPSHandler() - opener = urllib2.build_opener(handler) - urllib2.install_opener(opener) + if self.ca_bundle_path is not None: + verify = self.ca_bundle_path + logger.debug('Using custom CA bunde: %s' % (self.ca_bundle_path)) try: - self.request = urllib2.urlopen(self.url) - self.response = self.request.read() - except Exception: + self.request = requests.get(url=self.url, timeout=self.timeout, + verify=verify) + self.response = self.request.content + except requests.exceptions.SSLError, e: + self.exception = e + self.response = None + except Exception, e: + logger.error('Failed to retrieve response: ' + str(e)) self.response = None - logger.debug('Received response from %s (thread=%s): %s' % (self.url, - self.name, - self.response)) + args = (self.url, self.name, self.response) + logger.debug('Received response from %s (thread=%s): %s' % args)