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

Commit cda9f93

Browse files
authored
Merge pull request #325 from cloudant/51-add-basic-auth-session-support
51 add basic auth session support
2 parents ef15286 + d3b59a9 commit cda9f93

File tree

12 files changed

+598
-314
lines changed

12 files changed

+598
-314
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
Unreleased
22
==========
3+
- [NEW] Added API for upcoming Bluemix Identity and Access Management support for Cloudant on Bluemix. Note: IAM API key support is not yet enabled in the service.
4+
- [NEW] Added HTTP basic authentication support.
35
- [NEW] Added ``Result.all()`` convenience method.
46
- [NEW] Allow ``service_name`` to be specified when instantiating from a Bluemix VCAP_SERVICES environment variable.
57
- [IMPROVED] Updated ``posixpath.join`` references to use ``'/'.join`` when concatenating URL parts.

Jenkinsfile

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
1-
// Define the test routine for different python versions
2-
def test_python(pythonVersion)
3-
{
1+
def getEnvForSuite(suiteName) {
2+
// Base environment variables
3+
def envVars = [
4+
"CLOUDANT_ACCOUNT=$DB_USER",
5+
"RUN_CLOUDANT_TESTS=1",
6+
"SKIP_DB_UPDATES=1" // Disable pending resolution of case 71610
7+
]
8+
// Add test suite specific environment variables
9+
switch(suiteName) {
10+
case 'basic':
11+
envVars.add("RUN_BASIC_AUTH_TESTS=1")
12+
break
13+
case 'cookie':
14+
break
15+
case 'iam':
16+
// Setting IAM_API_KEY forces tests to run using an IAM enabled client.
17+
envVars.add("IAM_API_KEY=$DB_IAM_API_KEY")
18+
break
19+
default:
20+
error("Unknown test suite environment ${suiteName}")
21+
}
22+
return envVars
23+
}
24+
25+
def setupPythonAndTest(pythonVersion, testSuite) {
426
node {
527
// Unstash the source on this node
628
unstash name: 'source'
729
// Set up the environment and test
8-
withCredentials([[$class: 'UsernamePasswordMultiBinding', credentialsId: 'clientlibs-test', usernameVariable: 'DB_USER', passwordVariable: 'DB_PASSWORD']]) {
9-
try {
10-
sh """ virtualenv tmp -p /usr/local/lib/python${pythonVersion}/bin/${pythonVersion.startsWith('3') ? "python3" : "python"}
11-
. ./tmp/bin/activate
12-
echo \$DB_USER
13-
export RUN_CLOUDANT_TESTS=1
14-
export CLOUDANT_ACCOUNT=\$DB_USER
15-
# Temporarily disable the _db_updates tests pending resolution of case 71610
16-
export SKIP_DB_UPDATES=1
17-
pip install -r requirements.txt
18-
pip install -r test-requirements.txt
19-
pylint ./src/cloudant
20-
nosetests -w ./tests/unit --with-xunit"""
21-
} finally {
22-
// Load the test results
23-
junit 'nosetests.xml'
30+
withCredentials([usernamePassword(credentialsId: 'clientlibs-test', usernameVariable: 'DB_USER', passwordVariable: 'DB_PASSWORD'),
31+
string(credentialsId: 'clientlibs-test-iam', variable: 'DB_IAM_API_KEY')]) {
32+
withEnv(getEnvForSuite("${testSuite}")) {
33+
try {
34+
sh """
35+
virtualenv tmp -p /usr/local/lib/python${pythonVersion}/bin/${pythonVersion.startsWith('3') ? "python3" : "python"}
36+
. ./tmp/bin/activate
37+
pip install -r requirements.txt
38+
pip install -r test-requirements.txt
39+
pylint ./src/cloudant
40+
nosetests -w ./tests/unit --with-xunit
41+
"""
42+
} finally {
43+
// Load the test results
44+
junit 'nosetests.xml'
45+
}
2446
}
2547
}
2648
}
@@ -34,10 +56,14 @@ stage('Checkout'){
3456
stash name: 'source'
3557
}
3658
}
59+
3760
stage('Test'){
38-
// Run tests in parallel for multiple python versions
3961
parallel(
40-
Python2: {test_python('2.7.12')},
41-
Python3: {test_python('3.5.2')}
62+
'Python2-BASIC': { setupPythonAndTest('2.7.12', 'basic') },
63+
'Python3-BASIC': { setupPythonAndTest('3.5.2', 'basic') },
64+
'Python2-COOKIE': { setupPythonAndTest('2.7.12', 'cookie') },
65+
'Python3-COOKIE': { setupPythonAndTest('3.5.2', 'cookie') },
66+
'Python2-IAM': { setupPythonAndTest('2.7.12', 'iam') },
67+
'Python3-IAM': { setupPythonAndTest('3.5.2', 'iam') }
4268
)
4369
}

src/cloudant/_client_session.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
#!/usr/bin/env python
2+
# Copyright (c) 2015, 2017 IBM Corp. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""
16+
Module containing client session classes.
17+
"""
18+
import base64
19+
import json
20+
import os
21+
22+
from requests import RequestException, Session
23+
24+
from ._2to3 import bytes_, unicode_, url_join
25+
from .error import CloudantException
26+
27+
28+
class ClientSession(Session):
29+
"""
30+
This class extends Session and provides a default timeout.
31+
"""
32+
33+
def __init__(self, username=None, password=None, session_url=None, **kwargs):
34+
super(ClientSession, self).__init__()
35+
36+
self._username = username
37+
self._password = password
38+
self._session_url = session_url
39+
40+
self._auto_renew = kwargs.get('auto_renew', False)
41+
self._timeout = kwargs.get('timeout', None)
42+
43+
def base64_user_pass(self):
44+
"""
45+
Composes a basic http auth string, suitable for use with the
46+
_replicator database, and other places that need it.
47+
48+
:returns: Basic http authentication string
49+
"""
50+
if self._username is None or self._password is None:
51+
return None
52+
53+
hash_ = base64.urlsafe_b64encode(bytes_("{username}:{password}".format(
54+
username=self._username,
55+
password=self._password
56+
)))
57+
return "Basic {0}".format(unicode_(hash_))
58+
59+
# pylint: disable=arguments-differ
60+
def request(self, method, url, **kwargs):
61+
"""
62+
Overrides ``requests.Session.request`` to set the timeout.
63+
"""
64+
resp = super(ClientSession, self).request(
65+
method, url, timeout=self._timeout, **kwargs)
66+
67+
return resp
68+
69+
def info(self):
70+
"""
71+
Get session information.
72+
"""
73+
if self._session_url is None:
74+
return None
75+
76+
resp = self.get(self._session_url)
77+
resp.raise_for_status()
78+
return resp.json()
79+
80+
def set_credentials(self, username, password):
81+
"""
82+
Set a new username and password.
83+
84+
:param str username: New username.
85+
:param str password: New password.
86+
"""
87+
if username is not None:
88+
self._username = username
89+
90+
if password is not None:
91+
self._password = password
92+
93+
def login(self):
94+
"""
95+
No-op method - not implemented here.
96+
"""
97+
pass
98+
99+
def logout(self):
100+
"""
101+
No-op method - not implemented here.
102+
"""
103+
pass
104+
105+
106+
class BasicSession(ClientSession):
107+
"""
108+
This class extends ClientSession to provide basic access authentication.
109+
"""
110+
111+
def __init__(self, username, password, server_url, **kwargs):
112+
super(BasicSession, self).__init__(
113+
username=username,
114+
password=password,
115+
session_url=url_join(server_url, '_session'),
116+
**kwargs)
117+
118+
def request(self, method, url, **kwargs):
119+
"""
120+
Overrides ``requests.Session.request`` to provide basic access
121+
authentication.
122+
"""
123+
auth = None
124+
if self._username is not None and self._password is not None:
125+
auth = (self._username, self._password)
126+
127+
return super(BasicSession, self).request(
128+
method, url, auth=auth, **kwargs)
129+
130+
131+
class CookieSession(ClientSession):
132+
"""
133+
This class extends ClientSession and provides cookie authentication.
134+
"""
135+
136+
def __init__(self, username, password, server_url, **kwargs):
137+
super(CookieSession, self).__init__(
138+
username=username,
139+
password=password,
140+
session_url=url_join(server_url, '_session'),
141+
**kwargs)
142+
143+
def login(self):
144+
"""
145+
Perform cookie based user login.
146+
"""
147+
resp = super(CookieSession, self).request(
148+
'POST',
149+
self._session_url,
150+
data={'name': self._username, 'password': self._password},
151+
)
152+
resp.raise_for_status()
153+
154+
def logout(self):
155+
"""
156+
Logout cookie based user.
157+
"""
158+
resp = super(CookieSession, self).request('DELETE', self._session_url)
159+
resp.raise_for_status()
160+
161+
def request(self, method, url, **kwargs):
162+
"""
163+
Overrides ``requests.Session.request`` to renew the cookie and then
164+
retry the original request (if required).
165+
"""
166+
resp = super(CookieSession, self).request(method, url, **kwargs)
167+
168+
if not self._auto_renew:
169+
return resp
170+
171+
is_expired = any((
172+
resp.status_code == 403 and
173+
resp.json().get('error') == 'credentials_expired',
174+
resp.status_code == 401
175+
))
176+
177+
if is_expired:
178+
self.login()
179+
resp = super(CookieSession, self).request(method, url, **kwargs)
180+
181+
return resp
182+
183+
184+
class IAMSession(ClientSession):
185+
"""
186+
This class extends ClientSession and provides IAM authentication.
187+
"""
188+
189+
def __init__(self, api_key, server_url, **kwargs):
190+
super(IAMSession, self).__init__(
191+
session_url=url_join(server_url, '_iam_session'),
192+
**kwargs)
193+
194+
self._api_key = api_key
195+
self._token_url = os.environ.get(
196+
'IAM_TOKEN_URL', 'https://iam.bluemix.net/oidc/token')
197+
198+
def login(self):
199+
"""
200+
Perform IAM cookie based user login.
201+
"""
202+
access_token = self._get_access_token()
203+
try:
204+
super(IAMSession, self).request(
205+
'POST',
206+
self._session_url,
207+
headers={'Content-Type': 'application/json'},
208+
data=json.dumps({'access_token': access_token})
209+
).raise_for_status()
210+
211+
except RequestException:
212+
raise CloudantException(
213+
'Failed to exchange IAM token with Cloudant')
214+
215+
def logout(self):
216+
"""
217+
Logout IAM cookie based user.
218+
"""
219+
self.cookies.clear()
220+
221+
def request(self, method, url, **kwargs):
222+
"""
223+
Overrides ``requests.Session.request`` to renew the IAM cookie
224+
and then retry the original request (if required).
225+
"""
226+
# The CookieJar API prevents callers from getting an individual Cookie
227+
# object by name.
228+
# We are forced to use the only exposed method of discarding expired
229+
# cookies from the CookieJar. Internally this involves iterating over
230+
# the entire CookieJar and calling `.is_expired()` on each Cookie
231+
# object.
232+
self.cookies.clear_expired_cookies()
233+
234+
if self._auto_renew and 'IAMSession' not in self.cookies.keys():
235+
self.login()
236+
237+
resp = super(IAMSession, self).request(method, url, **kwargs)
238+
239+
if not self._auto_renew:
240+
return resp
241+
242+
if resp.status_code == 401:
243+
self.login()
244+
resp = super(IAMSession, self).request(method, url, **kwargs)
245+
246+
return resp
247+
248+
# pylint: disable=arguments-differ, unused-argument
249+
def set_credentials(self, username, api_key):
250+
"""
251+
Set a new IAM API key.
252+
253+
:param str username: Username parameter is unused.
254+
:param str api_key: New IAM API key.
255+
"""
256+
if api_key is not None:
257+
self._api_key = api_key
258+
259+
def _get_access_token(self):
260+
"""
261+
Get IAM access token using API key.
262+
"""
263+
err = 'Failed to contact IAM token service'
264+
try:
265+
resp = super(IAMSession, self).request(
266+
'POST',
267+
self._token_url,
268+
auth=('bx', 'bx'), # required for user API keys
269+
headers={'Accepts': 'application/json'},
270+
data={
271+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
272+
'response_type': 'cloud_iam',
273+
'apikey': self._api_key
274+
}
275+
)
276+
err = resp.json().get('errorMessage', err)
277+
resp.raise_for_status()
278+
279+
return resp.json()['access_token']
280+
281+
except KeyError:
282+
raise CloudantException('Invalid response from IAM token service')
283+
284+
except RequestException:
285+
raise CloudantException(err)

0 commit comments

Comments
 (0)