From 1062d71f8be929f1f7e6910a8d573ac643082bae Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 16:56:14 +0100 Subject: [PATCH 01/41] add tests for OAuth authentication --- rest_framework/tests/authentication.py | 145 ++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index c9df17330f..88b6fd166d 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -2,15 +2,19 @@ from django.contrib.auth.models import User from django.http import HttpResponse from django.test import Client, TestCase -from rest_framework import HTTP_HEADER_ENCODING +import time +from rest_framework import HTTP_HEADER_ENCODING, status from rest_framework import permissions from rest_framework.authtoken.models import Token -from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication +from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication, OAuthAuthentication from rest_framework.compat import patterns from rest_framework.views import APIView import json import base64 - +from oauth_provider.models import Consumer, Resource +from oauth_provider.models import Token as OAuthToken +from oauth_provider import consts as oauth_consts +import oauth2 as oauth class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) @@ -21,11 +25,15 @@ def post(self, request): def put(self, request): return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + def get(self, request): + return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + urlpatterns = patterns('', (r'^session/$', MockView.as_view(authentication_classes=[SessionAuthentication])), (r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])), (r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])), (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), + (r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication])) ) @@ -186,3 +194,134 @@ def test_token_login_form(self): {'username': self.username, 'password': self.password}) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.content.decode('ascii'))['token'], self.key) + +class OAuthTests(TestCase): + """OAuth 1.0a authentication""" + urls = 'rest_framework.tests.authentication' + + def setUp(self): + self.csrf_client = Client(enforce_csrf_checks=True) + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user(self.username, self.email, self.password) + + self.CONSUMER_KEY = 'consumer_key' + self.CONSUMER_SECRET = 'consumer_secret' + self.TOKEN_KEY = "token_key" + self.TOKEN_SECRET = "token_secret" + + self.consumer = Consumer.objects.create(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET, + name='example', user=self.user, status=oauth_consts.ACCEPTED) + + + self.resource = Resource.objects.create(name="resource name", url="api/") + self.token = OAuthToken.objects.create(user=self.user, consumer=self.consumer, resource=self.resource, + token_type=OAuthToken.ACCESS, key=self.TOKEN_KEY, secret=self.TOKEN_SECRET, is_approved=True + ) + + + def _create_authorization_header(self): + params = { + 'oauth_version': "1.0", + 'oauth_nonce': oauth.generate_nonce(), + 'oauth_timestamp': int(time.time()), + 'oauth_token': self.token.key, + 'oauth_consumer_key': self.consumer.key + } + + req = oauth.Request(method="GET", url="http://example.com", parameters=params) + + signature_method = oauth.SignatureMethod_PLAINTEXT() + req.sign_request(signature_method, self.consumer, self.token) + + return req.to_header()["Authorization"] + + def _create_authorization_url_parameters(self): + params = { + 'oauth_version': "1.0", + 'oauth_nonce': oauth.generate_nonce(), + 'oauth_timestamp': int(time.time()), + 'oauth_token': self.token.key, + 'oauth_consumer_key': self.consumer.key + } + + req = oauth.Request(method="GET", url="http://example.com", parameters=params) + + signature_method = oauth.SignatureMethod_PLAINTEXT() + req.sign_request(signature_method, self.consumer, self.token) + return dict(req) + + def test_post_form_passing_oauth(self): + """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" + auth = self._create_authorization_header() + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_post_form_repeated_nonce_failing_oauth(self): + """Ensure POSTing form over OAuth with repeated auth (same nonces and timestamp) credentials fails""" + auth = self._create_authorization_header() + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + # simulate reply attack auth header containes already used (nonce, timestamp) pair + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + def test_post_form_token_removed_failing_oauth(self): + """Ensure POSTing when there is no OAuth access token in db fails""" + self.token.delete() + auth = self._create_authorization_header() + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + def test_post_form_consumer_status_not_accepted_failing_oauth(self): + """Ensure POSTing when consumer status is anything other than ACCEPTED fails""" + for consumer_status in (oauth_consts.CANCELED, oauth_consts.PENDING, oauth_consts.REJECTED): + self.consumer.status = consumer_status + self.consumer.save() + + auth = self._create_authorization_header() + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + def test_post_form_with_request_token_failing_oauth(self): + """Ensure POSTing with unauthorized request token instead of access token fails""" + self.token.token_type = OAuthToken.REQUEST + self.token.save() + + auth = self._create_authorization_header() + response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + def test_post_form_with_urlencoded_parameters(self): + """Ensure POSTing with x-www-form-urlencoded auth parameters passes""" + params = self._create_authorization_url_parameters() + response = self.csrf_client.post('/oauth/', params) + self.assertEqual(response.status_code, 200) + + def test_get_form_with_url_parameters(self): + """Ensure GETing with auth in url parameters passes""" + params = self._create_authorization_url_parameters() + response = self.csrf_client.get('/oauth/', params) + self.assertEqual(response.status_code, 200) + + def test_post_hmac_sha1_signature_passes(self): + """Ensure POSTing using HMAC_SHA1 signature method passes""" + params = { + 'oauth_version': "1.0", + 'oauth_nonce': oauth.generate_nonce(), + 'oauth_timestamp': int(time.time()), + 'oauth_token': self.token.key, + 'oauth_consumer_key': self.consumer.key + } + + req = oauth.Request(method="POST", url="http://testserver/oauth/", parameters=params) + + signature_method = oauth.SignatureMethod_HMAC_SHA1() + req.sign_request(signature_method, self.consumer, self.token) + auth = req.to_header()["Authorization"] + + response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + From ced22db7cfe83d658283257bd898e7e11d125ad4 Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 16:57:45 +0100 Subject: [PATCH 02/41] add django-oauth-plus & oauth2 to installed apps in runtests settings.py --- rest_framework/runtests/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 03bfc21620..683669ce0c 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -97,7 +97,9 @@ # 'django.contrib.admindocs', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework.tests' + 'rest_framework.tests', + 'oauth_provider', + ) STATIC_URL = '/static/' From 1aed9c1604be3db8f3f2d2de748eb6f7c574637a Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 16:58:16 +0100 Subject: [PATCH 03/41] add OAuthAuthentication class --- rest_framework/authentication.py | 119 ++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 14b2136b3d..2bd0767e3c 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -9,6 +9,18 @@ from rest_framework.authtoken.models import Token import base64 +from django.core.exceptions import ImproperlyConfigured +try: + import oauth2 +except ImportError: + oauth2 = None + +try: + import oauth_provider + from oauth_provider.store import store +except ImportError: + oauth_provider = None + class BaseAuthentication(object): """ @@ -155,4 +167,109 @@ def authenticate_header(self, request): return 'Token' -# TODO: OAuthAuthentication +class OAuthAuthentication(BaseAuthentication): + """rest_framework OAuth authentication backend using + django-oath-plus""" + www_authenticate_realm = 'api' + require_active = True + + def __init__(self, **kwargs): + super(OAuthAuthentication, self).__init__(**kwargs) + + if oauth2 is None: + raise ImproperlyConfigured("The 'python-oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") + + if oauth_provider is None: + raise ImproperlyConfigured("The 'django-oauth-plus' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") + + + def authenticate(self, request): + """ + :returns: two-tuple of (user, auth) if authentication succeeds, or None otherwise. + """ + from oauth_provider.store import store + if self.is_valid_request(request): + oauth_request = oauth_provider.utils.get_oauth_request(request) + + if not self.check_nonce(request, oauth_request): + raise exceptions.AuthenticationFailed("Nonce check failed") + + try: + consumer = store.get_consumer(request, oauth_request, + oauth_request.get_parameter('oauth_consumer_key')) + except oauth_provider.store.InvalidConsumerError, e: + raise exceptions.AuthenticationFailed(e) + + if consumer.status != oauth_provider.consts.ACCEPTED: + raise exceptions.AuthenticationFailed('Invalid consumer key status: %s' % consumer.get_status_display()) + + try: + token = store.get_access_token(request, oauth_request, + consumer, oauth_request.get_parameter('oauth_token')) + + except oauth_provider.store.InvalidTokenError: + raise exceptions.AuthenticationFailed( + 'Invalid access token: %s' % oauth_request.get_parameter('oauth_token')) + + try: + self.validate_token(request, consumer, token) + except oauth2.Error, e: + print "got e" + raise exceptions.AuthenticationFailed(e.message) + + if not self.check_active(token.user): + raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username) + + if consumer and token: + request.user = token.user + return (request.user, None) + + raise exceptions.AuthenticationFailed( + 'You are not allowed to access this resource.') + + return None + + def authenticate_header(self, request): + return 'OAuth realm="%s"' % self.www_authenticate_realm + + def is_in(self, params): + """ + Checks to ensure that all the OAuth parameter names are in the + provided ``params``. + """ + from oauth_provider.consts import OAUTH_PARAMETERS_NAMES + + for param_name in OAUTH_PARAMETERS_NAMES: + if param_name not in params: + return False + + return True + + def is_valid_request(self, request): + """ + Checks whether the required parameters are either in the HTTP + ``Authorization`` header sent by some clients (the preferred method + according to OAuth spec) or fall back to ``GET/POST``. + """ + auth_params = request.META.get("HTTP_AUTHORIZATION", []) + return self.is_in(auth_params) or self.is_in(request.REQUEST) + + def validate_token(self, request, consumer, token): + oauth_server, oauth_request = oauth_provider.utils.initialize_server_request(request) + return oauth_server.verify_request(oauth_request, consumer, token) + + def check_active(self, user): + """ + Ensures the user has an active account. + + Optimized for the ``django.contrib.auth.models.User`` case. + """ + if not self.require_active: + # Ignore & move on. + return True + + return user.is_active + + def check_nonce(self, request, oauth_request): + """Checks nonce of request""" + return store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) From e2b11a29d129816c1d05cdd90838f64f07a12bb5 Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 17:00:09 +0100 Subject: [PATCH 04/41] add django-oauth-plus & oauth2 to .travis.yml --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 3787e5177b..046b6d9ccd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,8 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 + - pip install oauth2 + - pip install django-oauth-plus - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi" - export PYTHONPATH=. From cfce4555d7903720806d8acc9febc3b9fc3ed0a6 Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 17:02:13 +0100 Subject: [PATCH 05/41] add django-oauth-plus & oauth2 to optionals.txt --- optionals.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/optionals.txt b/optionals.txt index 3d98cc0e73..d0bc6a7080 100644 --- a/optionals.txt +++ b/optionals.txt @@ -2,3 +2,5 @@ markdown>=2.1.0 PyYAML>=3.10 defusedxml>=0.3 django-filter>=0.5.4 +django-oauth-plus>=2.0 +oauth2>=1.5.211 From 5d9ed34e4115f683294082faf9c9584a53bb7e49 Mon Sep 17 00:00:00 2001 From: swistakm Date: Mon, 25 Feb 2013 17:41:34 +0100 Subject: [PATCH 06/41] add OAuthAuthentication documentation stub --- docs/api-guide/authentication.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 8c1d118527..81b35909f6 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -182,6 +182,20 @@ Unauthenticated responses that are denied permission will result in an `HTTP 403 If you're using an AJAX style API with SessionAuthentication, you'll need to make sure you include a valid CSRF token for any "unsafe" HTTP method calls, such as `PUT`, `PATCH`, `POST` or `DELETE` requests. See the [Django CSRF documentation][csrf-ajax] for more details. +## OAuthAuthentication + +This authentication uses [OAuth 1.0](http://tools.ietf.org/html/rfc5849) authentication scheme. It depends on optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must istall these packages and add `oauth_provider` (from `django-oauth-plus`) to your `INSTALLED_APPS`: + + INSTALLED_APPS = ( + #(...) + `oauth_provider`, + ) + +OAuthAuthentication class provides only token verification and signature validation for requests. It doesn't provide authorization flow for your clients. You still need to implement your own views for accessing and authorizing Reqest/Access Tokens. This is because there are many different OAuth flows in use. Almost always they require end-user interaction, and most likely this is what you want to design yourself. + +Luckily `django-oauth-plus` provides simple foundation for classic 'three-legged' oauth flow, so if it is what you need please refer to [its documentation](http://code.larlet.fr/django-oauth-plus/wiki/Home). This documentation will provide you also information about how to work with supplied models and change basic settings. + + # Custom authentication To implement a custom authentication scheme, subclass `BaseAuthentication` and override the `.authenticate(self, request)` method. The method should return a two-tuple of `(user, auth)` if authentication succeeds, or `None` otherwise. From 59a6f5f463472656518cb0680b9da5f22a724882 Mon Sep 17 00:00:00 2001 From: swistakm Date: Tue, 26 Feb 2013 11:22:21 +0100 Subject: [PATCH 07/41] Move oauth2 and django-oauth-plus imports to compat and fix some minor issues - alias oauth2 as oauth - remove rouge print - remove docstring markups - OAuthAuthentication.authenticate() now returns (user, token) two-tuple on success - don't set request.user because it's already set --- rest_framework/authentication.py | 31 ++++++++++--------------------- rest_framework/compat.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 2bd0767e3c..b507c5e180 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -4,23 +4,14 @@ from __future__ import unicode_literals from django.contrib.auth import authenticate from django.utils.encoding import DjangoUnicodeDecodeError +from django.core.exceptions import ImproperlyConfigured from rest_framework import exceptions, HTTP_HEADER_ENCODING from rest_framework.compat import CsrfViewMiddleware +from rest_framework.compat import oauth +from rest_framework.compat import oauth_provider from rest_framework.authtoken.models import Token import base64 -from django.core.exceptions import ImproperlyConfigured -try: - import oauth2 -except ImportError: - oauth2 = None - -try: - import oauth_provider - from oauth_provider.store import store -except ImportError: - oauth_provider = None - class BaseAuthentication(object): """ @@ -169,15 +160,15 @@ def authenticate_header(self, request): class OAuthAuthentication(BaseAuthentication): """rest_framework OAuth authentication backend using - django-oath-plus""" + django-oath-plus and oauth2""" www_authenticate_realm = 'api' require_active = True def __init__(self, **kwargs): super(OAuthAuthentication, self).__init__(**kwargs) - if oauth2 is None: - raise ImproperlyConfigured("The 'python-oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") + if oauth is None: + raise ImproperlyConfigured("The 'oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") if oauth_provider is None: raise ImproperlyConfigured("The 'django-oauth-plus' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") @@ -185,7 +176,7 @@ def __init__(self, **kwargs): def authenticate(self, request): """ - :returns: two-tuple of (user, auth) if authentication succeeds, or None otherwise. + Returns two-tuple of (user, auth token) if authentication succeeds, or None otherwise. """ from oauth_provider.store import store if self.is_valid_request(request): @@ -213,16 +204,14 @@ def authenticate(self, request): try: self.validate_token(request, consumer, token) - except oauth2.Error, e: - print "got e" + except oauth.Error, e: raise exceptions.AuthenticationFailed(e.message) if not self.check_active(token.user): raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username) if consumer and token: - request.user = token.user - return (request.user, None) + return (token.user, token) raise exceptions.AuthenticationFailed( 'You are not allowed to access this resource.') @@ -272,4 +261,4 @@ def check_active(self, user): def check_nonce(self, request, oauth_request): """Checks nonce of request""" - return store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) + return oauth_provider.store.store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 07fdddce4c..e4bad0cbe2 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -426,3 +426,15 @@ def apply_markdown(text): import defusedxml.ElementTree as etree except ImportError: etree = None + +# OAuth is optional +try: + import oauth2 as oauth +except ImportError: + oauth = None + +# OAuth is optional +try: + import oauth_provider +except ImportError: + oauth_provider = None \ No newline at end of file From d84c2cf2752467c835200a54601f77ad1cee6b38 Mon Sep 17 00:00:00 2001 From: swistakm Date: Tue, 26 Feb 2013 11:25:27 +0100 Subject: [PATCH 08/41] OAuth tests now are skipped unless django-oauth-plus and oauth2 are installed. --- rest_framework/tests/authentication.py | 37 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 88b6fd166d..d1c978fc6c 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -9,12 +9,11 @@ from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication, OAuthAuthentication from rest_framework.compat import patterns from rest_framework.views import APIView +from rest_framework.compat import oauth +from rest_framework.compat import oauth_provider import json import base64 -from oauth_provider.models import Consumer, Resource -from oauth_provider.models import Token as OAuthToken -from oauth_provider import consts as oauth_consts -import oauth2 as oauth +import unittest class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) @@ -200,6 +199,14 @@ class OAuthTests(TestCase): urls = 'rest_framework.tests.authentication' def setUp(self): + # these imports are here because oauth is optional and hiding them in try..except block or compat + # could obscure problems if something breaks + from oauth_provider.models import Consumer, Resource + from oauth_provider.models import Token as OAuthToken + from oauth_provider import consts + + self.consts = consts + self.csrf_client = Client(enforce_csrf_checks=True) self.username = 'john' self.email = 'lennon@thebeatles.com' @@ -212,7 +219,7 @@ def setUp(self): self.TOKEN_SECRET = "token_secret" self.consumer = Consumer.objects.create(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET, - name='example', user=self.user, status=oauth_consts.ACCEPTED) + name='example', user=self.user, status=self.consts.ACCEPTED) self.resource = Resource.objects.create(name="resource name", url="api/") @@ -252,12 +259,16 @@ def _create_authorization_url_parameters(self): req.sign_request(signature_method, self.consumer, self.token) return dict(req) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_passing_oauth(self): """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" auth = self._create_authorization_header() response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_repeated_nonce_failing_oauth(self): """Ensure POSTing form over OAuth with repeated auth (same nonces and timestamp) credentials fails""" auth = self._create_authorization_header() @@ -268,6 +279,8 @@ def test_post_form_repeated_nonce_failing_oauth(self): response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_token_removed_failing_oauth(self): """Ensure POSTing when there is no OAuth access token in db fails""" self.token.delete() @@ -275,9 +288,11 @@ def test_post_form_token_removed_failing_oauth(self): response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_consumer_status_not_accepted_failing_oauth(self): """Ensure POSTing when consumer status is anything other than ACCEPTED fails""" - for consumer_status in (oauth_consts.CANCELED, oauth_consts.PENDING, oauth_consts.REJECTED): + for consumer_status in (self.consts.CANCELED, self.consts.PENDING, self.consts.REJECTED): self.consumer.status = consumer_status self.consumer.save() @@ -285,27 +300,35 @@ def test_post_form_consumer_status_not_accepted_failing_oauth(self): response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_with_request_token_failing_oauth(self): """Ensure POSTing with unauthorized request token instead of access token fails""" - self.token.token_type = OAuthToken.REQUEST + self.token.token_type = self.token.REQUEST self.token.save() auth = self._create_authorization_header() response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_form_with_urlencoded_parameters(self): """Ensure POSTing with x-www-form-urlencoded auth parameters passes""" params = self._create_authorization_url_parameters() response = self.csrf_client.post('/oauth/', params) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_get_form_with_url_parameters(self): """Ensure GETing with auth in url parameters passes""" params = self._create_authorization_url_parameters() response = self.csrf_client.get('/oauth/', params) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') def test_post_hmac_sha1_signature_passes(self): """Ensure POSTing using HMAC_SHA1 signature method passes""" params = { From a4304458f5a07acc400b7630a59a4a0996e166d9 Mon Sep 17 00:00:00 2001 From: swistakm Date: Tue, 26 Feb 2013 11:27:06 +0100 Subject: [PATCH 09/41] runtest.settings fixed if django-oauth-plus or oauth2 are not installed oauth_provider can be added to INSTALLED_APPS only if these packages are installed --- rest_framework/runtests/settings.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 683669ce0c..eb3f111595 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -98,10 +98,18 @@ 'rest_framework', 'rest_framework.authtoken', 'rest_framework.tests', - 'oauth_provider', - ) +# OAuth is optional and won't work if there is no oauth_provider & oauth2 +try: + import oauth_provider + import oauth2 +except ImportError: + pass +else: + INSTALLED_APPS += ('oauth_provider',) + + STATIC_URL = '/static/' PASSWORD_HASHERS = ( From dd355d511c2e1bf4b26be80955a4f53b20bfed1b Mon Sep 17 00:00:00 2001 From: swistakm Date: Wed, 27 Feb 2013 10:56:04 +0100 Subject: [PATCH 10/41] oauth2 & django-oauth-plus installed only on 2.x --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 046b6d9ccd..52ed562cc8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,8 +14,8 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 - - pip install oauth2 - - pip install django-oauth-plus + - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2; fi" + - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi" - export PYTHONPATH=. From 55ea5b9460842f6b8aefa67575a6d25b2d479593 Mon Sep 17 00:00:00 2001 From: swistakm Date: Wed, 27 Feb 2013 10:58:13 +0100 Subject: [PATCH 11/41] import compat version of unittest --- rest_framework/tests/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index d1c978fc6c..8ef9d3ffb4 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import User from django.http import HttpResponse from django.test import Client, TestCase +from django.utils import unittest import time from rest_framework import HTTP_HEADER_ENCODING, status from rest_framework import permissions @@ -13,7 +14,7 @@ from rest_framework.compat import oauth_provider import json import base64 -import unittest + class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) From 2eabc5c2b46d9f4cc7a467af849ff31397b9d7bf Mon Sep 17 00:00:00 2001 From: swistakm Date: Wed, 27 Feb 2013 11:02:50 +0100 Subject: [PATCH 12/41] rfc5849 link with anchor --- docs/api-guide/authentication.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 81b35909f6..d7918e10b8 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -184,7 +184,7 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak ## OAuthAuthentication -This authentication uses [OAuth 1.0](http://tools.ietf.org/html/rfc5849) authentication scheme. It depends on optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must istall these packages and add `oauth_provider` (from `django-oauth-plus`) to your `INSTALLED_APPS`: +This authentication uses [OAuth 1.0][rfc5849] authentication scheme. It depends on optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must istall these packages and add `oauth_provider` (from `django-oauth-plus`) to your `INSTALLED_APPS`: INSTALLED_APPS = ( #(...) @@ -249,3 +249,4 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [mod_wsgi_official]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization [juanriaza]: https://github.com/juanriaza [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth +[rfc5849] : http://tools.ietf.org/html/rfc5849 \ No newline at end of file From 468b5e43e2582513c4ae862efa4511ea8313031e Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 02:06:20 +0100 Subject: [PATCH 13/41] Add tests for OAuth2 authentication --- rest_framework/tests/authentication.py | 110 ++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 2a2bfba954..3ceab80858 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.http import HttpResponse from django.test import Client, TestCase @@ -6,11 +7,15 @@ from rest_framework import permissions from rest_framework import status from rest_framework.authtoken.models import Token -from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication -from rest_framework.compat import patterns +from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication, OAuth2Authentication +from rest_framework.compat import patterns, url, include +from rest_framework.compat import oauth2 +from rest_framework.compat import oauth2_provider from rest_framework.views import APIView import json import base64 +import datetime +import unittest class MockView(APIView): @@ -22,11 +27,16 @@ def post(self, request): def put(self, request): return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + def get(self, request): + return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + urlpatterns = patterns('', (r'^session/$', MockView.as_view(authentication_classes=[SessionAuthentication])), (r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])), (r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])), (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), + url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')), + url(r'^oauth2-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication])), ) @@ -187,3 +197,99 @@ def test_token_login_form(self): {'username': self.username, 'password': self.password}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(json.loads(response.content.decode('ascii'))['token'], self.key) + + +class OAuth2Tests(TestCase): + """OAuth 2.0 authentication""" + urls = 'rest_framework.tests.authentication' + + def setUp(self): + self.csrf_client = Client(enforce_csrf_checks=True) + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user(self.username, self.email, self.password) + + self.CLIENT_ID = 'client_key' + self.CLIENT_SECRET = 'client_secret' + self.ACCESS_TOKEN = "access_token" + self.REFRESH_TOKEN = "refresh_token" + + self.oauth2_client = oauth2.models.Client.objects.create( + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + redirect_uri='', + client_type=0, + name='example', + user=None, + ) + + self.access_token = oauth2.models.AccessToken.objects.create( + token=self.ACCESS_TOKEN, + client=self.oauth2_client, + user=self.user, + ) + self.refresh_token = oauth2.models.RefreshToken.objects.create( + user=self.user, + access_token=self.access_token, + client=self.oauth2_client + ) + + def _create_authorization_header(self, token=None): + return "Bearer {0}".format(token or self.access_token.token) + + def _client_credentials_params(self): + return {'client_id': self.CLIENT_ID, 'client_secret': self.CLIENT_SECRET} + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_get_form_with_wrong_client_data_failing_auth(self): + """Ensure GETing form over OAuth with incorrect client credentials fails""" + auth = self._create_authorization_header() + params = self._client_credentials_params() + params['client_id'] += 'a' + response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_get_form_passing_auth(self): + """Ensure GETing form over OAuth with correct client credentials succeed""" + auth = self._create_authorization_header() + params = self._client_credentials_params() + response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_post_form_passing_auth(self): + """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" + auth = self._create_authorization_header() + params = self._client_credentials_params() + response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_post_form_token_removed_failing_auth(self): + """Ensure POSTing when there is no OAuth access token in db fails""" + self.access_token.delete() + auth = self._create_authorization_header() + params = self._client_credentials_params() + response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_post_form_with_refresh_token_failing_auth(self): + """Ensure POSTing with refresh token instead of access token fails""" + auth = self._create_authorization_header(token=self.refresh_token.token) + params = self._client_credentials_params() + response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + def test_post_form_with_expired_access_token_failing_auth(self): + """Ensure POSTing with expired access token fails with an 'Invalid token' error""" + self.access_token.expires = datetime.datetime.now() - datetime.timedelta(seconds=10) # 10 seconds late + self.access_token.save() + auth = self._create_authorization_header() + params = self._client_credentials_params() + response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + self.assertIn('Invalid token', response.content) From 4e1f77db1ae48b156cb163a0853776cc23e230b5 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 02:07:53 +0100 Subject: [PATCH 14/41] Add django-oauth2-provider to the installed apps --- rest_framework/runtests/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 03bfc21620..67dc7fff5f 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -97,7 +97,9 @@ # 'django.contrib.admindocs', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework.tests' + 'rest_framework.tests', + 'provider', + 'provider.oauth2', ) STATIC_URL = '/static/' From 592a0a5980e4baf18102769997de8011be72cd6b Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 02:08:21 +0100 Subject: [PATCH 15/41] Add django-oauth2-provider to optionals.txt --- optionals.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/optionals.txt b/optionals.txt index 3d98cc0e73..a36e9e9400 100644 --- a/optionals.txt +++ b/optionals.txt @@ -2,3 +2,4 @@ markdown>=2.1.0 PyYAML>=3.10 defusedxml>=0.3 django-filter>=0.5.4 +-e git+git://github.com/caffeinehit/django-oauth2-provider.git@3198060acfe14730ce2d81310cbf2b13f6403438#egg=django_oauth2_provider-dev \ No newline at end of file From da9d7fb8ec19f289d9d2777738a45007c41a1289 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 02:08:58 +0100 Subject: [PATCH 16/41] Add the OAuth2Authentication class --- rest_framework/authentication.py | 85 +++++++++++++++++++++++++++++++- rest_framework/compat.py | 11 +++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 14b2136b3d..c20d9cb559 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -6,6 +6,7 @@ from django.utils.encoding import DjangoUnicodeDecodeError from rest_framework import exceptions, HTTP_HEADER_ENCODING from rest_framework.compat import CsrfViewMiddleware +from rest_framework.compat import oauth2_provider, oauth2 from rest_framework.authtoken.models import Token import base64 @@ -155,4 +156,86 @@ def authenticate_header(self, request): return 'Token' -# TODO: OAuthAuthentication +class OAuth2Authentication(BaseAuthentication): + """ + OAuth 2 authentication backend using `django-oauth2-provider` + """ + require_active = True + + def __init__(self, **kwargs): + super(OAuth2Authentication, self).__init__(**kwargs) + if oauth2_provider is None: + raise ImproperlyConfigured("The 'django-oauth2-provider' package could not be imported. It is required for use with the 'OAuth2Authentication' class.") + + def authenticate(self, request): + """ + The Bearer type is the only finalized type + + Read the spec for more details + http://tools.ietf.org/html/rfc6749#section-7.1 + """ + auth = request.META.get('HTTP_AUTHORIZATION', '').split() + print auth + if not auth or auth[0].lower() != "bearer": + return None + + if len(auth) != 2: + raise exceptions.AuthenticationFailed('Invalid token header') + + return self.authenticate_credentials(request, auth[1]) + + def authenticate_credentials(self, request, access_token): + """ + :returns: two-tuple of (user, auth) if authentication succeeds, or None otherwise. + """ + + # authenticate the client + oauth2_client_form = oauth2.forms.ClientAuthForm(request.REQUEST) + if not oauth2_client_form.is_valid(): + raise exceptions.AuthenticationFailed("Client could not be validated") + client = oauth2_client_form.cleaned_data.get('client') + + # retrieve the `oauth2.models.OAuth2AccessToken` instance from the access_token + auth_backend = oauth2.backends.AccessTokenBackend() + token = auth_backend.authenticate(access_token, client) + if token is None: + raise exceptions.AuthenticationFailed("Invalid token") # does not exist or is expired + + # TODO check scope + # try: + # self.validate_token(request, consumer, token) + # except oauth2.Error, e: + # print "got e" + # raise exceptions.AuthenticationFailed(e.message) + + if not self.check_active(token.user): + raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username) + + if client and token: + request.user = token.user + return (request.user, None) + + raise exceptions.AuthenticationFailed( + 'You are not allowed to access this resource.') + + return None + + def authenticate_header(self, request): + """ + Bearer is the only finalized type currently + + Check details on the `OAuth2Authentication.authenticate` method + """ + return 'Bearer' + + def check_active(self, user): + """ + Ensures the user has an active account. + + Optimized for the ``django.contrib.auth.models.User`` case. + """ + if not self.require_active: + # Ignore & move on. + return True + + return user.is_active diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 07fdddce4c..5bba0c8656 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -426,3 +426,14 @@ def apply_markdown(text): import defusedxml.ElementTree as etree except ImportError: etree = None + + +# OAuth 2 support is optional +try: + import provider as oauth2_provider +except ImportError: + oauth2_provider = None +try: + import provider.oauth2 as oauth2 +except ImportError: + oauth2 = None From d8f455bc0ff920e9e0cd1952f58b5a0eccdc2683 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 02:09:52 +0100 Subject: [PATCH 17/41] Add OAuth2Authentication documentation --- docs/api-guide/authentication.md | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 342fabe7bb..ba7c0c5814 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -182,6 +182,74 @@ Unauthenticated responses that are denied permission will result in an `HTTP 403 If you're using an AJAX style API with SessionAuthentication, you'll need to make sure you include a valid CSRF token for any "unsafe" HTTP method calls, such as `PUT`, `PATCH`, `POST` or `DELETE` requests. See the [Django CSRF documentation][csrf-ajax] for more details. +## OAuth2Authentication + +This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on optional [`django-oauth2-provider`](https://github.com/caffeinehit/django-oauth2-provider). In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : + + INSTALLED_APPS = ( + #(...) + 'provider', + 'provider.oauth2', + ) + +And include the urls needed in your root `urls.py` file to be able to begin the *oauth 2 dance* : + + url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')), + +--- + +** Note:** The *namespace* argument is required ! + +--- + +Finally, sync your database with those two new django apps. + + $ python manage.py syncdb + $ python manage.py migrate + +`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`](https://github.com/caffeinehit/django-oauth2-provider) dependency. Unfortunately, there isn't a lot of [documentation](https://django-oauth2-provider.readthedocs.org/en/latest/) currently on how to *dance* with this package on the client side. + +The Good news is, here is a minimal "How to start" because **OAuth 2** is dramatically simpler than **OAuth 1**, so no more headache with signature, cryptography on client side, and other complex things. + +### How to start with *django-oauth2-provider* ? + +#### Create a client in the django-admin panel + +Go to the admin panel and create a new `Provider.Client` entry. It will create the `client_id` and `client_secret` properties for you. + +#### Request an access token + +Your client interface – I mean by that your iOS code, HTML code, or whatever else language – just have to submit a `POST` request at the url `/oauth2/access_token` with the following fields : + +* `client_id` the client id you've just configured at the previous step. +* `client_secret` again configured at the previous step. +* `username` the username with which you want to log in. +* `password` well, that speaks for itself. + +--- + +**Note:** Remember that you are **highly encourage** to use HTTPS for all your OAuth 2 requests. And by *highly encourage* I mean you SHOULD always use HTTPS otherwise you will expose user passwords for any person who can intercept the request (like a man in the middle attack). + +--- + +You can use the command line to test that your local configuration is working : + + $ curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOU_PASSWORD" http://localhost:8000/oauth2/access_token/ + +Here is the response you should get : + + {"access_token": "", "scope": "read", "expires_in": 86399, "refresh_token": ""} + +#### Access the api + +The only thing needed to make the `OAuth2Authentication` class work is to insert the `access_token` you've received in the `Authorization` api request header. + +The command line to test the authentication looks like : + + $ curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET + +And hopefully, it will work like a charm. + # Custom authentication To implement a custom authentication scheme, subclass `BaseAuthentication` and override the `.authenticate(self, request)` method. The method should return a two-tuple of `(user, auth)` if authentication succeeds, or `None` otherwise. @@ -235,3 +303,4 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [mod_wsgi_official]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization [juanriaza]: https://github.com/juanriaza [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth +[rfc6749]: http://tools.ietf.org/html/rfc6749 From 9d5c3060386cc8deb4ee55eda022f0a134e897c0 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 11:53:30 +0100 Subject: [PATCH 18/41] Improve the `django-oauth2-provider` import block to avoid naming collision with `oauth2` used for OAuth 1 --- rest_framework/authentication.py | 10 +++++----- rest_framework/compat.py | 6 +----- rest_framework/tests/authentication.py | 19 +++++++++---------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index c20d9cb559..c94af40514 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -6,7 +6,7 @@ from django.utils.encoding import DjangoUnicodeDecodeError from rest_framework import exceptions, HTTP_HEADER_ENCODING from rest_framework.compat import CsrfViewMiddleware -from rest_framework.compat import oauth2_provider, oauth2 +from rest_framework.compat import oauth2_provider from rest_framework.authtoken.models import Token import base64 @@ -190,13 +190,13 @@ def authenticate_credentials(self, request, access_token): """ # authenticate the client - oauth2_client_form = oauth2.forms.ClientAuthForm(request.REQUEST) + oauth2_client_form = oauth2_provider.forms.ClientAuthForm(request.REQUEST) if not oauth2_client_form.is_valid(): raise exceptions.AuthenticationFailed("Client could not be validated") client = oauth2_client_form.cleaned_data.get('client') - # retrieve the `oauth2.models.OAuth2AccessToken` instance from the access_token - auth_backend = oauth2.backends.AccessTokenBackend() + # retrieve the `oauth2_provider.models.OAuth2AccessToken` instance from the access_token + auth_backend = oauth2_provider.backends.AccessTokenBackend() token = auth_backend.authenticate(access_token, client) if token is None: raise exceptions.AuthenticationFailed("Invalid token") # does not exist or is expired @@ -204,7 +204,7 @@ def authenticate_credentials(self, request, access_token): # TODO check scope # try: # self.validate_token(request, consumer, token) - # except oauth2.Error, e: + # except oauth2_provider.Error, e: # print "got e" # raise exceptions.AuthenticationFailed(e.message) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 5bba0c8656..e0a43f3f94 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -430,10 +430,6 @@ def apply_markdown(text): # OAuth 2 support is optional try: - import provider as oauth2_provider + import provider.oauth2 as oauth2_provider except ImportError: oauth2_provider = None -try: - import provider.oauth2 as oauth2 -except ImportError: - oauth2 = None diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index c2c23bccea..1212f0aac7 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -16,7 +16,6 @@ OAuth2Authentication ) from rest_framework.compat import patterns, url, include -from rest_framework.compat import oauth2 from rest_framework.compat import oauth2_provider from rest_framework.tests.utils import RequestFactory from rest_framework.views import APIView @@ -248,7 +247,7 @@ def setUp(self): self.ACCESS_TOKEN = "access_token" self.REFRESH_TOKEN = "refresh_token" - self.oauth2_client = oauth2.models.Client.objects.create( + self.oauth2_client = oauth2_provider.models.Client.objects.create( client_id=self.CLIENT_ID, client_secret=self.CLIENT_SECRET, redirect_uri='', @@ -257,12 +256,12 @@ def setUp(self): user=None, ) - self.access_token = oauth2.models.AccessToken.objects.create( + self.access_token = oauth2_provider.models.AccessToken.objects.create( token=self.ACCESS_TOKEN, client=self.oauth2_client, user=self.user, ) - self.refresh_token = oauth2.models.RefreshToken.objects.create( + self.refresh_token = oauth2_provider.models.RefreshToken.objects.create( user=self.user, access_token=self.access_token, client=self.oauth2_client @@ -274,7 +273,7 @@ def _create_authorization_header(self, token=None): def _client_credentials_params(self): return {'client_id': self.CLIENT_ID, 'client_secret': self.CLIENT_SECRET} - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_with_wrong_client_data_failing_auth(self): """Ensure GETing form over OAuth with incorrect client credentials fails""" auth = self._create_authorization_header() @@ -283,7 +282,7 @@ def test_get_form_with_wrong_client_data_failing_auth(self): response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_passing_auth(self): """Ensure GETing form over OAuth with correct client credentials succeed""" auth = self._create_authorization_header() @@ -291,7 +290,7 @@ def test_get_form_passing_auth(self): response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_passing_auth(self): """Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF""" auth = self._create_authorization_header() @@ -299,7 +298,7 @@ def test_post_form_passing_auth(self): response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_token_removed_failing_auth(self): """Ensure POSTing when there is no OAuth access token in db fails""" self.access_token.delete() @@ -308,7 +307,7 @@ def test_post_form_token_removed_failing_auth(self): response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_with_refresh_token_failing_auth(self): """Ensure POSTing with refresh token instead of access token fails""" auth = self._create_authorization_header(token=self.refresh_token.token) @@ -316,7 +315,7 @@ def test_post_form_with_refresh_token_failing_auth(self): response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) - @unittest.skipUnless(oauth2, 'django-oauth2-provider not installed') + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_with_expired_access_token_failing_auth(self): """Ensure POSTing with expired access token fails with an 'Invalid token' error""" self.access_token.expires = datetime.datetime.now() - datetime.timedelta(seconds=10) # 10 seconds late From 653fcf7e3315f9aefba8474591909e564492ecfe Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 12:02:03 +0100 Subject: [PATCH 19/41] Use the correct doc link style --- docs/api-guide/authentication.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 6a259500b4..c73de1f671 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -184,7 +184,7 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak ## OAuth2Authentication -This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on optional [`django-oauth2-provider`](https://github.com/caffeinehit/django-oauth2-provider). In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : +This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on optional [`django-oauth2-provider`][django-oauth2-provider]. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : INSTALLED_APPS = ( #(...) @@ -207,7 +207,7 @@ Finally, sync your database with those two new django apps. $ python manage.py syncdb $ python manage.py migrate -`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`](https://github.com/caffeinehit/django-oauth2-provider) dependency. Unfortunately, there isn't a lot of [documentation](https://django-oauth2-provider.readthedocs.org/en/latest/) currently on how to *dance* with this package on the client side. +`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`][django-oauth2-provider] dependency. Unfortunately, there isn't a lot of [documentation][django-oauth2-provider--doc] currently on how to *dance* with this package on the client side. The Good news is, here is a minimal "How to start" because **OAuth 2** is dramatically simpler than **OAuth 1**, so no more headache with signature, cryptography on client side, and other complex things. @@ -303,4 +303,6 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [mod_wsgi_official]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization [juanriaza]: https://github.com/juanriaza [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth +[django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider +[django-oauth2-provider--doc]: https://django-oauth2-provider.readthedocs.org/en/latest/ [rfc6749]: http://tools.ietf.org/html/rfc6749 From d4c2267187128c60927931c685a5c41a95c300bd Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 12:08:28 +0100 Subject: [PATCH 20/41] Clean up some print and comments --- rest_framework/authentication.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index c94af40514..c74078fcbf 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -175,7 +175,6 @@ def authenticate(self, request): http://tools.ietf.org/html/rfc6749#section-7.1 """ auth = request.META.get('HTTP_AUTHORIZATION', '').split() - print auth if not auth or auth[0].lower() != "bearer": return None @@ -202,11 +201,6 @@ def authenticate_credentials(self, request, access_token): raise exceptions.AuthenticationFailed("Invalid token") # does not exist or is expired # TODO check scope - # try: - # self.validate_token(request, consumer, token) - # except oauth2_provider.Error, e: - # print "got e" - # raise exceptions.AuthenticationFailed(e.message) if not self.check_active(token.user): raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username) From 182edb3e9bd5ea9586c2a02befeac405fc1ee59c Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 21:25:42 +0100 Subject: [PATCH 21/41] Add django-oauth2-provider to .travis.yml --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 5f07241085..44786bf225 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 + - "pip install -e git+git://github.com/caffeinehit/django-oauth2-provider.git#egg=django-oauth2-provider" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi" - export PYTHONPATH=. From 721dc519ecdb8435bdeed6aa67d99be6968c0972 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Fri, 1 Mar 2013 23:27:47 +0100 Subject: [PATCH 22/41] Use django.utils to import the unittest module for a cross python versions compatibility --- rest_framework/tests/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 1212f0aac7..0401ddd934 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.http import HttpResponse from django.test import Client, TestCase +from django.utils import unittest from rest_framework import HTTP_HEADER_ENCODING from rest_framework import exceptions from rest_framework import permissions @@ -22,7 +23,6 @@ import json import base64 import datetime -import unittest factory = RequestFactory() From 8809c46ab5d2a09d5a956ccffcb2ae2db95c5c1b Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sat, 2 Mar 2013 20:16:18 +0100 Subject: [PATCH 23/41] Add new OAuth2 tests --- rest_framework/tests/authentication.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 0401ddd934..9d67a00504 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -273,6 +273,36 @@ def _create_authorization_header(self, token=None): def _client_credentials_params(self): return {'client_id': self.CLIENT_ID, 'client_secret': self.CLIENT_SECRET} + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_get_form_with_wrong_authorization_header_token_type_failing(self): + """Ensure that a wrong token type lead to the correct HTTP error status code""" + auth = "Wrong token-type-obsviously" + response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + params = self._client_credentials_params() + response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_get_form_with_wrong_authorization_header_token_format_failing(self): + """Ensure that a wrong token format lead to the correct HTTP error status code""" + auth = "Bearer wrong token format" + response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + params = self._client_credentials_params() + response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_get_form_with_wrong_authorization_header_token_failing(self): + """Ensure that a wrong token lead to the correct HTTP error status code""" + auth = "Bearer wrong-token" + response = self.csrf_client.get('/oauth2-test/', {}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + params = self._client_credentials_params() + response = self.csrf_client.get('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_get_form_with_wrong_client_data_failing_auth(self): """Ensure GETing form over OAuth with incorrect client credentials fails""" From c449dd4f4d8c9602c826e906870a87c13d6689de Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sat, 2 Mar 2013 20:17:14 +0100 Subject: [PATCH 24/41] Properly fail to wrong Authorization token type --- rest_framework/authentication.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index c74078fcbf..d4ba7967e6 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -176,7 +176,7 @@ def authenticate(self, request): """ auth = request.META.get('HTTP_AUTHORIZATION', '').split() if not auth or auth[0].lower() != "bearer": - return None + raise exceptions.AuthenticationFailed('Invalid Authorization token type') if len(auth) != 2: raise exceptions.AuthenticationFailed('Invalid token header') @@ -212,8 +212,6 @@ def authenticate_credentials(self, request, access_token): raise exceptions.AuthenticationFailed( 'You are not allowed to access this resource.') - return None - def authenticate_header(self, request): """ Bearer is the only finalized type currently From 6f5764105b2c361a85bc3933c943d98659bdbd68 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sat, 2 Mar 2013 20:18:50 +0100 Subject: [PATCH 25/41] Use the PyPI django-oauth2-provider version --- .travis.yml | 2 +- optionals.txt | 2 +- tox.ini | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 44786bf225..82127276be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 - - "pip install -e git+git://github.com/caffeinehit/django-oauth2-provider.git#egg=django-oauth2-provider" + - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.3 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi" - export PYTHONPATH=. diff --git a/optionals.txt b/optionals.txt index a36e9e9400..99868c6606 100644 --- a/optionals.txt +++ b/optionals.txt @@ -2,4 +2,4 @@ markdown>=2.1.0 PyYAML>=3.10 defusedxml>=0.3 django-filter>=0.5.4 --e git+git://github.com/caffeinehit/django-oauth2-provider.git@3198060acfe14730ce2d81310cbf2b13f6403438#egg=django_oauth2_provider-dev \ No newline at end of file +django-oauth2-provider>=0.2.3 \ No newline at end of file diff --git a/tox.ini b/tox.ini index 58d308acae..ea68e2668e 100644 --- a/tox.ini +++ b/tox.ini @@ -21,33 +21,39 @@ deps = django==1.5 basepython = python2.7 deps = django==1.5 django-filter==0.5.4 + django-oauth2-provider==0.2.3 [testenv:py2.6-django1.5] basepython = python2.6 deps = django==1.5 django-filter==0.5.4 defusedxml==0.3 + django-oauth2-provider==0.2.3 [testenv:py2.7-django1.4] basepython = python2.7 deps = django==1.4.3 django-filter==0.5.4 defusedxml==0.3 + django-oauth2-provider==0.2.3 [testenv:py2.6-django1.4] basepython = python2.6 deps = django==1.4.3 django-filter==0.5.4 defusedxml==0.3 + django-oauth2-provider==0.2.3 [testenv:py2.7-django1.3] basepython = python2.7 deps = django==1.3.5 django-filter==0.5.4 defusedxml==0.3 + django-oauth2-provider==0.2.3 [testenv:py2.6-django1.3] basepython = python2.6 deps = django==1.3.5 django-filter==0.5.4 defusedxml==0.3 + django-oauth2-provider==0.2.3 From cda21a306e8fdb713d26726964be10e2862140c3 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sat, 2 Mar 2013 20:20:26 +0100 Subject: [PATCH 26/41] Only add the django-oauth2-provider apps if the module is installed otherwise log a warning --- rest_framework/runtests/settings.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 67dc7fff5f..b1501cf319 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -98,10 +98,18 @@ 'rest_framework', 'rest_framework.authtoken', 'rest_framework.tests', - 'provider', - 'provider.oauth2', ) +try: + import provider + INSTALLED_APPS += ( + 'provider', + 'provider.oauth2', + ) +except ImportError, inst: + import logging + logging.warning("django-oauth2-provider is not install, some tests will be skipped") + STATIC_URL = '/static/' PASSWORD_HASHERS = ( From 30e3775b8b209242141357bad0a69b6cc503c6f9 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sat, 2 Mar 2013 20:36:53 +0100 Subject: [PATCH 27/41] Update the documentation with a warning for incompatibility with Python 3 and taking @tomchristie advice into account on how to reformulate some sentences --- docs/api-guide/authentication.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index c73de1f671..b7251fd0d3 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -184,7 +184,13 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak ## OAuth2Authentication -This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on optional [`django-oauth2-provider`][django-oauth2-provider]. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : +--- + +** Note:** This isn't available for Python 3, because the module [`django-oauth2-provider`][django-oauth2-provider] is not Python 3 ready. + +--- + +This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on the optional [`django-oauth2-provider`][django-oauth2-provider] project. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : INSTALLED_APPS = ( #(...) @@ -207,7 +213,7 @@ Finally, sync your database with those two new django apps. $ python manage.py syncdb $ python manage.py migrate -`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`][django-oauth2-provider] dependency. Unfortunately, there isn't a lot of [documentation][django-oauth2-provider--doc] currently on how to *dance* with this package on the client side. +`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`][django-oauth2-provider] dependency. The official [documentation][django-oauth2-provider--doc] is being [rewritten][django-oauth2-provider--rewritten-doc]. The Good news is, here is a minimal "How to start" because **OAuth 2** is dramatically simpler than **OAuth 1**, so no more headache with signature, cryptography on client side, and other complex things. @@ -228,13 +234,13 @@ Your client interface – I mean by that your iOS code, HTML code, or whatev --- -**Note:** Remember that you are **highly encourage** to use HTTPS for all your OAuth 2 requests. And by *highly encourage* I mean you SHOULD always use HTTPS otherwise you will expose user passwords for any person who can intercept the request (like a man in the middle attack). +**Note:** Remember that you should use HTTPS in production. --- You can use the command line to test that your local configuration is working : - $ curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOU_PASSWORD" http://localhost:8000/oauth2/access_token/ + $ curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOUR_PASSWORD" http://localhost:8000/oauth2/access_token/ Here is the response you should get : @@ -248,7 +254,7 @@ The command line to test the authentication looks like : $ curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET -And hopefully, it will work like a charm. +And it will work like a charm. # Custom authentication @@ -305,4 +311,5 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth [django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider [django-oauth2-provider--doc]: https://django-oauth2-provider.readthedocs.org/en/latest/ +[django-oauth2-provider--rewritten-doc]: http://django-oauth2-provider-dulaccc.readthedocs.org/en/latest/ [rfc6749]: http://tools.ietf.org/html/rfc6749 From 8845c0be88bf68fa0e42d05c7196cd52d897623b Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sun, 3 Mar 2013 01:09:39 +0100 Subject: [PATCH 28/41] Fix import errors --- rest_framework/compat.py | 7 +++++++ rest_framework/runtests/settings.py | 2 +- rest_framework/tests/authentication.py | 8 ++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index e0a43f3f94..1e04e8f687 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -431,5 +431,12 @@ def apply_markdown(text): # OAuth 2 support is optional try: import provider.oauth2 as oauth2_provider + + # Hack to fix submodule import issues + submodules = ['backends', 'forms','managers','models','urls','views'] + for s in submodules: + mod = __import__('provider.oauth2.%s.*' % s) + setattr(oauth2_provider, s, mod) + except ImportError: oauth2_provider = None diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index b1501cf319..16ef1d2b6e 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -106,7 +106,7 @@ 'provider', 'provider.oauth2', ) -except ImportError, inst: +except ImportError: import logging logging.warning("django-oauth2-provider is not install, some tests will be skipped") diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 9d67a00504..a02aef5503 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -46,10 +46,14 @@ def put(self, request): (r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])), (r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])), (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), - url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')), - url(r'^oauth2-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication])), ) +if oauth2_provider is not None: + urlpatterns += patterns('', + url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')), + url(r'^oauth2-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication])), + ) + class BasicAuthTests(TestCase): """Basic authentication""" From 5a56f92abf5f52ac153c4faa1b75af519c96a207 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sun, 3 Mar 2013 01:38:03 +0100 Subject: [PATCH 29/41] Update the package dependency url style in tox.ini --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index ea68e2668e..f3c19375f8 100644 --- a/tox.ini +++ b/tox.ini @@ -8,13 +8,13 @@ commands = {envpython} rest_framework/runtests/runtests.py [testenv:py3.3-django1.5] basepython = python3.3 deps = django==1.5 - https://github.com/alex/django-filter/archive/master.tar.gz + -egit+git://github.com/alex/django-filter.git#egg=django_filter defusedxml==0.3 [testenv:py3.2-django1.5] basepython = python3.2 deps = django==1.5 - https://github.com/alex/django-filter/archive/master.tar.gz + -egit+git://github.com/alex/django-filter.git#egg=django_filter defusedxml==0.3 [testenv:py2.7-django1.5] From 44930f30915298cda8c1474ed9ec4415258c3e6f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 7 Mar 2013 09:15:05 +0000 Subject: [PATCH 30/41] Fix Py3k syntax errors --- rest_framework/authentication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 24a8e336a1..460c1e535a 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -185,7 +185,7 @@ def authenticate(self, request): try: consumer_key = oauth_request.get_parameter('oauth_consumer_key') consumer = oauth_provider_store.get_consumer(request, oauth_request, consumer_key) - except oauth_provider_store.InvalidConsumerError, err: + except oauth_provider_store.InvalidConsumerError as err: raise exceptions.AuthenticationFailed(err) if consumer.status != oauth_provider.consts.ACCEPTED: @@ -201,8 +201,8 @@ def authenticate(self, request): try: self.validate_token(request, consumer, token) - except oauth.Error, e: - raise exceptions.AuthenticationFailed(e.message) + except oauth.Error as err: + raise exceptions.AuthenticationFailed(err.message) user = token.user From 1d62594fa9ed87545a312681f999bbfa0237491b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 7 Mar 2013 15:44:36 +0000 Subject: [PATCH 31/41] Clean ups. --- rest_framework/authentication.py | 63 ++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 460c1e535a..8ee3a9003f 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -157,6 +157,7 @@ class OAuthAuthentication(BaseAuthentication): OAuth 1.0a authentication backend using `django-oauth-plus` and `oauth2`. Note: The `oauth2` package actually provides oauth1.0a support. Urg. + We import it from the `compat` module as `oauth`. """ www_authenticate_realm = 'api' @@ -164,23 +165,42 @@ def __init__(self, **kwargs): super(OAuthAuthentication, self).__init__(**kwargs) if oauth is None: - raise ImproperlyConfigured("The 'oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") + raise ImproperlyConfigured( + "The 'oauth2' package could not be imported." + "It is required for use with the 'OAuthAuthentication' class.") if oauth_provider is None: - raise ImproperlyConfigured("The 'django-oauth-plus' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") + raise ImproperlyConfigured( + "The 'django-oauth-plus' package could not be imported." + "It is required for use with the 'OAuthAuthentication' class.") def authenticate(self, request): """ Returns two-tuple of (user, token) if authentication succeeds, or None otherwise. """ - if not self.is_valid_request(request): + try: + oauth_request = oauth_provider.utils.get_oauth_request(request) + except oauth.Error as err: + raise exceptions.AuthenticationFailed(err.message) + + oauth_params = oauth_provider.consts.OAUTH_PARAMETERS_NAMES + + found = any(param for param in oauth_params if param in oauth_request) + missing = list(param for param in oauth_params if param not in oauth_request) + + if not found: + # OAuth authentication was not attempted. return None - oauth_request = oauth_provider.utils.get_oauth_request(request) + if missing: + # OAuth was attempted but missing parameters. + msg = 'Missing parameters: %s' % (', '.join(missing)) + raise exceptions.AuthenticationFailed(msg) if not self.check_nonce(request, oauth_request): - raise exceptions.AuthenticationFailed("Nonce check failed") + msg = 'Nonce check failed' + raise exceptions.AuthenticationFailed(msg) try: consumer_key = oauth_request.get_parameter('oauth_consumer_key') @@ -207,40 +227,27 @@ def authenticate(self, request): user = token.user if not user.is_active: - raise exceptions.AuthenticationFailed('User inactive or deleted: %s' % user.username) + msg = 'User inactive or deleted: %s' % user.username + raise exceptions.AuthenticationFailed(msg) return (token.user, token) def authenticate_header(self, request): - return 'OAuth realm="%s"' % self.www_authenticate_realm - - def is_in(self, params): """ - Checks to ensure that all the OAuth parameter names are in the - provided ``params``. + If permission is denied, return a '401 Unauthorized' response, + with an appropraite 'WWW-Authenticate' header. """ - for param_name in oauth_provider.consts.OAUTH_PARAMETERS_NAMES: - if param_name not in params: - return False - - return True + return 'OAuth realm="%s"' % self.www_authenticate_realm - def is_valid_request(self, request): + def validate_token(self, request, consumer, token): """ - Checks whether the required parameters are either in the HTTP - `Authorization` header sent by some clients. - (The preferred method according to OAuth spec.) - Or fall back to `GET/POST`. + Check the token and raise an `oauth.Error` exception if invalid. """ - auth_params = request.META.get('HTTP_AUTHORIZATION', []) - return self.is_in(auth_params) or self.is_in(request.REQUEST) - - def validate_token(self, request, consumer, token): oauth_server, oauth_request = oauth_provider.utils.initialize_server_request(request) - return oauth_server.verify_request(oauth_request, consumer, token) + oauth_server.verify_request(oauth_request, consumer, token) def check_nonce(self, request, oauth_request): """ - Checks nonce of request. + Checks nonce of request, and return True if valid. """ - return oauth_provider.store.store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) + return oauth_provider_store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) From c2eb27629cf13e044fe81f338ac912cabf9f891f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 7 Mar 2013 17:50:48 +0000 Subject: [PATCH 32/41] Update docs for OAuth 2.0 --- docs/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 7c472e351a..8e5097b37c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,8 +37,9 @@ The following packages are optional: * [defusedxml][defusedxml] (0.3+) - XML content-type support. * [django-filter][django-filter] (0.5.4+) - Filtering support. * [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support. +* [django-oauth2-provider][django-oauth2-provider] (0.2.3+) - OAuth 2.0 support. -**Note**: The `oauth2` python package is badly misnamed, and actually provides oauth1.0a support. +**Note**: The `oauth2` python package is badly misnamed, and actually provides OAuth 1.0a support. Also note that packages required for both OAuth 1.0a, and OAuth 2.0 are not yet Python 3 compatible. ## Installation @@ -185,6 +186,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [django-filter]: http://pypi.python.org/pypi/django-filter [oauth2]: https://github.com/simplegeo/python-oauth2 [django-oauth-plus]: https://bitbucket.org/david/django-oauth-plus/wiki/Home +[django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider [0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X [image]: img/quickstart.png [sandbox]: http://restframework.herokuapp.com/ From e42e49852d032a888a17a09be6732a4c6a8fee72 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 7 Mar 2013 19:49:36 +0000 Subject: [PATCH 33/41] Tweak docs --- docs/api-guide/authentication.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 52d43b5e74..9234938c0c 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -220,11 +220,11 @@ OAuthAuthentication class provides only token verification and signature validat #### Getting started with django-oauth-plus -The `django-oauth-plus` package provides simple foundation for classic 'three-legged' oauth flow, so if it is what you need please refer to [its documentation](http://code.larlet.fr/django-oauth-plus/wiki/Home). This documentation will provide you also information about how to work with supplied models and change basic settings. +The `django-oauth-plus` package provides simple foundation for classic 'three-legged' oauth flow, so if it is what you need please refer to [its documentation][django-oauth-plus]. This documentation will provide you also information about how to work with supplied models and change basic settings. ## OAuth2Authentication -This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on the optional [`django-oauth2-provider`][django-oauth2-provider] project. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : +This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on the optional [django-oauth2-provider][django-oauth2-provider] project. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : INSTALLED_APPS = ( ... @@ -236,14 +236,14 @@ And include the urls needed in your root `urls.py` file to be able to begin the url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')), -** Note**: The `namespace` argument is required +**Note**: The `namespace` argument is required Finally, sync your database with those two new django apps. $ python manage.py syncdb $ python manage.py migrate -`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [`django-oaut2-provider`][django-oauth2-provider] dependency. The official [documentation][django-oauth2-provider--doc] is being [rewritten][django-oauth2-provider--rewritten-doc]. +`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [django-oaut2-provider][django-oauth2-provider] dependency. The official [documentation][django-oauth2-provider--doc] is being [rewritten][django-oauth2-provider--rewritten-doc]. The Good news is, here is a minimal "How to start" because **OAuth 2** is dramatically simpler than **OAuth 1**, so no more headache with signature, cryptography on client side, and other complex things. @@ -284,6 +284,8 @@ The command line to test the authentication looks like: $ curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET +--- + # Custom authentication To implement a custom authentication scheme, subclass `BaseAuthentication` and override the `.authenticate(self, request)` method. The method should return a two-tuple of `(user, auth)` if authentication succeeds, or `None` otherwise. @@ -340,6 +342,7 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [juanriaza]: https://github.com/juanriaza [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth [oauth-1.0a]: http://oauth.net/core/1.0a +[django-oauth-plus]: http://code.larlet.fr/django-oauth-plus [django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider [django-oauth2-provider--doc]: https://django-oauth2-provider.readthedocs.org/en/latest/ [django-oauth2-provider--rewritten-doc]: http://django-oauth2-provider-dulaccc.readthedocs.org/en/latest/ From 650d8e6a8ecd092e1bdd7269097044563f4ea449 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 8 Mar 2013 20:23:11 +0000 Subject: [PATCH 34/41] More bits of cleanup --- rest_framework/authentication.py | 125 ++++++++++++++++--------------- 1 file changed, 66 insertions(+), 59 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 4d6e3375b7..3000de3ac0 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -12,6 +12,19 @@ import base64 +def get_authorization_header(request): + """ + Return request's 'Authorization:' header, as a bytestring. + + Hide some test client ickyness where the header can be unicode. + """ + auth = request.META.get('HTTP_AUTHORIZATION', b'') + if type(auth) == type(''): + # Work around django test client oddness + auth = auth.encode(HTTP_HEADER_ENCODING) + return auth + + class BaseAuthentication(object): """ All authentication classes should extend BaseAuthentication. @@ -43,22 +56,22 @@ def authenticate(self, request): Returns a `User` if a correct username and password have been supplied using HTTP Basic authentication. Otherwise returns `None`. """ - auth = request.META.get('HTTP_AUTHORIZATION', b'') - if type(auth) == type(''): - # Work around django test client oddness - auth = auth.encode(HTTP_HEADER_ENCODING) - auth = auth.split() + auth = get_authorization_header(request).split() if not auth or auth[0].lower() != b'basic': return None - if len(auth) != 2: - raise exceptions.AuthenticationFailed('Invalid basic header') + if len(auth) == 1: + msg = 'Invalid basic header. No credentials provided.' + if len(auth) > 2: + msg = 'Invalid basic header. Credentials string should not contain spaces.' + raise exceptions.AuthenticationFailed(msg) try: auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':') except (TypeError, UnicodeDecodeError): - raise exceptions.AuthenticationFailed('Invalid basic header') + msg = 'Invalid basic header. Credentials not correctly base64 encoded' + raise exceptions.AuthenticationFailed(msg) userid, password = auth_parts[0], auth_parts[2] return self.authenticate_credentials(userid, password) @@ -68,9 +81,9 @@ def authenticate_credentials(self, userid, password): Authenticate the userid and password against username and password. """ user = authenticate(username=userid, password=password) - if user is not None and user.is_active: - return (user, None) - raise exceptions.AuthenticationFailed('Invalid username/password') + if user is None or not user.is_active: + raise exceptions.AuthenticationFailed('Invalid username/password') + return (user, None) def authenticate_header(self, request): return 'Basic realm="%s"' % self.www_authenticate_realm @@ -129,13 +142,16 @@ class TokenAuthentication(BaseAuthentication): """ def authenticate(self, request): - auth = request.META.get('HTTP_AUTHORIZATION', '').split() + auth = get_authorization_header(request).split() if not auth or auth[0].lower() != "token": return None - if len(auth) != 2: - raise exceptions.AuthenticationFailed('Invalid token header') + if len(auth) == 1: + msg = 'Invalid token header. No credentials provided.' + if len(auth) > 2: + msg = 'Invalid token header. Token string should not contain spaces.' + raise exceptions.AuthenticationFailed(msg) return self.authenticate_credentials(auth[1]) @@ -145,9 +161,10 @@ def authenticate_credentials(self, key): except self.model.DoesNotExist: raise exceptions.AuthenticationFailed('Invalid token') - if token.user.is_active: - return (token.user, token) - raise exceptions.AuthenticationFailed('User inactive or deleted') + if not token.user.is_active: + raise exceptions.AuthenticationFailed('User inactive or deleted') + + return (token.user, token) def authenticate_header(self, request): return 'Token' @@ -162,8 +179,8 @@ class OAuthAuthentication(BaseAuthentication): """ www_authenticate_realm = 'api' - def __init__(self, **kwargs): - super(OAuthAuthentication, self).__init__(**kwargs) + def __init__(self, *args, **kwargs): + super(OAuthAuthentication, self).__init__(*args, **kwargs) if oauth is None: raise ImproperlyConfigured( @@ -258,57 +275,59 @@ class OAuth2Authentication(BaseAuthentication): """ OAuth 2 authentication backend using `django-oauth2-provider` """ - require_active = True + www_authenticate_realm = 'api' + + def __init__(self, *args, **kwargs): + super(OAuth2Authentication, self).__init__(*args, **kwargs) - def __init__(self, **kwargs): - super(OAuth2Authentication, self).__init__(**kwargs) if oauth2_provider is None: - raise ImproperlyConfigured("The 'django-oauth2-provider' package could not be imported. It is required for use with the 'OAuth2Authentication' class.") + raise ImproperlyConfigured( + "The 'django-oauth2-provider' package could not be imported. " + "It is required for use with the 'OAuth2Authentication' class.") def authenticate(self, request): """ - The Bearer type is the only finalized type - - Read the spec for more details - http://tools.ietf.org/html/rfc6749#section-7.1 + Returns two-tuple of (user, token) if authentication succeeds, + or None otherwise. """ - auth = request.META.get('HTTP_AUTHORIZATION', '').split() - if not auth or auth[0].lower() != "bearer": - raise exceptions.AuthenticationFailed('Invalid Authorization token type') - if len(auth) != 2: - raise exceptions.AuthenticationFailed('Invalid token header') + auth = get_authorization_header(request).split() + + if not auth or auth[0].lower() != 'bearer': + return None + + if len(auth) == 1: + msg = 'Invalid bearer header. No credentials provided.' + if len(auth) > 2: + msg = 'Invalid bearer header. Token string should not contain spaces.' + raise exceptions.AuthenticationFailed(msg) return self.authenticate_credentials(request, auth[1]) def authenticate_credentials(self, request, access_token): """ - :returns: two-tuple of (user, auth) if authentication succeeds, or None otherwise. + Authenticate the request, given the access token. """ - # authenticate the client + # Authenticate the client oauth2_client_form = oauth2_provider_forms.ClientAuthForm(request.REQUEST) if not oauth2_client_form.is_valid(): - raise exceptions.AuthenticationFailed("Client could not be validated") + raise exceptions.AuthenticationFailed('Client could not be validated') client = oauth2_client_form.cleaned_data.get('client') - # retrieve the `oauth2_provider.models.OAuth2AccessToken` instance from the access_token + # Retrieve the `OAuth2AccessToken` instance from the access_token auth_backend = oauth2_provider_backends.AccessTokenBackend() token = auth_backend.authenticate(access_token, client) if token is None: - raise exceptions.AuthenticationFailed("Invalid token") # does not exist or is expired - - # TODO check scope + raise exceptions.AuthenticationFailed('Invalid token') - if not self.check_active(token.user): - raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username) + user = token.user - if client and token: - request.user = token.user - return (request.user, None) + if not user.is_active: + msg = 'User inactive or deleted: %s' % user.username + raise exceptions.AuthenticationFailed(msg) - raise exceptions.AuthenticationFailed( - 'You are not allowed to access this resource.') + return (token.user, token) def authenticate_header(self, request): """ @@ -316,16 +335,4 @@ def authenticate_header(self, request): Check details on the `OAuth2Authentication.authenticate` method """ - return 'Bearer' - - def check_active(self, user): - """ - Ensures the user has an active account. - - Optimized for the ``django.contrib.auth.models.User`` case. - """ - if not self.require_active: - # Ignore & move on. - return True - - return user.is_active + return 'Bearer realm="%s"' % self.www_authenticate_realm From 1016c14a8a9eef1031c1a4000a2ae257775339d5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 8 Mar 2013 20:26:10 +0000 Subject: [PATCH 35/41] Added @dulaccc. For the awesome OAuth 2 support in #693. --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 190ce490e8..0899632f2c 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -108,6 +108,7 @@ The following people have helped make REST framework great. * Omer Katz - [thedrow] * Wiliam Souza - [waa] * Jonas Braun - [iekadou] +* Pierre Dulac - [dulaccc] Many thanks to everyone who's contributed to the project. @@ -250,3 +251,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [thedrow]: https://github.com/thedrow [waa]: https://github.com/wiliamsouza [iekadou]: https://github.com/iekadou +[dulaccc]: https://github.com/dulaccc From 2596c12a21003d230beb101aa93ddf83a1995305 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 8 Mar 2013 22:56:24 +0000 Subject: [PATCH 36/41] Fixes for auth header checking. --- rest_framework/authentication.py | 13 ++++++++----- rest_framework/tests/authentication.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 3000de3ac0..b4b73699eb 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -63,7 +63,8 @@ def authenticate(self, request): if len(auth) == 1: msg = 'Invalid basic header. No credentials provided.' - if len(auth) > 2: + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: msg = 'Invalid basic header. Credentials string should not contain spaces.' raise exceptions.AuthenticationFailed(msg) @@ -144,12 +145,13 @@ class TokenAuthentication(BaseAuthentication): def authenticate(self, request): auth = get_authorization_header(request).split() - if not auth or auth[0].lower() != "token": + if not auth or auth[0].lower() != b'token': return None if len(auth) == 1: msg = 'Invalid token header. No credentials provided.' - if len(auth) > 2: + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: msg = 'Invalid token header. Token string should not contain spaces.' raise exceptions.AuthenticationFailed(msg) @@ -293,12 +295,13 @@ def authenticate(self, request): auth = get_authorization_header(request).split() - if not auth or auth[0].lower() != 'bearer': + if not auth or auth[0].lower() != b'bearer': return None if len(auth) == 1: msg = 'Invalid bearer header. No credentials provided.' - if len(auth) > 2: + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: msg = 'Invalid bearer header. Token string should not contain spaces.' raise exceptions.AuthenticationFailed(msg) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index ddd61b63b4..9e86881a38 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -159,7 +159,7 @@ def setUp(self): def test_post_form_passing_token_auth(self): """Ensure POSTing json over token auth with correct credentials passes and does not require CSRF""" - auth = "Token " + self.key + auth = 'Token ' + self.key response = self.csrf_client.post('/token/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, status.HTTP_200_OK) From fd9d6c664be1273f4d2f4d56361345e630ce8b7b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 8 Mar 2013 23:08:55 +0000 Subject: [PATCH 37/41] Fix crazy typo. --- rest_framework/tests/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 54ab0c948a..9e86881a38 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -145,7 +145,7 @@ def test_post_form_session_auth_failing(self): class TokenAuthTests(TestCase): """Token authentication""" - urls = 'ยง.tests.authentication' + urls = 'rest_framework.tests.authentication' def setUp(self): self.csrf_client = Client(enforce_csrf_checks=True) From a34f45b06e68fbe69f02d79c883ca764d88ac44b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 9 Mar 2013 00:31:19 +0000 Subject: [PATCH 38/41] Docs polishing. --- docs/api-guide/authentication.md | 69 ++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 9234938c0c..541c657562 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -111,7 +111,7 @@ Unauthenticated responses that are denied permission will result in an `HTTP 401 ## TokenAuthentication -This authentication scheme uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. +This authentication scheme uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. To use the `TokenAuthentication` scheme, include `rest_framework.authtoken` in your `INSTALLED_APPS` setting: @@ -209,22 +209,30 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak ## OAuthAuthentication -This authentication uses [OAuth 1.0a][oauth-1.0a] authentication scheme. It depends on the optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must install these packages and add `oauth_provider` to your `INSTALLED_APPS`: +This authentication uses [OAuth 1.0a][oauth-1.0a] authentication scheme. OAuth 1.0a provides signature validation which provides a reasonable level of security over plain non-HTTPS connections. However, it may also be considered more complicated than OAuth2, as it requires clients to sign their requests. + +This authentication class depends on the optional `django-oauth-plus` and `oauth2` packages. In order to make it work you must install these packages and add `oauth_provider` to your `INSTALLED_APPS`: INSTALLED_APPS = ( ... `oauth_provider`, ) -OAuthAuthentication class provides only token verification and signature validation for requests. It doesn't provide authorization flow for your clients. You still need to implement your own views for accessing and authorizing Reqest/Access Tokens. This is because there are many different OAuth flows in use. Almost always they require end-user interaction, and most likely this is what you want to design yourself. +Don't forget to run `syncdb` once you've added the package. + + python manage.py syncdb #### Getting started with django-oauth-plus -The `django-oauth-plus` package provides simple foundation for classic 'three-legged' oauth flow, so if it is what you need please refer to [its documentation][django-oauth-plus]. This documentation will provide you also information about how to work with supplied models and change basic settings. +The OAuthAuthentication class only provides token verification and signature validation for requests. It doesn't provide authorization flow for your clients. You still need to implement your own views for accessing and authorizing tokens. + +The `django-oauth-plus` package provides simple foundation for classic 'three-legged' oauth flow. Please refer to [the documentation][django-oauth-plus] for more details. ## OAuth2Authentication -This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends on the optional [django-oauth2-provider][django-oauth2-provider] project. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS` : +This authentication uses [OAuth 2.0][rfc6749] authentication scheme. OAuth2 is more simple to work with than OAuth1, and provides much better security than simple token authentication. It is an unauthenticated scheme, and requires you to use an HTTPS connection. + +This authentication class depends on the optional [django-oauth2-provider][django-oauth2-provider] project. In order to make it work you must install this package and add `provider` and `provider.oauth2` to your `INSTALLED_APPS`: INSTALLED_APPS = ( ... @@ -232,57 +240,61 @@ This authentication uses [OAuth 2.0][rfc6749] authentication scheme. It depends 'provider.oauth2', ) -And include the urls needed in your root `urls.py` file to be able to begin the *oauth 2 dance* : +You must also include the following in your root `urls.py` module: url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')), -**Note**: The `namespace` argument is required +Note that the `namespace='oauth2'` argument is required. -Finally, sync your database with those two new django apps. +Finally, sync your database. - $ python manage.py syncdb - $ python manage.py migrate + python manage.py syncdb + python manage.py migrate -`OAuth2Authentication` class provides only token verification for requests. The *oauth 2 dance* is taken care by the [django-oaut2-provider][django-oauth2-provider] dependency. The official [documentation][django-oauth2-provider--doc] is being [rewritten][django-oauth2-provider--rewritten-doc]. +--- + +**Note:** If you use `OAuth2Authentication` in production you must ensure that your API is only available over `https` only. -The Good news is, here is a minimal "How to start" because **OAuth 2** is dramatically simpler than **OAuth 1**, so no more headache with signature, cryptography on client side, and other complex things. +--- #### Getting started with django-oauth2-provider -1. Create a client in the django-admin panel +The `OAuth2Authentication` class only provides token verification for requests. It doesn't provide authorization flow for your clients. + +The OAuth 2 authorization flow is taken care by the [django-oauth2-provider][django-oauth2-provider] dependency. A walkthrough is given here, but for more details you should refer to [the documentation][django-oauth2-provider-docs]. + +To get started: + +##### 1. Create a client + +You can create a client, either through the shell, or by using the Django admin. Go to the admin panel and create a new `Provider.Client` entry. It will create the `client_id` and `client_secret` properties for you. -2. Request an access token +##### 2. Request an access token -To request an access toke, submit a `POST` request to the url `/oauth2/access_token` with the following fields : +To request an access token, submit a `POST` request to the url `/oauth2/access_token` with the following fields: * `client_id` the client id you've just configured at the previous step. * `client_secret` again configured at the previous step. * `username` the username with which you want to log in. * `password` well, that speaks for itself. ---- - -**Note:** Remember that you should use HTTPS in production. - ---- - -You can use the command line to test that your local configuration is working : +You can use the command line to test that your local configuration is working: - $ curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOUR_PASSWORD" http://localhost:8000/oauth2/access_token/ + curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOUR_PASSWORD" http://localhost:8000/oauth2/access_token/ -Here is the response you should get : +You should get a response that looks something like this: {"access_token": "", "scope": "read", "expires_in": 86399, "refresh_token": ""} -3. Access the api +##### 3. Access the API -The only thing needed to make the `OAuth2Authentication` class work is to insert the `access_token` you've received in the `Authorization` api request header. +The only thing needed to make the `OAuth2Authentication` class work is to insert the `access_token` you've received in the `Authorization` request header. The command line to test the authentication looks like: - $ curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET + curl -H "Authorization: Bearer " http://localhost:8000/api/?client_id=YOUR_CLIENT_ID\&client_secret=YOUR_CLIENT_SECRET --- @@ -344,6 +356,5 @@ HTTP digest authentication is a widely implemented scheme that was intended to r [oauth-1.0a]: http://oauth.net/core/1.0a [django-oauth-plus]: http://code.larlet.fr/django-oauth-plus [django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider -[django-oauth2-provider--doc]: https://django-oauth2-provider.readthedocs.org/en/latest/ -[django-oauth2-provider--rewritten-doc]: http://django-oauth2-provider-dulaccc.readthedocs.org/en/latest/ +[django-oauth2-provider-docs]: https://django-oauth2-provider.readthedocs.org/en/latest/ [rfc6749]: http://tools.ietf.org/html/rfc6749 From e03906a5c4101853b709403266b738911680c4b5 Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sun, 10 Mar 2013 14:08:29 +0100 Subject: [PATCH 39/41] Add TokenHasReadWriteScope class for permissions based on scopes --- rest_framework/compat.py | 2 ++ rest_framework/permissions.py | 28 ++++++++++++++++++++++++++ rest_framework/tests/authentication.py | 28 +++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 69be954301..e9570a080f 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -453,9 +453,11 @@ def apply_markdown(text): from provider.oauth2 import backends as oauth2_provider_backends from provider.oauth2 import models as oauth2_provider_models from provider.oauth2 import forms as oauth2_provider_forms + from provider import scope as oauth2_provider_scope except ImportError: oauth2_provider = None oauth2_provider_backends = None oauth2_provider_models = None oauth2_provider_forms = None + oauth2_provider_scope = None diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 306f00ca2a..519a369145 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -7,6 +7,8 @@ SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] +from rest_framework.compat import oauth2_provider_scope + class BasePermission(object): """ @@ -125,3 +127,29 @@ def has_permission(self, request, view): request.user.has_perms(perms)): return True return False + + +class TokenHasReadWriteScope(BasePermission): + """ + The request is authenticated as a user and the token used has the right scope + """ + + def has_permission(self, request, view): + if not request.auth: + return False + + read_only = request.method in SAFE_METHODS + if hasattr(request.auth, 'resource'): # oauth 1 + pass + elif hasattr(request.auth, 'scope'): # oauth 2 + scope_valid = lambda scope_wanted_key, scope_had: oauth2_provider_scope.check( + oauth2_provider_scope.SCOPE_NAME_DICT[scope_wanted_key], scope_had) + + if (read_only and scope_valid('read', request.auth.scope)): + return True + elif scope_valid('write', request.auth.scope): + return True + return False + else: + # Improperly configured! + pass diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 9e86881a38..693dbb4d6e 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -17,7 +17,7 @@ ) from rest_framework.authtoken.models import Token from rest_framework.compat import patterns, url, include -from rest_framework.compat import oauth2_provider, oauth2_provider_models +from rest_framework.compat import oauth2_provider, oauth2_provider_models, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.tests.utils import RequestFactory from rest_framework.views import APIView @@ -54,6 +54,8 @@ def put(self, request): urlpatterns += patterns('', url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')), url(r'^oauth2-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication])), + url(r'^oauth2-with-scope-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication], + permission_classes=[permissions.TokenHasReadWriteScope])), ) @@ -514,3 +516,27 @@ def test_post_form_with_expired_access_token_failing_auth(self): response = self.csrf_client.post('/oauth2-test/', params, HTTP_AUTHORIZATION=auth) self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) self.assertIn('Invalid token', response.content) + + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_post_form_with_invalid_scope_failing_auth(self): + """Ensure POSTing with a readonly scope instead of a write scope fails""" + read_only_access_token = self.access_token + read_only_access_token.scope = oauth2_provider_scope.SCOPE_NAME_DICT['read'] + read_only_access_token.save() + auth = self._create_authorization_header(token=read_only_access_token.token) + params = self._client_credentials_params() + response = self.csrf_client.get('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + response = self.csrf_client.post('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') + def test_post_form_with_valid_scope_passing_auth(self): + """Ensure POSTing with a write scope succeed""" + read_write_access_token = self.access_token + read_write_access_token.scope = oauth2_provider_scope.SCOPE_NAME_DICT['write'] + read_write_access_token.save() + auth = self._create_authorization_header(token=read_write_access_token.token) + params = self._client_credentials_params() + response = self.csrf_client.post('/oauth2-with-scope-test/', params, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) From eec8efafc3eeacf00696208d2e1e55a11821257b Mon Sep 17 00:00:00 2001 From: Pierre Dulac Date: Sun, 10 Mar 2013 14:40:20 +0100 Subject: [PATCH 40/41] Add the implementation for TokenHasReadWriteScope permissions w/ oauth 1 --- rest_framework/permissions.py | 8 ++++-- rest_framework/tests/authentication.py | 37 +++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 519a369145..c477474c9f 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -140,12 +140,16 @@ def has_permission(self, request, view): read_only = request.method in SAFE_METHODS if hasattr(request.auth, 'resource'): # oauth 1 - pass + if read_only: + return True + elif request.auth.resource.is_readonly is False: + return True + return False elif hasattr(request.auth, 'scope'): # oauth 2 scope_valid = lambda scope_wanted_key, scope_had: oauth2_provider_scope.check( oauth2_provider_scope.SCOPE_NAME_DICT[scope_wanted_key], scope_had) - if (read_only and scope_valid('read', request.auth.scope)): + if read_only and scope_valid('read', request.auth.scope): return True elif scope_valid('write', request.auth.scope): return True diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 693dbb4d6e..b663ca48fc 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -47,7 +47,9 @@ def put(self, request): (r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])), (r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])), (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), - (r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication])) + (r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication])), + (r'^oauth-with-scope/$', MockView.as_view(authentication_classes=[OAuthAuthentication], + permission_classes=[permissions.TokenHasReadWriteScope])) ) if oauth2_provider is not None: @@ -391,6 +393,39 @@ def test_post_hmac_sha1_signature_passes(self): response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') + def test_get_form_with_readonly_resource_passing_auth(self): + """Ensure POSTing with a readonly resource instead of a write scope fails""" + read_only_access_token = self.token + read_only_access_token.resource.is_readonly = True + read_only_access_token.resource.save() + params = self._create_authorization_url_parameters() + response = self.csrf_client.get('/oauth-with-scope/', params) + self.assertEqual(response.status_code, 200) + + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') + def test_post_form_with_readonly_resource_failing_auth(self): + """Ensure POSTing with a readonly resource instead of a write scope fails""" + read_only_access_token = self.token + read_only_access_token.resource.is_readonly = True + read_only_access_token.resource.save() + params = self._create_authorization_url_parameters() + response = self.csrf_client.post('/oauth-with-scope/', params) + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + @unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed') + @unittest.skipUnless(oauth, 'oauth2 not installed') + def test_post_form_with_write_resource_passing_auth(self): + """Ensure POSTing with a write resource succeed""" + read_write_access_token = self.token + read_write_access_token.resource.is_readonly = False + read_write_access_token.resource.save() + params = self._create_authorization_url_parameters() + response = self.csrf_client.post('/oauth-with-scope/', params) + self.assertEqual(response.status_code, 200) + class OAuth2Tests(TestCase): """OAuth 2.0 authentication""" From f513db714db76849448bf2e2412428ee7121ebf6 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 12 Mar 2013 19:07:30 +0000 Subject: [PATCH 41/41] Clean up TokenHasReadWriteScope slightly --- rest_framework/compat.py | 8 ++------ rest_framework/permissions.py | 33 +++++++++++++-------------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index e9570a080f..7b2ef7384b 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -445,19 +445,15 @@ def apply_markdown(text): # OAuth 2 support is optional try: import provider.oauth2 as oauth2_provider - # # Hack to fix submodule import issues - # submodules = ['backends', 'forms', 'managers', 'models', 'urls', 'views'] - # for s in submodules: - # mod = __import__('provider.oauth2.%s.*' % s) - # setattr(oauth2_provider, s, mod) from provider.oauth2 import backends as oauth2_provider_backends from provider.oauth2 import models as oauth2_provider_models from provider.oauth2 import forms as oauth2_provider_forms from provider import scope as oauth2_provider_scope - + from provider import constants as oauth2_constants except ImportError: oauth2_provider = None oauth2_provider_backends = None oauth2_provider_models = None oauth2_provider_forms = None oauth2_provider_scope = None + oauth2_constants = None diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 92f8215ac1..f026850a06 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -7,7 +7,7 @@ SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -from rest_framework.compat import oauth2_provider_scope +from rest_framework.compat import oauth2_provider_scope, oauth2_constants class BasePermission(object): @@ -142,25 +142,18 @@ class TokenHasReadWriteScope(BasePermission): """ def has_permission(self, request, view): - if not request.auth: - return False - + token = request.auth read_only = request.method in SAFE_METHODS - if hasattr(request.auth, 'resource'): # oauth 1 - if read_only: - return True - elif request.auth.resource.is_readonly is False: - return True - return False - elif hasattr(request.auth, 'scope'): # oauth 2 - scope_valid = lambda scope_wanted_key, scope_had: oauth2_provider_scope.check( - oauth2_provider_scope.SCOPE_NAME_DICT[scope_wanted_key], scope_had) - - if read_only and scope_valid('read', request.auth.scope): - return True - elif scope_valid('write', request.auth.scope): - return True + + if not token: return False + + if hasattr(token, 'resource'): # OAuth 1 + return read_only or not request.auth.resource.is_readonly + elif hasattr(token, 'scope'): # OAuth 2 + required = oauth2_constants.READ if read_only else oauth2_constants.WRITE + return oauth2_provider_scope.check(required, request.auth.scope) else: - # Improperly configured! - pass + assert False, ('TokenHasReadWriteScope requires either the' + '`OAuthAuthentication` or `OAuth2Authentication` authentication ' + 'class to be used.')