Skip to content

Commit

Permalink
Merge 9cc50fa into c2ca9cc
Browse files Browse the repository at this point in the history
  • Loading branch information
n2ygk committed Mar 2, 2018
2 parents c2ca9cc + 9cc50fa commit 309bbda
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -1,6 +1,7 @@
### 1.1.0 [unreleased]

* **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth.
* **New feature**: Added TokenHasMethodScope and TokenHasMethodPathScope Permissions.

### 1.0.0 [2017-06-07]

Expand Down
59 changes: 59 additions & 0 deletions docs/rest-framework/permissions.rst
Expand Up @@ -48,6 +48,7 @@ For example:
When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' scopes are required to be authorized for the current access token.


TokenHasResourceScope
----------------------
The `TokenHasResourceScope` permission class allows the 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.
Expand Down Expand Up @@ -81,3 +82,61 @@ For example:
required_scopes = ['music']
The `required_scopes` attribute is mandatory.


TokenHasMethodScope
-------------------

The `TokenHasMethodScope` permission class allows the access based on a per-method map.

The `required_scopes_map` attribute is a required map of methods and required scopes for each method.

For example:

.. code-block:: python
class SongView(views.APIView):
authentication_classes = [OAuth2Authentication]
permission_classes = [TokenHasMethodScope]
required_scopes_map = {
"GET": ["read"],
"POST": ["create"],
"PUT": ["update", "put"],
"DELETE": ["delete"],
}
When a `GET` request is performed the 'read' scope is required to be authorized
for the current access token. When a `PUT` is performed, 'update' and 'put' are required
and when a `DELETE` is performed, the 'delete' scope is required.

TokenHasMethodPathScope
-----------------------

The `TokenHasMethodPathScope` permission class allows the access based on a per-method and resource regex
map and allows for alternative lists of required scopes. This permission provides full functionality
required by REST API specifications like the
`OpenAPI Specification's security requirement object <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject>`_.

The `required_scopes_map_list` attribute is a required list of `RequiredMethodScopes` instances.

For example:

.. code-block:: python
class SongView(views.APIView):
authentication_classes = [OAuth2Authentication]
permission_classes = [TokenHasMethodPathScope]
required_scopes_map_list = [
RequiredMethodScopes("GET", r"^/widgets/?[^/]*/?$", ["read", "get widget"]),
RequiredMethodScopes("POST", r"^/widgets/?$", ["create", "post widget"]),
RequiredMethodScopes("PUT", r"^/widgets/[^/]+/?$", ["update", "put widget"]),
RequiredMethodScopes("DELETE", r"^/widgets/[^/]+/?$", ["delete", "scope2 scope3"]),
RequiredMethodScopes("GET", r"^/gadgets/?[^/]*/?$", ["read gadget", "get scope1"]),
RequiredMethodScopes("POST", r"^/gadgets/?$", ["create scope1", "post scope2"]),
RequiredMethodScopes("PUT", r"^/gadgets/[^/]+/?$", ["update scope2 scope3", "put gadget"]),
RequiredMethodScopes("DELETE", r"^/gadgets/[^/]+/?$", ["delete scope1", "scope2 scope3"]),
]
For each listed method and the regex resource path, any matching list of possible alternative required scopes is required to succeed. For the above example, `GET /widgets/1234` will be permitted if either
'read' _or_ 'get' and 'widget' scopes are authorized. `POST /gadgets/` will be permitted if 'create' and
'scope1' _or_ 'post' and 'scope2' are authorized.
7 changes: 5 additions & 2 deletions oauth2_provider/contrib/rest_framework/__init__.py
@@ -1,4 +1,7 @@
# flake8: noqa
from .authentication import OAuth2Authentication
from .permissions import TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope
from .permissions import IsAuthenticatedOrTokenHasScope
from .permissions import (
TokenHasScope, TokenHasReadWriteScope, TokenHasMethodScope, RequiredMethodScopes,
TokenHasMethodPathScope, TokenHasResourceScope, IsAuthenticatedOrTokenHasScope
)

125 changes: 125 additions & 0 deletions oauth2_provider/contrib/rest_framework/permissions.py
@@ -1,4 +1,5 @@
import logging
import re

from django.core.exceptions import ImproperlyConfigured
from rest_framework.exceptions import PermissionDenied
Expand Down Expand Up @@ -121,3 +122,127 @@ def has_permission(self, request, view):

token_has_scope = TokenHasScope()
return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view)


class TokenHasMethodScope(BasePermission):
"""
Similar to TokenHasReadWriteScope but require separate scopes for each HTTP method.
:attr:required_scopes_map: dict keyed by HTTP method name with value: iterable scope list
Example:
required_scopes_map = {
'GET': ['scope1','scope2'],
'POST': ['scope3','scope4'],
}
"""

def has_permission(self, request, view):
token = request.auth

if not token:
return False

if hasattr(token, "scope"): # OAuth 2
required_scopes_map = self.get_scopes_map(request, view)

m = request.method.upper()
if m in required_scopes_map:
log.debug("Required scopes to access resource: {0}".format(required_scopes_map[m]))
return token.is_valid(required_scopes_map[m])
else:
log.warning("no scopes defined for method {}".format(m))
return False

assert False, ("TokenHasMethodScope requires the"
"`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
"class to be used.")

def get_scopes_map(self, request, view):
try:
return getattr(view, "required_scopes_map")
except AttributeError:
raise ImproperlyConfigured(
"TokenHasMethodScope requires the view to define the required_scopes_map attribute"
)


