diff --git a/README.md b/README.md index 314b2aa..8ce1f8c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,37 @@ if (auth_status.pending == false and auth_status.granted == true): #### Handling Errors If any request runs into an error a `ToopherApiError` will be thrown with more details on what went wrong. +#### Zero-Storage usage option +Requesters can choose to integrate the Toopher API in a way does not require storing any per-user data such as Pairing ID and Terminal ID - all of the storage +is handled by the Toopher API Web Service, allowing your local database to remain unchanged. If the Toopher API needs more data, it will `die()` with a specific +error string that allows your code to respond appropriately. + +```python +try: + # optimistically try to authenticate against Toopher API with username and a Terminal Identifier + # Terminal Identifer is typically a randomly generated secure browser cookie. It does not + # need to be human-readable + auth = api.authenticate_by_user_name("username@yourservice.com", "") + + # if you got here, everything is good! poll the auth request status as described above + # there are four distinct errors ToopherAPI can return if it needs more data +except UserDisabledError: + # you have marked this user as disabled in the Toopher API. +except UnknownUserError: + # This user has not yet paired a mobile device with their account. Pair them + # using api.pair() as described above, then re-try authentication +except UnknownTerminalError: + # This user has not assigned a "Friendly Name" to this terminal identifier. + # Prompt them to enter a terminal name, then submit that "friendly name" to + # the Toopher API: + # api.assign_user_friendly_name_to_terminal(user_name, terminal_friendly_name, terminal_identifier) + # Afterwards, re-try authentication +except PairingDeactivatedError: + # this user does not have an active pairing, + # typically because they deleted the pairing. You can prompt + # the user to re-pair with a new mobile device. +``` + #### Dependencies This library uses the python-oauth2 library to handle OAuth signing and httplib2 to make the web requests. If you install using pip (or easy_install) they'll be installed automatically for you. diff --git a/tests.py b/tests.py index f5dafae..d6f0dd7 100644 --- a/tests.py +++ b/tests.py @@ -1,8 +1,8 @@ -import unittest +import json import os -import urlparse - import toopher +import unittest +import urlparse class HttpClientMock(object): def __init__(self, paths): @@ -177,6 +177,56 @@ def test_access_arbitrary_keys_in_authentication_status(self): self.assertEqual(auth_request.random_key, "84") + def test_disabled_user_raises_correct_error(self): + api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1') + api.client = HttpClientMock({ + 'https://toopher.test/v1/authentication_requests/initiate': + ({'status': 409}, json.dumps( + {'error_code': 704, + 'error_message': 'disabled user'}))}) + with self.assertRaises(toopher.UserDisabledError): + auth_request = api.authenticate_by_user_name('disabled user', 'terminal name') + + def test_unknown_user_raises_correct_error(self): + api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1') + api.client = HttpClientMock({ + 'https://toopher.test/v1/authentication_requests/initiate': + ({'status': 409}, json.dumps( + {'error_code': 705, + 'error_message': 'unknown user'}))}) + with self.assertRaises(toopher.UserUnknownError): + auth_request = api.authenticate_by_user_name('unknown user', 'terminal name') + + def test_unknown_terminal_raises_correct_error(self): + api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1') + api.client = HttpClientMock({ + 'https://toopher.test/v1/authentication_requests/initiate': + ({'status': 409}, json.dumps( + {'error_code': 706, + 'error_message': 'unknown terminal'}))}) + with self.assertRaises(toopher.TerminalUnknownError): + auth_request = api.authenticate_by_user_name('user', 'unknown terminal name') + + def test_disabled_pairing_raises_correct_error(self): + api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1') + api.client = HttpClientMock({ + 'https://toopher.test/v1/authentication_requests/initiate': + ({'status': 409}, json.dumps( + {'error_code': 601, + 'error_message': 'pairing has been deactivated'}))}) + with self.assertRaises(toopher.PairingDeactivatedError): + auth_request = api.authenticate_by_user_name('user', 'terminal name') + + def test_disabled_pairing_raises_correct_error(self): + api = toopher.ToopherApi('key', 'secret', api_url='https://toopher.test/v1') + api.client = HttpClientMock({ + 'https://toopher.test/v1/authentication_requests/initiate': + ({'status': 409}, json.dumps( + {'error_code': 601, + 'error_message': 'pairing has not been authorized'}))}) + with self.assertRaises(toopher.PairingDeactivatedError): + auth_request = api.authenticate_by_user_name('user', 'terminal name') + def main(): unittest.main() diff --git a/toopher/__init__.py b/toopher/__init__.py index 4b5e075..71b3ea6 100644 --- a/toopher/__init__.py +++ b/toopher/__init__.py @@ -6,6 +6,14 @@ DEFAULT_BASE_URL = "https://api.toopher.com/v1" VERSION = "1.0.6" +class ToopherApiError(Exception): pass +class UserDisabledError(ToopherApiError): pass +class UserUnknownError(ToopherApiError): pass +class TerminalUnknownError(ToopherApiError): pass +class PairingDeactivatedError(ToopherApiError): pass +error_codes_to_errors = {704: UserDisabledError, + 705: UserUnknownError, + 706: TerminalUnknownError} class ToopherApi(object): def __init__(self, key, secret, api_url=None): @@ -25,7 +33,7 @@ def pair(self, pairing_phrase, user_name, **kwargs): return PairingStatus(result) def pair_sms(self, phone_number, user_name, phone_country=None): - uri = BASE_URL + "/pairings/create/sms" + uri = self.base_url + "/pairings/create/sms" params = {'phone_number': phone_number, 'user_name': user_name} @@ -60,30 +68,62 @@ def get_authentication_status(self, authentication_request_id): return AuthenticationStatus(result) def authenticate_with_otp(self, authentication_request_id, otp): - uri = BASE_URL + "/authentication_requests/" + authentication_request_id + '/otp_auth' + uri = self.base_url + "/authentication_requests/" + authentication_request_id + '/otp_auth' params = {'otp' : otp} result = self._request(uri, "POST", params) return AuthenticationStatus(result) + def authenticate_by_user_name(self, user_name, terminal_name_extra, action_name=None, **kwargs): + kwargs.update(user_name=user_name, terminal_name_extra=terminal_name_extra) + return self.authenticate('', '', action_name, **kwargs) + + def create_user_terminal(self, user_name, terminal_name, requester_terminal_id): + uri = self.base_url + '/user_terminals/create' + params = {'user_name': user_name, + 'name': terminal_name, + 'name_extra': requester_terminal_id} + result = self._request(uri, 'POST', params) + + def set_enable_toopher_for_user(self, user_name, enabled): + uri = self.base_url + '/users' + users = self._request(uri, 'GET') + if len(users) > 1: + raise ToopherApiException('Multiple users with name = {}'.format(user_name)) + elif not len(users): + raise ToopherApiException('No users with name = {}'.format(user_name)) + + uri = self.base_url + '/users/' + users[0]['id'] + params = {'disable_toopher_auth': bool(enabled)} + result = self._request(uri, 'POST', params) + def _request(self, uri, method, params=None): data = urllib.urlencode(params or {}) header_data = {'User-Agent':'Toopher-Python/{} (Python {})'.format(VERSION, sys.version.split()[0])} - resp, content = self.client.request(uri, method, data, headers=header_data) - if resp['status'] != '200': - try: - error_message = json.loads(content)['error_message'] - except Exception: - error_message = content - raise ToopherApiError(error_message) - + response, content = self.client.request(uri, method, data, headers=header_data) try: - result = json.loads(content) - except Exception, e: - raise ToopherApiError("Response from server could not be decoded as JSON: %s" % e) + content = json.loads(content) + except ValueError: + raise ToopherApiError('Response from server could not be decoded as JSON.') + + if int(response['status']) > 300: + self._parse_request_error(content) + + return content - return result + def _parse_request_error(self, content): + error_code = content['error_code'] + error_message = content['error_message'] + if error_code in error_codes_to_errors: + error = error_codes_to_errors[error_code] + raise error(error_message) + # TODO: Add an error code for PairingDeactivatedError. + if ('pairing has been deactivated' in error_message + or 'pairing has not been authorized' in error_message): + raise PairingDeactivatedError(error_message) + + raise ToopherApiError(error_message) class PairingStatus(object): def __init__(self, json_response): @@ -128,7 +168,3 @@ def __nonzero__(self): def __getattr__(self, name): return self._raw_data[name] - - -class ToopherApiError(Exception): pass -