From 090afb735223c0fed4b29c6428385b2de35cb034 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Mon, 26 Jun 2017 17:48:39 +0100 Subject: [PATCH 01/14] Add IAM authentication support --- src/cloudant/_2to3.py | 2 + src/cloudant/__init__.py | 15 +++ src/cloudant/_common_util.py | 190 +++++++++++++++++++++++++++++------ src/cloudant/client.py | 42 ++++++-- 4 files changed, 205 insertions(+), 44 deletions(-) diff --git a/src/cloudant/_2to3.py b/src/cloudant/_2to3.py index 2e52af9b..26f995ac 100644 --- a/src/cloudant/_2to3.py +++ b/src/cloudant/_2to3.py @@ -39,6 +39,7 @@ # 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 def iteritems_(adict): @@ -60,6 +61,7 @@ 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 diff --git a/src/cloudant/__init__.py b/src/cloudant/__init__.py index 8cb9f75f..b2a06483 100644 --- a/src/cloudant/__init__.py +++ b/src/cloudant/__init__.py @@ -62,6 +62,21 @@ def cloudant(user, passwd, **kwargs): yield cloudant_session cloudant_session.disconnect() +@contextlib.contextmanager +def cloudant_iam(api_key, account_name, **kwargs): + """ + Provides a context manager to create a Cloudant session and provide access + to databases, docs etc. + + :param api_key: IAM authentication API key. + :param account_name: Cloudant account name. + """ + cloudant_session = Cloudant(account_name, api_key, use_iam=True, **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..0a2344ce 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -17,13 +17,15 @@ 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_parse, \ + url_join from .error import CloudantArgumentError, CloudantException # Library Constants @@ -276,6 +278,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 +290,187 @@ def __new__(cls, code): return str.__new__(cls, code.encode('utf8')) return str.__new__(cls, code) -class InfiniteSession(Session): + +class ClientSession(Session): """ - This class provides for the ability to automatically renew session login - information in the event of expired session authentication. + 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 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) + resp = super(CookieSession, self).request(method, url, **kwargs) + path = url_parse(url).path.lower() post_to_session = method.upper() == 'POST' and path == '/_session' + + if not self._auto_renew or post_to_session: + 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): + +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) + resp = super(IAMSession, self).request(method, url, **kwargs) + + if not self._auto_renew or url in [self._session_url, self._token_url]: + return resp + + is_expired = any(( + resp.status_code == 403 and + resp.json().get('error') == 'credentials_expired', + resp.status_code == 401 + )) + + if is_expired: + self.login() + resp = super(IAMSession, self).request(method, url, **kwargs) + return resp + 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..3bb95c51 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -31,8 +31,9 @@ append_response_error_content, InfiniteSession, ClientSession, - CloudFoundryService) - + CloudFoundryService, + CookieSession, + IAMSession) class CouchDB(dict): """ @@ -83,6 +84,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() @@ -95,26 +97,32 @@ def connect(self): if self.r_session: return - 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.session_login() + self._client_session = self.session() # Utilize an event hook to append to the response message # using :func:`~cloudant.common_util.append_response_error_content` @@ -137,11 +145,16 @@ def session(self): """ if self.admin_party: return None +<<<<<<< HEAD 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() +>>>>>>> Add IAM authentication support def session_cookie(self): """ @@ -153,16 +166,14 @@ def session_cookie(self): return None return self.r_session.cookies.get('AuthSession') - def session_login(self, user, passwd): + def session_login(self): """ 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 +<<<<<<< HEAD sess_url = '/'.join((self.server_url, '_session')) resp = self.r_session.post( sess_url, @@ -173,6 +184,10 @@ def session_login(self, user, passwd): headers={'Content-Type': 'application/x-www-form-urlencoded'} ) resp.raise_for_status() +======= + + self.r_session.login() +>>>>>>> Add IAM authentication support def session_logout(self): """ @@ -181,9 +196,14 @@ def session_logout(self): """ if self.admin_party: return +<<<<<<< HEAD sess_url = '/'.join((self.server_url, '_session')) resp = self.r_session.delete(sess_url) resp.raise_for_status() +======= + + self.r_session.logout() +>>>>>>> Add IAM authentication support def basic_auth_str(self): """ From cdba134c853b416bd1d9afb9fd2edea7a8a31b16 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 27 Jun 2017 10:11:20 +0100 Subject: [PATCH 02/14] Rename InfiniteSession -> CookieSession in unit tests --- tests/unit/auth_renewal_tests.py | 12 ++++++------ tests/unit/client_tests.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/unit/auth_renewal_tests.py b/tests/unit/auth_renewal_tests.py index 799783d3..4fdf0a6c 100644 --- a/tests/unit/auth_renewal_tests.py +++ b/tests/unit/auth_renewal_tests.py @@ -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..15a12777 100644 --- a/tests/unit/client_tests.py +++ b/tests/unit/client_tests.py @@ -34,7 +34,7 @@ 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 +163,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 +171,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 +185,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() From c828b857c310fcf046ff9517d0544587c021231c Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 27 Jun 2017 14:20:29 +0100 Subject: [PATCH 03/14] Add IAM authentication tests --- src/cloudant/_2to3.py | 2 + tests/unit/iam_auth_tests.py | 334 +++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 tests/unit/iam_auth_tests.py diff --git a/src/cloudant/_2to3.py b/src/cloudant/_2to3.py index 26f995ac..6cbd79a3 100644 --- a/src/cloudant/_2to3.py +++ b/src/cloudant/_2to3.py @@ -41,6 +41,7 @@ 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): """ @@ -65,6 +66,7 @@ def next_(itr): 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/tests/unit/iam_auth_tests.py b/tests/unit/iam_auth_tests.py new file mode 100644 index 00000000..e2d79e48 --- /dev/null +++ b/tests/unit/iam_auth_tests.py @@ -0,0 +1,334 @@ +#!/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) + + @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_403(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 403 + m_response_bad = mock.MagicMock() + type(m_response_bad).status_code = mock.PropertyMock(return_value=403) + m_response_bad.json.return_value = {'error': 'credentials_expired'} + + m_req.side_effect = [m_response_bad, m_response_ok] + + iam = IAMSession('foo', 'http://127.0.0.1:5984', auto_renew=True) + iam.login() + + resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') + + self.assertEqual(m_login.call_count, 2) + self.assertTrue(resp.json()['ok']) + + @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']) + + +if __name__ == '__main__': + unittest.main() From 0f6176ba2167278320c9841088b31a7d9a74d242 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Wed, 28 Jun 2017 12:59:33 +0100 Subject: [PATCH 04/14] Add IAM notes to getting_started.rst --- docs/getting_started.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 17fe3e60..fc5d10be 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -88,6 +88,26 @@ 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. + +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 **************** From 275057a296323cdb24e8e702f34ba17f8f6efa1f Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Wed, 28 Jun 2017 12:59:13 +0100 Subject: [PATCH 05/14] Allow multiple calls to client .connect() --- src/cloudant/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cloudant/client.py b/src/cloudant/client.py index 3bb95c51..02dc0837 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -95,7 +95,7 @@ def connect(self): authentication if necessary. """ if self.r_session: - return + self.session_logout() if self.admin_party: self.r_session = ClientSession(timeout=self._timeout) @@ -132,7 +132,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() From 59ca68f3152e5ed75789861f9d29051e7158aff8 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Thu, 20 Jul 2017 15:29:30 +0100 Subject: [PATCH 06/14] Renew IAM token on 401 status code or cookie expiry --- src/cloudant/_common_util.py | 12 +++++------- tests/unit/iam_auth_tests.py | 23 ----------------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index 0a2344ce..5ebec71f 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -425,18 +425,16 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 Overrides ``requests.Session.request`` to renew the IAM cookie and then retry the original request (if required). """ + 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 or url in [self._session_url, self._token_url]: return resp - is_expired = any(( - resp.status_code == 403 and - resp.json().get('error') == 'credentials_expired', - resp.status_code == 401 - )) - - if is_expired: + if resp.status_code == 401: self.login() resp = super(IAMSession, self).request(method, url, **kwargs) diff --git a/tests/unit/iam_auth_tests.py b/tests/unit/iam_auth_tests.py index e2d79e48..fd181dad 100644 --- a/tests/unit/iam_auth_tests.py +++ b/tests/unit/iam_auth_tests.py @@ -234,29 +234,6 @@ def test_iam_renew_cookie_on_401_success(self, m_req, m_login): 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_403(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 403 - m_response_bad = mock.MagicMock() - type(m_response_bad).status_code = mock.PropertyMock(return_value=403) - m_response_bad.json.return_value = {'error': 'credentials_expired'} - - m_req.side_effect = [m_response_bad, m_response_ok] - - iam = IAMSession('foo', 'http://127.0.0.1:5984', auto_renew=True) - iam.login() - - resp = iam.request('GET', 'http://127.0.0.1:5984/mydb1') - - self.assertEqual(m_login.call_count, 2) - self.assertTrue(resp.json()['ok']) - @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): From 6b85dc31815a713bd5bd186b1d7fa327caff5ca0 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Thu, 20 Jul 2017 16:36:12 +0100 Subject: [PATCH 07/14] Add IAM class method to Cloudant class --- src/cloudant/__init__.py | 24 ++++++++++++++++++----- src/cloudant/client.py | 42 ++++++++++++++-------------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/cloudant/__init__.py b/src/cloudant/__init__.py index b2a06483..75c22413 100644 --- a/src/cloudant/__init__.py +++ b/src/cloudant/__init__.py @@ -63,15 +63,29 @@ def cloudant(user, passwd, **kwargs): cloudant_session.disconnect() @contextlib.contextmanager -def cloudant_iam(api_key, account_name, **kwargs): +def cloudant_iam(account_name, api_key, **kwargs): """ - Provides a context manager to create a Cloudant session and provide access - to databases, docs etc. + Provides a context manager to create a Cloudant session using IAM + authentication and provide access to databases, docs etc. - :param api_key: IAM authentication API key. :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(account_name, api_key, use_iam=True, **kwargs) + cloudant_session = Cloudant.iam(account_name, api_key, **kwargs) cloudant_session.connect() yield cloudant_session diff --git a/src/cloudant/client.py b/src/cloudant/client.py index 02dc0837..7e39a303 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -29,7 +29,6 @@ from ._common_util import ( USER_AGENT, append_response_error_content, - InfiniteSession, ClientSession, CloudFoundryService, CookieSession, @@ -68,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 @@ -147,16 +150,8 @@ def session(self): """ if self.admin_party: return None -<<<<<<< HEAD - 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() ->>>>>>> Add IAM authentication support def session_cookie(self): """ @@ -175,21 +170,8 @@ def session_login(self): """ if self.admin_party: return -<<<<<<< HEAD - 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.login() ->>>>>>> Add IAM authentication support def session_logout(self): """ @@ -198,14 +180,8 @@ def session_logout(self): """ if self.admin_party: return -<<<<<<< HEAD - sess_url = '/'.join((self.server_url, '_session')) - resp = self.r_session.delete(sess_url) - resp.raise_for_status() -======= self.r_session.logout() ->>>>>>> Add IAM authentication support def basic_auth_str(self): """ @@ -805,3 +781,13 @@ 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, use_iam=True, **kwargs) From c115f9c0c7a85570ddc8132d55ba31965d862848 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Thu, 20 Jul 2017 16:50:55 +0100 Subject: [PATCH 08/14] Always use auto_renew=True by default for IAM sessions --- src/cloudant/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cloudant/client.py b/src/cloudant/client.py index 7e39a303..f05d3af1 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -790,4 +790,9 @@ def iam(cls, account_name, api_key, **kwargs): :param account_name: Cloudant account name. :param api_key: IAM authentication API key. """ - return cls(None, api_key, account=account_name, use_iam=True, **kwargs) + return cls(None, + api_key, + account=account_name, + auto_renew=kwargs.get('auto_renew', True), + use_iam=True, + **kwargs) From 54d8fbef04689086cbc3e42a46dc8094b39bc181 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Fri, 21 Jul 2017 14:57:01 +0100 Subject: [PATCH 09/14] Remove session endpoint checks These checks are redundant. Session endpoint requests are always made using the base request method. --- src/cloudant/_common_util.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index 5ebec71f..a7f2bddc 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -24,8 +24,7 @@ import json from requests import RequestException, Session -from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse, \ - url_join +from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_join from .error import CloudantArgumentError, CloudantException # Library Constants @@ -356,10 +355,7 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 """ resp = super(CookieSession, self).request(method, url, **kwargs) - path = url_parse(url).path.lower() - post_to_session = method.upper() == 'POST' and path == '/_session' - - if not self._auto_renew or post_to_session: + if not self._auto_renew: return resp is_expired = any(( @@ -431,7 +427,7 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 resp = super(IAMSession, self).request(method, url, **kwargs) - if not self._auto_renew or url in [self._session_url, self._token_url]: + if not self._auto_renew: return resp if resp.status_code == 401: From 5f4935a65937eb42a781c3b21fa4b2282c725845 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Wed, 26 Jul 2017 09:55:06 +0100 Subject: [PATCH 10/14] Remove unused CouchDB._client_session --- src/cloudant/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cloudant/client.py b/src/cloudant/client.py index f05d3af1..3e52f338 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -78,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 @@ -126,7 +125,6 @@ def connect(self): self.session_login() - self._client_session = self.session() # 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) From 550b00b6c82b404533320fd14930e443088cb5a2 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Wed, 26 Jul 2017 10:32:29 +0100 Subject: [PATCH 11/14] Update year in copyright headers --- src/cloudant/_2to3.py | 2 +- src/cloudant/__init__.py | 2 +- src/cloudant/_common_util.py | 2 +- src/cloudant/client.py | 2 +- tests/unit/auth_renewal_tests.py | 2 +- tests/unit/client_tests.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cloudant/_2to3.py b/src/cloudant/_2to3.py index 6cbd79a3..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. diff --git a/src/cloudant/__init__.py b/src/cloudant/__init__.py index 75c22413..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. diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index a7f2bddc..e22e458b 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. diff --git a/src/cloudant/client.py b/src/cloudant/client.py index 3e52f338..f53d6c75 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. diff --git a/tests/unit/auth_renewal_tests.py b/tests/unit/auth_renewal_tests.py index 4fdf0a6c..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. diff --git a/tests/unit/client_tests.py b/tests/unit/client_tests.py index 15a12777..33171417 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. From fdb08de75002aa3acc72f31ae6ae01928fc5521a Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Fri, 28 Jul 2017 15:10:02 +0100 Subject: [PATCH 12/14] Add IAM_TOKEN_URL env var note to docs/getting_started.rst --- docs/getting_started.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index fc5d10be..f4ce1303 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -100,6 +100,10 @@ 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 From fcbe6301f87e0687055076445498898fcbb89e85 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 8 Aug 2017 10:20:01 +0100 Subject: [PATCH 13/14] Add code comment about discarding expired IAMSession cookies --- src/cloudant/_common_util.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index e22e458b..dce99494 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -421,7 +421,14 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 Overrides ``requests.Session.request`` to renew the IAM cookie and then retry the original request (if required). """ + # 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() From 1a905fb6057ba34927104f418c321a415f464746 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 29 Aug 2017 17:14:46 +0100 Subject: [PATCH 14/14] Allow `CouchDB.session_login` to take credentials as arguments --- src/cloudant/_common_util.py | 23 ++++++++++++++++++++ src/cloudant/client.py | 3 ++- tests/unit/client_tests.py | 27 +++++++++++++++++++++++- tests/unit/iam_auth_tests.py | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py index dce99494..fe2e0680 100644 --- a/src/cloudant/_common_util.py +++ b/src/cloudant/_common_util.py @@ -370,6 +370,19 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 return resp + 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): """ @@ -443,6 +456,16 @@ def request(self, method, url, **kwargs): # pylint: disable=W0221 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. diff --git a/src/cloudant/client.py b/src/cloudant/client.py index f53d6c75..3a1360cc 100755 --- a/src/cloudant/client.py +++ b/src/cloudant/client.py @@ -161,7 +161,7 @@ def session_cookie(self): return None return self.r_session.cookies.get('AuthSession') - def session_login(self): + def session_login(self, user=None, passwd=None): """ Performs a session login by posting the auth information to the _session endpoint. @@ -169,6 +169,7 @@ def session_login(self): if self.admin_party: return + self.r_session.set_credentials(user, passwd) self.r_session.login() def session_logout(self): diff --git a/tests/unit/client_tests.py b/tests/unit/client_tests.py index 33171417..796e5fce 100644 --- a/tests/unit/client_tests.py +++ b/tests/unit/client_tests.py @@ -28,7 +28,8 @@ 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 @@ -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 index fd181dad..a65d3f04 100644 --- a/tests/unit/iam_auth_tests.py +++ b/tests/unit/iam_auth_tests.py @@ -88,6 +88,15 @@ def _mock_cookie(expires_secs=300): 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() @@ -306,6 +315,38 @@ def test_iam_client_create(self, m_req, m_login): 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()