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

Commit 73cb8b1

Browse files
authored
Merge pull request #306 from cloudant/303-support-iam-auth
303 support iam auth
2 parents 56795a1 + 1a905fb commit 73cb8b1

File tree

8 files changed

+681
-87
lines changed

8 files changed

+681
-87
lines changed

docs/getting_started.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ following statements hold true:
8888
connect=True,
8989
auto_renew=True)
9090
91+
92+
************************************
93+
Identity and Access Management (IAM)
94+
************************************
95+
96+
IBM Cloud Identity & Access Management enables you to securely authenticate
97+
users and control access to all cloud resources consistently in the IBM Bluemix
98+
Cloud Platform.
99+
100+
See `IBM Cloud Identity and Access Management <https://console.bluemix.net/docs/services/Cloudant/guides/iam.html#ibm-cloud-identity-and-access-management>`_
101+
for more information.
102+
103+
The production IAM token service at *https://iam.bluemix.net/oidc/token* is used
104+
by default. You can set an ``IAM_TOKEN_URL`` environment variable to override
105+
this.
106+
107+
You can easily connect to your Cloudant account using an IAM API key:
108+
109+
.. code-block:: python
110+
111+
# Authenticate using an IAM API key
112+
client = Cloudant.iam(ACCOUNT_NAME, API_KEY, connect=True)
113+
114+
91115
****************
92116
Resource sharing
93117
****************

src/cloudant/_2to3.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2016 IBM. All rights reserved.
1+
# Copyright (c) 2016, 2017 IBM. All rights reserved.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -39,7 +39,9 @@
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
44+
from cookielib import Cookie
4345

4446
def iteritems_(adict):
4547
"""
@@ -60,9 +62,11 @@ def next_(itr):
6062
return itr.next()
6163
else:
6264
from urllib.parse import urlparse as url_parse # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
65+
from urllib.parse import urljoin as url_join # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6366
from urllib.parse import quote as url_quote # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6467
from urllib.parse import quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
6568
from configparser import RawConfigParser # pylint: disable=wrong-import-position,no-name-in-module,import-error
69+
from http.cookiejar import Cookie # pylint: disable=wrong-import-position,no-name-in-module,import-error
6670

6771
def iteritems_(adict):
6872
"""

src/cloudant/__init__.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python
2-
# Copyright (c) 2015 IBM. All rights reserved.
2+
# Copyright (c) 2015, 2017 IBM. All rights reserved.
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -62,6 +62,35 @@ def cloudant(user, passwd, **kwargs):
6262
yield cloudant_session
6363
cloudant_session.disconnect()
6464

65+
@contextlib.contextmanager
66+
def cloudant_iam(account_name, api_key, **kwargs):
67+
"""
68+
Provides a context manager to create a Cloudant session using IAM
69+
authentication and provide access to databases, docs etc.
70+
71+
:param account_name: Cloudant account name.
72+
:param api_key: IAM authentication API key.
73+
74+
For example:
75+
76+
.. code-block:: python
77+
78+
# cloudant context manager
79+
from cloudant import cloudant_iam
80+
81+
with cloudant_iam(ACCOUNT_NAME, API_KEY) as client:
82+
# Context handles connect() and disconnect() for you.
83+
# Perform library operations within this context. Such as:
84+
print client.all_dbs()
85+
# ...
86+
87+
"""
88+
cloudant_session = Cloudant.iam(account_name, api_key, **kwargs)
89+
90+
cloudant_session.connect()
91+
yield cloudant_session
92+
cloudant_session.disconnect()
93+
6594
@contextlib.contextmanager
6695
def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
6796
"""

src/cloudant/_common_util.py

Lines changed: 184 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python
2-
# Copyright (c) 2015, 2016, 2017 IBM Corp. All rights reserved.
2+
# Copyright (c) 2015, 2017 IBM Corp. All rights reserved.
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -17,13 +17,14 @@
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_join
2728
from .error import CloudantArgumentError, CloudantException
2829

2930
# Library Constants
@@ -276,6 +277,7 @@ def append_response_error_content(response, **kwargs):
276277

277278
# Classes
278279

280+
279281
class _Code(str):
280282
"""
281283
Wraps a ``str`` object as a _Code object providing the means to handle
@@ -287,66 +289,212 @@ def __new__(cls, code):
287289
return str.__new__(cls, code.encode('utf8'))
288290
return str.__new__(cls, code)
289291

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

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

