Skip to content
Merged
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 AUTHORS
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ Federico Frenguelli
Contributors
============

Abhishek Patel
Alessandro De Angelis
Alan Crosswell
Asif Saif Uddin
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,11 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
-->

## [1.3.1] unreleased
### Added
* #725: HTTP Basic Auth support for introspection (Fix issue #709)

### Fixed
* #812: Reverts #643 pass wrong request object to authenticate function.
* Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810))
* #817: Reverts #734 tutorial documentation error.


## [1.3.0] 2020-03-02

### Added
Expand Down
8 changes: 7 additions & 1 deletion docs/settings.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,18 @@ Only applicable when used with `Django REST Framework <http://django-rest-framew

RESOURCE_SERVER_INTROSPECTION_URL
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The introspection endpoint for validating token remotely (RFC7662).
The introspection endpoint for validating token remotely (RFC7662). This URL requires either an authorization
token (RESOURCE_SERVER_AUTH_TOKEN)
or HTTP Basic Auth client credentials (RESOURCE_SERVER_INTROSPECTION_CREDENTIALS):

RESOURCE_SERVER_AUTH_TOKEN
~~~~~~~~~~~~~~~~~~~~~~~~~~
The bearer token to authenticate the introspection request towards the introspection endpoint (RFC7662).

RESOURCE_SERVER_INTROSPECTION_CREDENTIALS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The HTTP Basic Auth Client_ID and Client_Secret to authenticate the introspection request
towards the introspect endpoint (RFC7662) as a tuple: (client_id,client_secret).

RESOURCE_SERVER_TOKEN_CACHING_SECONDS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
21 changes: 18 additions & 3 deletions oauth2_provider/oauth2_backends.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@
from urllib.parse import urlparse, urlunparse

from oauthlib import oauth2
from oauthlib.common import Request as OauthlibRequest
from oauthlib.common import quote, urlencode, urlencoded

from .exceptions import FatalClientError, OAuthToolkitError
Expand All@@ -15,6 +16,7 @@ class OAuthLibCore(object):
Meant for things like extracting request data and converting
everything to formats more palatable for oauthlib's Server.
"""

def __init__(self, server=None):
"""
:params server: An instance of oauthlib.oauth2.Server class
Expand DownExpand Up@@ -128,9 +130,11 @@ def create_authorization_response(self, request, scopes, credentials, allow):
return uri, headers, body, status

except oauth2.FatalClientError as error:
raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"])
raise FatalClientError(
error=error, redirect_uri=credentials["redirect_uri"])
except oauth2.OAuth2Error as error:
raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"])
raise OAuthToolkitError(
error=error, redirect_uri=credentials["redirect_uri"])

def create_token_response(self, request):
"""
Expand DownExpand Up@@ -171,14 +175,25 @@ def verify_request(self, request, scopes):
"""
uri, http_method, body, headers = self._extract_params(request)

valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes)
valid, r = self.server.verify_request(
uri, http_method, body, headers, scopes=scopes)
return valid, r

def authenticate_client(self, request):
"""Wrapper to call `authenticate_client` on `server_class` instance.

:param request: The current django.http.HttpRequest object
"""
uri, http_method, body, headers = self._extract_params(request)
oauth_request = OauthlibRequest(uri, http_method, body, headers)
return self.server.request_validator.authenticate_client(oauth_request)


class JSONOAuthLibCore(OAuthLibCore):
"""
Extends the default OAuthLibCore to parse correctly application/json requests
"""

def extract_body(self, request):
"""
Extracts the JSON body from the Django request object
Expand Down
4 changes: 3 additions & 1 deletion oauth2_provider/views/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,8 @@
from .base import AuthorizationView, TokenView, RevokeTokenView
from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \
ApplicationDelete, ApplicationUpdate
from .generic import ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView
from .generic import (
ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView,
ClientProtectedResourceView, ClientProtectedScopedResourceView)
from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView
from .introspect import IntrospectTokenView
34 changes: 30 additions & 4 deletions oauth2_provider/views/generic.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,19 +2,28 @@

from ..settings import oauth2_settings
from .mixins import (
ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin
ClientProtectedResourceMixin, OAuthLibMixin, ProtectedResourceMixin,
ReadWriteScopedResourceMixin, ScopedResourceMixin
)


class ProtectedResourceView(ProtectedResourceMixin, View):
"""
Generic view protecting resources by providing OAuth2 authentication out of the box
class InitializationMixin(OAuthLibMixin):

"""Initializer for OauthLibMixin
"""

server_class = oauth2_settings.OAUTH2_SERVER_CLASS
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS


class ProtectedResourceView(ProtectedResourceMixin, InitializationMixin, View):
"""
Generic view protecting resources by providing OAuth2 authentication out of the box
"""
pass


class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView):
"""
Generic view protecting resources by providing OAuth2 authentication and Scopes handling
Expand All@@ -29,3 +38,20 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc
GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required.
"""
pass


class ClientProtectedResourceView(ClientProtectedResourceMixin, InitializationMixin, View):

"""View for protecting a resource with client-credentials method.
This involves allowing access tokens, Basic Auth and plain credentials in request body.
"""

pass


class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView):

"""Impose scope restrictions if client protection fallsback to access token.
"""

pass
4 changes: 2 additions & 2 deletions oauth2_provider/views/introspect.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,11 @@
from django.views.decorators.csrf import csrf_exempt

from oauth2_provider.models import get_access_token_model
from oauth2_provider.views import ScopedProtectedResourceView
from oauth2_provider.views import ClientProtectedScopedResourceView


@method_decorator(csrf_exempt, name="dispatch")
class IntrospectTokenView(ScopedProtectedResourceView):
class IntrospectTokenView(ClientProtectedScopedResourceView):
"""
Implements an endpoint for token introspection based
on RFC 7662 https://tools.ietf.org/html/rfc7662
Expand Down
43 changes: 41 additions & 2 deletions oauth2_provider/views/mixins.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,15 @@ def error_response(self, error, **kwargs):

return redirect, error_response

def authenticate_client(self, request):
"""Returns a boolean representing if client is authenticated with client credentials
method. Returns `True` if authenticated.

:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.authenticate_client(request)


class ScopedResourceMixin(object):
"""
Expand All@@ -200,6 +209,7 @@ class ProtectedResourceMixin(OAuthLibMixin):
Helper mixin that implements OAuth2 protection on request dispatch,
specially useful for Django Generic Views
"""

def dispatch(self, request, *args, **kwargs):
# let preflight OPTIONS requests pass
if request.method.upper() == "OPTIONS":
Expand All@@ -223,12 +233,14 @@ class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin):

def __new__(cls, *args, **kwargs):
provided_scopes = get_scopes_backend().get_all_scopes()
read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE]
read_write_scopes = [oauth2_settings.READ_SCOPE,
oauth2_settings.WRITE_SCOPE]

if not set(read_write_scopes).issubset(set(provided_scopes)):
raise ImproperlyConfigured(
"ReadWriteScopedResourceMixin requires following scopes{}"
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes)
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(
read_write_scopes)
)

return super().__new__(cls, *args, **kwargs)
Expand All@@ -246,3 +258,30 @@ def get_scopes(self, *args, **kwargs):

# this returns a copy so that self.required_scopes is not modified
return scopes + [self.read_write_scope]


class ClientProtectedResourceMixin(OAuthLibMixin):

"""Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1`
This involves authenticating with any of: HTTP Basic Auth, Client Credentials and
Access token in that order. Breaks off after first validation.
"""

def dispatch(self, request, *args, **kwargs):
# let preflight OPTIONS requests pass
if request.method.upper() == "OPTIONS":
return super().dispatch(request, *args, **kwargs)
# Validate either with HTTP basic or client creds in request body.
# TODO: Restrict to POST.
valid = self.authenticate_client(request)
if not valid:
# Alternatively allow access tokens
# check if the request is valid and the protected resource may be accessed
valid, r = self.verify_request(request)
if valid:
request.resource_owner = r.user
return super().dispatch(request, *args, **kwargs)
else:
return HttpResponseForbidden()
else:
return super().dispatch(request, *args, **kwargs)
69 changes: 67 additions & 2 deletions tests/test_introspection_view.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@
from oauth2_provider.models import get_access_token_model, get_application_model
from oauth2_provider.settings import oauth2_settings

from .utils import get_basic_auth_header


Application = get_application_model()
AccessToken = get_access_token_model()
Expand All@@ -19,9 +21,12 @@ class TestTokenIntrospectionViews(TestCase):
"""
Tests for Authorized Token Introspection Views
"""

def setUp(self):
self.resource_server_user = UserModel.objects.create_user("resource_server", "[email protected]")
self.test_user = UserModel.objects.create_user("bar_user", "[email protected]")
self.resource_server_user = UserModel.objects.create_user(
"resource_server", "[email protected]")
self.test_user = UserModel.objects.create_user(
"bar_user", "[email protected]")

self.application = Application.objects.create(
name="Test Application",
Expand DownExpand Up@@ -256,3 +261,63 @@ def test_view_post_notexisting_token(self):
self.assertDictEqual(content,{
"active": False,
})

def test_view_post_valid_client_creds_basic_auth(self):
"""Test HTTP basic auth working
"""
auth_headers = get_basic_auth_header(
self.application.client_id, self.application.client_secret)
response = self.client.post(
reverse("oauth2_provider:introspect"),
{"token": self.valid_token.token},
**auth_headers)
self.assertEqual(response.status_code, 200)
content = response.json()
self.assertIsInstance(content, dict)
self.assertDictEqual(content,{
"active": True,
"scope": self.valid_token.scope,
"client_id": self.valid_token.application.client_id,
"username": self.valid_token.user.get_username(),
"exp": int(calendar.timegm(self.valid_token.expires.timetuple())),
})

def test_view_post_invalid_client_creds_basic_auth(self):
"""Must fail for invalid client credentials
"""
auth_headers = get_basic_auth_header(
self.application.client_id, self.application.client_secret + "_so_wrong")
response = self.client.post(
reverse("oauth2_provider:introspect"),
{"token": self.valid_token.token},
**auth_headers)
self.assertEqual(response.status_code, 403)

def test_view_post_valid_client_creds_plaintext(self):
"""Test introspecting with credentials in request body
"""
response = self.client.post(
reverse("oauth2_provider:introspect"),
{"token": self.valid_token.token,
"client_id": self.application.client_id,
"client_secret": self.application.client_secret})
self.assertEqual(response.status_code, 200)
content = response.json()
self.assertIsInstance(content, dict)
self.assertDictEqual(content,{
"active": True,
"scope": self.valid_token.scope,
"client_id": self.valid_token.application.client_id,
"username": self.valid_token.user.get_username(),
"exp": int(calendar.timegm(self.valid_token.expires.timetuple())),
})

def test_view_post_invalid_client_creds_plaintext(self):
"""Must fail for invalid creds in request body.
"""
response = self.client.post(
reverse("oauth2_provider:introspect"),
{"token": self.valid_token.token,
"client_id": self.application.client_id,
"client_secret": self.application.client_secret + "_so_wrong"})
self.assertEqual(response.status_code, 403)
Loading