Skip to content

Commit

Permalink
Merge pull request #81 from box/auth
Browse files Browse the repository at this point in the history
Add network and auth subclasses.
  • Loading branch information
Jeff-Meadows committed Nov 5, 2015
2 parents 42a03fa + 3fff540 commit 0fd40a6
Show file tree
Hide file tree
Showing 26 changed files with 630 additions and 19 deletions.
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`)
"""
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):
"""
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

0 comments on commit 0fd40a6

Please sign in to comment.