Skip to content

Commit 0fd40a6

Browse files
committed
Merge pull request #81 from box/auth
Add network and auth subclasses.
2 parents 42a03fa + 3fff540 commit 0fd40a6

26 files changed

+630
-19
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
- Stream uploads of files from disk.
1315

1416
1.2.2 (2015-07-22)

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,31 @@ 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+
- `CooperativelyManagedOAuth2`: Allows multiple auth instances to share tokens.
291+
- `RemoteOAuth2`: Allows use of the SDK on clients without access to your application's client secret. Instead, you
292+
provide a `retrieve_access_token` callback. That callback should perform the token refresh, perhaps on your server
293+
that does have access to the client secret.
294+
- `RedisManagedOAuth2`: Stores access and refresh tokens in Redis. This allows multiple processes (possibly spanning
295+
multiple machines) to share access tokens while synchronizing token refresh. This could be useful for a multiprocess
296+
web server, for example.
297+
298+
Other Network Options
299+
---------------------
300+
301+
For more insight into the network calls the SDK is making, you can use the `LoggingNetwork` class. This class logs
302+
information about network requests and responses made to the Box API.
303+
304+
.. code-block:: python
305+
306+
from boxsdk import Client
307+
from boxsdk.network.logging_network import LoggingNetwork
308+
309+
client = Client(oauth, network_layer=LoggingNetwork())
285310
286311
Contributing
287312
------------
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# coding: utf-8
2+
3+
from __future__ import unicode_literals
4+
from boxsdk import OAuth2
5+
6+
7+
class CooperativelyManagedOAuth2Mixin(OAuth2):
8+
"""
9+
Box SDK OAuth2 mixin.
10+
Allows for sharing auth tokens between multiple clients.
11+
"""
12+
def __init__(self, retrieve_tokens=None, *args, **kwargs):
13+
"""
14+
:param retrieve_tokens:
15+
Callback to get the current access/refresh token pair.
16+
:type retrieve_tokens:
17+
`callable` of () => (`unicode`, `unicode`)
18+
"""
19+
self._retrieve_tokens = retrieve_tokens
20+
super(CooperativelyManagedOAuth2Mixin, self).__init__(*args, **kwargs)
21+
22+
def _get_tokens(self):
23+
"""
24+
Base class override. Get the tokens from the user-specified callback.
25+
"""
26+
return self._retrieve_tokens()
27+
28+
29+
class CooperativelyManagedOAuth2(CooperativelyManagedOAuth2Mixin):
30+
"""
31+
Box SDK OAuth2 subclass.
32+
Allows for sharing auth tokens between multiple clients. The retrieve_tokens callback should
33+
return the current access/refresh token pair.
34+
"""
35+
pass

boxsdk/auth/oauth2.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def __init__(
2828
access_token=None,
2929
refresh_token=None,
3030
network_layer=None,
31+
refresh_lock=None,
3132
):
3233
"""
3334
:param client_id:
@@ -62,14 +63,18 @@ def __init__(
6263
If specified, use it to make network requests. If not, the default network implementation will be used.
6364
:type network_layer:
6465
:class:`Network`
66+
:param refresh_lock:
67+
Lock used to synchronize token refresh. If not specified, then a :class:`threading.Lock` will be used.
68+
:type refresh_lock:
69+
Context Manager
6570
"""
6671
self._client_id = client_id
6772
self._client_secret = client_secret
68-
self._store_tokens = store_tokens
73+
self._store_tokens_callback = store_tokens
6974
self._access_token = access_token
7075
self._refresh_token = refresh_token
7176
self._network_layer = network_layer if network_layer else DefaultNetwork()
72-
self._refresh_lock = Lock()
77+
self._refresh_lock = refresh_lock or Lock()
7378
self._box_device_id = box_device_id
7479
self._box_device_name = box_device_name
7580

@@ -158,6 +163,17 @@ def _refresh(self, access_token):
158163

