├── README.adoc ├── test ├── __init__.py ├── test_auth.py ├── test_exc.py └── test_client.py ├── MANIFEST.in ├── .gitignore ├── setup.cfg ├── NEWS ├── .travis.yml ├── README ├── COPYING ├── u2fval_client ├── __init__.py ├── auth.py ├── exc.py └── client.py ├── setup.py └── release.py /README.adoc: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include NEWS 3 | include ChangeLog 4 | include release.py 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | .eggs/ 5 | build/ 6 | dist/ 7 | .ropeproject/ 8 | ChangeLog 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | * Version 2.0.0 (released 2017-04-07) 2 | ** New major release: Now targets the U2FVAL REST API V2, which is not 3 | compatible with V1. 4 | 5 | * Version 1.0.1 (released 2015-10-27) 6 | ** Fix package so it installs. 7 | 8 | * Version 1.0.0 (released 2015-10-26) 9 | ** First release. 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | - "pypy-5.4.1" 11 | - "pypy3.3-5.2-alpha1" 12 | 13 | cache: 14 | directories: 15 | - $HOME/.cache/pip 16 | 17 | install: 18 | - pip install --disable-pip-version-check --upgrade pip 19 | - pip install -e . 20 | 21 | script: 22 | - python setup.py test 23 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == u2fval-client 2 | Client library for communicating with a U2FVAL server. This is intended to be 3 | used for Python applications which wish to implement 2FA using U2F. 4 | 5 | === License 6 | The project is licensed under a BSD license. See the file COPYING for 7 | exact wording. For any copyright year range specified as YYYY-ZZZZ in 8 | this package note that the range specifies every single year in that 9 | closed interval. 10 | 11 | === Installation 12 | This package should be installed using pip or simmilar: 13 | 14 | pip install u2fval-client 15 | -------------------------------------------------------------------------------- /test/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from u2fval_client.auth import ( 4 | ApiToken, 5 | HttpAuth, 6 | no_auth, 7 | ) 8 | 9 | 10 | class TestNoAuth(unittest.TestCase): 11 | def test_no_auth(self): 12 | self.assertEqual(no_auth({}), {}) 13 | self.assertEqual(no_auth({'foo': 'bar'}), {'foo': 'bar'}) 14 | 15 | 16 | class TestApiToken(unittest.TestCase): 17 | def setUp(self): 18 | self.api_token = ApiToken('abc123') 19 | 20 | def test_call_headers_empty(self): 21 | self.assertEqual(self.api_token({}), 22 | {'headers': {'Authorization': 'Bearer abc123'}}) 23 | 24 | def test_call_headers_present(self): 25 | self.assertEqual(self.api_token({'headers': {'foo': 'bar'}}), 26 | {'headers': {'Authorization': 'Bearer abc123', 27 | 'foo': 'bar'}}) 28 | 29 | 30 | class TestHttpAuth(unittest.TestCase): 31 | def test_without_authtype(self): 32 | http_auth = HttpAuth('black_knight', 'Just a flesh wound!') 33 | self.assertEqual(http_auth({}), 34 | {'auth': ('black_knight', 'Just a flesh wound!')}) 35 | 36 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Yubico AB 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or 5 | without modification, are permitted provided that the following 6 | conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /u2fval_client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | __version__ = '2.0.0' 29 | -------------------------------------------------------------------------------- /test/test_exc.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from u2fval_client.exc import ( 4 | BadAuthException, 5 | BadInputException, 6 | DeviceCompromisedException, 7 | NoEligableDevicesException, 8 | U2fValException, 9 | from_response, 10 | ) 11 | 12 | 13 | class TestU2fValException(unittest.TestCase): 14 | def test_message_only(self): 15 | self.assertEqual(U2fValException("It's dead").message, "It's dead") 16 | 17 | def test_message_and_data(self): 18 | self.assertEqual(U2fValException("It's dead", "Nailed to perch").data, "Nailed to perch") 19 | 20 | 21 | class TestNoEligableDevicesException(unittest.TestCase): 22 | def test_has_devices_message_only(self): 23 | self.assertFalse(NoEligableDevicesException("It's dead").has_devices()) 24 | 25 | def test_has_devices_with_data(self): 26 | self.assertFalse(NoEligableDevicesException("It's dead", False).has_devices()) 27 | self.assertTrue(NoEligableDevicesException("It's dead", True).has_devices()) 28 | 29 | 30 | class TestFromResponse(unittest.TestCase): 31 | 32 | def test_known_errorcode(self): 33 | self.assertIsInstance(from_response({'errorCode': 10}), BadInputException) 34 | self.assertIsInstance(from_response({'errorCode': 11}), NoEligableDevicesException) 35 | self.assertIsInstance(from_response({'errorCode': 12}), DeviceCompromisedException) 36 | self.assertIsInstance(from_response({'errorCode': 401}), BadAuthException) 37 | 38 | def test_unknown_errorcode(self): 39 | self.assertIsInstance(from_response({'errorCode': 0xdeadbeef}), U2fValException) 40 | 41 | def test_error_message(self): 42 | exc = from_response({'errorCode': 10, 'errorMessage': "It's dead"}) 43 | self.assertEqual(exc.message, "It's dead") 44 | self.assertIsNone(exc.data) 45 | 46 | def test_error_data(self): 47 | exc = from_response({'errorCode': 10, 'errorData': "Nailed to perch"}) 48 | self.assertIsNone(exc.message) 49 | self.assertEqual(exc.data, "Nailed to perch") 50 | 51 | -------------------------------------------------------------------------------- /u2fval_client/auth.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | __all__ = ['no_auth', 'ApiToken', 'HttpAuth'] 29 | 30 | 31 | def no_auth(kwargs): 32 | return kwargs 33 | 34 | 35 | class ApiToken(object): 36 | 37 | def __init__(self, api_token): 38 | self._token = api_token 39 | 40 | def __call__(self, kwargs): 41 | headers = kwargs.get('headers', {}) 42 | headers['Authorization'] = 'Bearer %s' % self._token 43 | kwargs['headers'] = headers 44 | return kwargs 45 | 46 | 47 | class HttpAuth(object): 48 | def __init__(self, username, password, authtype=None): 49 | if authtype is not None: 50 | self._auth = authtype(username, password) 51 | else: 52 | self._auth = (username, password) 53 | 54 | def __call__(self, kwargs): 55 | kwargs['auth'] = self._auth 56 | return kwargs 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from release import setup 29 | 30 | 31 | setup( 32 | name='u2fval-client', 33 | author='Dain Nilsson', 34 | author_email='dain@yubico.com', 35 | description='Python based U2FVAL connector library', 36 | maintainer='Yubico Open Source Maintainers', 37 | maintainer_email='ossmaint@yubico.com', 38 | url='https://github.com/Yubico/u2fval-client-python', 39 | license='BSD 2 clause', 40 | install_requires=['requests'], 41 | test_suite='test', 42 | tests_require=[ 43 | 'httpretty', 44 | ], 45 | classifiers=[ 46 | 'License :: OSI Approved :: BSD License', 47 | 'Programming Language :: Python :: 2', 48 | 'Programming Language :: Python :: 2.7', 49 | 'Programming Language :: Python :: 3', 50 | 'Programming Language :: Python :: 3.3', 51 | 'Programming Language :: Python :: 3.4', 52 | 'Programming Language :: Python :: 3.5', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: Implementation :: PyPy', 55 | 'Operating System :: OS Independent', 56 | 'Development Status :: 4 - Beta', 57 | 'Intended Audience :: Developers', 58 | 'Intended Audience :: System Administrators', 59 | 'Topic :: Internet', 60 | 'Topic :: Security :: Cryptography', 61 | 'Topic :: Software Development :: Libraries :: Python Modules', 62 | ] 63 | ) 64 | -------------------------------------------------------------------------------- /u2fval_client/exc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | __all__ = [ 29 | 'U2fValClientException', 30 | 'ServerUnreachableException', 31 | 'BadAuthException', 32 | 'U2fValException', 33 | 'BadInputException', 34 | 'NoEligableDevicesException', 35 | 'DeviceCompromisedException', 36 | 'from_response' 37 | ] 38 | 39 | 40 | class U2fValClientException(Exception): 41 | 42 | "Exception generated on the client" 43 | 44 | 45 | class ServerUnreachableException(U2fValClientException): 46 | 47 | "The U2FVAL server cannot be reached" 48 | 49 | 50 | class InvalidResponseException(U2fValClientException): 51 | 52 | "The server sent something which is not valid" 53 | 54 | 55 | class BadAuthException(U2fValClientException): 56 | 57 | "Access was denied" 58 | 59 | 60 | class U2fValException(Exception): 61 | 62 | "Exception sent from the U2FVAL server" 63 | 64 | def __init__(self, message, data=None): 65 | super(U2fValException, self).__init__(message, data) 66 | 67 | self.message = message 68 | self.data = data 69 | 70 | 71 | class BadInputException(U2fValException): 72 | 73 | "The arguments passed to the function are invalid" 74 | code = 10 75 | 76 | 77 | class NoEligableDevicesException(U2fValException): 78 | 79 | "The user has no eligable devices capable of the requested action" 80 | code = 11 81 | 82 | def has_devices(self): 83 | return bool(self.data) 84 | 85 | 86 | class DeviceCompromisedException(U2fValException): 87 | 88 | "The device might be compromised, and has been blocked" 89 | code = 12 90 | 91 | 92 | _ERRORS = { 93 | 10: BadInputException, 94 | 11: NoEligableDevicesException, 95 | 12: DeviceCompromisedException, 96 | 401: BadAuthException 97 | } 98 | 99 | 100 | def from_response(response): 101 | error_cls = _ERRORS.get(response['errorCode'], U2fValException) 102 | return error_cls(response.get('errorMessage'), response.get('errorData')) 103 | -------------------------------------------------------------------------------- /test/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import httpretty 4 | 5 | from u2fval_client.client import ( 6 | Client, 7 | ) 8 | from u2fval_client.exc import ( 9 | BadAuthException, 10 | BadInputException, 11 | ServerUnreachableException, 12 | InvalidResponseException, 13 | U2fValClientException, 14 | ) 15 | 16 | 17 | @httpretty.activate 18 | class TestClient(unittest.TestCase): 19 | def setUp(self): 20 | self.client = Client('https://example') 21 | 22 | def test_endpoint_sanitised(self): 23 | self.assertEqual('https://example/', self.client._endpoint) 24 | 25 | def test_get_trusted_facets(self): 26 | httpretty.register_uri('GET', 'https://example/', 27 | body='{}') 28 | self.assertEqual(self.client.get_trusted_facets(), {}) 29 | 30 | def test_get_trusted_facets_empty_response_body(self): 31 | httpretty.register_uri('GET', 'https://example/', 32 | body='') 33 | self.assertRaises(InvalidResponseException, 34 | self.client.get_trusted_facets) 35 | 36 | def test_get_trusted_facets_error_code(self): 37 | httpretty.register_uri('GET', 'https://example/', 38 | body='{"errorCode": 10}', 39 | status=400) 40 | self.assertRaises(BadInputException, self.client.get_trusted_facets) 41 | 42 | def test_get_trusted_facets_unauthorized(self): 43 | httpretty.register_uri('GET', 'https://example/', status=401) 44 | self.assertRaises(BadAuthException, self.client.get_trusted_facets) 45 | 46 | def test_get_trusted_facets_not_found(self): 47 | httpretty.register_uri('GET', 'https://example/', status=404) 48 | self.assertRaises(U2fValClientException, self.client.get_trusted_facets) 49 | 50 | def test_get_trusted_facets_internal_server_error(self): 51 | httpretty.register_uri('GET', 'https://example/', status=500) 52 | self.assertRaises(U2fValClientException, self.client.get_trusted_facets) 53 | 54 | def test_get_trusted_facets_server_unreachable(self): 55 | # Intentionally has no httpretty mock registered 56 | self.assertRaises(ServerUnreachableException, 57 | self.client.get_trusted_facets) 58 | 59 | def test_list_devices(self): 60 | httpretty.register_uri('GET', 'https://example/black_knight/', 61 | body='{}') 62 | self.assertEqual(self.client.list_devices('black_knight'), {}) 63 | 64 | def test_register_begin(self): 65 | httpretty.register_uri('GET', 'https://example/black_knight/register', 66 | body='{}') 67 | self.assertEqual(self.client.register_begin('black_knight'), {}) 68 | 69 | def test_register_complete(self): 70 | httpretty.register_uri('POST', 'https://example/black_knight/register', 71 | body='{}') 72 | self.assertEqual(self.client.register_complete('black_knight', '{}'), 73 | {}) 74 | req = httpretty.last_request() 75 | self.assertEqual(req.parsed_body, {'registerResponse': {}}) 76 | 77 | def test_unregister(self): 78 | httpretty.register_uri('DELETE', 'https://example/black_knight/abc123', 79 | body='', status=204) 80 | self.assertIsNone(self.client.unregister('black_knight', 'abc123')) 81 | 82 | def test_auth_begin(self): 83 | httpretty.register_uri('GET', 'https://example/black_knight/sign', 84 | body='{}') 85 | self.assertEqual(self.client.auth_begin('black_knight'), {}) 86 | 87 | def test_auth_complete(self): 88 | httpretty.register_uri('POST', 'https://example/black_knight/sign', 89 | body='{}') 90 | self.assertEqual(self.client.auth_complete('black_knight', '{}'), {}) 91 | req = httpretty.last_request() 92 | self.assertEqual(req.parsed_body, {'signResponse': {}}) 93 | -------------------------------------------------------------------------------- /u2fval_client/client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from u2fval_client import auth, exc 29 | import requests 30 | import json as _json 31 | 32 | 33 | class Client(object): 34 | 35 | def __init__(self, endpoint, auth=auth.no_auth, extra_args={}): 36 | if endpoint[-1] != '/': 37 | endpoint = endpoint + '/' 38 | self._endpoint = endpoint 39 | self._auth = auth 40 | self._extra_args = extra_args 41 | 42 | def _req(self, method, url, json=None, resp_is_json=True, **kwargs): 43 | args = dict(self._extra_args) 44 | args.update(kwargs) 45 | args = self._auth(args) 46 | if json is not None: 47 | headers = args.get('headers', {}) 48 | headers['Content-type'] = 'application/json' 49 | args['headers'] = headers 50 | args['data'] = _json.dumps(json) 51 | 52 | status = -1 53 | try: 54 | resp = requests.request(method, url, **args) 55 | status = resp.status_code 56 | if status < 400: 57 | return resp.json() if resp_is_json else resp.content 58 | else: 59 | raise exc.from_response(resp.json()) 60 | except requests.ConnectionError as e: 61 | raise exc.ServerUnreachableException(str(e)) 62 | except ValueError: 63 | if status == 401: 64 | raise exc.BadAuthException('Access denied') 65 | elif status == 404: 66 | raise exc.U2fValClientException('Not found') 67 | else: 68 | raise exc.InvalidResponseException( 69 | 'The server responded with invalid data') 70 | 71 | def get_trusted_facets(self): 72 | return self._req('GET', self._endpoint) 73 | 74 | def get_device(self, username, handle): 75 | url = self._endpoint + username + '/' + handle 76 | return self._req('GET', url) 77 | 78 | def get_certificate(self, username, handle): 79 | url = self._endpoint + username + '/' + handle 80 | return self._req('GET', url, resp_is_json=False) 81 | 82 | def delete_user(self, username): 83 | url = self._endpoint + username + '/' 84 | self._req('DELETE', url, resp_is_json=False) 85 | 86 | def list_devices(self, username): 87 | url = self._endpoint + username + '/' 88 | return self._req('GET', url) 89 | 90 | def update_device(self, username, handle, properties): 91 | url = self._endpoint + username + '/' + handle 92 | return self._req('POST', url, json=properties) 93 | 94 | def register_begin(self, username, properties=None, challenge=None): 95 | url = self._endpoint + username + '/register' 96 | params = {} 97 | if properties is not None: 98 | params['properties'] = _json.dumps(properties) 99 | if challenge is not None: 100 | params['challenge'] = challenge 101 | return self._req('GET', url, params=params) 102 | 103 | def register_complete(self, username, register_response, properties=None): 104 | url = self._endpoint + username + '/register' 105 | data = {'registerResponse': _json.loads(register_response)} 106 | if properties: 107 | data['properties'] = properties 108 | 109 | return self._req('POST', url, json=data) 110 | 111 | def unregister(self, username, handle): 112 | url = self._endpoint + username + '/' + handle 113 | self._req('DELETE', url, resp_is_json=False) 114 | 115 | def auth_begin(self, username, properties=None, challenge=None, 116 | handles=None): 117 | url = self._endpoint + username + '/sign' 118 | params = {} 119 | if properties is not None: 120 | params['properties'] = _json.dumps(properties) 121 | if challenge is not None: 122 | params['challenge'] = challenge 123 | if handles is not None: 124 | params['handle'] = handles 125 | return self._req('GET', url, params=params) 126 | 127 | def auth_complete(self, username, sign_response, properties=None): 128 | url = self._endpoint + username + '/sign' 129 | data = {'signResponse': _json.loads(sign_response)} 130 | if properties: 131 | data['properties'] = properties 132 | return self._req('POST', url, json=data) 133 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Yubico AB 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or 5 | # without modification, are permitted provided that the following 6 | # conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following 12 | # disclaimer in the documentation and/or other materials provided 13 | # with the distribution. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 18 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 19 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 21 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | from __future__ import absolute_import 29 | 30 | 31 | from setuptools import setup as _setup, find_packages, Command 32 | from setuptools.command.sdist import sdist 33 | from distutils import log 34 | from distutils.errors import DistutilsSetupError 35 | from datetime import date 36 | from glob import glob 37 | import os 38 | import re 39 | 40 | VERSION_PATTERN = re.compile(r"(?m)^__version__\s*=\s*['\"](.+)['\"]$") 41 | 42 | base_module = __name__.rsplit('.', 1)[0] 43 | 44 | 45 | def get_version(module_name_or_file=None): 46 | """Return the current version as defined by the given module/file.""" 47 | 48 | if module_name_or_file is None: 49 | parts = base_module.split('.') 50 | module_name_or_file = parts[0] if len(parts) > 1 else \ 51 | find_packages(exclude=['test', 'test.*'])[0] 52 | 53 | if os.path.isdir(module_name_or_file): 54 | module_name_or_file = os.path.join(module_name_or_file, '__init__.py') 55 | 56 | with open(module_name_or_file, 'r') as f: 57 | match = VERSION_PATTERN.search(f.read()) 58 | return match.group(1) 59 | 60 | 61 | def setup(**kwargs): 62 | if 'version' not in kwargs: 63 | kwargs['version'] = get_version() 64 | kwargs.setdefault('packages', find_packages(exclude=['test', 'test.*'])) 65 | cmdclass = kwargs.setdefault('cmdclass', {}) 66 | cmdclass.setdefault('release', release) 67 | cmdclass.setdefault('build_man', build_man) 68 | cmdclass.setdefault('sdist', custom_sdist) 69 | return _setup(**kwargs) 70 | 71 | 72 | class custom_sdist(sdist): 73 | def run(self): 74 | self.run_command('build_man') 75 | 76 | sdist.run(self) 77 | 78 | 79 | class build_man(Command): 80 | description = "create man pages from asciidoc source" 81 | user_options = [] 82 | boolean_options = [] 83 | 84 | def initialize_options(self): 85 | pass 86 | 87 | def finalize_options(self): 88 | self.cwd = os.getcwd() 89 | self.fullname = self.distribution.get_fullname() 90 | self.name = self.distribution.get_name() 91 | self.version = self.distribution.get_version() 92 | 93 | def run(self): 94 | if os.getcwd() != self.cwd: 95 | raise DistutilsSetupError("Must be in package root!") 96 | 97 | for fname in glob(os.path.join('man', '*.adoc')): 98 | self.announce("Converting: " + fname, log.INFO) 99 | self.execute(os.system, 100 | ('a2x -d manpage -f manpage "%s"' % fname,)) 101 | 102 | 103 | class release(Command): 104 | description = "create and release a new version" 105 | user_options = [ 106 | ('keyid', None, "GPG key to sign with"), 107 | ('skip-tests', None, "skip running the tests"), 108 | ('pypi', None, "publish to pypi"), 109 | ] 110 | boolean_options = ['skip-tests', 'pypi'] 111 | 112 | def initialize_options(self): 113 | self.keyid = None 114 | self.skip_tests = 0 115 | self.pypi = 0 116 | 117 | def finalize_options(self): 118 | self.cwd = os.getcwd() 119 | self.fullname = self.distribution.get_fullname() 120 | self.name = self.distribution.get_name() 121 | self.version = self.distribution.get_version() 122 | 123 | def _verify_version(self): 124 | with open('NEWS', 'r') as news_file: 125 | line = news_file.readline() 126 | now = date.today().strftime('%Y-%m-%d') 127 | if not re.search(r'Version %s \(released %s\)' % (self.version, now), 128 | line): 129 | raise DistutilsSetupError("Incorrect date/version in NEWS!") 130 | 131 | def _verify_tag(self): 132 | if os.system('git tag | grep -q "^%s\$"' % self.fullname) == 0: 133 | raise DistutilsSetupError( 134 | "Tag '%s' already exists!" % self.fullname) 135 | 136 | def _verify_not_dirty(self): 137 | if os.system('git diff --shortstat | grep -q "."') == 0: 138 | raise DistutilsSetupError("Git has uncommitted changes!") 139 | 140 | def _sign(self): 141 | if os.path.isfile('dist/%s.tar.gz.asc' % self.fullname): 142 | # Signature exists from upload, re-use it: 143 | sign_opts = ['--output dist/%s.tar.gz.sig' % self.fullname, 144 | '--dearmor dist/%s.tar.gz.asc' % self.fullname] 145 | else: 146 | # No signature, create it: 147 | sign_opts = ['--detach-sign', 'dist/%s.tar.gz' % self.fullname] 148 | if self.keyid: 149 | sign_opts.insert(1, '--default-key ' + self.keyid) 150 | self.execute(os.system, ('gpg ' + (' '.join(sign_opts)),)) 151 | 152 | if os.system('gpg --verify dist/%s.tar.gz.sig' % self.fullname) != 0: 153 | raise DistutilsSetupError("Error verifying signature!") 154 | 155 | def _tag(self): 156 | tag_opts = ['-s', '-m ' + self.fullname, self.fullname] 157 | if self.keyid: 158 | tag_opts[0] = '-u ' + self.keyid 159 | self.execute(os.system, ('git tag ' + (' '.join(tag_opts)),)) 160 | 161 | def run(self): 162 | if os.getcwd() != self.cwd: 163 | raise DistutilsSetupError("Must be in package root!") 164 | 165 | self._verify_version() 166 | self._verify_tag() 167 | self._verify_not_dirty() 168 | self.run_command('check') 169 | 170 | self.execute(os.system, ('git2cl > ChangeLog',)) 171 | 172 | self.run_command('sdist') 173 | 174 | if not self.skip_tests: 175 | try: 176 | self.run_command('test') 177 | except SystemExit as e: 178 | if e.code != 0: 179 | raise DistutilsSetupError("There were test failures!") 180 | 181 | if self.pypi: 182 | cmd_obj = self.distribution.get_command_obj('upload') 183 | cmd_obj.sign = True 184 | if self.keyid: 185 | cmd_obj.identity = self.keyid 186 | self.run_command('upload') 187 | 188 | self._sign() 189 | self._tag() 190 | 191 | self.announce("Release complete! Don't forget to:", log.INFO) 192 | self.announce("") 193 | self.announce(" git push && git push --tags", log.INFO) 194 | self.announce("") 195 | --------------------------------------------------------------------------------