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

Commit 3d95c61

Browse files
authored
Merge pull request #239 from cloudant/235-add-session-renewal-option
Add session auto-renewal option
2 parents 68a1751 + 5e33426 commit 3d95c61

File tree

7 files changed

+240
-12
lines changed

7 files changed

+240
-12
lines changed

src/cloudant/_2to3.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
if PY2:
3434
# pylint: disable=wrong-import-position,no-name-in-module,import-error,unused-import
3535
from urllib import quote as url_quote, quote_plus as url_quote_plus
36+
from urlparse import urlparse as url_parse
3637
from ConfigParser import RawConfigParser
3738

3839
def iteritems_(adict):
@@ -53,7 +54,9 @@ def next_(itr):
5354
"""
5455
return itr.next()
5556
else:
56-
from urllib.parse import quote as url_quote, quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
57+
from urllib.parse import urlparse as url_parse # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
58+
from urllib.parse import quote as url_quote # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
59+
from urllib.parse import quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
5760
from configparser import RawConfigParser # pylint: disable=wrong-import-position,no-name-in-module,import-error
5861

5962
def iteritems_(adict):

src/cloudant/_common_util.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
import platform
2222
from collections import Sequence
2323
import json
24+
from requests import Session
2425

25-
from ._2to3 import STRTYPE, NONETYPE, UNITYPE, iteritems_
26+
from ._2to3 import STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse
2627
from .error import CloudantArgumentError
2728

2829
# Library Constants
@@ -288,3 +289,40 @@ class _Code(str):
288289
"""
289290
def __new__(cls, code):
290291
return str.__new__(cls, code)
292+
293+
class InfiniteSession(Session):
294+
"""
295+
This class provides for the ability to automatically renew session login
296+
information in the event of expired session authentication.
297+
"""
298+
299+
def __init__(self, username, password, server_url):
300+
super(InfiniteSession, self).__init__()
301+
self._username = username
302+
self._password = password
303+
self._server_url = server_url
304+
305+
def request(self, method, url, **kwargs):
306+
"""
307+
Overrides ``requests.Session.request`` to perform a POST to the
308+
_session endpoint to renew Session cookie authentication settings and
309+
then retry the original request, if necessary.
310+
"""
311+
resp = super(InfiniteSession, self).request(method, url, **kwargs)
312+
path = url_parse(url).path.lower()
313+
post_to_session = method.upper() == 'POST' and path == '/_session'
314+
is_expired = any((
315+
resp.status_code == 403 and
316+
resp.json().get('error') == 'credentials_expired',
317+
resp.status_code == 401
318+
))
319+
if not post_to_session and is_expired:
320+
super(InfiniteSession, self).request(
321+
'POST',
322+
'/'.join([self._server_url, '_session']),
323+
data={'name': self._username, 'password': self._password},
324+
headers={'Content-Type': 'application/x-www-form-urlencoded'}
325+
)
326+
resp = super(InfiniteSession, self).request(method, url, **kwargs)
327+
328+
return resp

src/cloudant/client.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
from .database import CloudantDatabase, CouchDatabase
2626
from .feed import Feed, InfiniteFeed
2727
from .error import CloudantException, CloudantArgumentError
28-
from ._common_util import USER_AGENT, append_response_error_content
28+
from ._common_util import (
29+
USER_AGENT,
30+
append_response_error_content,
31+
InfiniteSession
32+
)
2933

3034

3135
class CouchDB(dict):
@@ -49,6 +53,9 @@ class CouchDB(dict):
4953
configuring requests.
5054
:param bool connect: Keyword argument, if set to True performs the call to
5155
connect as part of client construction. Default is False.
56+
:param bool auto_renew: Keyword argument, if set to True performs
57+
automatic renewal of expired session authentication settings.
58+
Default is False.
5259
"""
5360
_DATABASE_CLASS = CouchDatabase
5461

@@ -63,7 +70,9 @@ def __init__(self, user, auth_token, admin_party=False, **kwargs):
6370
self.encoder = kwargs.get('encoder') or json.JSONEncoder
6471
self.adapter = kwargs.get('adapter')
6572
self.r_session = None
66-
if kwargs.get('connect', False):
73+
self._auto_renew = kwargs.get('auto_renew', False)
74+
connect_to_couch = kwargs.get('connect', False)
75+
if connect_to_couch and self._DATABASE_CLASS == CouchDatabase:
6776
self.connect()
6877

6978
def connect(self):
@@ -74,7 +83,14 @@ def connect(self):
7483
if self.r_session:
7584
return
7685

77-
self.r_session = requests.Session()
86+
if self._auto_renew and not self.admin_party:
87+
self.r_session = InfiniteSession(
88+
self._user,
89+
self._auth_token,
90+
self.server_url
91+
)
92+
else:
93+
self.r_session = requests.Session()
7894
# If a Transport Adapter was supplied add it to the session
7995
if self.adapter is not None:
8096
self.r_session.mount(self.server_url, self.adapter)
@@ -398,7 +414,6 @@ class Cloudant(CouchDB):
398414

399415
def __init__(self, cloudant_user, auth_token, **kwargs):
400416
super(Cloudant, self).__init__(cloudant_user, auth_token, **kwargs)
401-
402417
self._client_user_header = {'User-Agent': USER_AGENT}
403418
account = kwargs.get('account')
404419
url = kwargs.get('url')
@@ -413,6 +428,9 @@ def __init__(self, cloudant_user, auth_token, **kwargs):
413428
if self.server_url is None:
414429
raise CloudantException('You must provide a url or an account.')
415430

431+
if kwargs.get('connect', False):
432+
self.connect()
433+
416434
def db_updates(self, raw_data=False, **kwargs):
417435
"""
418436
Returns the ``_db_updates`` feed iterator. The ``_db_updates`` feed can

