Skip to content

Commit abb304a

Browse files
symptogjleclanche
authored andcommitted
Extend bearer validation for token introspection
1 parent c1cd5d7 commit abb304a

File tree

3 files changed

+284
-6
lines changed

3 files changed

+284
-6
lines changed

oauth2_provider/oauth2_validators.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import base64
44
import binascii
55
import logging
6-
from datetime import timedelta
6+
from datetime import datetime, timedelta
77

8+
import requests
89
from django.conf import settings
9-
from django.contrib.auth import authenticate
10+
from django.contrib.auth import authenticate, get_user_model
1011
from django.core.exceptions import ObjectDoesNotExist
1112
from django.db import transaction
1213
from django.utils import timezone
14+
from django.utils.timezone import make_aware
1315
from oauthlib.oauth2 import RequestValidator
1416

1517
from .compat import unquote_plus
@@ -24,7 +26,6 @@
2426
from .scopes import get_scopes_backend
2527
from .settings import oauth2_settings
2628

27-
2829
log = logging.getLogger('oauth2_provider')
2930

3031
GRANT_TYPE_MAPPING = {
@@ -40,6 +41,7 @@
4041
AccessToken = get_access_token_model()
4142
Grant = get_grant_model()
4243
RefreshToken = get_refresh_token_model()
44+
UserModel = get_user_model()
4345

4446

4547
class OAuth2Validator(RequestValidator):
@@ -237,17 +239,84 @@ def validate_client_id(self, client_id, request, *args, **kwargs):
237239
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
238240
return request.client.default_redirect_uri
239241

242+
def _get_token_from_authentication_server(self, token, introspection_url, introspection_token):
243+
bearer = "Bearer {}".format(introspection_token)
244+
245+
try:
246+
response = requests.post(
247+
introspection_url,
248+
data={"token": token}, headers={"Authorization": bearer}
249+
)
250+
except requests.exceptions.RequestException:
251+
log.exception("Introspection: Failed POST to %r in token lookup", introspection_url)
252+
return None
253+
254+
try:
255+
content = response.json()
256+
except ValueError:
257+
log.exception("Introspection: Failed to parse response as json")
258+
return None
259+
260+
if "active" in content and content['active'] is True:
261+
if "username" in content:
262+
user, _created = UserModel.objects.get_or_create(
263+
**{UserModel.USERNAME_FIELD: content["username"]}
264+
)
265+
else:
266+
user = None
267+
268+
max_caching_time = datetime.now() + timedelta(
269+
seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS
270+
)
271+
272+
if "exp" in content:
273+
expires = datetime.utcfromtimestamp(content["exp"])
274+
if expires > max_caching_time:
275+
expires = max_caching_time
276+
else:
277+
expires = max_caching_time
278+
279+
scope = content.get("scope", "")
280+
expires = make_aware(expires)
281+
282+
try:
283+
access_token = AccessToken.objects.select_related("application", "user").get(token=token)
284+
except AccessToken.DoesNotExist:
285+
access_token = AccessToken.objects.create(
286+
token=token,
287+
user=user,
288+
application=None,
289+
scope=scope,
290+
expires=expires
291+
)
292+
else:
293+
access_token.expires = expires
294+
access_token.scope = scope
295+
access_token.save()
296+
297+
return access_token
298+
240299
def validate_bearer_token(self, token, scopes, request):
241300
"""
242301
When users try to access resources, check that provided token is valid
243302
"""
244303
if not token:
245304
return False
246305

306+
introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL
307+
introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN
308+
247309
try:
248-
access_token = AccessToken.objects.select_related("application", "user").get(
249-
token=token)
250-
if access_token.is_valid(scopes):
310+
access_token = AccessToken.objects.select_related("application", "user").get(token=token)
311+
# if there is a token but invalid then look up the token
312+
if introspection_url and introspection_token:
313+
if not access_token.is_valid(scopes):
314+
access_token = self._get_token_from_authentication_server(
315+
token,
316+
introspection_url,
317+
introspection_token
318+
)
319+
if access_token and access_token.is_valid(scopes):
251320
request.client = access_token.application
252321
request.user = access_token.user
253322
request.scopes = scopes
@@ -257,6 +326,21 @@ def validate_bearer_token(self, token, scopes, request):
257326
return True
258327
return False
259328
except AccessToken.DoesNotExist:
329+
# there is no initial token, look up the token
330+
if introspection_url and introspection_token:
331+
access_token = self._get_token_from_authentication_server(
332+
token,
333+
introspection_url,
334+
introspection_token
335+
)
336+
if access_token and access_token.is_valid(scopes):
337+
request.client = access_token.application
338+
request.user = access_token.user
339+
request.scopes = scopes
340+
341+
# this is needed by django rest framework
342+
request.access_token = access_token
343+
return True
260344
return False
261345

262346
def validate_code(self, client_id, code, client, request, *args, **kwargs):

oauth2_provider/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
# Special settings that will be evaluated at runtime
5757
'_SCOPES': [],
5858
'_DEFAULT_SCOPES': [],
59+
60+
# Resource Server with Token Introspection
61+
'RESOURCE_SERVER_INTROSPECTION_URL': None,
62+
'RESOURCE_SERVER_AUTH_TOKEN': None,
63+
'RESOURCE_SERVER_TOKEN_CACHING_SECONDS': 36000,
5964
}
6065

