Skip to content

Commit b4378b3

Browse files
n2ygkjleclanche
authored andcommitted
Add TokenHasMethodScopeAlternative
1 parent be659ca commit b4378b3

File tree

6 files changed

+282
-6
lines changed

6 files changed

+282
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
* **Compatibility**: Python 3.4 is the new minimum required version.
44
* **Compatibility**: Django 2.0 is the new minimum required version.
5+
* **New feature**: Added TokenHasMethodScopeAlternative Permissions.
56

67
### 1.1.1 [2018-05-08]
78

@@ -25,6 +26,7 @@
2526
refresh tokens may be re-used.
2627
* An `app_authorized` signal is fired when a token is generated.
2728

29+
2830
### 1.0.0 [2017-06-07]
2931

3032
* **New feature**: AccessToken, RefreshToken and Grant models are now swappable.

docs/rest-framework/openapi.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: songs
4+
version: v1
5+
components:
6+
securitySchemes:
7+
song_auth:
8+
type: oauth2
9+
flows:
10+
implicit:
11+
authorizationUrl: http://localhost:8000/o/authorize
12+
scopes:
13+
read: read about a song
14+
create: create a new song
15+
update: update an existing song
16+
delete: delete a song
17+
post: create a new song
18+
widget: widget scope
19+
scope2: scope too
20+
scope3: another scope
21+
paths:
22+
/songs:
23+
get:
24+
security:
25+
- song_auth: [read]
26+
responses:
27+
'200':
28+
description: A list of songs.
29+
post:
30+
security:
31+
- song_auth: [create]
32+
- song_auth: [post, widget]
33+
responses:
34+
'201':
35+
description: new song added
36+
put:
37+
security:
38+
- song_auth: [update]
39+
- song_auth: [put, widget]
40+
responses:
41+
'204':
42+
description: song updated
43+
delete:
44+
security:
45+
- song_auth: [delete]
46+
- song_auth: [scope2, scope3]
47+
responses:
48+
'200':
49+
description: song deleted

docs/rest-framework/permissions.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ For example:
4848
4949
When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' scopes are required to be authorized for the current access token.
5050

51+
5152
TokenHasResourceScope
5253
----------------------
5354
The `TokenHasResourceScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method.
@@ -81,3 +82,36 @@ For example:
8182
required_scopes = ['music']
8283
8384
The `required_scopes` attribute is mandatory.
85+
86+
87+
TokenHasMethodScopeAlternative
88+
------------------------------
89+
90+
The `TokenHasMethodScopeAlternative` permission class allows the access based on a per-method basis
91+
and with alternative lists of required scopes. This permission provides full functionality
92+
required by REST API specifications like the
93+
`OpenAPI Specification (OAS) security requirement object <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject>`_.
94+
95+
The `required_alternate_scopes` attribute is a required map keyed by HTTP method name where each value is
96+
a list of alternative lists of required scopes.
97+
98+
In the follow example GET requires "read" scope, POST requires either "create" scope **OR** "post" and "widget" scopes,
99+
etc.
100+
101+
.. code-block:: python
102+
103+
class SongView(views.APIView):
104+
authentication_classes = [OAuth2Authentication]
105+
permission_classes = [TokenHasMethodScopeAlternative]
106+
required_alternate_scopes = {
107+
"GET": [["read"]],
108+
"POST": [["create"], ["post", "widget"]],
109+
"PUT": [["update"], ["put", "widget"]],
110+
"DELETE": [["delete"], ["scope2", "scope3"]],
111+
}
112+
113+
The following is a minimal OAS declaration that shows the same required alternate scopes. It is complete enough
114+
to try it in the `swagger editor <https://editor.swagger.io>`_.
115+
116+
.. literalinclude:: openapi.yaml
117+
:language: YAML
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# flake8: noqa
22
from .authentication import OAuth2Authentication
3-
from .permissions import TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope
4-
from .permissions import IsAuthenticatedOrTokenHasScope
3+
from .permissions import (
4+
TokenHasScope, TokenHasReadWriteScope, TokenHasMethodScopeAlternative,
5+
TokenHasResourceScope, IsAuthenticatedOrTokenHasScope
6+
)

oauth2_provider/contrib/rest_framework/permissions.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,58 @@ def has_permission(self, request, view):
121121