159164
return self.send_token_request(data, access_token)
160165

166+
def _get_tokens(self):
167+
"""
168+
Get the current access and refresh tokens.
169+
170+
:return:
171+
Tuple containing the current access token and refresh token.
172+
:rtype:
173+
`tuple` of (`unicode`, `unicode`)
174+
"""
175+
return self._access_token, self._refresh_token
176+
161177
def refresh(self, access_token_to_refresh):
162178
"""
163179
Refresh the access token and the refresh token and return the access_token, refresh_token tuple. The access
@@ -169,16 +185,17 @@ def refresh(self, access_token_to_refresh):
169185
`unicode`
170186
"""
171187
with self._refresh_lock:
188+
access_token, refresh_token = self._get_tokens()
172189
# The lock here is for handling that case that multiple requests fail, due to access token expired, at the
173190
# same time to avoid multiple session renewals.
174-
if access_token_to_refresh == self._access_token:
191+
if access_token_to_refresh == access_token:
175192
# If the active access token is the same as the token needs to be refreshed, we make the request to
176193
# refresh the token.
177194
return self._refresh(access_token_to_refresh)
178195
else:
179196
# If the active access token (self._access_token) is not the same as the token needs to be refreshed,
180197
# it means the expired token has already been refreshed. Simply return the current active tokens.
181-
return self._access_token, self._refresh_token
198+
return access_token, refresh_token
182199

183200
@staticmethod
184201
def _get_state_csrf_token():
@@ -195,6 +212,10 @@ def _get_state_csrf_token():
195212
ascii_len = len(ascii_alphabet)
196213
return 'box_csrf_token_' + ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(16))
197214

