Skip to content

Commit

Permalink
HTTP Basic Auth support for introspection (Fix issue #709) (#725)
Browse files Browse the repository at this point in the history
* fix issue #709

- Add a new mixin that allows authenticating with HTTP basic auth, credentials in body or access tokens
- Introduce and abstraction in views.generic to initialize the OauthLibMixin
- Change parent class of IntrospectTokenView from 'ScopedProtectedResourceView' to 'ClientProtectedScopedResourceView'

* fix failing tests after master merge

- test failed because they sent url query params in a post request. That is no longer allowed for security purposes.
- Fix: send query params as POST body instead of query params

* add newline

* update AUTHORS and CHANGELOG

* fix flake8 failing tests

* document RESOURCE_SERVER_INTROSPECTION_CREDENTIALS

Co-authored-by: Asif Saif Uddin <auvipy@gmail.com>
Co-authored-by: Mariano ramirez <marianoramirez353@gmail.com>
Co-authored-by: Mattia Procopio <promat85@gmail.com>
Co-authored-by: Alan Crosswell <alan@columbia.edu>
  • Loading branch information
5 people authored Mar 23, 2020
1 parent 7756901 commit 9bb0703
Show file tree
Hide file tree
Showing 10 changed files with 182 additions and 15 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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", "test@example.com")
self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com")
self.resource_server_user = UserModel.objects.create_user(
"resource_server", "test@example.com")
self.test_user = UserModel.objects.create_user(
"bar_user", "dev@example.com")

self.application = Application.objects.create(
name="Test Application",
Expand Down Expand 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

0 comments on commit 9bb0703

Please sign in to comment.