Skip to content

Commit 9bb0703

Browse files
Abhishek8394auvipymarianoeramirezMattBlack85n2ygk
authored
HTTP Basic Auth support for introspection (Fix issue #709) (#725)
* 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 <[email protected]> Co-authored-by: Mariano ramirez <[email protected]> Co-authored-by: Mattia Procopio <[email protected]> Co-authored-by: Alan Crosswell <[email protected]>
1 parent 7756901 commit 9bb0703

File tree

10 files changed

+182
-15
lines changed

10 files changed

+182
-15
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Federico Frenguelli
77
Contributors
88
============
99

10+
Abhishek Patel
1011
Alessandro De Angelis
1112
Alan Crosswell
1213
Asif Saif Uddin

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
-->
1616

1717
## [1.3.1] unreleased
18+
### Added
19+
* #725: HTTP Basic Auth support for introspection (Fix issue #709)
20+
1821
### Fixed
1922
* #812: Reverts #643 pass wrong request object to authenticate function.
2023
* Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810))
2124
* #817: Reverts #734 tutorial documentation error.
2225

26+
2327
## [1.3.0] 2020-03-02
2428

2529
### Added

docs/settings.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,18 @@ Only applicable when used with `Django REST Framework <http://django-rest-framew
198198

199199
RESOURCE_SERVER_INTROSPECTION_URL
200200
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
201-
The introspection endpoint for validating token remotely (RFC7662).
201+
The introspection endpoint for validating token remotely (RFC7662). This URL requires either an authorization
202+
token (RESOURCE_SERVER_AUTH_TOKEN)
203+
or HTTP Basic Auth client credentials (RESOURCE_SERVER_INTROSPECTION_CREDENTIALS):
202204

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

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

208214
RESOURCE_SERVER_TOKEN_CACHING_SECONDS
209215
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

oauth2_provider/oauth2_backends.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from urllib.parse import urlparse, urlunparse
33

44
from oauthlib import oauth2
5+
from oauthlib.common import Request as OauthlibRequest
56
from oauthlib.common import quote, urlencode, urlencoded
67

78
from .exceptions import FatalClientError, OAuthToolkitError
@@ -15,6 +16,7 @@ class OAuthLibCore(object):
1516
Meant for things like extracting request data and converting
1617
everything to formats more palatable for oauthlib's Server.
1718
"""
19+
1820
def __init__(self, server=None):
1921
"""
2022
:params server: An instance of oauthlib.oauth2.Server class
@@ -128,9 +130,11 @@ def create_authorization_response(self, request, scopes, credentials, allow):
128130
return uri, headers, body, status
129131

130132
except oauth2.FatalClientError as error:
131-
raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"])
133+
raise FatalClientError(
134+
error=error, redirect_uri=credentials["redirect_uri"])
132135
except oauth2.OAuth2Error as error:
133-
raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"])
136+
raise OAuthToolkitError(
137+
error=error, redirect_uri=credentials["redirect_uri"])
134138

135139
def create_token_response(self, request):
136140
"""
@@ -171,14 +175,25 @@ def verify_request(self, request, scopes):
171175
"""
172176
uri, http_method, body, headers = self._extract_params(request)
173177

174-
valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes)
178+
valid, r = self.server.verify_request(
179+
uri, http_method, body, headers, scopes=scopes)
175180
return valid, r
176181

182+
def authenticate_client(self, request):
183+
"""Wrapper to call `authenticate_client` on `server_class` instance.
184+
185+
:param request: The current django.http.HttpRequest object
186+
"""
187+
uri, http_method, body, headers = self._extract_params(request)
188+
oauth_request = OauthlibRequest(uri, http_method, body, headers)
189+
return self.server.request_validator.authenticate_client(oauth_request)
190+
177191

178192
class JSONOAuthLibCore(OAuthLibCore):
179193
"""
180194
Extends the default OAuthLibCore to parse correctly application/json requests
181195
"""
196+
182197
def extract_body(self, request):
183198
"""
184199
Extracts the JSON body from the Django request object

oauth2_provider/views/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from .base import AuthorizationView, TokenView, RevokeTokenView
33
from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \
44
ApplicationDelete, ApplicationUpdate
5-
from .generic import ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView
5+
from .generic import (
6+
ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView,
7+
ClientProtectedResourceView, ClientProtectedScopedResourceView)
68
from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView
79
from .introspect import IntrospectTokenView

oauth2_provider/views/generic.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@
22

33
from ..settings import oauth2_settings
44
from .mixins import (
5-
ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin
5+
ClientProtectedResourceMixin, OAuthLibMixin, ProtectedResourceMixin,
6+
ReadWriteScopedResourceMixin, ScopedResourceMixin
67
)
78

89

9-
class ProtectedResourceView(ProtectedResourceMixin, View):
10-
"""
11-
Generic view protecting resources by providing OAuth2 authentication out of the box
10+
class InitializationMixin(OAuthLibMixin):
11+
12+
"""Initializer for OauthLibMixin
1213
"""
14+
1315
server_class = oauth2_settings.OAUTH2_SERVER_CLASS
1416
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
1517
oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS
1618

1719

20+
class ProtectedResourceView(ProtectedResourceMixin, InitializationMixin, View):
21+
"""
22+
Generic view protecting resources by providing OAuth2 authentication out of the box
23+
"""
24+
pass
25+
26+
1827
class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView):
1928
"""
2029
Generic view protecting resources by providing OAuth2 authentication and Scopes handling
@@ -29,3 +38,20 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc
2938
GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required.
3039
"""
3140
pass
41+
42+
43+
class ClientProtectedResourceView(ClientProtectedResourceMixin, InitializationMixin, View):
44+
45+
"""View for protecting a resource with client-credentials method.
46+
This involves allowing access tokens, Basic Auth and plain credentials in request body.
47+
"""
48+
49+
pass
50+
51+
52+
class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView):
53+
54+
"""Impose scope restrictions if client protection fallsback to access token.
55+
"""
56+
57+
pass

oauth2_provider/views/introspect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from django.views.decorators.csrf import csrf_exempt
88

99
from oauth2_provider.models import get_access_token_model
10-
from oauth2_provider.views import ScopedProtectedResourceView
10+
from oauth2_provider.views import ClientProtectedScopedResourceView
1111

1212

1313
@method_decorator(csrf_exempt, name="dispatch")
14-
class IntrospectTokenView(ScopedProtectedResourceView):
14+
class IntrospectTokenView(ClientProtectedScopedResourceView):
1515
"""
1616
Implements an endpoint for token introspection based
1717
on RFC 7662 https://tools.ietf.org/html/rfc7662

oauth2_provider/views/mixins.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,15 @@ def error_response(self, error, **kwargs):
174174

175175
return redirect, error_response
176176

177+
def authenticate_client(self, request):
178+
"""Returns a boolean representing if client is authenticated with client credentials
179+
method. Returns `True` if authenticated.
180+
181+
:param request: The current django.http.HttpRequest object
182+
"""
183+
core = self.get_oauthlib_core()
184+
return core.authenticate_client(request)
185+
177186

178187
class ScopedResourceMixin(object):
179188
"""
@@ -200,6 +209,7 @@ class ProtectedResourceMixin(OAuthLibMixin):
200209
Helper mixin that implements OAuth2 protection on request dispatch,
201210
specially useful for Django Generic Views
202211
"""
212+
203213
def dispatch(self, request, *args, **kwargs):
204214
# let preflight OPTIONS requests pass
205215
if request.method.upper() == "OPTIONS":
@@ -223,12 +233,14 @@ class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin):
223233

224234
def __new__(cls, *args, **kwargs):
225235
provided_scopes = get_scopes_backend().get_all_scopes()
226-
read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE]
236+
read_write_scopes = [oauth2_settings.READ_SCOPE,
237+
oauth2_settings.WRITE_SCOPE]
227238

228239
if not set(read_write_scopes).issubset(set(provided_scopes)):
229240
raise ImproperlyConfigured(
230241
"ReadWriteScopedResourceMixin requires following scopes {}"
231-
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes)
242+
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(
243+
read_write_scopes)
232244
)
233245

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

247259
# this returns a copy so that self.required_scopes is not modified
248260
return scopes + [self.read_write_scope]
261+
262+
263+
class ClientProtectedResourceMixin(OAuthLibMixin):
264+
265+
"""Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1`
266+
This involves authenticating with any of: HTTP Basic Auth, Client Credentials and
267+
Access token in that order. Breaks off after first validation.
268+
"""
269+
270+
def dispatch(self, request, *args, **kwargs):
271+
# let preflight OPTIONS requests pass
272+
if request.method.upper() == "OPTIONS":
273+
return super().dispatch(request, *args, **kwargs)
274+
# Validate either with HTTP basic or client creds in request body.
275+
# TODO: Restrict to POST.
276+
valid = self.authenticate_client(request)
277+
if not valid:
278+
# Alternatively allow access tokens
279+
# check if the request is valid and the protected resource may be accessed
280+
valid, r = self.verify_request(request)
281+
if valid:
282+
request.resource_owner = r.user
283+
return super().dispatch(request, *args, **kwargs)
284+
else:
285+
return HttpResponseForbidden()
286+
else:
287+
return super().dispatch(request, *args, **kwargs)

tests/test_introspection_view.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from oauth2_provider.models import get_access_token_model, get_application_model
1010
from oauth2_provider.settings import oauth2_settings
1111

12+
from .utils import get_basic_auth_header
13+
1214

1315
Application = get_application_model()
1416
AccessToken = get_access_token_model()
@@ -19,9 +21,12 @@ class TestTokenIntrospectionViews(TestCase):
1921
"""
2022
Tests for Authorized Token Introspection Views
2123
"""
24+
2225
def setUp(self):
23-
self.resource_server_user = UserModel.objects.create_user("resource_server", "[email protected]")
24-
self.test_user = UserModel.objects.create_user("bar_user", "[email protected]")
26+
self.resource_server_user = UserModel.objects.create_user(
27+
"resource_server", "[email protected]")
28+
self.test_user = UserModel.objects.create_user(
29+
"bar_user", "[email protected]")
2530

2631
self.application = Application.objects.create(
2732
name="Test Application",
@@ -256,3 +261,63 @@ def test_view_post_notexisting_token(self):
256261
self.assertDictEqual(content, {
257262
"active": False,
258263
})
264+
265+
def test_view_post_valid_client_creds_basic_auth(self):
266+
"""Test HTTP basic auth working
267+
"""
268+
auth_headers = get_basic_auth_header(
269+
self.application.client_id, self.application.client_secret)
270+
response = self.client.post(
271+
reverse("oauth2_provider:introspect"),
272+
{"token": self.valid_token.token},
273+
**auth_headers)
274+
self.assertEqual(response.status_code, 200)
275+
content = response.json()
276+
self.assertIsInstance(content, dict)
277+
self.assertDictEqual(content, {
278+
"active": True,
279+
"scope": self.valid_token.scope,
280+
"client_id": self.valid_token.application.client_id,
281+
"username": self.valid_token.user.get_username(),
282+
"exp": int(calendar.timegm(self.valid_token.expires.timetuple())),
283+
})
284+
285+
def test_view_post_invalid_client_creds_basic_auth(self):
286+
"""Must fail for invalid client credentials
287+
"""
288+
auth_headers = get_basic_auth_header(
289+
self.application.client_id, self.application.client_secret + "_so_wrong")
290+
response = self.client.post(
291+
reverse("oauth2_provider:introspect"),
292+
{"token": self.valid_token.token},
293+
**auth_headers)
294+
self.assertEqual(response.status_code, 403)
295+
296+
def test_view_post_valid_client_creds_plaintext(self):
297+
"""Test introspecting with credentials in request body
298+
"""
299+
response = self.client.post(
300+
reverse("oauth2_provider:introspect"),
301+
{"token": self.valid_token.token,
302+
"client_id": self.application.client_id,
303+
"client_secret": self.application.client_secret})
304+
self.assertEqual(response.status_code, 200)
305+
content = response.json()
306+
self.assertIsInstance(content, dict)
307+
self.assertDictEqual(content, {
308+
"active": True,
309+
"scope": self.valid_token.scope,
310+
"client_id": self.valid_token.application.client_id,
311+
"username": self.valid_token.user.get_username(),
312+
"exp": int(calendar.timegm(self.valid_token.expires.timetuple())),
313+
})
314+
315+
def test_view_post_invalid_client_creds_plaintext(self):
316+
"""Must fail for invalid creds in request body.
317+
"""
318+
response = self.client.post(
319+
reverse("oauth2_provider:introspect"),
320+
{"token": self.valid_token.token,
321+
"client_id": self.application.client_id,
322+
"client_secret": self.application.client_secret + "_so_wrong"})
323+
self.assertEqual(response.status_code, 403)

0 commit comments

Comments
 (0)