diff --git a/CHANGELOG.md b/CHANGELOG.md index 52be33b7e..948eb6ac2 100644 --- a/CHANGELOG.md +++ b/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] diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index d10c4a9b5..20cdcd883 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -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. @@ -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 `_. + +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. diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py index 4b826720c..9dd86a75e 100644 --- a/oauth2_provider/contrib/rest_framework/__init__.py +++ b/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 +) + diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 00a1ca0ca..38b4222e2 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/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 @@ -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" + ) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 71fbda072..bd2937310 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -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): @@ -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"] @@ -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 @@ -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)