tests/unit/auth_renewal_tests.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python
2+
# Copyright (c) 2016 IBM. 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+
Unit tests for the renewal of cookie auth
17+
18+
See configuration options for environment variables in unit_t_db_base
19+
module docstring.
20+
"""
21+
import unittest
22+
import os
23+
import requests
24+
import time
25+
26+
from cloudant._common_util import InfiniteSession
27+
28+
from .unit_t_db_base import UnitTestDbBase
29+
30+
@unittest.skipIf(os.environ.get('ADMIN_PARTY') == 'true', 'Skipping - Admin Party mode')
31+
class AuthRenewalTests(UnitTestDbBase):
32+
"""
33+
Auto renewal tests primarily testing the InfiniteSession functionality
34+
"""
35+
36+
def setUp(self):
37+
"""
38+
Override UnitTestDbBase.setUp() with no set up
39+
"""
40+
pass
41+
42+
def tearDown(self):
43+
"""
44+
Override UnitTestDbBase.tearDown() with no tear down
45+
"""
46+
pass
47+
48+
def test_client_db_doc_stack_success(self):
49+
"""
50+
Ensure that auto renewal of cookie auth happens as expected and applies
51+
to all references of r_session throughout the library.
52+
"""
53+
try:
54+
self.set_up_client(auto_connect=True, auto_renew=True)
55+
db = self.client._DATABASE_CLASS(self.client, self.dbname())
56+
db.create()
57+
db_2 = self.client._DATABASE_CLASS(self.client, self.dbname())
58+
doc = db.create_document({'_id': 'julia001', 'name': 'julia'})
59+
60+
auth_session = self.client.r_session.cookies.get('AuthSession')
61+
db_auth_session = db.r_session.cookies.get('AuthSession')
62+
db_2_auth_session = db_2.r_session.cookies.get('AuthSession')
63+
doc_auth_session = doc.r_session.cookies.get('AuthSession')
64+
65+
self.assertIsInstance(self.client.r_session, InfiniteSession)
66+
self.assertIsInstance(db.r_session, InfiniteSession)
67+
self.assertIsInstance(db_2.r_session, InfiniteSession)
68+
self.assertIsInstance(doc.r_session, InfiniteSession)
69+
self.assertIsNotNone(auth_session)
70+
self.assertTrue(
71+
auth_session ==
72+
db_auth_session ==
73+
db_2_auth_session ==
74+
doc_auth_session
75+
)
76+
self.assertTrue(db.exists())
77+
self.assertTrue(doc.exists())
78+
79+
# Will cause a 401 response to be handled internally
80+
self.client.r_session.cookies.clear()
81+
self.assertIsNone(self.client.r_session.cookies.get('AuthSession'))
82+
self.assertIsNone(db.r_session.cookies.get('AuthSession'))
83+
self.assertIsNone(db_2.r_session.cookies.get('AuthSession'))
84+
self.assertIsNone(doc.r_session.cookies.get('AuthSession'))
85+
86+
time.sleep(1) # Ensure a different cookie auth value
87+
88+
# 401 response handled by renew of cookie auth and retry of request
89+
db_2.create()
90+
91+
new_auth_session = self.client.r_session.cookies.get('AuthSession')
92+
new_db_auth_session = db.r_session.cookies.get('AuthSession')
93+
new_db_2_auth_session = db_2.r_session.cookies.get('AuthSession')
94+
new_doc_auth_session = doc.r_session.cookies.get('AuthSession')
95+
self.assertIsNotNone(new_auth_session)
96+
self.assertNotEqual(new_auth_session, auth_session)
97+
self.assertTrue(
98+
new_auth_session ==
99+
new_db_auth_session ==
100+
new_db_2_auth_session ==
101+
new_doc_auth_session
102+
)
103+
self.assertTrue(db.exists())
104+
self.assertTrue(doc.exists())
105+
finally:
106+
# Clean up
107+
self.client.delete_database(db.database_name)
108+
self.client.delete_database(db_2.database_name)
109+
self.client.disconnect()
110+
del self.client
111+
112+
def test_client_db_doc_stack_failure(self):
113+
"""
114+
Ensure that when the regular requests.Session is used that
115+
cookie auth renewal is not handled.
116+
"""
117+
try:
118+
self.set_up_client(auto_connect=True)
119+
db = self.client._DATABASE_CLASS(self.client, self.dbname())
120+
db.create()
121+
122+
self.assertIsInstance(self.client.r_session, requests.Session)
123+
self.assertIsInstance(db.r_session, requests.Session)
124+
125+
# Will cause a 401 response
126+
self.client.r_session.cookies.clear()
127+
128+
# 401 response expected to raised
129+
with self.assertRaises(requests.HTTPError) as cm:
130+
db.delete()
131+
self.assertEqual(cm.exception.response.status_code, 401)
132+
finally:
133+
# Manual reconnect
134+
self.client.disconnect()
135+
self.client.connect()
136+
# Clean up
137+
self.client.delete_database(db.database_name)
138+
self.client.disconnect()
139+
del self.client
140+
141+
142+
if __name__ == '__main__':
143+
unittest.main()

tests/unit/client_tests.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from cloudant.client import Cloudant, CouchDB
3333
from cloudant.error import CloudantException, CloudantArgumentError
3434
from cloudant.feed import Feed, InfiniteFeed
35+
from cloudant._common_util import InfiniteSession
3536

3637
from .unit_t_db_base import UnitTestDbBase
3738
from .. import bytes_, str_
@@ -120,6 +121,33 @@ def test_multiple_connect(self):
120121
self.client.disconnect()
121122
self.assertIsNone(self.client.r_session)
122123

124+
def test_auto_renew_enabled(self):
125+
"""
126+
Test that InfiniteSession is used when auto_renew is enabled.
127+
"""
128+
try:
129+
self.set_up_client(auto_renew=True)
130+
self.client.connect()
131+
if os.environ.get('ADMIN_PARTY') == 'true':
132+
self.assertIsInstance(self.client.r_session, requests.Session)
133+
else:
134+
self.assertIsInstance(self.client.r_session, InfiniteSession)
135+
finally:
136+
self.client.disconnect()
137+
138+
def test_auto_renew_enabled_with_auto_connect(self):
139+
"""
140+
Test that InfiniteSession is used when auto_renew is enabled along with
141+
an auto_connect.
142+
"""
143+
try:
144+
self.set_up_client(auto_connect=True, auto_renew=True)
145+
if os.environ.get('ADMIN_PARTY') == 'true':
146+
self.assertIsInstance(self.client.r_session, requests.Session)
147+
else:
148+
self.assertIsInstance(self.client.r_session, InfiniteSession)
149+
finally:
150+
self.client.disconnect()
123151

124152
def test_session(self):
125153
"""