122122
token_has_scope = TokenHasScope()
123123
return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view)
124+
125+
126+
class TokenHasMethodScopeAlternative(BasePermission):
127+
"""
128+
:attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists
129+
130+
This fulfills the [Open API Specification (OAS; formerly Swagger)](https://www.openapis.org/)
131+
list of alternative Security Requirements Objects for oauth2 or openIdConnect:
132+
When a list of Security Requirement Objects is defined on the Open API object or Operation Object,
133+
only one of Security Requirement Objects in the list needs to be satisfied to authorize the request.
134+
[1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject)
135+
136+
For each method, a list of lists of allowed scopes is tried in order and the first to match succeeds.
137+
138+
@example
139+
required_alternate_scopes = {
140+
'GET': [['read']],
141+
'POST': [['create1','scope2'], ['alt-scope3'], ['alt-scope4','alt-scope5']],
142+
}
143+
144+
TODO: DRY: subclass TokenHasScope and iterate over values of required_scope?
145+
"""
146+
147+
def has_permission(self, request, view):
148+
token = request.auth
149+
150+
if not token:
151+
return False
152+
153+
if hasattr(token, "scope"): # OAuth 2
154+
required_alternate_scopes = self.get_required_alternate_scopes(request, view)
155+
156+
m = request.method.upper()
157+
if m in required_alternate_scopes:
158+
log.debug("Required scopes alternatives to access resource: {0}"
159+
.format(required_alternate_scopes[m]))
160+
for alt in required_alternate_scopes[m]:
161+
if token.is_valid(alt):
162+
return True
163+
return False
164+
else:
165+
log.warning("no scope alternates defined for method {0}".format(m))
166+
return False
167+
168+
assert False, ("TokenHasMethodScope requires the"
169+
"`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
170+
"class to be used.")
171+
172+
def get_required_alternate_scopes(self, request, view):
173+
try:
174+
return getattr(view, "required_alternate_scopes")
175+
except AttributeError:
176+
raise ImproperlyConfigured(
177+
"TokenHasMethodScopeAlternative requires the view to"
178+
" define the required_alternate_scopes attribute")

tests/test_rest_framework.py

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
from django.conf.urls import include, url
44
from django.contrib.auth import get_user_model
5+
from django.core.exceptions import ImproperlyConfigured
56
from django.http import HttpResponse
67
from django.test import TestCase
78
from django.test.utils import override_settings
89
from django.utils import timezone
910
from rest_framework import permissions
11+
from rest_framework.authentication import BaseAuthentication
1012
from rest_framework.test import APIRequestFactory, force_authenticate
1113
from rest_framework.views import APIView
1214

1315
from oauth2_provider.contrib.rest_framework import (
1416
IsAuthenticatedOrTokenHasScope, OAuth2Authentication,
15-
TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope
17+
TokenHasMethodScopeAlternative, TokenHasReadWriteScope,
18+
TokenHasResourceScope, TokenHasScope
1619
)
1720
from oauth2_provider.models import get_access_token_model, get_application_model
1821
from oauth2_provider.settings import oauth2_settings
@@ -38,14 +41,17 @@ def get(self, request):
3841
def post(self, request):
3942
return HttpResponse({"a": 1, "b": 2, "c": 3})
4043

44+
def put(self, request):
45+
return HttpResponse({"a": 1, "b": 2, "c": 3})
46+
4147

4248
class OAuth2View(MockView):
4349
authentication_classes = [OAuth2Authentication]
4450

4551

4652
class ScopedView(OAuth2View):
4753
permission_classes = [permissions.IsAuthenticated, TokenHasScope]
48-
required_scopes = ["scope1"]
54+
required_scopes = ["scope1", "another"]
4955

5056

5157
class AuthenticatedOrScopedView(OAuth2View):
@@ -62,13 +68,48 @@ class ResourceScopedView(OAuth2View):
6268
required_scopes = ["resource1"]
6369

6470

71+
class MethodScopeAltView(OAuth2View):
72+
permission_classes = [TokenHasMethodScopeAlternative]
73+
required_alternate_scopes = {
74+
"GET": [["read"]],
75+
"POST": [["create"]],
76+
"PUT": [["update", "put"], ["update", "edit"]],
77+
"DELETE": [["delete"], ["deleter", "write"]],
78+
}
79+
80+
81+
class MethodScopeAltViewBad(OAuth2View):
82+
permission_classes = [TokenHasMethodScopeAlternative]
83+
84+
85+
class MissingAuthentication(BaseAuthentication):
86+
def authenticate(self, request):
87+
return ("junk", "junk",)
88+
89+
90+
class BrokenOAuth2View(MockView):
91+
authentication_classes = [MissingAuthentication]
92+
93+
94+
class TokenHasScopeViewWrongAuth(BrokenOAuth2View):
95+
permission_classes = [TokenHasScope]
96+
97+
98+
class MethodScopeAltViewWrongAuth(BrokenOAuth2View):
99+
permission_classes = [TokenHasMethodScopeAlternative]
100+
101+
65102
urlpatterns = [
66103
url(r"^oauth2/", include("oauth2_provider.urls")),
67104
url(r"^oauth2-test/$", OAuth2View.as_view()),
68105
url(r"^oauth2-scoped-test/$", ScopedView.as_view()),
106+
url(r"^oauth2-scoped-missing-auth/$", TokenHasScopeViewWrongAuth.as_view()),
69107
url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()),
70108
url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()),
71109
url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()),
110+
url(r"^oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()),
111+
url(r"^oauth2-method-scope-fail/$", MethodScopeAltViewBad.as_view()),
112+
url(r"^oauth2-method-scope-missing-auth/$", MethodScopeAltViewWrongAuth.as_view()),
72113
]
73114

