Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 090afb7

Browse files
committed
Add IAM authentication support
1 parent 56795a1 commit 090afb7

File tree

4 files changed

+205
-44
lines changed

4 files changed

+205
-44
lines changed

src/cloudant/_2to3.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
# pylint: disable=wrong-import-position,no-name-in-module,import-error,unused-import
4040
from urllib import quote as url_quote, quote_plus as url_quote_plus
4141
from urlparse import urlparse as url_parse
42+
from urlparse import urljoin as url_join
4243
from ConfigParser import RawConfigParser
4344

4445
def iteritems_(adict):
@@ -60,6 +61,7 @@ def next_(itr):
6061
return itr.next()
6162
else:
6263
from urllib.parse import urlparse as url_parse # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
64+
from urllib.parse import urljoin as url_join # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6365
from urllib.parse import quote as url_quote # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6466
from urllib.parse import quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6567
from configparser import RawConfigParser # pylint: disable=wrong-import-position,no-name-in-module,import-error

src/cloudant/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ def cloudant(user, passwd, **kwargs):
6262
yield cloudant_session
6363
cloudant_session.disconnect()
6464

65+
@contextlib.contextmanager
66+
def cloudant_iam(api_key, account_name, **kwargs):
67+
"""
68+
Provides a context manager to create a Cloudant session and provide access
69+
to databases, docs etc.
70+
71+
:param api_key: IAM authentication API key.
72+
:param account_name: Cloudant account name.
73+
"""
74+
cloudant_session = Cloudant(account_name, api_key, use_iam=True, **kwargs)
75+
76+
cloudant_session.connect()
77+
yield cloudant_session
78+
cloudant_session.disconnect()
79+
6580
@contextlib.contextmanager
6681
def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
6782
"""

src/cloudant/_common_util.py

Lines changed: 157 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
throughout the library.
1818
"""
1919

20+
import os
2021
import sys
2122
import platform
2223
from collections import Sequence
2324
import json
24-
from requests import Session
25+
from requests import RequestException, Session
2526

26-
from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse
27+
from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse, \
28+
url_join
2729
from .error import CloudantArgumentError, CloudantException
2830

2931
# Library Constants
@@ -276,6 +278,7 @@ def append_response_error_content(response, **kwargs):
276278

277279
# Classes
278280

