Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Upcoming
- CPython 3.5 support.
- Support for cryptography>=1.0 on PyPy 2.6.
- Travis CI testing for CPython 3.5 and PyPy 2.6.0.
- Added a logging network class that logs requests and responses.
- Added new options for auth classes, including storing tokens in Redis and storing them on a remote server.
- Stream uploads of files from disk.

1.2.2 (2015-07-22)
Expand Down
25 changes: 25 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,31 @@ These users can then be authenticated:
Requests made with `ned_client` (or objects returned from `ned_client`'s methods)
will be performed on behalf of the newly created app user.

Other Auth Options
------------------

For advanced uses of the SDK, two additional auth classes are provided:

- `CooperativelyManagedOAuth2`: Allows multiple auth instances to share tokens.
- `RemoteOAuth2`: Allows use of the SDK on clients without access to your application's client secret. Instead, you
provide a `retrieve_access_token` callback. That callback should perform the token refresh, perhaps on your server
that does have access to the client secret.
- `RedisManagedOAuth2`: Stores access and refresh tokens in Redis. This allows multiple processes (possibly spanning
multiple machines) to share access tokens while synchronizing token refresh. This could be useful for a multiprocess
web server, for example.

Other Network Options
---------------------

For more insight into the network calls the SDK is making, you can use the `LoggingNetwork` class. This class logs
information about network requests and responses made to the Box API.

.. code-block:: python

from boxsdk import Client
from boxsdk.network.logging_network import LoggingNetwork

client = Client(oauth, network_layer=LoggingNetwork())

Contributing
------------
Expand Down
35 changes: 35 additions & 0 deletions boxsdk/auth/cooperatively_managed_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# coding: utf-8

from __future__ import unicode_literals
from boxsdk import OAuth2


class CooperativelyManagedOAuth2Mixin(OAuth2):
"""
Box SDK OAuth2 mixin.
Allows for sharing auth tokens between multiple clients.
"""
def __init__(self, retrieve_tokens=None, *args, **kwargs):
"""
:param retrieve_tokens:
Callback to get the current access/refresh token pair.
:type retrieve_tokens:
`callable` of () => (`unicode`, `unicode`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callable might not be able to retrieve anything (e.g., the cache might not have this user yet). Should we allow the callable to return None, or allow the tuple elements to be None?

"""
self._retrieve_tokens = retrieve_tokens
super(CooperativelyManagedOAuth2Mixin, self).__init__(*args, **kwargs)

def _get_tokens(self):
"""
Base class override. Get the tokens from the user-specified callback.
"""
return self._retrieve_tokens()


class CooperativelyManagedOAuth2(CooperativelyManagedOAuth2Mixin):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we create a concrete class for JWT as well?

"""
Box SDK OAuth2 subclass.
Allows for sharing auth tokens between multiple clients. The retrieve_tokens callback should
return the current access/refresh token pair.
"""
pass
32 changes: 26 additions & 6 deletions boxsdk/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def __init__(
access_token=None,
refresh_token=None,
network_layer=None,
refresh_lock=None,
):
"""
:param client_id:
Expand Down Expand Up @@ -62,14 +63,18 @@ def __init__(
If specified, use it to make network requests. If not, the default network implementation will be used.
:type network_layer:
:class:`Network`
:param refresh_lock:
Lock used to synchronize token refresh. If not specified, then a :class:`threading.Lock` will be used.
:type refresh_lock:
Context Manager
"""
self._client_id = client_id
self._client_secret = client_secret
self._store_tokens = store_tokens
self._store_tokens_callback = store_tokens
self._access_token = access_token
self._refresh_token = refresh_token
self._network_layer = network_layer if network_layer else DefaultNetwork()
self._refresh_lock = Lock()
self._refresh_lock = refresh_lock or Lock()
self._box_device_id = box_device_id
self._box_device_name = box_device_name

Expand Down Expand Up @@ -158,6 +163,17 @@ def _refresh(self, access_token):

return self.send_token_request(data, access_token)

def _get_tokens(self):
"""
Get the current access and refresh tokens.

:return:
Tuple containing the current access token and refresh token.
:rtype:
`tuple` of (`unicode`, `unicode`)
"""
return self._access_token, self._refresh_token

def refresh(self, access_token_to_refresh):
"""
Refresh the access token and the refresh token and return the access_token, refresh_token tuple. The access
Expand All @@ -169,16 +185,17 @@ def refresh(self, access_token_to_refresh):
`unicode`
"""
with self._refresh_lock:
access_token, refresh_token = self._get_tokens()
# The lock here is for handling that case that multiple requests fail, due to access token expired, at the
# same time to avoid multiple session renewals.
if access_token_to_refresh == self._access_token:
if access_token_to_refresh == access_token:
# If the active access token is the same as the token needs to be refreshed, we make the request to
# refresh the token.
return self._refresh(access_token_to_refresh)
else:
# If the active access token (self._access_token) is not the same as the token needs to be refreshed,
# it means the expired token has already been refreshed. Simply return the current active tokens.
return self._access_token, self._refresh_token
return access_token, refresh_token

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

def _store_tokens(self, access_token, refresh_token):
if self._store_tokens_callback is not None:
self._store_tokens_callback(access_token, refresh_token)

def send_token_request(self, data, access_token, expect_refresh_token=True):
"""
Send the request to acquire or refresh an access token.
Expand Down Expand Up @@ -231,6 +252,5 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
except (ValueError, KeyError):
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
if self._store_tokens:
self._store_tokens(self._access_token, self._refresh_token)
self._store_tokens(self._access_token, self._refresh_token)
return self._access_token, self._refresh_token
78 changes: 78 additions & 0 deletions boxsdk/auth/redis_managed_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# coding: utf-8

from __future__ import unicode_literals
from redis import StrictRedis
from redis.lock import Lock
from uuid import uuid4
from boxsdk import JWTAuth, OAuth2


class RedisManagedOAuth2Mixin(OAuth2):
"""
Box SDK OAuth2 subclass.
Allows for storing auth tokens in redis.

:param unique_id:
An identifier for this auth object. Auth instances which wish to share tokens must use the same ID.
:type unique_id:
`unicode`
:param redis_server:
An instance of a Redis server, configured to talk to Redis.
:type redis_server:
:class:`Redis`
"""
def __init__(self, unique_id=uuid4(), redis_server=None, *args, **kwargs):
self._unique_id = unique_id
self._redis_server = redis_server or StrictRedis()
refresh_lock = Lock(redis=self._redis_server, name='{0}_lock'.format(self._unique_id))
super(RedisManagedOAuth2Mixin, self).__init__(*args, refresh_lock=refresh_lock, **kwargs)
if self._access_token is None:
self._update_current_tokens()

def _update_current_tokens(self):
"""
Get the latest tokens from redis and store them.
"""
self._access_token, self._refresh_token = self._redis_server.hvals(self._unique_id) or (None, None)

@property
def unique_id(self):
"""
Get the unique ID used by this auth instance. Other instances can share tokens with this instance
if they share the ID with this instance.
"""
return self._unique_id

def _get_tokens(self):
"""
Base class override.
Gets the latest tokens from redis before returning them.
"""
self._update_current_tokens()
return super(RedisManagedOAuth2Mixin, self)._get_tokens()

def _store_tokens(self, access_token, refresh_token):
"""
Base class override.
Saves the refreshed tokens in redis.
"""
super(RedisManagedOAuth2Mixin, self)._store_tokens(access_token, refresh_token)
self._redis_server.hmset(self._unique_id, {'access': access_token, 'refresh': refresh_token})


class RedisManagedOAuth2(RedisManagedOAuth2Mixin):
"""
OAuth2 subclass which uses Redis to manage tokens.
"""
pass


class RedisManagedJWTAuth(RedisManagedOAuth2Mixin, JWTAuth):
"""
JWT Auth subclass which uses Redis to manage access tokens.
"""
def _auth_with_jwt(self, sub, sub_type):
"""
Base class override. Returns the access token in a tuple to match the OAuth2 interface.
"""
return super(RedisManagedJWTAuth, self)._auth_with_jwt(sub, sub_type), None
37 changes: 37 additions & 0 deletions boxsdk/auth/remote_managed_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# coding: utf-8

from __future__ import unicode_literals
from boxsdk import OAuth2


class RemoteOAuth2Mixin(OAuth2):
"""
Box SDK OAuth2 mixin.
Allows for storing auth tokens remotely.

"""
def __init__(self, retrieve_access_token=None, *args, **kwargs):
"""
:param retrieve_access_token:
Callback to exchange an existing access token for a new one.
:type retrieve_access_token:
`callable` of `unicode` => `unicode`
"""
self._retrieve_access_token = retrieve_access_token
super(RemoteOAuth2Mixin, self).__init__(*args, **kwargs)

def _refresh(self, access_token):
"""
Base class override. Ask the remote host for a new token.
"""
self._access_token = self._retrieve_access_token(access_token)
return self._access_token, None


class RemoteOAuth2(RemoteOAuth2Mixin):
"""
Box SDK OAuth2 subclass.
Allows for storing auth tokens remotely. The retrieve_access_token callback should
return an access token, presumably acquired from a remote server on which your auth credentials are available.
"""
pass
75 changes: 75 additions & 0 deletions boxsdk/network/logging_network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# coding: utf-8

from __future__ import unicode_literals
from pprint import pformat
from boxsdk.network.default_network import DefaultNetwork
from boxsdk.util.log import setup_logging


class LoggingNetwork(DefaultNetwork):
"""
SDK Network subclass that logs requests and responses.
"""
LOGGER_NAME = 'boxsdk.network'
REQUEST_FORMAT = '\x1b[36m%s %s %s\x1b[0m'
SUCCESSFUL_RESPONSE_FORMAT = '\x1b[32m%s\x1b[0m'
ERROR_RESPONSE_FORMAT = '\x1b[31m%s\n%s\n%s\n\x1b[0m'

def __init__(self, logger=None):
"""
:param logger:
The logger to use. If you instantiate this class more than once, you should use the same logger
to avoid duplicate log entries.
:type logger:
:class:`Logger`
"""
super(LoggingNetwork, self).__init__()
self._logger = logger or setup_logging(name=self.LOGGER_NAME)

@property
def logger(self):
return self._logger

def _log_request(self, method, url, **kwargs):
"""
Logs information about the Box API request.

:param method:
The HTTP verb that should be used to make the request.
:type method:
`unicode`
:param url:
The URL for the request.
:type url:
`unicode`
:param access_token:
The OAuth2 access token used to authorize the request.
:type access_token:
`unicode`
"""
self._logger.info(self.REQUEST_FORMAT, method, url, pformat(kwargs))

def _log_response(self, response):
"""
Logs information about the Box API response.

:param response: The Box API response.
"""
if response.ok:
self._logger.info(self.SUCCESSFUL_RESPONSE_FORMAT, response.content)
else:
self._logger.warning(
self.ERROR_RESPONSE_FORMAT,
response.status_code,
response.headers,
pformat(response.content),
)

def request(self, method, url, access_token, **kwargs):
"""
Base class override. Logs information about an API request and response in addition to making the request.
"""
self._log_request(method, url, **kwargs)
response = super(LoggingNetwork, self).request(method, url, access_token, **kwargs)
self._log_response(response)
return response
37 changes: 37 additions & 0 deletions boxsdk/util/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# coding: utf-8

from __future__ import unicode_literals
import logging
import sys


def setup_logging(stream_or_file=None, debug=False, name=None):
"""
Create a logger for communicating with the user or writing to log files.
By default, creates a root logger that prints to stdout.

:param stream_or_file:
The destination of the log messages. If None, stdout will be used.
:type stream_or_file:
`unicode` or `file` or None
:param debug:
Whether or not the logger will be at the DEBUG level (if False, the logger will be at the INFO level).
:type debug:
`bool` or None
:param name:
The logging channel. If None, a root logger will be created.
:type name:
`unicode` or None
:return:
A logger that's been set up according to the specified parameters.
:rtype:
:class:`Logger`
"""
logger = logging.getLogger(name)
if isinstance(stream_or_file, basestring):
handler = logging.FileHandler(stream_or_file, mode='w')
else:
handler = logging.StreamHandler(stream_or_file or sys.stdout)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG if debug else logging.INFO)
return logger
Loading