Merge OAuth2 work.

This commit is contained in:
Tom Christie 2013-03-07 17:43:13 +00:00
commit a4b33992a5
8 changed files with 328 additions and 11 deletions

View File

@ -16,6 +16,7 @@ install:
- pip install defusedxml==0.3 - pip install defusedxml==0.3
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi"
- "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 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" - "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi"
- export PYTHONPATH=. - export PYTHONPATH=.

View File

@ -209,17 +209,80 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak
## OAuthAuthentication ## OAuthAuthentication
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`: 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`:
INSTALLED_APPS = ( INSTALLED_APPS = (
#(...) ...
`oauth_provider`, `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. 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. #### 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.
## 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` :
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.
#### Getting started with django-oauth2-provider
1. 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.
2. Request an access token
To request an access toke, 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 :
$ 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>"}
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 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
# Custom authentication # Custom authentication
@ -276,4 +339,8 @@ HTTP digest authentication is a widely implemented scheme that was intended to r
[south-dependencies]: http://south.readthedocs.org/en/latest/dependencies.html [south-dependencies]: http://south.readthedocs.org/en/latest/dependencies.html
[juanriaza]: https://github.com/juanriaza [juanriaza]: https://github.com/juanriaza
[djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth [djangorestframework-digestauth]: https://github.com/juanriaza/django-rest-framework-digestauth
[rfc5849] : http://tools.ietf.org/html/rfc5849 [oauth-1.0a]: http://oauth.net/core/1.0a
[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

@ -4,3 +4,4 @@ defusedxml>=0.3
django-filter>=0.5.4 django-filter>=0.5.4
django-oauth-plus>=2.0 django-oauth-plus>=2.0
oauth2>=1.5.211 oauth2>=1.5.211
django-oauth2-provider>=0.2.3

View File

@ -7,6 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
from rest_framework import exceptions, HTTP_HEADER_ENCODING from rest_framework import exceptions, HTTP_HEADER_ENCODING
from rest_framework.compat import CsrfViewMiddleware from rest_framework.compat import CsrfViewMiddleware
from rest_framework.compat import oauth, oauth_provider, oauth_provider_store from rest_framework.compat import oauth, oauth_provider, oauth_provider_store
from rest_framework.compat import oauth2_provider, oauth2_provider_forms, oauth2_provider_backends
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
import base64 import base64
@ -251,3 +252,80 @@ class OAuthAuthentication(BaseAuthentication):
Checks nonce of request, and return True if valid. Checks nonce of request, and return True if valid.
""" """
return oauth_provider_store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) return oauth_provider_store.check_nonce(request, oauth_request, oauth_request['oauth_nonce'])
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

@ -441,3 +441,21 @@ try:
except ImportError: except ImportError:
oauth_provider = None oauth_provider = None
oauth_provider_store = None oauth_provider_store = 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)
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
except ImportError:
oauth2_provider = None
oauth2_provider_backends = None
oauth2_provider_models = None
oauth2_provider_forms = None

View File

@ -107,8 +107,19 @@ try:
except ImportError: except ImportError:
pass pass
else: else:
INSTALLED_APPS += ('oauth_provider',) INSTALLED_APPS += (
'oauth_provider',
)
try:
import provider
except ImportError:
pass
else:
INSTALLED_APPS += (
'provider',
'provider.oauth2',
)
STATIC_URL = '/static/' STATIC_URL = '/static/'

View File

@ -12,17 +12,19 @@ from rest_framework.authentication import (
TokenAuthentication, TokenAuthentication,
BasicAuthentication, BasicAuthentication,
SessionAuthentication, SessionAuthentication,
OAuthAuthentication OAuthAuthentication,
OAuth2Authentication
) )
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.compat import patterns from rest_framework.compat import patterns, url, include
from rest_framework.compat import oauth2_provider, oauth2_provider_models
from rest_framework.compat import oauth, oauth_provider
from rest_framework.tests.utils import RequestFactory from rest_framework.tests.utils import RequestFactory
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.compat import oauth, oauth_provider
import json import json
import base64 import base64
import time import time
import datetime
factory = RequestFactory() factory = RequestFactory()
@ -48,6 +50,12 @@ urlpatterns = patterns('',
(r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication])) (r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication]))
) )
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): class BasicAuthTests(TestCase):
"""Basic authentication""" """Basic authentication"""
@ -380,3 +388,129 @@ class OAuthTests(TestCase):
response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth) response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
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)

11
tox.ini
View File

@ -8,21 +8,23 @@ commands = {envpython} rest_framework/runtests/runtests.py
[testenv:py3.3-django1.5] [testenv:py3.3-django1.5]
basepython = python3.3 basepython = python3.3
deps = django==1.5 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 defusedxml==0.3
[testenv:py3.2-django1.5] [testenv:py3.2-django1.5]
basepython = python3.2 basepython = python3.2
deps = django==1.5 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 defusedxml==0.3
[testenv:py2.7-django1.5] [testenv:py2.7-django1.5]
basepython = python2.7 basepython = python2.7
deps = django==1.5 deps = django==1.5
django-filter==0.5.4 django-filter==0.5.4
defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3
[testenv:py2.6-django1.5] [testenv:py2.6-django1.5]
basepython = python2.6 basepython = python2.6
@ -31,6 +33,7 @@ deps = django==1.5
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3
[testenv:py2.7-django1.4] [testenv:py2.7-django1.4]
basepython = python2.7 basepython = python2.7
@ -39,6 +42,7 @@ deps = django==1.4.3
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3
[testenv:py2.6-django1.4] [testenv:py2.6-django1.4]
basepython = python2.6 basepython = python2.6
@ -47,6 +51,7 @@ deps = django==1.4.3
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3
[testenv:py2.7-django1.3] [testenv:py2.7-django1.3]
basepython = python2.7 basepython = python2.7
@ -55,6 +60,7 @@ deps = django==1.3.5
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3
[testenv:py2.6-django1.3] [testenv:py2.6-django1.3]
basepython = python2.6 basepython = python2.6
@ -63,3 +69,4 @@ deps = django==1.3.5
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3