This commit is contained in:
Pierre Dulac 2013-03-08 12:25:44 -08:00
commit bea6882112
8 changed files with 328 additions and 6 deletions

View File

@ -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=.

View File

@ -207,6 +207,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": "<your-access-token>", "scope": "read", "expires_in": 86399, "refresh_token": "<your-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 <your-access-token>" 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.
@ -262,3 +336,7 @@ HTTP digest authentication is a widely implemented scheme that was intended to r
[south-dependencies]: http://south.readthedocs.org/en/latest/dependencies.html
[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

View File

@ -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

View File

@ -6,6 +6,7 @@ from django.contrib.auth import authenticate
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 @@ class TokenAuthentication(BaseAuthentication):
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

View File

@ -426,3 +426,17 @@ try:
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

View File

@ -97,9 +97,19 @@ INSTALLED_APPS = (
# '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 = (

View File

@ -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 @@ from rest_framework.authentication import (
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 @@ urlpatterns = patterns('',
(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 @@ class IncorrectCredentialsTests(TestCase):
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)

10
tox.ini
View File

@ -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