281+
279282
class _Code(str):
280283
"""
281284
Wraps a ``str`` object as a _Code object providing the means to handle
@@ -287,66 +290,187 @@ def __new__(cls, code):
287290
return str.__new__(cls, code.encode('utf8'))
288291
return str.__new__(cls, code)
289292

290-
class InfiniteSession(Session):
293+
294+
class ClientSession(Session):
291295
"""
292-
This class provides for the ability to automatically renew session login
293-
information in the event of expired session authentication.
296+
This class extends Session and provides a default timeout.
297+
"""
298+
299+
def __init__(self, **kwargs):
300+
super(ClientSession, self).__init__()
301+
self._timeout = kwargs.get('timeout', None)
302+
303+
def request(self, method, url, **kwargs): # pylint: disable=W0221
304+
"""
305+
Overrides ``requests.Session.request`` to set the timeout.
306+
"""
307+
resp = super(ClientSession, self).request(
308+
method, url, timeout=self._timeout, **kwargs)
309+
310+
return resp
311+
312+
313+
class CookieSession(ClientSession):
314+
"""
315+
This class extends ClientSession and provides cookie authentication.
294316
"""
295317

296318
def __init__(self, username, password, server_url, **kwargs):
297-
super(InfiniteSession, self).__init__()
319+
super(CookieSession, self).__init__(**kwargs)
298320
self._username = username
299321
self._password = password
300-
self._server_url = server_url
301-
self._timeout = kwargs.get('timeout', None)
322+
self._auto_renew = kwargs.get('auto_renew', False)
323+
self._session_url = url_join(server_url, '_session')
324+
325+
def info(self):
326+
"""
327+
Get cookie based login user information.
328+
"""
329+
resp = self.get(self._session_url)
330+
resp.raise_for_status()
331+
332+
return resp.json()
333+
334+
def login(self):
335+
"""
336+
Perform cookie based user login.
337+
"""
338+
resp = super(CookieSession, self).request(
339+
'POST',
340+
self._session_url,
341+
data={'name': self._username, 'password': self._password},
342+
)
343+
resp.raise_for_status()
344+
345+
def logout(self):
346+
"""
347+
Logout cookie based user.
348+
"""
349+
resp = super(CookieSession, self).request('DELETE', self._session_url)
350+
resp.raise_for_status()
302351

303352
def request(self, method, url, **kwargs): # pylint: disable=W0221
304353
"""
305-
Overrides ``requests.Session.request`` to perform a POST to the
306-
_session endpoint to renew Session cookie authentication settings and
307-
then retry the original request, if necessary.
354+
Overrides ``requests.Session.request`` to renew the cookie and then
355+
retry the original request (if required).
308356
"""
309-
resp = super(InfiniteSession, self).request(
310-
method, url, timeout=self._timeout, **kwargs)
357+
resp = super(CookieSession, self).request(method, url, **kwargs)
358+
311359
path = url_parse(url).path.lower()
312360
post_to_session = method.upper() == 'POST' and path == '/_session'
361+
362+
if not self._auto_renew or post_to_session:
363+
return resp
364+
313365
is_expired = any((
314366
resp.status_code == 403 and
315367
resp.json().get('error') == 'credentials_expired',
316368
resp.status_code == 401
317369
))
318-
if not post_to_session and is_expired:
319-
super(InfiniteSession, self).request(
320-
'POST',
321-
'/'.join([self._server_url, '_session']),
322-
data={'name': self._username, 'password': self._password},
323-
headers={'Content-Type': 'application/x-www-form-urlencoded'}
324-
)
325-
resp = super(InfiniteSession, self).request(
326-
method, url, timeout=self._timeout, **kwargs)
370+
371+
if is_expired:
372+
self.login()
373+
resp = super(CookieSession, self).request(method, url, **kwargs)
327374

328375
return resp
329376

330-
class ClientSession(Session):
377+
378+
class IAMSession(ClientSession):
331379
"""
332-
This class extends Session and provides a default timeout.
380+
This class extends ClientSession and provides IAM authentication.
333381
"""
334382

335-
def __init__(self, username, password, server_url, **kwargs):
336-
super(ClientSession, self).__init__()
337-
self._username = username
338-
self._password = password
339-
self._server_url = server_url
340-
self._timeout = kwargs.get('timeout', None)
383+
def __init__(self, api_key, server_url, **kwargs):
384+
super(IAMSession, self).__init__(**kwargs)
385+
self._api_key = api_key
386+
self._auto_renew = kwargs.get('auto_renew', False)
387+
self._session_url = url_join(server_url, '_iam_session')
388+
self._token_url = os.environ.get(
389+
'IAM_TOKEN_URL', 'https://iam.bluemix.net/oidc/token')
390+
391+
def info(self):
392+
"""
393+
Get IAM cookie based login user information.
394+
"""
395+
resp = self.get(self._session_url)
396+
resp.raise_for_status()
397+
398+
return resp.json()
399+
400+
def login(self):
401+
"""
402+
Perform IAM cookie based user login.
403+
"""
404+
access_token = self._get_access_token()
405+
try:
406+
super(IAMSession, self).request(
407+
'POST',
408+
self._session_url,
409+
headers={'Content-Type': 'application/json'},
410+
data=json.dumps({'access_token': access_token})
411+
).raise_for_status()
412+
413+
except RequestException:
414+
raise CloudantException(
415+
'Failed to exchange IAM token with Cloudant')
416+
417+
def logout(self):
418+
"""
419+
Logout IAM cookie based user.
420+
"""
421+
self.cookies.clear()
341422

342423
def request(self, method, url, **kwargs): # pylint: disable=W0221
343424
"""
344-
Overrides ``requests.Session.request`` to set the timeout.
425+
Overrides ``requests.Session.request`` to renew the IAM cookie
426+
and then retry the original request (if required).
345427
"""
346-
resp = super(ClientSession, self).request(
347-
method, url, timeout=self._timeout, **kwargs)
428+
resp = super(IAMSession, self).request(method, url, **kwargs)
429+
430+
if not self._auto_renew or url in [self._session_url, self._token_url]:
431+
return resp
432+
433+
is_expired = any((
434+
resp.status_code == 403 and
435+
resp.json().get('error') == 'credentials_expired',
436+
resp.status_code == 401
437+
))
438+
439+
if is_expired:
440+
self.login()
441+
resp = super(IAMSession, self).request(method, url, **kwargs)
442+
348443
return resp
349444

445+
def _get_access_token(self):
446+
"""
447+
Get IAM access token using API key.
448+
"""
449+
err = 'Failed to contact IAM token service'
450+
try:
451+
resp = super(IAMSession, self).request(
452+
'POST',
453+
self._token_url,
454+
auth=('bx', 'bx'), # required for user API keys
455+
headers={'Accepts': 'application/json'},
456+
data={
457+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
458+
'response_type': 'cloud_iam',
459+
'apikey': self._api_key
460+
}
461+
)
462+
err = resp.json().get('errorMessage', err)
463+
resp.raise_for_status()
464+
465+
return resp.json()['access_token']
466+
467+
except KeyError:
468+
raise CloudantException('Invalid response from IAM token service')
469+
470+
except RequestException:
471+
raise CloudantException(err)
472+
473+
350474
class CloudFoundryService(object):
351475
""" Manages Cloud Foundry service configuration. """
352476

0 commit comments

Comments
 (0)