Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 Authentication #693

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=.
Expand Down
71 changes: 71 additions & 0 deletions docs/api-guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`][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`][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.

### 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": "<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 hopefully, it will work like a charm.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll make some changes to the tone of these docs, but looks good so far.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah ^^ I understand what you mean when I read it again this morning.
And, English is not my native language so I've probably made mistakes or use weird ways to tell thing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fine :), just some things like "And hopefully," - sounds a bit uncertain - we'd like to be a bit more assertive than that. :p
Also, we'll use something less opinionated than "Unfortunately, there isn't a lot of documentation...".
Finally, we probably shouldn't state that HTTPS is highly recommended, but instead simply say that in production you must use HTTPS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will "refactor" the documentation very soon.

And just to let you know, I've made a pull request on the django-oauth2-provider project to improve its documentation. I learn to use sphinx by the way, that was fun.

I've configured a temporary project on readthedocs.org to let you see the work. Because I don't know if I will have a reply for my pull request.

# 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.
Expand Down Expand Up @@ -235,3 +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
1 change: 1 addition & 0 deletions optionals.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 78 additions & 1 deletion rest_framework/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -155,4 +156,80 @@ 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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we check for the oauth2.provider module instead, then we don't need to import this module anywhere, which'll help us simplify the names somewhat.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I understand what you're suggesting, it's to replace

if oauth2_provider is None:

with a try block to avoid importing oauth2_provider in the head of the file ?

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":
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_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.')

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
7 changes: 7 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,10 @@ 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
except ImportError:
oauth2_provider = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this import isn't really required by the codebase can we just drop it and rely on provider.oauth2 instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you're right, initially I added it to manage the provider.constants.SCOPES but I realize that in fact the "scope"/"permission" thing is taken care of by django-rest-framework, right ?
So I'll clean up this part

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to talk about scopes & permissions, but let's leave that aside for a moment.

4 changes: 3 additions & 1 deletion rest_framework/runtests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@
# 'django.contrib.admindocs',
'rest_framework',
'rest_framework.authtoken',
'rest_framework.tests'
'rest_framework.tests',
'provider',
'provider.oauth2',
)

STATIC_URL = '/static/'
Expand Down
107 changes: 105 additions & 2 deletions rest_framework/tests/authentication.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,13 +12,17 @@
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
import unittest
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use 'from django.utils import unittest' for compatibility across different py versions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @tomchristie for this tip!



factory = RequestFactory()
Expand All @@ -41,6 +46,8 @@ 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])),
)


Expand Down Expand Up @@ -222,3 +229,99 @@ 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 = '[email protected]'
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_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)