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 all 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
- "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=.
Expand Down
78 changes: 78 additions & 0 deletions docs/api-guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<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.

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 +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
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
django-oauth2-provider>=0.2.3
77 changes: 76 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,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:
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":
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
14 changes: 14 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

12 changes: 11 additions & 1 deletion rest_framework/runtests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
Loading