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

HTTP Basic Auth support for introspection (Fix issue #709) #725

Merged
merged 14 commits into from
Mar 23, 2020
12 changes: 10 additions & 2 deletions oauth2_provider/oauth2_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
from urllib.parse import urlparse, urlunparse

from oauthlib import oauth2
from oauthlib.common import quote, urlencode, urlencoded

from oauthlib.common import quote, urlencode, urlencoded, Request as OauthlibRequest
from .exceptions import FatalClientError, OAuthToolkitError
from .settings import oauth2_settings

Expand Down Expand Up @@ -174,6 +173,15 @@ def verify_request(self, request, 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):
"""
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
31 changes: 27 additions & 4 deletions oauth2_provider/views/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@

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

class InitializationMixin(OAuthLibMixin):

class ProtectedResourceView(ProtectedResourceMixin, View):
"""
Generic view protecting resources by providing OAuth2 authentication out of the box
"""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):
"""
Expand All @@ -29,3 +37,18 @@ 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
35 changes: 35 additions & 0 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 Down Expand Up @@ -246,3 +255,29 @@ 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)
59 changes: 59 additions & 0 deletions tests/test_introspection_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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 Down Expand Up @@ -256,3 +257,61 @@ 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)
9 changes: 9 additions & 0 deletions tests/test_token_revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def test_revoke_access_token(self):
expires=timezone.now() + datetime.timedelta(days=1),
scope="read write"
)

data = {
"client_id": self.application.client_id,
"client_secret": self.application.client_secret,
Expand Down Expand Up @@ -96,12 +97,14 @@ def test_revoke_access_token_with_hint(self):
expires=timezone.now() + datetime.timedelta(days=1),
scope="read write"
)

data = {
"client_id": self.application.client_id,
"client_secret": self.application.client_secret,
"token": tok.token,
"token_type_hint": "access_token"
}

url = reverse("oauth2_provider:revoke-token")
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 200)
Expand All @@ -115,12 +118,14 @@ def test_revoke_access_token_with_invalid_hint(self):
scope="read write"
)
# invalid hint should have no effect

data = {
"client_id": self.application.client_id,
"client_secret": self.application.client_secret,
"token": tok.token,
"token_type_hint": "bad_hint"
}

url = reverse("oauth2_provider:revoke-token")
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 200)
Expand All @@ -137,11 +142,13 @@ def test_revoke_refresh_token(self):
user=self.test_user, token="999999999",
application=self.application, access_token=tok
)

data = {
"client_id": self.application.client_id,
"client_secret": self.application.client_secret,
"token": rtok.token,
}

url = reverse("oauth2_provider:revoke-token")
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 200)
Expand All @@ -166,6 +173,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self):
"client_secret": self.application.client_secret,
"token": token,
}

url = reverse("oauth2_provider:revoke-token")
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 200)
Expand Down Expand Up @@ -195,6 +203,7 @@ def test_revoke_token_with_wrong_hint(self):
"token": tok.token,
"token_type_hint": "refresh_token"
}

url = reverse("oauth2_provider:revoke-token")
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 200)
Expand Down