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

OAuth 1.0a authentication #678

Closed
wants to merge 12 commits into from
Closed
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ env:
install:
- pip install $DJANGO
- pip install defusedxml==0.3
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus; 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
15 changes: 15 additions & 0 deletions docs/api-guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ 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.

## 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`:

INSTALLED_APPS = (
#(...)
`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.

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.


# 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 +249,4 @@ 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
[rfc5849] : http://tools.ietf.org/html/rfc5849
2 changes: 2 additions & 0 deletions optionals.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ markdown>=2.1.0
PyYAML>=3.10
defusedxml>=0.3
django-filter>=0.5.4
django-oauth-plus>=2.0
oauth2>=1.5.211
108 changes: 107 additions & 1 deletion rest_framework/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
from __future__ import unicode_literals
from django.contrib.auth import authenticate
from django.utils.encoding import DjangoUnicodeDecodeError
from django.core.exceptions import ImproperlyConfigured
from rest_framework import exceptions, HTTP_HEADER_ENCODING
from rest_framework.compat import CsrfViewMiddleware
from rest_framework.compat import oauth
from rest_framework.compat import oauth_provider
from rest_framework.authtoken.models import Token
import base64

Expand Down Expand Up @@ -155,4 +158,107 @@ def authenticate_header(self, request):
return 'Token'


# TODO: OAuthAuthentication
class OAuthAuthentication(BaseAuthentication):
"""rest_framework OAuth authentication backend using
django-oath-plus and oauth2"""
www_authenticate_realm = 'api'
require_active = True

def __init__(self, **kwargs):
super(OAuthAuthentication, self).__init__(**kwargs)

if oauth is None:
raise ImproperlyConfigured("The 'oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.")

if oauth_provider is None:
raise ImproperlyConfigured("The 'django-oauth-plus' package could not be imported. It is required for use with the 'OAuthAuthentication' class.")


def authenticate(self, request):
"""
Returns two-tuple of (user, auth token) if authentication succeeds, or None otherwise.
"""
from oauth_provider.store import store
if self.is_valid_request(request):
oauth_request = oauth_provider.utils.get_oauth_request(request)

if not self.check_nonce(request, oauth_request):
raise exceptions.AuthenticationFailed("Nonce check failed")

try:
consumer = store.get_consumer(request, oauth_request,
oauth_request.get_parameter('oauth_consumer_key'))
except oauth_provider.store.InvalidConsumerError, e:
raise exceptions.AuthenticationFailed(e)

if consumer.status != oauth_provider.consts.ACCEPTED:
raise exceptions.AuthenticationFailed('Invalid consumer key status: %s' % consumer.get_status_display())

try:
token = store.get_access_token(request, oauth_request,
consumer, oauth_request.get_parameter('oauth_token'))

except oauth_provider.store.InvalidTokenError:
raise exceptions.AuthenticationFailed(
'Invalid access token: %s' % oauth_request.get_parameter('oauth_token'))

try:
self.validate_token(request, consumer, token)
except oauth.Error, e:
raise exceptions.AuthenticationFailed(e.message)

if not self.check_active(token.user):
raise exceptions.AuthenticationFailed('User not active: %s' % token.user.username)

if consumer and token:
return (token.user, token)

raise exceptions.AuthenticationFailed(
'You are not allowed to access this resource.')

return None

def authenticate_header(self, request):
return 'OAuth realm="%s"' % self.www_authenticate_realm

def is_in(self, params):
"""
Checks to ensure that all the OAuth parameter names are in the
provided ``params``.
"""
from oauth_provider.consts import OAUTH_PARAMETERS_NAMES

for param_name in OAUTH_PARAMETERS_NAMES:
if param_name not in params:
return False

return True

def is_valid_request(self, request):
"""
Checks whether the required parameters are either in the HTTP
``Authorization`` header sent by some clients (the preferred method
according to OAuth spec) or fall back to ``GET/POST``.
"""
auth_params = request.META.get("HTTP_AUTHORIZATION", [])
return self.is_in(auth_params) or self.is_in(request.REQUEST)

def validate_token(self, request, consumer, token):
oauth_server, oauth_request = oauth_provider.utils.initialize_server_request(request)
return oauth_server.verify_request(oauth_request, consumer, token)

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

def check_nonce(self, request, oauth_request):
"""Checks nonce of request"""
return oauth_provider.store.store.check_nonce(request, oauth_request, oauth_request['oauth_nonce'])
12 changes: 12 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,15 @@ def apply_markdown(text):
import defusedxml.ElementTree as etree
except ImportError:
etree = None

# OAuth is optional
try:
import oauth2 as oauth
except ImportError:
oauth = None

# OAuth is optional
try:
import oauth_provider
except ImportError:
oauth_provider = None
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',
)

# OAuth is optional and won't work if there is no oauth_provider & oauth2
try:
import oauth_provider
import oauth2
except ImportError:
pass
else:
INSTALLED_APPS += ('oauth_provider',)


STATIC_URL = '/static/'

PASSWORD_HASHERS = (
Expand Down
167 changes: 165 additions & 2 deletions rest_framework/tests/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.test import Client, TestCase
from rest_framework import HTTP_HEADER_ENCODING
from django.utils import unittest
import time
from rest_framework import HTTP_HEADER_ENCODING, status
from rest_framework import permissions
from rest_framework.authtoken.models import Token
from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication
from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication, OAuthAuthentication
from rest_framework.compat import patterns
from rest_framework.views import APIView
from rest_framework.compat import oauth
from rest_framework.compat import oauth_provider
import json
import base64

Expand All @@ -21,11 +25,15 @@ def post(self, request):
def put(self, request):
return HttpResponse({'a': 1, 'b': 2, 'c': 3})

def get(self, request):
return HttpResponse({'a': 1, 'b': 2, 'c': 3})

urlpatterns = patterns('',
(r'^session/$', MockView.as_view(authentication_classes=[SessionAuthentication])),
(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'),
(r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication]))
)


Expand Down Expand Up @@ -186,3 +194,158 @@ def test_token_login_form(self):
{'username': self.username, 'password': self.password})
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content.decode('ascii'))['token'], self.key)

class OAuthTests(TestCase):
Copy link
Member

Choose a reason for hiding this comment

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

These tests should be excluded unless both oauth2 and django-oauth-plus are installed.
grep for skipUnless in some of the other tests to see some examples.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now are skipped.

"""OAuth 1.0a authentication"""
urls = 'rest_framework.tests.authentication'

def setUp(self):
# these imports are here because oauth is optional and hiding them in try..except block or compat
# could obscure problems if something breaks
from oauth_provider.models import Consumer, Resource
from oauth_provider.models import Token as OAuthToken
from oauth_provider import consts

self.consts = consts

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.CONSUMER_KEY = 'consumer_key'
self.CONSUMER_SECRET = 'consumer_secret'
self.TOKEN_KEY = "token_key"
self.TOKEN_SECRET = "token_secret"

self.consumer = Consumer.objects.create(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET,
name='example', user=self.user, status=self.consts.ACCEPTED)


self.resource = Resource.objects.create(name="resource name", url="api/")
self.token = OAuthToken.objects.create(user=self.user, consumer=self.consumer, resource=self.resource,
token_type=OAuthToken.ACCESS, key=self.TOKEN_KEY, secret=self.TOKEN_SECRET, is_approved=True
)


def _create_authorization_header(self):
params = {
'oauth_version': "1.0",
'oauth_nonce': oauth.generate_nonce(),
'oauth_timestamp': int(time.time()),
'oauth_token': self.token.key,
'oauth_consumer_key': self.consumer.key
}

req = oauth.Request(method="GET", url="http://example.com", parameters=params)

signature_method = oauth.SignatureMethod_PLAINTEXT()
req.sign_request(signature_method, self.consumer, self.token)

return req.to_header()["Authorization"]

def _create_authorization_url_parameters(self):
params = {
'oauth_version': "1.0",
'oauth_nonce': oauth.generate_nonce(),
'oauth_timestamp': int(time.time()),
'oauth_token': self.token.key,
'oauth_consumer_key': self.consumer.key
}

req = oauth.Request(method="GET", url="http://example.com", parameters=params)

signature_method = oauth.SignatureMethod_PLAINTEXT()
req.sign_request(signature_method, self.consumer, self.token)
return dict(req)

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_passing_oauth(self):
"""Ensure POSTing form over OAuth with correct credentials passes and does not require CSRF"""
auth = self._create_authorization_header()
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_repeated_nonce_failing_oauth(self):
"""Ensure POSTing form over OAuth with repeated auth (same nonces and timestamp) credentials fails"""
auth = self._create_authorization_header()
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

# simulate reply attack auth header containes already used (nonce, timestamp) pair
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_token_removed_failing_oauth(self):
"""Ensure POSTing when there is no OAuth access token in db fails"""
self.token.delete()
auth = self._create_authorization_header()
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_consumer_status_not_accepted_failing_oauth(self):
"""Ensure POSTing when consumer status is anything other than ACCEPTED fails"""
for consumer_status in (self.consts.CANCELED, self.consts.PENDING, self.consts.REJECTED):
self.consumer.status = consumer_status
self.consumer.save()

auth = self._create_authorization_header()
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_with_request_token_failing_oauth(self):
"""Ensure POSTing with unauthorized request token instead of access token fails"""
self.token.token_type = self.token.REQUEST
self.token.save()

auth = self._create_authorization_header()
response = self.csrf_client.post('/oauth/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_form_with_urlencoded_parameters(self):
"""Ensure POSTing with x-www-form-urlencoded auth parameters passes"""
params = self._create_authorization_url_parameters()
response = self.csrf_client.post('/oauth/', params)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_get_form_with_url_parameters(self):
"""Ensure GETing with auth in url parameters passes"""
params = self._create_authorization_url_parameters()
response = self.csrf_client.get('/oauth/', params)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(oauth_provider, 'django-oauth-plus not installed')
@unittest.skipUnless(oauth, 'oauth2 not installed')
def test_post_hmac_sha1_signature_passes(self):
"""Ensure POSTing using HMAC_SHA1 signature method passes"""
params = {
'oauth_version': "1.0",
'oauth_nonce': oauth.generate_nonce(),
'oauth_timestamp': int(time.time()),
'oauth_token': self.token.key,
'oauth_consumer_key': self.consumer.key
}

req = oauth.Request(method="POST", url="http://testserver/oauth/", parameters=params)

signature_method = oauth.SignatureMethod_HMAC_SHA1()
req.sign_request(signature_method, self.consumer, self.token)
auth = req.to_header()["Authorization"]

response = self.csrf_client.post('/oauth/', HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)