diff --git a/.travis.yml b/.travis.yml index 5f07241085..82127276be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 + - "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/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index fae86386d3..b7251fd0d3 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -182,6 +182,80 @@ 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 + +--- + +** 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 = ( + #(...) + '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`][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. + +### 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 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=YOUR_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 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 +309,7 @@ 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/ +[django-oauth2-provider--rewritten-doc]: http://django-oauth2-provider-dulaccc.readthedocs.org/en/latest/ +[rfc6749]: http://tools.ietf.org/html/rfc6749 diff --git a/optionals.txt b/optionals.txt index 3d98cc0e73..99868c6606 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 +django-oauth2-provider>=0.2.3 \ No newline at end of file diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 14b2136b3d..d4ba7967e6 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 from rest_framework.authtoken.models import Token import base64 @@ -155,4 +156,78 @@ 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() + 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') + + 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_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_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 + + # TODO check scope + + 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.') + + 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..1e04e8f687 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -426,3 +426,17 @@ def apply_markdown(text): import defusedxml.ElementTree as etree except ImportError: etree = None + + +# 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 03bfc21620..16ef1d2b6e 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -97,9 +97,19 @@ # 'django.contrib.admindocs', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework.tests' + 'rest_framework.tests', ) +try: + import provider + INSTALLED_APPS += ( + 'provider', + 'provider.oauth2', + ) +except ImportError: + import logging + logging.warning("django-oauth2-provider is not install, some tests will be skipped") + STATIC_URL = '/static/' PASSWORD_HASHERS = ( diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 7b754af5d6..a02aef5503 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -1,7 +1,9 @@ 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 +from django.utils import unittest from rest_framework import HTTP_HEADER_ENCODING from rest_framework import exceptions from rest_framework import permissions @@ -11,13 +13,16 @@ BaseAuthentication, TokenAuthentication, BasicAuthentication, - SessionAuthentication + SessionAuthentication, + OAuth2Authentication ) -from rest_framework.compat import patterns +from rest_framework.compat import patterns, url, include +from rest_framework.compat import oauth2_provider from rest_framework.tests.utils import RequestFactory from rest_framework.views import APIView import json import base64 +import datetime factory = RequestFactory() @@ -43,6 +48,12 @@ def put(self, request): (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), ) +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""" @@ -222,3 +233,129 @@ def authenticate(self, request): response = view(request) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.data, {'detail': 'Bad credentials'}) + + +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_provider.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_provider.models.AccessToken.objects.create( + token=self.ACCESS_TOKEN, + client=self.oauth2_client, + user=self.user, + ) + self.refresh_token = oauth2_provider.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_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""" + 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_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() + 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_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() + 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_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() + 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_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) + 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_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 + 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) diff --git a/tox.ini b/tox.ini index 58d308acae..f3c19375f8 100644 --- a/tox.ini +++ b/tox.ini @@ -8,46 +8,52 @@ 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] 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