Skip to content

Commit e48c9e3

Browse files
committed
Add network and auth subclasses.
-Adds LoggingNetwork to allow clients to see what network calls the SDK is making. -Adds RemoteOAuth2 to allow auth to occur in a different process or machine from the SDK. -Adds RedisManagedOAuth2 to allow token storage in Redis, to enable multiple processes or machines to share tokens.
1 parent 51a8a47 commit e48c9e3

File tree

9 files changed

+258
-6
lines changed

9 files changed

+258
-6
lines changed

HISTORY.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Upcoming
99
- CPython 3.5 support.
1010
- Support for cryptography>=1.0 on PyPy 2.6.
1111
- Travis CI testing for CPython 3.5 and PyPy 2.6.0.
12+
- Added a logging network class that logs requests and responses.
13+
- Added new options for auth classes, including storing tokens in Redis and storing them on a remote server.
1214

1315
1.2.1 (2015-07-22)
1416
++++++++++++++++++

README.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,30 @@ These users can then be authenticated:
282282
Requests made with `ned_client` (or objects returned from `ned_client`'s methods)
283283
will be performed on behalf of the newly created app user.
284284

285+
Other Auth Options
286+
------------------
287+
288+
For advanced uses of the SDK, two additional auth classes are provided:
289+
290+
- `RemoteOAuth2`: Allows use of the SDK on clients without access to your application's client secret. Instead, you
291+
provide a `retrieve_access_token` callback. That callback should perform the token refresh, perhaps on your server
292+
that does have access to the client secret.
293+
- `RedisManagedOAuth2`: Stores access and refresh tokens in Redis. This allows multiple processes (possibly spanning
294+
multiple machines) to share access tokens while synchronizing token refresh. This could be useful for a multiprocess
295+
web server, for example.
296+
297+
Other Network Options
298+
---------------------
299+
300+
For more insight into the network calls the SDK is making, you can use the `LoggingNetwork` class. This class logs
301+
information about network requests and responses made to the Box API.
302+
303+
.. code-block:: python
304+
305+
from boxsdk import Client
306+
from boxsdk.network.logging_network import LoggingNetwork
307+
308+
client = Client(oauth, network_layer=LoggingNetwork())
285309
286310
Contributing
287311
------------

boxsdk/auth/oauth2.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def __init__(
6565
"""
6666
self._client_id = client_id
6767
self._client_secret = client_secret
68-
self._store_tokens = store_tokens
68+
self._store_tokens_callback = store_tokens
6969
self._access_token = access_token
7070
self._refresh_token = refresh_token
7171
self._network_layer = network_layer if network_layer else DefaultNetwork()
@@ -158,6 +158,17 @@ def _refresh(self, access_token):
158158

159159
return self.send_token_request(data, access_token)
160160

161+
def _get_tokens(self):
162+
"""
163+
Get the current access and refresh tokens.
164+
165+
:return:
166+
Tuple containing the current access token and refresh token.
167+
:rtype:
168+
`tuple` of (`unicode`, `unicode`)
169+
"""
170+
return self._access_token, self._refresh_token
171+
161172
def refresh(self, access_token_to_refresh):
162173
"""
163174
Refresh the access token and the refresh token and return the access_token, refresh_token tuple. The access
@@ -169,16 +180,17 @@ def refresh(self, access_token_to_refresh):
169180
`unicode`
170181
"""
171182
with self._refresh_lock:
183+
access_token, refresh_token = self._get_tokens()
172184
# The lock here is for handling that case that multiple requests fail, due to access token expired, at the
173185
# same time to avoid multiple session renewals.
174-
if access_token_to_refresh == self._access_token:
186+
if access_token_to_refresh == access_token:
175187
# If the active access token is the same as the token needs to be refreshed, we make the request to
176188
# refresh the token.
177189
return self._refresh(access_token_to_refresh)
178190
else:
179191
# If the active access token (self._access_token) is not the same as the token needs to be refreshed,
180192
# it means the expired token has already been refreshed. Simply return the current active tokens.
181-
return self._access_token, self._refresh_token
193+
return access_token, refresh_token
182194

183195
@staticmethod
184196
def _get_state_csrf_token():
@@ -195,6 +207,10 @@ def _get_state_csrf_token():
195207
ascii_len = len(ascii_alphabet)
196208
return 'box_csrf_token_' + ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(16))
197209

210+
def _store_tokens(self, access_token, refresh_token):
211+
if self._store_tokens_callback is not None:
212+
self._store_tokens_callback(access_token, refresh_token)
213+
198214
def send_token_request(self, data, access_token, expect_refresh_token=True):
199215
"""
200216
Send the request to acquire or refresh an access token.
@@ -231,6 +247,5 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
231247
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
232248
except (ValueError, KeyError):
233249
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
234-
if self._store_tokens:
235-
self._store_tokens(self._access_token, self._refresh_token)
250+
self._store_tokens(self._access_token, self._refresh_token)
236251
return self._access_token, self._refresh_token
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
from redis import StrictRedis
5+
from redis.lock import Lock
6+
from uuid import uuid4
7+
from boxsdk import JWTAuth, OAuth2
8+
9+
10+
class RedisManagedOAuth2Mixin(OAuth2):
11+
"""
12+
Box SDK OAuth2 subclass.
13+
Allows for storing auth tokens in redis.
14+
15+
:param unique_id:
16+
An identifier for this auth object. Auth instances which wish to share tokens must use the same ID.
17+
:type unique_id:
18+
`unicode`
19+
:param redis_server:
20+
An instance of a Redis server, configured to talk to Redis.
21+
:type redis_server:
22+
:class:`Redis`
23+
"""
24+
def __init__(self, unique_id=uuid4(), redis_server=None, *args, **kwargs):
25+
self._unique_id = unique_id
26+
self._redis_server = redis_server or StrictRedis()
27+
super(RedisManagedOAuth2Mixin, self).__init__(*args, **kwargs)
28+
if self._access_token is None:
29+
self._update_current_tokens()
30+
self._refresh_lock = Lock(redis=self._redis_server, name='{0}_lock'.format(self._unique_id))
31+
32+
def _update_current_tokens(self):
33+
self._access_token, self._refresh_token = self._redis_server.hvals(self._unique_id) or (None, None)
34+
35+
@property
36+
def unique_id(self):
37+
"""
38+
Get the unique ID used by this auth instance. Other instances can share tokens with this instance
39+
if they share the ID with this instance.
40+
"""
41+
return self._unique_id
42+
43+
def _get_tokens(self):
44+
"""
45+
Base class override.
46+
Gets the latest tokens from redis before returning them.
47+
"""
48+
self._update_current_tokens()
49+
return super(RedisManagedOAuth2Mixin, self)._get_tokens()
50+
51+
def send_token_request(self, *args, **kwargs):
52+
"""
53+
Base class override.
54+
Saves the refreshed tokens in redis.
55+
"""
56+
tokens = super(RedisManagedOAuth2Mixin, self).send_token_request(*args, **kwargs)
57+
self._redis_server.hmset(self._unique_id, {'access': tokens[0], 'refresh': tokens[1]})
58+
return tokens
59+
60+
61+
class RedisManagedOAuth2(RedisManagedOAuth2Mixin):
62+
"""
63+
OAuth2 subclass which uses Redis to manage tokens.
64+
"""
65+
pass
66+
67+
68+
class RedisManagedJWTAuth(RedisManagedOAuth2Mixin, JWTAuth):
69+
"""
70+
JWT Auth subclass which uses Redis to manage access tokens.
71+
"""
72+
def _auth_with_jwt(self, sub, sub_type):
73+
"""
74+
Base class override. Returns the access token in a tuple to match the OAuth2 interface.
75+
"""
76+
return super(RedisManagedJWTAuth, self)._auth_with_jwt(sub, sub_type), None

boxsdk/auth/remote_oauth2.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
from boxsdk import OAuth2
5+
6+
7+
class RemoteOAuth2Mixin(OAuth2):
8+
"""
9+
Box SDK OAuth2 subclass.
10+
Allows for storing auth tokens remotely.
11+
12+
:param retrieve_access_token:
13+
Callback to exchange an existing access token for a new one.
14+
:type retrieve_access_token:
15+
`callable` of `unicode` => `unicode`
16+
"""
17+
def __init__(self, retrieve_access_token=None, *args, **kwargs):
18+
self._retrieve_access_token = retrieve_access_token
19+
super(RemoteOAuth2Mixin, self).__init__(*args, **kwargs)
20+
21+
def _refresh(self, access_token):
22+
"""
23+
Base class override. Ask the remote host for a new token.
24+
"""
25+
self._access_token = self._retrieve_access_token(access_token)
26+
return self._access_token, None

boxsdk/network/logging_network.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
from pprint import pformat
5+
from boxsdk.network.default_network import DefaultNetwork
6+
from boxsdk.util.log import setup_logging
7+
8+
9+
class LoggingNetwork(DefaultNetwork):
10+
"""
11+
SDK Network subclass that logs requests and responses.
12+
"""
13+
def __init__(self, logger=None):
14+
"""
15+
:param logger:
16+
The logger to use. If you instantiate this class more than once, you should use the same logger
17+
to avoid duplicate log entries.
18+
:type logger:
19+
:class:`Logger`
20+
"""
21+
super(LoggingNetwork, self).__init__()
22+
self._logger = logger or setup_logging(name='network')
23+
24+
@property
25+
def logger(self):
26+
return self._logger
27+
28+
def _log_request(self, method, url, **kwargs):
29+
"""
30+
Logs information about the Box API request.
31+
32+
:param method:
33+
The HTTP verb that should be used to make the request.
34+
:type method:
35+
`unicode`
36+
:param url:
37+
The URL for the request.
38+
:type url:
39+
`unicode`
40+
:param access_token:
41+
The OAuth2 access token used to authorize the request.
42+
:type access_token:
43+
`unicode`
44+
"""
45+
self._logger.info('\x1b[36m%s %s %s\x1b[0m', method, url, pformat(kwargs))
46+
47+
def _log_response(self, response):
48+
"""
49+
Logs information about the Box API response.
50+
51+
:param response: The Box API response.
52+
"""
53+
if response.ok:
54+
self._logger.info('\x1b[32m%s\x1b[0m', response.content)
55+
else:
56+
self._logger.warning(
57+
'\x1b[31m%s\n%s\n%s\n\x1b[0m',
58+
response.status_code,
59+
response.headers,
60+
pformat(response.content),
61+
)
62+
63+
def request(self, method, url, access_token, **kwargs):
64+
"""
65+
Base class override. Logs information about an API request and response in addition to making the request.
66+
"""
67+
self._log_request(method, url, **kwargs)
68+
response = super(LoggingNetwork, self).request(method, url, access_token, **kwargs)
69+
self._log_response(response)
70+
return response

boxsdk/util/log.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
import logging
5+
import sys
6+
7+
8+
def setup_logging(stream_or_file=None, debug=False, name=None):
9+
"""
10+
Create a logger for communicating with the user or writing to log files.
11+
By default, creates a root logger that prints to stdout.
12+
13+
:param stream_or_file:
14+
The destination of the log messages. If None, stdout will be used.
15+
:type stream_or_file:
16+
`unicode` or `file` or None
17+
:param debug:
18+
Whether or not the logger will be at the DEBUG level (if False, the logger will be at the INFO level).
19+
:type debug:
20+
`bool` or None
21+
:param name:
22+
The logging channel. If None, a root logger will be created.
23+
:type name:
24+
`unicode` or None
25+
:return:
26+
A logger that's been set up according to the specified parameters.
27+
:rtype:
28+
:class:`Logger`
29+
"""
30+
logger = logging.getLogger(name)
31+
if isinstance(stream_or_file, basestring):
32+
handler = logging.FileHandler(stream_or_file, mode='w')
33+
else:
34+
handler = logging.StreamHandler(stream_or_file or sys.stdout)
35+
logger.addHandler(handler)
36+
logger.setLevel(logging.DEBUG if debug else logging.INFO)
37+
return logger

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
cryptography>=0.9.2
2+
redis>=2.10.3
23
pyjwt>=1.3.0
34
requests>=2.4.3
45
six >= 1.4.0

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def run_tests(self):
5353
def main():
5454
base_dir = dirname(__file__)
5555
install_requires = ['requests>=2.4.3', 'six>=1.4.0']
56+
redis_requires = ['redis>=2.10.3']
5657
jwt_requires = ['pyjwt>=1.3.0', 'cryptography>=0.9.2']
5758
if version_info < (3, 4):
5859
install_requires.append('enum34>=1.0.4')
@@ -68,7 +69,7 @@ def main():
6869
url='http://opensource.box.com',
6970
packages=find_packages(exclude=['demo', 'docs', 'test']),
7071
install_requires=install_requires,
71-
extras_require={'jwt': jwt_requires},
72+
extras_require={'jwt': jwt_requires, 'redis': redis_requires},
7273
tests_require=['pytest', 'pytest-xdist', 'mock', 'sqlalchemy', 'bottle', 'jsonpatch'],
7374
cmdclass={'test': PyTest},
7475
classifiers=CLASSIFIERS,

0 commit comments

Comments
 (0)