diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 17fe3e60..f4ce1303 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -88,6 +88,30 @@ following statements hold true: connect=True, auto_renew=True) + +************************************ +Identity and Access Management (IAM) +************************************ + +IBM Cloud Identity & Access Management enables you to securely authenticate +users and control access to all cloud resources consistently in the IBM Bluemix +Cloud Platform. + +See `IBM Cloud Identity and Access Management `_ +for more information. + +The production IAM token service at *https://iam.bluemix.net/oidc/token* is used +by default. You can set an ``IAM_TOKEN_URL`` environment variable to override +this. + +You can easily connect to your Cloudant account using an IAM API key: + +.. code-block:: python + + # Authenticate using an IAM API key + client = Cloudant.iam(ACCOUNT_NAME, API_KEY, connect=True) + + **************** Resource sharing **************** diff --git a/src/cloudant/_2to3.py b/src/cloudant/_2to3.py index 2e52af9b..5c7d412b 100644 --- a/src/cloudant/_2to3.py +++ b/src/cloudant/_2to3.py @@ -1,4 +1,4 @@ -# Copyright (c) 2016 IBM. All rights reserved. +# Copyright (c) 2016, 2017 IBM. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -39,7 +39,9 @@ # pylint: disable=wrong-import-position,no-name-in-module,import-error,unused-import from urllib import quote as url_quote, quote_plus as url_quote_plus from urlparse import urlparse as url_parse + from urlparse import urljoin as url_join from ConfigParser import RawConfigParser + from cookielib import Cookie def iteritems_(adict): """ @@ -60,9 +62,11 @@ def next_(itr): return itr.next() else: from urllib.parse import urlparse as url_parse # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports + from urllib.parse import urljoin as url_join # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports from urllib.parse import quote as url_quote # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports from urllib.parse import quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports from configparser import RawConfigParser # pylint: disable=wrong-import-position,no-name-in-module,import-error + from http.cookiejar import Cookie # pylint: disable=wrong-import-position,no-name-in-module,import-error def iteritems_(adict): """ diff --git a/src/cloudant/__init__.py b/src/cloudant/__init__.py index 8cb9f75f..04131db7 100644 --- a/src/cloudant/__init__.py +++ b/src/cloudant/__init__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (c) 2015 IBM. All rights reserved. +# Copyright (c) 2015, 2017 IBM. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -62,6 +62,35 @@ def cloudant(user, passwd, **kwargs): yield cloudant_session cloudant_session.disconnect() +@contextlib.contextmanager +def cloudant_iam(account_name, api_key, **kwargs): + """ + Provides a context manager to create a Cloudant session using IAM + authentication and provide access to databases, docs etc. + + :param account_name: Cloudant account name. + :param api_key: IAM authentication API key. + + For example: + + .. code-block:: python + + # cloudant context manager + from cloudant import cloudant_iam + + with cloudant_iam(ACCOUNT_NAME, API_KEY) as client: + # Context handles connect() and disconnect() for you. + # Perform library operations within this context. Such as: + print client.all_dbs() + # ... + + """ + cloudant_session = Cloudant.iam(account_name, api_key, **kwargs) + + cloudant_session.connect() + yield cloudant_session + cloudant_session.disconnect() + @contextlib.contextmanager def cloudant_bluemix(vcap_services, instance_name=None, **kwargs): """ diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index 6dcf331d..fe2e0680 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (c) 2015, 2016, 2017 IBM Corp. All rights reserved. +# Copyright (c) 2015, 2017 IBM Corp. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,13 +17,14 @@ throughout the library. """ +import os import sys import platform from collections import Sequence import json -from requests import Session +from requests import RequestException, Session -from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse +from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_join from .error import CloudantArgumentError, CloudantException # Library Constants @@ -276,6 +277,7 @@ def append_response_error_content(response, **kwargs): # Classes + class _Code(str): """ Wraps a ``str`` object as a _Code object providing the means to handle @@ -287,66 +289,212 @@ def __new__(cls, code): return str.__new__(cls, code.encode('utf8')) return str.__new__(cls, code) -class InfiniteSession(Session): + +class ClientSession(Session): + """ + This class extends Session and provides a default timeout. + """ + + def __init__(self, **kwargs): + super(ClientSession, self).__init__() + self._timeout = kwargs.get('timeout', None) + + def request(self, method, url, **kwargs): # pylint: disable=W0221 + """ + Overrides ``requests.Session.request`` to set the timeout. + """ + resp = super(ClientSession, self).request( + method, url, timeout=self._timeout, **kwargs) + + return resp + + +class CookieSession(ClientSession): """ - This class provides for the ability to automatically renew session login - information in the event of expired session authentication. + This class extends ClientSession and provides cookie authentication. """ def __init__(self, username, password, server_url, **kwargs): - super(InfiniteSession, self).__init__() + super(CookieSession, self).__init__(**kwargs) self._username = username self._password = password - self._server_url = server_url - self._timeout = kwargs.get('timeout', None) + self._auto_renew = kwargs.get('auto_renew', False) + self._session_url = url_join(server_url, '_session') + + def info(self): + """ + Get cookie based login user information. + """ + resp = self.get(self._session_url) + resp.raise_for_status() + + return resp.json() + + def login(self): + """ + Perform cookie based user login. + """ + resp = super(CookieSession, self).request( + 'POST', + self._session_url, + data={'name': self._username, 'password': self._password}, + ) + resp.raise_for_status() + + def logout(self): + """ + Logout cookie based user. + """ + resp = super(CookieSession, self).request('DELETE', self._session_url) + resp.raise_for_status() def request(self, method, url, **kwargs): # pylint: disable=W0221 """ - Overrides ``requests.Session.request`` to perform a POST to the - _session endpoint to renew Session cookie authentication settings and - then retry the original request, if necessary. + Overrides ``requests.Session.request`` to renew the cookie and then + retry the original request (if required). """ - resp = super(InfiniteSession, self).request( - method, url, timeout=self._timeout, **kwargs) - path = url_parse(url).path.lower() - post_to_session = method.upper() == 'POST' and path == '/_session' + resp = super(CookieSession, self).request(method, url, **kwargs) + + if not self._auto_renew: + return resp + is_expired = any(( resp.status_code == 403 and resp.json().get('error') == 'credentials_expired', resp.status_code == 401 )) - if not post_to_session and is_expired: - super(InfiniteSession, self).request( - 'POST', - '/'.join([self._server_url, '_session']), - data={'name': self._username, 'password': self._password}, - headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - resp = super(InfiniteSession, self).request( - method, url, timeout=self._timeout, **kwargs) + + if is_expired: + self.login() + resp = super(CookieSession, self).request(method, url, **kwargs) return resp -class ClientSession(Session): + def set_credentials(self, username, password): + """ + Set a new username and password. + + :param str username: New username. + :param str password: New password. + """ + if username is not None: + self._username = username + + if password is not None: + self._password = password + + +class IAMSession(ClientSession): """ - This class extends Session and provides a default timeout. + This class extends ClientSession and provides IAM authentication. """ - def __init__(self, username, password, server_url, **kwargs): - super(ClientSession, self).__init__() - self._username = username - self._password = password - self._server_url = server_url - self._timeout = kwargs.get('timeout', None) + def __init__(self, api_key, server_url, **kwargs): + super(IAMSession, self).__init__(**kwargs) + self._api_key = api_key + self._auto_renew = kwargs.get('auto_renew', False) + self._session_url = url_join(server_url, '_iam_session') + self._token_url = os.environ.get( + 'IAM_TOKEN_URL', 'https://iam.bluemix.net/oidc/token') + + def info(self): + """ + Get IAM cookie based login user information. + """ + resp = self.get(self._session_url) + resp.raise_for_status() + + return resp.json() + + def login(self): + """ + Perform IAM cookie based user login. + """ + access_token = self._get_access_token() + try: + super(IAMSession, self).request( + 'POST', + self._session_url, + headers={'Content-Type': 'application/json'}, + data=json.dumps({'access_token': access_token}) + ).raise_for_status() + + except RequestException: + raise CloudantException( + 'Failed to exchange IAM token with Cloudant') + + def logout(self): + """ + Logout IAM cookie based user. + """ + self.cookies.clear() def request(self, method, url, **kwargs): # pylint: disable=W0221 """ - Overrides ``requests.Session.request`` to set the timeout. + Overrides ``requests.Session.request`` to renew the IAM cookie + and then retry the original request (if required). """ - resp = super(ClientSession, self).request( - method, url, timeout=self._timeout, **kwargs) + # The CookieJar API prevents callers from getting an individual Cookie + # object by name. + # We are forced to use the only exposed method of discarding expired + # cookies from the CookieJar. Internally this involves iterating over + # the entire CookieJar and calling `.is_expired()` on each Cookie + # object. + self.cookies.clear_expired_cookies() + + if self._auto_renew and 'IAMSession' not in self.cookies.keys(): + self.login() + + resp = super(IAMSession, self).request(method, url, **kwargs) + + if not self._auto_renew: + return resp + + if resp.status_code == 401: + self.login() + resp = super(IAMSession, self).request(method, url, **kwargs) + return resp + def set_credentials(self, username, api_key): + """ + Set a new IAM API key. + + :param str username: Username parameter is unused. + :param str api_key: New IAM API key. + """ + if api_key is not None: + self._api_key = api_key + + def _get_access_token(self): + """ + Get IAM access token using API key. + """ + err = 'Failed to contact IAM token service' + try: + resp = super(IAMSession, self).request( + 'POST', + self._token_url, + auth=('bx', 'bx'), # required for user API keys + headers={'Accepts': 'application/json'}, + data={ + 'grant_type': 'urn:ibm:params:oauth:grant-type:apikey', + 'response_type': 'cloud_iam', + 'apikey': self._api_key + } + ) + err = resp.json().get('errorMessage', err) + resp.raise_for_status() + + return resp.json()['access_token'] + + except KeyError: + raise CloudantException('Invalid response from IAM token service') + + except RequestException: + raise CloudantException(err) + + class CloudFoundryService(object): """ Manages Cloud Foundry service configuration. """ diff --git a/src/cloudant/client.py b/src/cloudant/client.py index 7c69c461..3a1360cc 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2015, 2016, 2017 IBM Corp. All rights reserved. +# Copyright (C) 2015, 2017 IBM Corp. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,10 +29,10 @@ from ._common_util import ( USER_AGENT, append_response_error_content, - InfiniteSession, ClientSession, - CloudFoundryService) - + CloudFoundryService, + CookieSession, + IAMSession) class CouchDB(dict): """ @@ -67,6 +67,10 @@ class CouchDB(dict): `Requests library timeout argument `_. but will apply to every request made using this client. + :param bool use_iam: Keyword argument, if set to True performs + IAM authentication with server. Default is False. + Use :func:`~cloudant.client.CouchDB.iam` to construct an IAM + authenticated client. """ _DATABASE_CLASS = CouchDatabase @@ -74,7 +78,6 @@ def __init__(self, user, auth_token, admin_party=False, **kwargs): super(CouchDB, self).__init__() self._user = user self._auth_token = auth_token - self._client_session = None self.server_url = kwargs.get('url') self._client_user_header = None self.admin_party = admin_party @@ -83,6 +86,7 @@ def __init__(self, user, auth_token, admin_party=False, **kwargs): self._timeout = kwargs.get('timeout', None) self.r_session = None self._auto_renew = kwargs.get('auto_renew', False) + self._use_iam = kwargs.get('use_iam', False) connect_to_couch = kwargs.get('connect', False) if connect_to_couch and self._DATABASE_CLASS == CouchDatabase: self.connect() @@ -93,29 +97,34 @@ def connect(self): authentication if necessary. """ if self.r_session: - return + self.session_logout() - if self._auto_renew and not self.admin_party: - self.r_session = InfiniteSession( - self._user, + if self.admin_party: + self.r_session = ClientSession(timeout=self._timeout) + elif self._use_iam: + self.r_session = IAMSession( self._auth_token, self.server_url, + auto_renew=self._auto_renew, timeout=self._timeout ) else: - self.r_session = ClientSession( + self.r_session = CookieSession( self._user, self._auth_token, self.server_url, + auto_renew=self._auto_renew, timeout=self._timeout ) + # If a Transport Adapter was supplied add it to the session if self.adapter is not None: self.r_session.mount(self.server_url, self.adapter) if self._client_user_header is not None: self.r_session.headers.update(self._client_user_header) - self.session_login(self._user, self._auth_token) - self._client_session = self.session() + + self.session_login() + # Utilize an event hook to append to the response message # using :func:`~cloudant.common_util.append_response_error_content` self.r_session.hooks['response'].append(append_response_error_content) @@ -124,7 +133,9 @@ def disconnect(self): """ Ends a client authentication session, performs a logout and a clean up. """ - self.session_logout() + if self.r_session: + self.session_logout() + self.r_session = None self.clear() @@ -137,11 +148,8 @@ def session(self): """ if self.admin_party: return None - sess_url = '/'.join((self.server_url, '_session')) - resp = self.r_session.get(sess_url) - resp.raise_for_status() - sess_data = resp.json() - return sess_data + + return self.r_session.info() def session_cookie(self): """ @@ -153,26 +161,16 @@ def session_cookie(self): return None return self.r_session.cookies.get('AuthSession') - def session_login(self, user, passwd): + def session_login(self, user=None, passwd=None): """ Performs a session login by posting the auth information to the _session endpoint. - - :param str user: Username used to connect. - :param str passwd: Passcode used to connect. """ if self.admin_party: return - sess_url = '/'.join((self.server_url, '_session')) - resp = self.r_session.post( - sess_url, - data={ - 'name': user, - 'password': passwd - }, - headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - resp.raise_for_status() + + self.r_session.set_credentials(user, passwd) + self.r_session.login() def session_logout(self): """ @@ -181,9 +179,8 @@ def session_logout(self): """ if self.admin_party: return - sess_url = '/'.join((self.server_url, '_session')) - resp = self.r_session.delete(sess_url) - resp.raise_for_status() + + self.r_session.logout() def basic_auth_str(self): """ @@ -783,3 +780,18 @@ def bluemix(cls, vcap_services, instance_name=None, **kwargs): service.password, url=service.url, **kwargs) + + @classmethod + def iam(cls, account_name, api_key, **kwargs): + """ + Create a Cloudant client that uses IAM authentication. + + :param account_name: Cloudant account name. + :param api_key: IAM authentication API key. + """ + return cls(None, + api_key, + account=account_name, + auto_renew=kwargs.get('auto_renew', True), + use_iam=True, + **kwargs) diff --git a/tests/unit/auth_renewal_tests.py b/tests/unit/auth_renewal_tests.py index 799783d3..3d9b7cc6 100644 --- a/tests/unit/auth_renewal_tests.py +++ b/tests/unit/auth_renewal_tests.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (c) 2016 IBM. All rights reserved. +# Copyright (c) 2016, 2017 IBM. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,14 +23,14 @@ import requests import time -from cloudant._common_util import InfiniteSession +from cloudant._common_util import CookieSession from .unit_t_db_base import UnitTestDbBase @unittest.skipIf(os.environ.get('ADMIN_PARTY') == 'true', 'Skipping - Admin Party mode') class AuthRenewalTests(UnitTestDbBase): """ - Auto renewal tests primarily testing the InfiniteSession functionality + Auto renewal tests primarily testing the CookieSession functionality """ def setUp(self): @@ -62,10 +62,10 @@ def test_client_db_doc_stack_success(self): db_2_auth_session = db_2.r_session.cookies.get('AuthSession') doc_auth_session = doc.r_session.cookies.get('AuthSession') - self.assertIsInstance(self.client.r_session, InfiniteSession) - self.assertIsInstance(db.r_session, InfiniteSession) - self.assertIsInstance(db_2.r_session, InfiniteSession) - self.assertIsInstance(doc.r_session, InfiniteSession) + self.assertIsInstance(self.client.r_session, CookieSession) + self.assertIsInstance(db.r_session, CookieSession) + self.assertIsInstance(db_2.r_session, CookieSession) + self.assertIsInstance(doc.r_session, CookieSession) self.assertIsNotNone(auth_session) self.assertTrue( auth_session == diff --git a/tests/unit/client_tests.py b/tests/unit/client_tests.py index b2dbcbf0..796e5fce 100644 --- a/tests/unit/client_tests.py +++ b/tests/unit/client_tests.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (c) 2015, 2016, 2017 IBM Corp. All rights reserved. +# Copyright (c) 2015, 2017 IBM Corp. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,13 +28,14 @@ import os import datetime -from requests import ConnectTimeout +from requests import ConnectTimeout, HTTPError +from time import sleep from cloudant import cloudant, cloudant_bluemix, couchdb, couchdb_admin_party from cloudant.client import Cloudant, CouchDB from cloudant.error import CloudantArgumentError, CloudantClientException from cloudant.feed import Feed, InfiniteFeed -from cloudant._common_util import InfiniteSession +from cloudant._common_util import CookieSession from .unit_t_db_base import UnitTestDbBase from .. import bytes_, str_ @@ -163,7 +164,7 @@ def test_multiple_connect(self): def test_auto_renew_enabled(self): """ - Test that InfiniteSession is used when auto_renew is enabled. + Test that CookieSession is used when auto_renew is enabled. """ try: self.set_up_client(auto_renew=True) @@ -171,13 +172,13 @@ def test_auto_renew_enabled(self): if os.environ.get('ADMIN_PARTY') == 'true': self.assertIsInstance(self.client.r_session, requests.Session) else: - self.assertIsInstance(self.client.r_session, InfiniteSession) + self.assertIsInstance(self.client.r_session, CookieSession) finally: self.client.disconnect() def test_auto_renew_enabled_with_auto_connect(self): """ - Test that InfiniteSession is used when auto_renew is enabled along with + Test that CookieSession is used when auto_renew is enabled along with an auto_connect. """ try: @@ -185,7 +186,7 @@ def test_auto_renew_enabled_with_auto_connect(self): if os.environ.get('ADMIN_PARTY') == 'true': self.assertIsInstance(self.client.r_session, requests.Session) else: - self.assertIsInstance(self.client.r_session, InfiniteSession) + self.assertIsInstance(self.client.r_session, CookieSession) finally: self.client.disconnect() @@ -492,6 +493,30 @@ class CloudantClientTests(UnitTestDbBase): Cloudant specific client unit tests """ + def test_cloudant_session_login(self): + """ + Test that the Cloudant client session successfully authenticates. + """ + self.client.connect() + old_cookie = self.client.session_cookie() + + sleep(5) # ensure we get a different cookie back + + self.client.session_login() + self.assertNotEqual(self.client.session_cookie(), old_cookie) + + def test_cloudant_session_login_with_new_credentials(self): + """ + Test that the Cloudant client session fails to authenticate when + passed incorrect credentials. + """ + self.client.connect() + + with self.assertRaises(HTTPError) as cm: + self.client.session_login('invalid-user-123', 'pa$$w0rd01') + + self.assertTrue(str(cm.exception).find('Name or password is incorrect')) + def test_cloudant_context_helper(self): """ Test that the cloudant context helper works as expected. diff --git a/tests/unit/iam_auth_tests.py b/tests/unit/iam_auth_tests.py new file mode 100644 index 00000000..a65d3f04 --- /dev/null +++ b/tests/unit/iam_auth_tests.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python +# Copyright (c) 2017 IBM. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" Unit tests for IAM authentication. """ +import time +import unittest +import json +import mock + +from cloudant._2to3 import Cookie +from cloudant._common_util import IAMSession +from cloudant.client import Cloudant + +MOCK_API_KEY = 'CqbrIYzdO3btWV-5t4teJLY_etfT_dkccq-vO-5vCXSo' + +MOCK_ACCESS_TOKEN = ('eyJraWQiOiIyMDE3MDQwMi0wMDowMDowMCIsImFsZyI6IlJTMjU2In0.e' + 'yJpYW1faWQiOiJJQk1pZC0yNzAwMDdHRjBEIiwiaWQiOiJJQk1pZC0yNz' + 'AwMDdHRjBEIiwicmVhbG1pZCI6IklCTWlkIiwiaWRlbnRpZmllciI6IjI' + '3MDAwN0dGMEQiLCJnaXZlbl9uYW1lIjoiVG9tIiwiZmFtaWx5X25hbWUi' + 'OiJCbGVuY2giLCJuYW1lIjoiVG9tIEJsZW5jaCIsImVtYWlsIjoidGJsZ' + 'W5jaEB1ay5pYm0uY29tIiwic3ViIjoidGJsZW5jaEB1ay5pYm0uY29tIi' + 'wiYWNjb3VudCI6eyJic3MiOiI1ZTM1ZTZhMjlmYjJlZWNhNDAwYWU0YzN' + 'lMWZhY2Y2MSJ9LCJpYXQiOjE1MDA0NjcxMDIsImV4cCI6MTUwMDQ3MDcw' + 'MiwiaXNzIjoiaHR0cHM6Ly9pYW0ubmcuYmx1ZW1peC5uZXQvb2lkYy90b' + '2tlbiIsImdyYW50X3R5cGUiOiJ1cm46aWJtOnBhcmFtczpvYXV0aDpncm' + 'FudC10eXBlOmFwaWtleSIsInNjb3BlIjoib3BlbmlkIiwiY2xpZW50X2l' + 'kIjoiZGVmYXVsdCJ9.XAPdb5K4n2nYih-JWTWBGoKkxTXM31c1BB1g-Ci' + 'auc2LxuoNXVTyz_mNqf1zQL07FUde1Cb_dwrbotjickNcxVPost6byQzt' + 'fc0mRF1x2S6VR8tn7SGiRmXBjLofkTh1JQq-jutp2MS315XbTG6K6m16u' + 'YzL9qfMnRvQHxsZWErzfPiJx-Trg_j7OX-qNFjdNUGnRpU7FmULy0r7Rx' + 'Ld8mhG-M1yxVzRBAZzvM63s0XXfMnk1oLi-BuUUTqVOdrM0KyYMWfD0Q7' + '2PTo4Exa17V-R_73Nq8VPCwpOvZcwKRA2sPTVgTMzU34max8b5kpTzVGJ' + '6SXSItTVOUdAygZBng') + +MOCK_OIDC_TOKEN_RESPONSE = { + 'access_token': MOCK_ACCESS_TOKEN, + 'refresh_token': ('MO61FKNvVRWkSa4vmBZqYv_Jt1kkGMUc-XzTcNnR-GnIhVKXHUWxJVV3' + 'RddE8Kqh3X_TZRmyK8UySIWKxoJ2t6obUSUalPm90SBpTdoXtaljpNyo' + 'rmqCCYPROnk6JBym72ikSJqKHHEZVQkT0B5ggZCwPMnKagFj0ufs-VIh' + 'CF97xhDxDKcIPMWG02xxPuESaSTJJug7e_dUDoak_ZXm9xxBmOTRKwOx' + 'n5sTKthNyvVpEYPE7jIHeiRdVDOWhN5LomgCn3TqFCLpMErnqwgNYbyC' + 'Bd9rNm-alYKDb6Jle4njuIBpXxQPb4euDwLd1osApaSME3nEarFWqRBz' + 'hjoqCe1Kv564s_rY7qzD1nHGvKOdpSa0ZkMcfJ0LbXSQPs7gBTSVrBFZ' + 'qwlg-2F-U3Cto62-9qRR_cEu_K9ZyVwL4jWgOlngKmxV6Ku4L5mHp4Kg' + 'EJSnY_78_V2nm64E--i2ZA1FhiKwIVHDOivVNhggE9oabxg54vd63glp' + '4GfpNnmZsMOUYG9blJJpH4fDX4Ifjbw-iNBD7S2LRpP8b8vG9pb4WioG' + 'zN43lE5CysveKYWrQEZpThznxXlw1snDu_A48JiL3Lrvo1LobLhF3zFV' + '-kQ='), + 'token_type': 'Bearer', + 'expires_in': 3600, # 60mins + 'expiration': 1500470702 # Wed Jul 19 14:25:02 2017 +} + + +class IAMAuthTests(unittest.TestCase): + """ Unit tests for IAM authentication. """ + + @staticmethod + def _mock_cookie(expires_secs=300): + return Cookie( + version=0, + name='IAMSession', + value=('SQJCaUQxMqEfMEAyRKU6UopLVXceS0c9RPuQgDArCEYoN3l_TEY4gdf-DJ7' + '4sHfjcNEUVjfdOvA'), + port=None, + port_specified=False, + domain='localhost', + domain_specified=False, + domain_initial_dot=False, + path="/", + path_specified=True, + secure=True, + expires=int(time.time() + expires_secs), + discard=False, + comment=None, + comment_url=None, + rest={'HttpOnly': None}, + rfc2109=True) + + def test_iam_set_credentials(self): + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984') + self.assertEquals(iam._api_key, MOCK_API_KEY) + + new_api_key = 'some_new_api_key' + iam.set_credentials(None, new_api_key) + + self.assertEquals(iam._api_key, new_api_key) + + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_get_access_token(self, m_req): + m_response = mock.MagicMock() + m_response.json.return_value = MOCK_OIDC_TOKEN_RESPONSE + m_req.return_value = m_response + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984') + access_token = iam._get_access_token() + + m_req.assert_called_once_with( + 'POST', + iam._token_url, + auth=('bx', 'bx'), + headers={'Accepts': 'application/json'}, + data={ + 'grant_type': 'urn:ibm:params:oauth:grant-type:apikey', + 'response_type': 'cloud_iam', + 'apikey': MOCK_API_KEY + } + ) + + self.assertEqual(access_token, MOCK_ACCESS_TOKEN) + self.assertTrue(m_response.raise_for_status.called) + self.assertTrue(m_response.json.called) + + @mock.patch('cloudant._common_util.ClientSession.request') + @mock.patch('cloudant._common_util.IAMSession._get_access_token') + def test_iam_login(self, m_token, m_req): + m_token.return_value = MOCK_ACCESS_TOKEN + m_response = mock.MagicMock() + m_req.return_value = m_response + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984') + iam.login() + + m_req.assert_called_once_with( + 'POST', + iam._session_url, + headers={'Content-Type': 'application/json'}, + data=json.dumps({'access_token': MOCK_ACCESS_TOKEN}) + ) + + self.assertEqual(m_token.call_count, 1) + self.assertTrue(m_response.raise_for_status.called) + + def test_iam_logout(self): + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984') + # add a valid cookie to jar + iam.cookies.set_cookie(self._mock_cookie()) + self.assertEqual(len(iam.cookies.keys()), 1) + iam.logout() + self.assertEqual(len(iam.cookies.keys()), 0) + + @mock.patch('cloudant._common_util.ClientSession.get') + def test_iam_get_session_info(self, m_get): + m_info = {'ok': True, 'info': {'authentication_db': '_users'}} + + m_response = mock.MagicMock() + m_response.json.return_value = m_info + m_get.return_value = m_response + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984') + info = iam.info() + + m_get.assert_called_once_with(iam._session_url) + + self.assertEqual(info, m_info) + self.assertTrue(m_response.raise_for_status.called) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_first_request(self, m_req, m_login): + # mock 200 + m_response_ok = mock.MagicMock() + type(m_response_ok).status_code = mock.PropertyMock(return_value=200) + m_response_ok.json.return_value = {'ok': True} + + m_req.return_value = m_response_ok + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984', auto_renew=True) + iam.login() + + self.assertEqual(m_login.call_count, 1) + self.assertEqual(m_req.call_count, 0) + + # add a valid cookie to jar + iam.cookies.set_cookie(self._mock_cookie()) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + + self.assertEqual(m_login.call_count, 1) + self.assertEqual(m_req.call_count, 1) + self.assertEqual(resp.status_code, 200) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_renew_cookie_on_expiry(self, m_req, m_login): + # mock 200 + m_response_ok = mock.MagicMock() + type(m_response_ok).status_code = mock.PropertyMock(return_value=200) + m_response_ok.json.return_value = {'ok': True} + + m_req.return_value = m_response_ok + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984', auto_renew=True) + iam.login() + + # add an expired cookie to jar + iam.cookies.set_cookie(self._mock_cookie(expires_secs=-300)) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_req.call_count, 1) + self.assertEqual(resp.status_code, 200) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_renew_cookie_on_401_success(self, m_req, m_login): + # mock 200 + m_response_ok = mock.MagicMock() + type(m_response_ok).status_code = mock.PropertyMock(return_value=200) + m_response_ok.json.return_value = {'ok': True} + # mock 401 + m_response_bad = mock.MagicMock() + type(m_response_bad).status_code = mock.PropertyMock(return_value=401) + + m_req.side_effect = [m_response_bad, m_response_ok, m_response_ok] + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984', auto_renew=True) + iam.login() + self.assertEqual(m_login.call_count, 1) + + # add a valid cookie to jar + iam.cookies.set_cookie(self._mock_cookie()) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 200) + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_req.call_count, 2) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 200) + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_req.call_count, 3) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_renew_cookie_on_401_failure(self, m_req, m_login): + # mock 401 + m_response_bad = mock.MagicMock() + type(m_response_bad).status_code = mock.PropertyMock(return_value=401) + + m_req.return_value = m_response_bad + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984', auto_renew=True) + iam.login() + self.assertEqual(m_login.call_count, 1) + + # add a valid cookie to jar + iam.cookies.set_cookie(self._mock_cookie()) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 401) + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_req.call_count, 2) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 401) + self.assertEqual(m_login.call_count, 3) + self.assertEqual(m_req.call_count, 4) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_renew_cookie_disabled(self, m_req, m_login): + # mock 401 + m_response_bad = mock.MagicMock() + type(m_response_bad).status_code = mock.PropertyMock(return_value=401) + + m_req.return_value = m_response_bad + + iam = IAMSession(MOCK_API_KEY, 'http://127.0.0.1:5984', auto_renew=False) + iam.login() + self.assertEqual(m_login.call_count, 1) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 401) + self.assertEqual(m_login.call_count, 1) # no attempt to renew + self.assertEqual(m_req.call_count, 1) + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + self.assertEqual(resp.status_code, 401) + self.assertEqual(m_login.call_count, 1) # no attempt to renew + self.assertEqual(m_req.call_count, 2) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.ClientSession.request') + def test_iam_client_create(self, m_req, m_login): + # mock 200 + m_response_ok = mock.MagicMock() + type(m_response_ok).status_code = mock.PropertyMock(return_value=200) + m_response_ok.json.return_value = ['animaldb'] + + m_req.return_value = m_response_ok + + # create IAM client + client = Cloudant.iam('foo', MOCK_API_KEY) + client.connect() + + # add a valid cookie to jar + client.r_session.cookies.set_cookie(self._mock_cookie()) + + dbs = client.all_dbs() + + self.assertEqual(m_login.call_count, 1) + self.assertEqual(m_req.call_count, 1) + self.assertEqual(dbs, ['animaldb']) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.IAMSession.set_credentials') + def test_iam_client_session_login(self, m_set, m_login): + # create IAM client + client = Cloudant.iam('foo', MOCK_API_KEY) + client.connect() + + # add a valid cookie to jar + client.r_session.cookies.set_cookie(self._mock_cookie()) + + client.session_login() + + m_set.assert_called_with(None, None) + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_set.call_count, 2) + + @mock.patch('cloudant._common_util.IAMSession.login') + @mock.patch('cloudant._common_util.IAMSession.set_credentials') + def test_iam_client_session_login_with_new_credentials(self, m_set, m_login): + # create IAM client + client = Cloudant.iam('foo', MOCK_API_KEY) + client.connect() + + # add a valid cookie to jar + client.r_session.cookies.set_cookie(self._mock_cookie()) + + client.session_login('bar', 'baz') # new creds + + m_set.assert_called_with('bar', 'baz') + self.assertEqual(m_login.call_count, 2) + self.assertEqual(m_set.call_count, 2) + + +if __name__ == '__main__': + unittest.main()