Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

add in optional oauth (bug 849075)

  • Loading branch information...
commit 0ff08ffbff9529e8fc9c4e32f91182288d84c4b6 1 parent fbbd0f6
@andymckay andymckay authored
View
4 requirements/python27_prod.txt
@@ -7,6 +7,7 @@ cef==0.5
celery==2.5.1
celery-tasktree==0.3.2
commonware==0.4.0
+curling==0.2.2
dj-database-url==0.2.1
django-aesfield==0.1
django-celery==2.2.4
@@ -21,6 +22,7 @@ django-statsd-mozilla==0.3.8.3
django-tastypie==0.9.11
funfactory==2.1.1
gunicorn==0.17.2
+httplib2==0.7.6
kombu==2.1.2
lxml==2.2.6
m2secret==0.1.1
@@ -29,6 +31,7 @@ metlog-py==0.9.8
mimeparse==0.1.3
mock==1.0b1
nose==1.2.1
+oauth2==1.5.211
ordereddict==1.1
py-bcrypt==0.2
python-dateutil==1.5
@@ -39,6 +42,7 @@ requests==0.14.1
schematic==0.2
simplejson==2.6.2
six==1.2.0
+slumber==0.5.3
statsd==1.0.0
suds==0.4
tastypie-services==0.1.1
View
2  settings_test.py
@@ -46,3 +46,5 @@
# We don't want to hit the live bango in tests.
BANGO_MOCK = True
+
+SITE_URL = 'http://localhost/'
View
112 solitude/authentication.py
@@ -0,0 +1,112 @@
+import logging
+from urlparse import urljoin
+
+
+from django.conf import settings
+import oauth2
+
+from tastypie.authentication import Authentication
+
+log = logging.getLogger('s.auth')
+
+
+class Consumer(object):
+
+ def __init__(self, key, secret=None):
+ self.key = key
+ self.secret = secret or settings.CLIENT_OAUTH_KEYS[key]
+
+
+class OAuthError(RuntimeError):
+ def __init__(self, message='OAuth error occured.'):
+ self.message = message
+
+
+class OAuthAuthentication(Authentication):
+ """
+ This is based on https://github.com/amrox/django-tastypie-two-legged-oauth
+ with permission.
+ """
+
+ def __init__(self, realm='', consumer=None):
+ self.realm = realm
+ self.consumer = consumer
+
+ def _header(self, request):
+ return request.META.get('HTTP_AUTHORIZATION', None)
+
+ def is_authenticated(self, request, **kwargs):
+ auth_header_value = self._header(request)
+ oauth_server, oauth_request = initialize_oauth_server_request(request)
+ try:
+ key = get_oauth_consumer_key_from_header(auth_header_value)
+ if not key:
+ if settings.REQUIRE_OAUTH:
+ return False
+ return True
+ oauth_server.verify_request(oauth_request, Consumer(key), None)
+ log.error(u'Access granted: %s' % key)
+ return True
+
+ except KeyError:
+ log.error(u'No key: %s' % key)
+ return False
+
+ except:
+ log.error(u'Access failed: %s' % key, exc_info=True)
+ return False
+
+
+def initialize_oauth_server_request(request):
+ """
+ OAuth initialization.
+ """
+
+ # Since 'Authorization' header comes through as 'HTTP_AUTHORIZATION',
+ # convert it back.
+ auth_header = {}
+ if 'HTTP_AUTHORIZATION' in request.META:
+ auth_header = {'Authorization': request.META.get('HTTP_AUTHORIZATION')}
+
+ url = urljoin(settings.SITE_URL, request.path)
+
+ # Note: we are only signing using the QUERY STRING. We are not signing the
+ # body yet. According to the spec we should be including an oauth_body_hash
+ # as per:
+ #
+ # http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/drafts/1/spec.html
+ #
+ # There is no support in python-oauth2 for this yet. There is an
+ # outstanding pull request for this:
+ #
+ # https://github.com/simplegeo/python-oauth2/pull/110
+ #
+ # Or time to move to a better OAuth implementation.
+ method = getattr(request, 'signed_method', request.method)
+ oauth_request = oauth2.Request.from_request(
+ method, url, headers=auth_header,
+ query_string=request.META['QUERY_STRING'])
+ oauth_server = oauth2.Server(signature_methods={
+ 'HMAC-SHA1': oauth2.SignatureMethod_HMAC_SHA1()
+ })
+ return oauth_server, oauth_request
+
+
+def get_oauth_consumer_key_from_header(auth_header_value):
+ key = None
+
+ # Process Auth Header
+ if not auth_header_value:
+ return None
+ # Check that the authorization header is OAuth.
+ if auth_header_value[:6] == 'OAuth ':
+ auth_header = auth_header_value[6:]
+ try:
+ # Get the parameters from the header.
+ header_params = oauth2.Request._split_header(auth_header)
+ if 'oauth_consumer_key' in header_params:
+ key = header_params['oauth_consumer_key']
+ except:
+ raise OAuthError('Unable to parse OAuth parameters from '
+ 'Authorization header.')
+ return key
View
80 solitude/base.py
@@ -21,20 +21,19 @@
pass
from cef import log_cef as _log_cef
-import jwt
from tastypie import http
-from tastypie.authentication import Authentication
from tastypie.authorization import Authorization
from tastypie.exceptions import ImmediateHttpResponse, InvalidFilterError
from tastypie.resources import (ModelResource as TastyPieModelResource,
Resource as TastyPieResource)
-from tastypie.serializers import Serializer
from tastypie.utils import dict_strip_unicode_keys
from tastypie.validation import FormValidation
import test_utils
from lib.delayable.tasks import delayable
+from authentication import OAuthAuthentication
+
log = logging.getLogger('s')
tasty_log = logging.getLogger('django.request.tastypie')
@@ -143,11 +142,6 @@ def get_errors(self, content, field):
return json.loads(content)[field]
-class Authentication(Authentication):
- # TODO(andym): add in authentication here.
- pass
-
-
class Authorization(Authorization):
pass
@@ -202,15 +196,6 @@ def dehydrate(self, bundle):
return super(BaseResource, self).dehydrate(bundle)
def _handle_500(self, request, exception):
- # I'd prefer it if JWT errors went back as unauth errors, not
- # 500 errors.
- if isinstance(exception, JWTDecodeError):
- # Let's log these with a higher severity.
- log_cef(str(exception), request, severity=10)
- return http.HttpUnauthorized(
- content=json.dumps({'reason': str(exception)}),
- content_type='application/json; charset=utf-8')
-
# Print some nice 500 errors back to the clients if not in debug mode.
tb = traceback.format_tb(sys.exc_traceback)
tasty_log.error('%s: %s %s\n%s' % (request.path,
@@ -365,77 +350,20 @@ def is_valid(self, bundle, request=None):
return form.errors
-class JWTDecodeError(Exception):
- pass
-
-
-class JWTSerializer(Serializer):
- jwt_key = None
- formats = ['json', 'jwt']
- content_types = {
- 'jwt': 'application/jwt',
- 'json': 'application/json',
- }
-
- def _error(self, msg, error='none'):
- log.error('%s (%s)' % (msg, error), exc_info=True)
- return JWTDecodeError(msg)
-
- def from_json(self, content):
- if settings.REQUIRE_JWT:
- raise self._error('JWT is required', None)
-
- return super(JWTSerializer, self).from_json(content)
-
- def from_jwt(self, content):
- try:
- key = jwt.decode(content, verify=False).get('jwt-encode-key', '')
- except jwt.DecodeError as err:
- raise self._error('Error decoding JWT', error=err)
-
- if not key:
- raise self._error('No JWT key')
-
- secret = settings.CLIENT_JWT_KEYS.get(key, '')
- if not secret:
- raise self._error('No JWT secret for that key')
-
- try:
- content = jwt.decode(content, secret, verify=True)
- except jwt.DecodeError as err:
- raise self._error('Error decoding JWT', err)
-
- # We don't need this key anymore, delete it so that solitude
- # doesn't trigger a warning on it.
- del content['jwt-encode-key']
- # Store the key and secret on the serializer, so that we can
- # return the same data. Assuming it's always going to be the
- # same object.
- self.jwt_key = {'key': key, 'secret': secret}
- return content
-
- def to_jwt(self, content, options=None):
- assert self.jwt_key
- content['jwt-encode-key'] = self.jwt_key['key']
- return jwt.encode(content, self.jwt_key['secret'])
-
-
class Resource(BaseResource, TastyPieResource):
class Meta:
always_return_data = True
- authentication = Authentication()
+ authentication = OAuthAuthentication()
authorization = Authorization()
- serializer = JWTSerializer()
class ModelResource(BaseResource, TastyPieModelResource):
class Meta:
always_return_data = True
- authentication = Authentication()
+ authentication = OAuthAuthentication()
authorization = Authorization()
- serializer = JWTSerializer()
class Cached(object):
View
15 solitude/settings/base.py
@@ -1,7 +1,6 @@
import dj_database_url
import logging.handlers
import os
-import urlparse
from funfactory.settings_base import *
@@ -156,14 +155,13 @@
DUMP_REQUESTS = False
-# If this flag is set, any communication will require JWT encoding of the
-# data using a key set in CLIENT_JWT_KEYS. Note: this does not require JWT for
-# all things, eg: nagios checks.
-REQUIRE_JWT = False
+# If this flag is set, any communication will require OAuth signing of the
+# request. Without this, OAuth is optional. This should be True for production.
+REQUIRE_OAUTH = False
-# A mapping of the keys and secrets that will be used to encode the JWT
+# A mapping of the keys and secrets that will be used to sign OAuth
# for any server talking to this server.
-CLIENT_JWT_KEYS = {}
+CLIENT_OAUTH_KEYS = {}
# Bango API settings.
BANGO_AUTH = {'USER': 'Mozilla', 'PASSWORD': ''}
@@ -201,3 +199,6 @@
}
# Sensitive keys.
SENSITIVE_DATA_KEYS = ['bankAccountNumber', 'pin', 'secret']
+
+# Set this for OAuth.
+SITE_URL = ''
View
82 solitude/tests/test.py
@@ -1,19 +1,16 @@
import json
from django import forms
-from django.conf import settings
-import jwt
import mock
from nose.tools import eq_, raises
-import simplejson
from tastypie.exceptions import ImmediateHttpResponse, InvalidFilterError
import test_utils
from lib.paypal.errors import PaypalError
from lib.sellers.models import Seller
from lib.sellers.resources import SellerResource
-from solitude.base import APITest, JWTDecodeError, JWTSerializer, Resource
+from solitude.base import Resource
from solitude.fields import URLField
@@ -24,8 +21,9 @@ def setUp(self):
self.resource = Resource()
def test_error(self):
+ res = None
try:
- 1/0
+ 1 / 0
except Exception as error:
res = self.resource._handle_500(self.request, error)
@@ -34,6 +32,7 @@ def test_error(self):
eq_(data['error_message'], 'integer division or modulo by zero')
def test_paypal_error(self):
+ res = None
try:
raise PaypalError(id=520003, message='wat?')
except Exception as error:
@@ -50,7 +49,6 @@ def setUp(self):
self.request = test_utils.RequestFactory().get('/',
CONTENT_TYPE='application/json')
self.resource = Resource()
- self.resource._meta.serializer = JWTSerializer()
@mock.patch('solitude.base._log_cef')
def test_cef(self, log_cef):
@@ -91,78 +89,6 @@ def test_deserialize(self):
eq_(self.resource.deserialize_body(self.request)['foo'], 'bar')
-class TestSerialize(test_utils.TestCase):
-
- def setUp(self):
- self.serializer = JWTSerializer()
- self.resource = Resource()
-
- def test_good(self):
- data = jwt.encode({'jwt-encode-key': 'key', 'foo': 'bar'}, 'secret')
- with self.settings(CLIENT_JWT_KEYS={'key': 'secret'}):
- assert self.serializer.deserialize(data, 'application/jwt')
-
- def test_no_secret(self):
- data = jwt.encode({'jwt-encode-key': 'key', 'foo': 'bar'}, 'secret')
- with self.assertRaises(JWTDecodeError):
- self.serializer.deserialize(data, 'application/jwt')
-
- def test_no_key(self):
- data = jwt.encode({'foo': 'bar'}, 'secret')
- with self.assertRaises(JWTDecodeError):
- self.serializer.deserialize(data, 'application/jwt')
-
- def test_wrong_encoding(self):
- data = jwt.encode({'foo': 'bar'}, 'secret')
- with self.assertRaises(simplejson.decoder.JSONDecodeError):
- self.serializer.deserialize(data, 'application/json')
-
- def test_jwt_required(self):
- data = json.dumps({'foo': 'bar'})
- with self.settings(REQUIRE_JWT=True):
- with self.assertRaises(JWTDecodeError):
- self.serializer.deserialize(data, 'application/json')
-
-
-@mock.patch.object(settings, 'DEBUG', False)
-class TestJWT(APITest):
- urls = 'solitude.tests.urls'
- url = '/test/fake/'
-
- def test_just_json(self):
- res = self.client.post(self.url, json.dumps({'foo': 'bar'}))
- eq_(res.status_code, 201, res.content)
-
- def test_requires_jwt(self):
- with self.settings(REQUIRE_JWT=True):
- res = self.client.post(self.url, json.dumps({'foo': 'bar'}))
- eq_(res.status_code, 401, res.status_code)
-
- def test_bogus_jwt(self):
- with self.settings(REQUIRE_JWT=True,
- CLIENT_JWT_KEYS={'f': 'b'}):
- res = self.client.post(self.url, data='1.2',
- content_type='application/jwt')
- eq_(res.status_code, 401, res.status_code)
-
- def test_some_jwt(self):
- with self.settings(REQUIRE_JWT=True,
- CLIENT_JWT_KEYS={'f': 'b'}):
- enc = jwt.encode({'jwt-encode-key': 'f', 'name': 'x'}, 'b')
- res = self.client.post(self.url, data=enc,
- content_type='application/jwt')
- eq_(res.status_code, 201, res.status_code)
-
- @mock.patch('solitude.base._log_cef')
- def test_logged_cef(self, log_cef):
- with self.settings(REQUIRE_JWT=True):
- res = self.client.post(self.url, json.dumps({'foo': 'bar'}))
- eq_(res.status_code, 401, res.status_code)
- args = log_cef.call_args[0]
- eq_(args[0], 'JWT is required')
- eq_(args[1], 10)
-
-
class TestURLField(test_utils.TestCase):
def test_valid(self):
View
45 solitude/tests/test_authentication.py
@@ -0,0 +1,45 @@
+from django.conf import settings
+from django.test import RequestFactory
+
+from curling.lib import sign_request
+from mock import patch
+from nose.tools import ok_
+import test_utils
+
+from solitude.authentication import Consumer, OAuthAuthentication
+
+keys = {'foo': 'bar'}
+keys_dict = {'key': 'foo', 'secret': 'bar'}
+
+
+@patch.object(settings, 'CLIENT_OAUTH_KEYS', keys)
+class TestAuthentication(test_utils.TestCase):
+
+ def setUp(self):
+ self.authentication = OAuthAuthentication('api')
+ self.factory = RequestFactory()
+ self.consumer = Consumer(*keys.items()[0])
+
+ def test_not_required(self):
+ req = self.factory.get('/')
+ with self.settings(REQUIRE_OAUTH=False):
+ ok_(self.authentication.is_authenticated(req))
+
+ def test_required(self):
+ req = self.factory.get('/')
+ with self.settings(REQUIRE_OAUTH=True):
+ ok_(not self.authentication.is_authenticated(req))
+
+ def test_signed(self):
+ res = sign_request('GET', keys_dict, settings.SITE_URL)
+ req = self.factory.get('/', HTTP_AUTHORIZATION=res)
+ with self.settings(REQUIRE_OAUTH=True):
+ ok_(self.authentication.is_authenticated(req))
+
+ def test_signed_incorrectly(self):
+ keys_ = keys_dict.copy()
+ keys_['secret'] = 'baz'
+ res = sign_request('GET', keys_, settings.SITE_URL)
+ req = self.factory.get('/foo/', HTTP_AUTHORIZATION=res)
+ with self.settings(REQUIRE_OAUTH=True):
+ ok_(not self.authentication.is_authenticated(req))
Please sign in to comment.
Something went wrong with that request. Please try again.