303351
def request(self, method, url, **kwargs): # pylint: disable=W0221
304352
"""
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.
353+
Overrides ``requests.Session.request`` to renew the cookie and then
354+
retry the original request (if required).
308355
"""
309-
resp = super(InfiniteSession, self).request(
310-
method, url, timeout=self._timeout, **kwargs)
311-
path = url_parse(url).path.lower()
312-
post_to_session = method.upper() == 'POST' and path == '/_session'
356+
resp = super(CookieSession, self).request(method, url, **kwargs)
357+
358+
if not self._auto_renew:
359+
return resp
360+
313361
is_expired = any((
314362
resp.status_code == 403 and
315363
resp.json().get('error') == 'credentials_expired',
316364
resp.status_code == 401
317365
))
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)
366+
367+
if is_expired:
368+
self.login()
369+
resp = super(CookieSession, self).request(method, url, **kwargs)
327370

328371
return resp
329372

330-
class ClientSession(Session):
373+
def set_credentials(self, username, password):
374+
"""
375+
Set a new username and password.
376+
377+
:param str username: New username.
378+
:param str password: New password.
379+
"""
380+
if username is not None:
381+
self._username = username
382+
383+
if password is not None:
384+
self._password = password
385+
386+
387+
class IAMSession(ClientSession):
331388
"""
332-
This class extends Session and provides a default timeout.
389+
This class extends ClientSession and provides IAM authentication.
333390
"""
334391

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)
392+
def __init__(self, api_key, server_url, **kwargs):
393+
super(IAMSession, self).__init__(**kwargs)
394+
self._api_key = api_key
395+
self._auto_renew = kwargs.get('auto_renew', False)
396+
self._session_url = url_join(server_url, '_iam_session')
397+
self._token_url = os.environ.get(
398+
'IAM_TOKEN_URL', 'https://iam.bluemix.net/oidc/token')
399+
400+
def info(self):
401+
"""
402+
Get IAM cookie based login user information.
403+
"""
404+
resp = self.get(self._session_url)
405+
resp.raise_for_status()
406+
407+
return resp.json()
408+
409+
def login(self):
410+
"""
411+
Perform IAM cookie based user login.
412+
"""
413+
access_token = self._get_access_token()
414+
try:
415+
super(IAMSession, self).request(
416+
'POST',
417+
self._session_url,
418+
headers={'Content-Type': 'application/json'},
419+
data=json.dumps({'access_token': access_token})
420+
).raise_for_status()
421+
422+
except RequestException:
423+
raise CloudantException(
424+
'Failed to exchange IAM token with Cloudant')
425+
426+
def logout(self):
427+
"""
428+
Logout IAM cookie based user.
429+
"""
430+
self.cookies.clear()
341431

342432
def request(self, method, url, **kwargs): # pylint: disable=W0221
343433
"""
344-
Overrides ``requests.Session.request`` to set the timeout.
434+
Overrides ``requests.Session.request`` to renew the IAM cookie
435+
and then retry the original request (if required).
345436
"""
346-
resp = super(ClientSession, self).request(
347-
method, url, timeout=self._timeout, **kwargs)
437+
# The CookieJar API prevents callers from getting an individual Cookie
438+
# object by name.
439+
# We are forced to use the only exposed method of discarding expired
440+
# cookies from the CookieJar. Internally this involves iterating over
441+
# the entire CookieJar and calling `.is_expired()` on each Cookie
442+
# object.
443+
self.cookies.clear_expired_cookies()
444+
445+
if self._auto_renew and 'IAMSession' not in self.cookies.keys():
446+
self.login()
447+
448+
resp = super(IAMSession, self).request(method, url, **kwargs)
449+
450+
if not self._auto_renew:
451+
return resp
452+
453+
if resp.status_code == 401:
454+
self.login()
455+
resp = super(IAMSession, self).request(method, url, **kwargs)
456+
348457
return resp
349458

459+
def set_credentials(self, username, api_key):
460+
"""
461+
Set a new IAM API key.
462+
463+
:param str username: Username parameter is unused.
464+
:param str api_key: New IAM API key.
465+
"""
466+
if api_key is not None:
467+
self._api_key = api_key
468+
469+
def _get_access_token(self):
470+
"""
471+
Get IAM access token using API key.
472+
"""
473+
err = 'Failed to contact IAM token service'
474+
try:
475+
resp = super(IAMSession, self).request(
476+
'POST',
477+
self._token_url,
478+
auth=('bx', 'bx'), # required for user API keys
479+
headers={'Accepts': 'application/json'},
480+
data={
481+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
482+
'response_type': 'cloud_iam',
483+
'apikey': self._api_key
484+
}
485+
)
486+
err = resp.json().get('errorMessage', err)
487+
resp.raise_for_status()
488+
489+
return resp.json()['access_token']
490+
491+
except KeyError:
492+
raise CloudantException('Invalid response from IAM token service')
493+
494+
except RequestException:
495+
raise CloudantException(err)
496+
497+
350498
class CloudFoundryService(object):
351499
""" Manages Cloud Foundry service configuration. """
352500

0 commit comments

Comments
 (0)