74115

@@ -142,13 +183,19 @@ def test_authentication_or_scope_denied(self):
142183
self.assertEqual(response.status_code, 403)
143184

144185
def test_scoped_permission_allow(self):
145-
self.access_token.scope = "scope1"
186+
self.access_token.scope = "scope1 another"
146187
self.access_token.save()
147188

148189
auth = self._create_authorization_header(self.access_token.token)
149190
response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
150191
self.assertEqual(response.status_code, 200)
151192

193+
def test_scope_missing_scope_attr(self):
194+
auth = self._create_authorization_header("fake-token")
195+
with self.assertRaises(AssertionError) as e:
196+
self.client.get("/oauth2-scoped-missing-auth/", HTTP_AUTHORIZATION=auth)
197+
self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception))
198+
152199
def test_authenticated_or_scoped_permission_allow(self):
153200
self.access_token.scope = "scope1"
154201
self.access_token.save()
@@ -255,7 +302,7 @@ def test_required_scope_in_response(self):
255302
auth = self._create_authorization_header(self.access_token.token)
256303
response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
257304
self.assertEqual(response.status_code, 403)
258-
self.assertEqual(response.data["required_scopes"], ["scope1"])
305+
self.assertEqual(response.data["required_scopes"], ["scope1", "another"])
259306

260307
def test_required_scope_not_in_response_by_default(self):
261308
self.access_token.scope = "scope2"
@@ -265,3 +312,90 @@ def test_required_scope_not_in_response_by_default(self):
265312
response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
266313
self.assertEqual(response.status_code, 403)
267314
self.assertNotIn("required_scopes", response.data)
315+
316+
def test_method_scope_alt_permission_get_allow(self):
317+
self.access_token.scope = "read"
318+
self.access_token.save()
319+
320+
auth = self._create_authorization_header(self.access_token.token)
321+
response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
322+
self.assertEqual(response.status_code, 200)
323+
324+
def test_method_scope_alt_permission_post_allow(self):
325+
self.access_token.scope = "create"
326+
self.access_token.save()
327+
328+
auth = self._create_authorization_header(self.access_token.token)
329+
response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
330+
self.assertEqual(response.status_code, 200)
331+
332+
def test_method_scope_alt_permission_put_allow(self):
333+
self.access_token.scope = "edit update"
334+
self.access_token.save()
335+
336+
auth = self._create_authorization_header(self.access_token.token)
337+
response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth)
338+
self.assertEqual(response.status_code, 200)
339+
340+
def test_method_scope_alt_permission_put_fail(self):
341+
self.access_token.scope = "edit"
342+
self.access_token.save()
343+
344+
auth = self._create_authorization_header(self.access_token.token)
345+
response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth)
346+
self.assertEqual(response.status_code, 403)
347+
348+
def test_method_scope_alt_permission_get_deny(self):
349+
self.access_token.scope = "write"
350+
self.access_token.save()
351+
352+
auth = self._create_authorization_header(self.access_token.token)
353+
response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
354+
self.assertEqual(response.status_code, 403)
355+
356+
def test_method_scope_alt_permission_post_deny(self):
357+
self.access_token.scope = "read"
358+
self.access_token.save()
359+
360+
auth = self._create_authorization_header(self.access_token.token)
361+
response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
362+
self.assertEqual(response.status_code, 403)
363+
364+
def test_method_scope_alt_no_token(self):
365+
self.access_token.scope = ""
366+
self.access_token.save()
367+
368+
auth = self._create_authorization_header(self.access_token.token)
369+
self.access_token = None
370+
response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
371+
self.assertEqual(response.status_code, 403)
372+
373+
def test_method_scope_alt_missing_attr(self):
374+
self.access_token.scope = "read"
375+
self.access_token.save()
376+
377+
auth = self._create_authorization_header(self.access_token.token)
378+
with self.assertRaises(ImproperlyConfigured):
379+
self.client.post("/oauth2-method-scope-fail/", HTTP_AUTHORIZATION=auth)
380+
381+
def test_method_scope_alt_missing_patch_method(self):
382+
self.access_token.scope = "update"
383+
self.access_token.save()
384+
385+
auth = self._create_authorization_header(self.access_token.token)
386+
response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
387+
self.assertEqual(response.status_code, 403)
388+
389+
def test_method_scope_alt_empty_scope(self):
390+
self.access_token.scope = ""
391+
self.access_token.save()
392+
393+
auth = self._create_authorization_header(self.access_token.token)
394+
response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
395+
self.assertEqual(response.status_code, 403)
396+
397+
def test_method_scope_alt_missing_scope_attr(self):
398+
auth = self._create_authorization_header("fake-token")
399+
with self.assertRaises(AssertionError) as e:
400+
self.client.get("/oauth2-method-scope-missing-auth/", HTTP_AUTHORIZATION=auth)
401+
self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception))

0 commit comments

Comments
 (0)