class RequiredMethodScopes(object):
"""
Each instance keyed by HTTP method and path-matching regex with a list of alternative
required scopes lists.
For example:
("POST", r"^/api/v1/widgets/+.*$", ["auth-none create","auth-columbia create demo-netphone-admin"])
"""
def __init__(self, method, pathpattern, scopesalternatives):
"""
:param method: HTTP method, one of "GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"
:param pathpattern: regex pattern for resource
:param scopesalternatives: list of alternative scope strings
"""
self.method = method.upper()
if self.method not in ["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"]:
raise ValueError
self.path = pathpattern
self.pathregex = re.compile(self.path)
self.scopesalternatives = [s.split() for s in scopesalternatives]

def __str__(self):
return "{}:{}:{}".format(self.method, self.path, self.scopesalternatives)

@classmethod
def find_alt_scopes(cls, maplist, method, path):
"""
Find a matching RequiredMethodScopes instance and return list of alternate required scopes
:param maplist:class RequiredMethodScopes[]: iterable of instances to search
:param method: method to search for ("GET", "POST", etc.)
:param path: path to search for a match
:return: iterable of alternative scope lists or None
"""
for m in maplist:
if m.method == method and re.match(m.pathregex, path):
return m.scopesalternatives
return None


class TokenHasMethodPathScope(BasePermission):
"""
Token"s scope list is checked against a map of possible alternative methods and paths.
:attr:class RequiredMethodScopes[]: required_method_scopes_map_list
:return: True if a scopes match, else False.
"""

def has_permission(self, request, view):
token = request.auth

if not token:
return False

if hasattr(token, "scope"): # OAuth 2
required_scopes_map_list = self.get_scopes_map_list(request, view)

m = request.method.upper()
p = request.path
required_scopes_list = RequiredMethodScopes.find_alt_scopes(required_scopes_map_list, m, p)
if required_scopes_list:
log.debug("Alternative required scopes to access resource: {0}".format(required_scopes_list))
for scopelist in required_scopes_list:
r = token.is_valid(scopelist)
if r:
return r
return False
else:
log.warning("no scopes defined for method: {} path: {}".format(m, p))
return False

assert False, ("TokenHasMethodPathScope requires the"
"`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
"class to be used.")

def get_scopes_map_list(self, request, view):
try:
return getattr(view, "required_scopes_map_list")
except AttributeError:
raise ImproperlyConfigured(
"TokenHasMethodPathScope requires the view to define the required_scopes_map_list attribute"
)
113 changes: 112 additions & 1 deletion tests/test_rest_framework.py
Expand Up @@ -29,7 +29,8 @@
from rest_framework.test import force_authenticate, APIRequestFactory
from oauth2_provider.contrib.rest_framework import (
IsAuthenticatedOrTokenHasScope, OAuth2Authentication, TokenHasScope,
TokenHasReadWriteScope, TokenHasResourceScope
TokenHasReadWriteScope, TokenHasMethodScope, RequiredMethodScopes,
TokenHasMethodPathScope, TokenHasResourceScope
)

class MockView(APIView):
Expand All @@ -55,6 +56,24 @@ class AuthenticatedOrScopedView(OAuth2View):
class ReadWriteScopedView(OAuth2View):
permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope]

class MethodScopeView(OAuth2View):
permission_classes = [TokenHasMethodScope]
required_scopes_map = {
"GET": ["read"],
"POST": ["create"],
"PUT": ["update", "put"],
"DELETE": ["delete"],
}

class MethodPathScopeView(OAuth2View):
permission_classes = [TokenHasMethodPathScope]
required_scopes_map_list = [
RequiredMethodScopes("GET", r"^/oauth2-method-path-scope-test/$", ["read", "get scope2"]),
RequiredMethodScopes("POST", r"^/oauth2-method-path-scope-test/$", ["create", "post scope2"]),
RequiredMethodScopes("PUT", r"^/oauth2-method-path-scope-test/$", ["update", "put scope2"]),
RequiredMethodScopes("DELETE", r"^/oauth2-method-path-scope-test/$", ["delete", "scope2 scope3"]),
]

class ResourceScopedView(OAuth2View):
permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope]
required_scopes = ["resource1"]
Expand All @@ -66,6 +85,8 @@ class ResourceScopedView(OAuth2View):
url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()),
url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()),
url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()),
url(r"^oauth2-method-scope-test/$", MethodScopeView.as_view()),
url(r"^oauth2-method-path-scope-test/$", MethodPathScopeView.as_view()),
]

rest_framework_installed = True
Expand Down Expand Up @@ -270,3 +291,93 @@ def test_required_scope_not_in_response_by_default(self):
response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 403)
self.assertNotIn("required_scopes", response.data)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_scope_permission_get_allow(self):
self.access_token.scope = "read"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_scope_permission_post_allow(self):
self.access_token.scope = "create"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_scope_permission_get_deny(self):
self.access_token.scope = "write"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 403)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_scope_permission_post_deny(self):
self.access_token.scope = "read"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 403)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_get_allow_1(self):
self.access_token.scope = "read"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.get("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_get_allow_2(self):
self.access_token.scope = "get scope2"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.get("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_post_allow_1(self):
self.access_token.scope = "create"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.post("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_post_allow_2(self):
self.access_token.scope = "post scope2"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.post("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_get_deny(self):
self.access_token.scope = "write"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.get("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 403)

@unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
def test_method_path_scope_permission_post_deny(self):
self.access_token.scope = "read"
self.access_token.save()

auth = self._create_authorization_header(self.access_token.token)
response = self.client.post("/oauth2-method-path-scope-test/", HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 403)

0 comments on commit 309bbda

Please sign in to comment.