6166
# List of settings that cannot be empty

tests/test_introspection_auth.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from __future__ import unicode_literals
2+
3+
import calendar
4+
import datetime
5+
6+
from django.conf.urls import include, url
7+
from django.contrib.auth import get_user_model
8+
from django.http import HttpResponse
9+
from django.test import override_settings, TestCase
10+
from django.utils import timezone
11+
from oauthlib.common import Request
12+
13+
from oauth2_provider.models import get_access_token_model, get_application_model
14+
from oauth2_provider.oauth2_validators import OAuth2Validator
15+
from oauth2_provider.settings import oauth2_settings
16+
from oauth2_provider.views import ScopedProtectedResourceView
17+
18+
try:
19+
from unittest import mock
20+
except ImportError:
21+
import mock
22+
23+
24+
Application = get_application_model()
25+
AccessToken = get_access_token_model()
26+
UserModel = get_user_model()
27+
28+
exp = datetime.datetime.now() + datetime.timedelta(days=1)
29+
30+
31+
class ScopeResourceView(ScopedProtectedResourceView):
32+
required_scopes = ["dolphin"]
33+
34+
def get(self, request, *args, **kwargs):
35+
return HttpResponse("This is a protected resource", 200)
36+
37+
def post(self, request, *args, **kwargs):
38+
return HttpResponse("This is a protected resource", 200)
39+
40+
41+
def mocked_requests_post(url, data, *args, **kwargs):
42+
"""
43+
Mock the response from the authentication server
44+
"""
45+
class MockResponse:
46+
def __init__(self, json_data, status_code):
47+
self.json_data = json_data
48+
self.status_code = status_code
49+
50+
def json(self):
51+
return self.json_data
52+
53+
if "token" in data and data["token"] and data["token"] != "12345678900":
54+
return MockResponse({
55+
"active": True,
56+
"scope": "read write dolphin",
57+
"client_id": "client_id_{}".format(data["token"]),
58+
"username": "{}_user".format(data["token"]),
59+
"exp": int(calendar.timegm(exp.timetuple())),
60+
}, 200)
61+
62+
return MockResponse({
63+
"active": False,
64+
}, 200)
65+
66+
67+
urlpatterns = [
68+
url(r"^oauth2/", include("oauth2_provider.urls")),
69+
url(r"^oauth2-test-resource/$", ScopeResourceView.as_view()),
70+
]
71+
72+
73+
@override_settings(ROOT_URLCONF=__name__)
74+
class TestTokenIntrospectionAuth(TestCase):
75+
"""
76+
Tests for Authorization through token introspection
77+
"""
78+
def setUp(self):
79+
self.validator = OAuth2Validator()
80+
self.request = mock.MagicMock(wraps=Request)
81+
self.resource_server_user = UserModel.objects.create_user(
82+
"resource_server", "test@example.com", "123456"
83+
)
84+
85+
self.application = Application(
86+
name="Test Application",
87+
redirect_uris="http://localhost http://example.com http://example.org",
88+
user=self.resource_server_user,
89+
client_type=Application.CLIENT_CONFIDENTIAL,
90+
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
91+
)
92+
self.application.save()
93+
94+
self.resource_server_token = AccessToken.objects.create(
95+
user=self.resource_server_user, token="12345678900",
96+
application=self.application,
97+
expires=timezone.now() + datetime.timedelta(days=1),
98+
scope="read write introspection"
99+
)
100+
101+
self.invalid_token = AccessToken.objects.create(
102+
user=self.resource_server_user, token="12345678901",
103+
application=self.application,
104+
expires=timezone.now() + datetime.timedelta(days=-1),
105+
scope="read write dolphin"
106+
)
107+
108+
oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"]
109+
oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = "http://example.org/introspection"
110+
oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token
111+
oauth2_settings.READ_SCOPE = "read"
112+
oauth2_settings.WRITE_SCOPE = "write"
113+
114+
def tearDown(self):
115+
oauth2_settings._SCOPES = ["read", "write"]
116+
oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = None
117+
oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = None
118+
self.resource_server_token.delete()
119+
self.application.delete()
120+
AccessToken.objects.all().delete()
121+
UserModel.objects.all().delete()
122+
123+
@mock.patch("requests.post", side_effect=mocked_requests_post)
124+
def test_get_token_from_authentication_server_not_existing_token(self, mock_get):
125+
"""
126+
Test method _get_token_from_authentication_server with non existing token
127+
"""
128+
token = self.validator._get_token_from_authentication_server(
129+
self.resource_server_token.token,
130+
oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL,
131+
oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN
132+
)
133+
self.assertIsNone(token)
134+
135+
@mock.patch("requests.post", side_effect=mocked_requests_post)
136+
def test_get_token_from_authentication_server_existing_token(self, mock_get):
137+
"""
138+
Test method _get_token_from_authentication_server with existing token
139+
"""
140+
token = self.validator._get_token_from_authentication_server(
141+
"foo",
142+
oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL,
143+
oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN
144+
)
145+
self.assertIsInstance(token, AccessToken)
146+
self.assertEqual(token.user.username, "foo_user")
147+
self.assertEqual(token.scope, "read write dolphin")
148+
149+
@mock.patch("requests.post", side_effect=mocked_requests_post)
150+
def test_validate_bearer_token(self, mock_get):
151+
"""
152+
Test method validate_bearer_token
153+
"""
154+
# with token = None
155+
self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request))
156+
# with valid token and scope
157+
self.assertTrue(self.validator.validate_bearer_token(self.resource_server_token.token, ["introspection"], self.request))
158+
# with initially invalid token, but validated through request
159+
self.assertTrue(self.validator.validate_bearer_token(self.invalid_token.token, ["dolphin"], self.request))
160+
# with locally unavailable token, but validated through request
161+
self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request))
162+
# with valid token but invalid scope
163+
self.assertFalse(self.validator.validate_bearer_token("foo", ["kaudawelsch"], self.request))
164+
# with token validated through request, but invalid scope
165+
self.assertFalse(self.validator.validate_bearer_token("butz", ["kaudawelsch"], self.request))
166+
# with token validated through request and valid scope
167+
self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request))
168+
169+
@mock.patch("requests.post", side_effect=mocked_requests_post)
170+
def test_get_resource(self, mock_get):
171+
"""
172+
Test that we can access the resource with a get request and a remotely validated token
173+
"""
174+
auth_headers = {
175+
"HTTP_AUTHORIZATION": "Bearer bar",
176+
}
177+
response = self.client.get("/oauth2-test-resource/", **auth_headers)
178+
self.assertEqual(response.content.decode("utf-8"), "This is a protected resource")
179+
180+
@mock.patch("requests.post", side_effect=mocked_requests_post)
181+
def test_post_resource(self, mock_get):
182+
"""
183+
Test that we can access the resource with a post request and a remotely validated token
184+
"""
185+
auth_headers = {
186+
"HTTP_AUTHORIZATION": "Bearer batz",
187+
}
188+
response = self.client.post("/oauth2-test-resource/", **auth_headers)
189+
self.assertEqual(response.content.decode("utf-8"), "This is a protected resource")

0 commit comments

Comments
 (0)