-
-
Notifications
You must be signed in to change notification settings - Fork 6.9k
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
Changes from 6 commits
1062d71
ced22db
1aed9c1
e2b11a2
cfce455
5d9ed34
59a6f5f
d84c2cf
a430445
dd355d5
55ea5b9
2eabc5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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](http://tools.ietf.org/html/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`: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note link style in docs is to not use inline linking. Use square bracket for both text and marker and a descriptive anchor eg 'rfc5849' |
||
|
||
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. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,18 @@ | |
from rest_framework.authtoken.models import Token | ||
import base64 | ||
|
||
from django.core.exceptions import ImproperlyConfigured | ||
try: | ||
import oauth2 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's use |
||
except ImportError: | ||
oauth2 = None | ||
|
||
try: | ||
import oauth_provider | ||
from oauth_provider.store import store | ||
except ImportError: | ||
oauth_provider = None | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both these |
||
|
||
|
||
class BaseAuthentication(object): | ||
""" | ||
|
@@ -155,4 +167,109 @@ def authenticate_header(self, request): | |
return 'Token' | ||
|
||
|
||
# TODO: OAuthAuthentication | ||
class OAuthAuthentication(BaseAuthentication): | ||
"""rest_framework OAuth authentication backend using | ||
django-oath-plus""" | ||
www_authenticate_realm = 'api' | ||
require_active = True | ||
|
||
def __init__(self, **kwargs): | ||
super(OAuthAuthentication, self).__init__(**kwargs) | ||
|
||
if oauth2 is None: | ||
raise ImproperlyConfigured("The 'python-oauth2' package could not be imported. It is required for use with the 'OAuthAuthentication' class.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 'oauth2', not 'python-oauth2' |
||
|
||
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) if authentication succeeds, or None otherwise. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use docstring markup. |
||
""" | ||
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 oauth2.Error, e: | ||
print "got e" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rouge print statement on the loose. :p |
||
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: | ||
request.user = token.user | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don't need to set the request user here, that's handled by the auth machinery itself. |
||
return (request.user, None) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
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 store.check_nonce(request, oauth_request, oauth_request['oauth_nonce']) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,15 +2,19 @@ | |
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 | ||
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 | ||
import json | ||
import base64 | ||
|
||
from oauth_provider.models import Consumer, Resource | ||
from oauth_provider.models import Token as OAuthToken | ||
from oauth_provider import consts as oauth_consts | ||
import oauth2 as oauth | ||
|
||
class MockView(APIView): | ||
permission_classes = (permissions.IsAuthenticated,) | ||
|
@@ -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])) | ||
) | ||
|
||
|
||
|
@@ -186,3 +194,134 @@ 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests should be excluded unless both There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
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=oauth_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) | ||
|
||
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) | ||
|
||
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)) | ||
|
||
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)) | ||
|
||
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 (oauth_consts.CANCELED, oauth_consts.PENDING, oauth_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)) | ||
|
||
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 = OAuthToken.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)) | ||
|
||
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) | ||
|
||
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) | ||
|
||
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) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs to only install these on Python 2.x