tests/unit/database_tests.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,11 +1333,7 @@ def test_get_search_result_with_both_q_and_query(self):
13331333
self.db.get_search_result('searchddoc001', 'searchindex001',
13341334
query='julia*', q='julia*')
13351335
err = cm.exception
1336-
self.assertEqual(
1337-
str(err),
1338-
'A single query/q parameter is required. '
1339-
'Found: {\'q\': \'julia*\', \'query\': \'julia*\'}'
1340-
)
1336+
self.assertTrue(str(err).startswith('A single query/q parameter is required.'))
13411337

13421338
def test_get_search_result_with_invalid_value_types(self):
13431339
"""

tests/unit/unit_t_db_base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def setUp(self):
135135
"""
136136
self.set_up_client()
137137

138-
def set_up_client(self, auto_connect=False, encoder=None):
138+
def set_up_client(self, auto_connect=False, auto_renew=False, encoder=None):
139139
if os.environ.get('RUN_CLOUDANT_TESTS') is None:
140140
admin_party = False
141141
if (os.environ.get('ADMIN_PARTY') and
@@ -150,6 +150,7 @@ def set_up_client(self, auto_connect=False, encoder=None):
150150
admin_party,
151151
url=self.url,
152152
connect=auto_connect,
153+
auto_renew=auto_renew,
153154
encoder=encoder
154155
)
155156
else:
@@ -165,6 +166,7 @@ def set_up_client(self, auto_connect=False, encoder=None):
165166
url=self.url,
166167
x_cloudant_user=self.account,
167168
connect=auto_connect,
169+
auto_renew=auto_renew,
168170
encoder=encoder
169171
)
170172

0 commit comments

Comments
 (0)