215+
def _store_tokens(self, access_token, refresh_token):
216+
if self._store_tokens_callback is not None:
217+
self._store_tokens_callback(access_token, refresh_token)
218+
198219
def send_token_request(self, data, access_token, expect_refresh_token=True):
199220
"""
200221
Send the request to acquire or refresh an access token.
@@ -231,6 +252,5 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
231252
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
232253
except (ValueError, KeyError):
233254
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)
255+
self._store_tokens(self._access_token, self._refresh_token)
236256
return self._access_token, self._refresh_token
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
refresh_lock = Lock(redis=self._redis_server, name='{0}_lock'.format(self._unique_id))
28+
super(RedisManagedOAuth2Mixin, self).__init__(*args, refresh_lock=refresh_lock, **kwargs)
29+
if self._access_token is None:
30+
self._update_current_tokens()
31+
32+
def _update_current_tokens(self):
33+
"""
34+
Get the latest tokens from redis and store them.
35+
"""
36+
self._access_token, self._refresh_token = self._redis_server.hvals(self._unique_id) or (None, None)
37+
38+
@property
39+
def unique_id(self):
40+
"""
41+
Get the unique ID used by this auth instance. Other instances can share tokens with this instance
42+
if they share the ID with this instance.
43+
"""
44+
return self._unique_id
45+
46+
def _get_tokens(self):
47+
"""
48+
Base class override.
49+
Gets the latest tokens from redis before returning them.
50+
"""
51+
self._update_current_tokens()
52+
return super(RedisManagedOAuth2Mixin, self)._get_tokens()
53+
54+
def _store_tokens(self, access_token, refresh_token):
55+
"""
56+
Base class override.
57+
Saves the refreshed tokens in redis.
58+
"""
59+
super(RedisManagedOAuth2Mixin, self)._store_tokens(access_token, refresh_token)
60+
self._redis_server.hmset(self._unique_id, {'access': access_token, 'refresh': refresh_token})
61+
62+
63+
class RedisManagedOAuth2(RedisManagedOAuth2Mixin):
64+
"""
65+
OAuth2 subclass which uses Redis to manage tokens.
66+
"""
67+
pass
68+
69+
70+
class RedisManagedJWTAuth(RedisManagedOAuth2Mixin, JWTAuth):
71+
"""
72+
JWT Auth subclass which uses Redis to manage access tokens.
73+
"""
74+
def _auth_with_jwt(self, sub, sub_type):
75+
"""
76+
Base class override. Returns the access token in a tuple to match the OAuth2 interface.
77+
"""
78+
return super(RedisManagedJWTAuth, self)._auth_with_jwt(sub, sub_type), None
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+
from boxsdk import OAuth2
5+
6+
7+
class RemoteOAuth2Mixin(OAuth2):
8+
"""
9+
Box SDK OAuth2 mixin.
10+
Allows for storing auth tokens remotely.
11+
12+
"""
13+
def __init__(self, retrieve_access_token=None, *args, **kwargs):
14+
"""
15+
:param retrieve_access_token:
16+
Callback to exchange an existing access token for a new one.
17+
:type retrieve_access_token:
18+
`callable` of `unicode` => `unicode`
19+
"""
20+
self._retrieve_access_token = retrieve_access_token
21+
super(RemoteOAuth2Mixin, self).__init__(*args, **kwargs)
22+
23+
def _refresh(self, access_token):
24+
"""
25+
Base class override. Ask the remote host for a new token.
26+
"""
27+
self._access_token = self._retrieve_access_token(access_token)
28+
return self._access_token, None
29+
30+
31+
class RemoteOAuth2(RemoteOAuth2Mixin):
32+
"""
33+
Box SDK OAuth2 subclass.
34+
Allows for storing auth tokens remotely. The retrieve_access_token callback should
35+
return an access token, presumably acquired from a remote server on which your auth credentials are available.
36+
"""
37+
pass

boxsdk/network/logging_network.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
LOGGER_NAME = 'boxsdk.network'
14+
REQUEST_FORMAT = '\x1b[36m%s %s %s\x1b[0m'
15+
SUCCESSFUL_RESPONSE_FORMAT = '\x1b[32m%s\x1b[0m'
16+
ERROR_RESPONSE_FORMAT = '\x1b[31m%s\n%s\n%s\n\x1b[0m'
17+
18+
def __init__(self, logger=None):
19+
"""
20+
:param logger:
21+
The logger to use. If you instantiate this class more than once, you should use the same logger
22+
to avoid duplicate log entries.
23+
:type logger:
24+
:class:`Logger`
25+
"""
26+
super(LoggingNetwork, self).__init__()
27+
self._logger = logger or setup_logging(name=self.LOGGER_NAME)
28+
29+
@property
30+
def logger(self):
31+
return self._logger
32+
33+
def _log_request(self, method, url, **kwargs):
34+
"""
35+
Logs information about the Box API request.
36+
37+
:param method:
38+
The HTTP verb that should be used to make the request.
39+
:type method:
40+
`unicode`
41+
:param url:
42+
The URL for the request.
43+
:type url:
44+
`unicode`
45+
:param access_token:
46+
The OAuth2 access token used to authorize the request.
47+
:type access_token:
48+
`unicode`
49+
"""
50+
self._logger.info(self.REQUEST_FORMAT, method, url, pformat(kwargs))
51+
52+
def _log_response(self, response):
53+
"""
54+
Logs information about the Box API response.
55+
56+
:param response: The Box API response.
57+
"""
58+
if response.ok:
59+
self._logger.info(self.SUCCESSFUL_RESPONSE_FORMAT, response.content)
60+
else:
61+
self._logger.warning(
62+
self.ERROR_RESPONSE_FORMAT,
63+
response.status_code,
64+
response.headers,
65+
pformat(response.content),
66+
)
67+
68+
def request(self, method, url, access_token, **kwargs):
69+
"""
70+
Base class override. Logs information about an API request and response in addition to making the request.
71+
"""
72+
self._log_request(method, url, **kwargs)
73+
response = super(LoggingNetwork, self).request(method, url, access_token, **kwargs)
74+
self._log_response(response)
75+
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

0 commit comments

Comments
 (0)