diff --git a/CHANGELOG.md b/CHANGELOG.md index e356163c556f261e9d99240c0ace8f053ccc5a8c..2bcdcf995fb9c79e4aeb1e6c5c74947ae092e298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.0.5 + +Added: + +- When authenticated, a non-database backed user object is associated with the request. + ## 0.0.4 Added: diff --git a/README.md b/README.md index 11b679f3897abfd7c2d984fb0811d3c3fc3d8941..df5c0c747f1b9bf44f12f98baf5e424df56e031b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ to your restframework settings: REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ # rely on the API Gateway to provide authentication details - 'apigatewayauth.APIGatewayAuthentication' + 'apigatewayauth.authentication.APIGatewayAuthentication' ], } ``` diff --git a/apigatewayauth/__init__.py b/apigatewayauth/__init__.py index da487e602d2cdca9876cd7c8cd9fe32cb3e78f8b..d4a4149404bee442679bef8bc3f8a8a5b1383b29 100644 --- a/apigatewayauth/__init__.py +++ b/apigatewayauth/__init__.py @@ -1,4 +1,8 @@ -from .api_gateway_auth import APIGatewayAuthentication # noqa: F401 -from .api_gateway_auth import APIGatewayAuthenticationDetails # noqa: F401 +# This import needs to be removed but to do so breaks compatibility with +# existing users. See comments in apigatewayauth.auth.APIGatewayAuthentication. +from .authentication import ( # noqa: F401 + APIGatewayAuthentication, + APIGatewayAuthenticationDetails, +) default_app_config = "apigatewayauth.apps.APIGatewayAuthConfig" diff --git a/apigatewayauth/api_gateway_auth.py b/apigatewayauth/authentication.py similarity index 65% rename from apigatewayauth/api_gateway_auth.py rename to apigatewayauth/authentication.py index 612bb3b12179bab27083f9aa5dc38eadefca57c2..2e323308fea18b048dcbd62b4c271e4cae5d2734 100644 --- a/apigatewayauth/api_gateway_auth.py +++ b/apigatewayauth/authentication.py @@ -31,6 +31,24 @@ class APIGatewayAuthentication(authentication.BaseAuthentication): """ def authenticate(self, request: Request): + # This import is needed here because APIGatewayUser references + # AnonymousUser from django.contrib.auth which, in turn, means that + # applications have to be ready at import time. + # + # This file is imported from apigatewayauth/__init__.py and + # apigatewayauth is imported at application configure time. The net + # upshot is that we cannot import directly or indirectly from + # django.contrib.auth at the top of this file and have to do it here. + # + # We can't remove the imports at the top of apigatewayauth/__init__.py + # because we have users of this library which set the DRF default + # authentication class to "apigatewayauth.APIGatewayAuthentication". + # + # These users need to be fixed up to use + # "apigatewayauth.authentication.APIGatewayAuthentication" instead and + # then we can move this import back where it belongs. + from .user import APIGatewayUser + if not request.META.get("HTTP_X_API_ORG_NAME", None): # bail early if we look like we're not being called by the API Gateway return None @@ -51,6 +69,5 @@ class APIGatewayAuthentication(authentication.BaseAuthentication): app_id=request.META.get("HTTP_X_API_DEVELOPER_APP_ID", None), client_id=request.META.get("HTTP_X_API_OAUTH2_CLIENT_ID", None), ) - # the first item in the tuple represents the 'user' which we don't have when we've - # used the API Gateway for authentication. - return None, auth + user = APIGatewayUser(auth) + return user, auth diff --git a/apigatewayauth/permissions.py b/apigatewayauth/permissions.py index 51f11773c0263f9daf5f3aa6f8161037d0e24a9b..b996e7e182b076cb85a6f01a3a743c3f9d1312f2 100644 --- a/apigatewayauth/permissions.py +++ b/apigatewayauth/permissions.py @@ -7,7 +7,7 @@ from rest_framework import permissions, request from ucamlookup.ibisclient import IbisException, PersonMethods from ucamlookup.utils import get_connection -from .api_gateway_auth import APIGatewayAuthenticationDetails +from .authentication import APIGatewayAuthenticationDetails from .permissions_spec import ( get_groups_with_permission, get_permission_spec, diff --git a/apigatewayauth/tests/test_apigateway_auth.py b/apigatewayauth/tests/test_apigateway_auth.py index 7f186a8d4a58536d87de1d27ae9c0c1bbd92edbd..8a28b70fa16ce63d116f35e18edd6216a27d1df7 100644 --- a/apigatewayauth/tests/test_apigateway_auth.py +++ b/apigatewayauth/tests/test_apigateway_auth.py @@ -3,7 +3,7 @@ from identitylib.identifiers import Identifier, IdentifierSchemes from rest_framework.exceptions import AuthenticationFailed from rest_framework.test import APIRequestFactory -from apigatewayauth.api_gateway_auth import ( +from apigatewayauth.authentication import ( APIGatewayAuthentication, APIGatewayAuthenticationDetails, ) @@ -90,7 +90,7 @@ class APIGatewayAuthTestCase(TestCase): } ) ) - self.assertIsNone(user) + self.assertEqual(user.id, str(Identifier("a123", IdentifierSchemes.CRSID))) self.assertEqual( auth, @@ -102,6 +102,19 @@ class APIGatewayAuthTestCase(TestCase): ), ) + def test_returns_authenticated_non_anonymous_user(self): + user, _ = self.auth.authenticate( + self.request_with_headers( + { + "x-api-org-name": "test", + "x-api-developer-app-class": "public", + "x-api-oauth2-user": str(Identifier("a123", IdentifierSchemes.CRSID)), + } + ) + ) + self.assertFalse(user.is_anonymous) + self.assertTrue(user.is_authenticated) + def test_will_pass_through_scopes(self): _, auth = self.auth.authenticate( self.request_with_headers( diff --git a/apigatewayauth/tests/test_permissions.py b/apigatewayauth/tests/test_permissions.py index b2abc6ef92f0efbdc735a85247974a43da45bab8..a1448618e934cb1ddc710a3972ff150022046b66 100644 --- a/apigatewayauth/tests/test_permissions.py +++ b/apigatewayauth/tests/test_permissions.py @@ -5,7 +5,7 @@ from identitylib.identifiers import Identifier, IdentifierSchemes from rest_framework.test import APIRequestFactory from ucamlookup.ibisclient import IbisException -from apigatewayauth.api_gateway_auth import APIGatewayAuthenticationDetails +from apigatewayauth.authentication import APIGatewayAuthenticationDetails from apigatewayauth.permissions import ( Disallowed, HasAnyScope, diff --git a/apigatewayauth/user.py b/apigatewayauth/user.py new file mode 100644 index 0000000000000000000000000000000000000000..086bc7a596a41bfda9923eb361be3a1fcd830e4a --- /dev/null +++ b/apigatewayauth/user.py @@ -0,0 +1,26 @@ +from django.contrib.auth.models import AnonymousUser + +from .authentication import APIGatewayAuthenticationDetails + + +class APIGatewayUser(AnonymousUser): + """ + A Django user representing the authenticated principal. This user is not + backed by a database object and so they can have no permissions in the + Django sense. + """ + + def __init__(self, auth: APIGatewayAuthenticationDetails): + super().__init__() + self.username = self.id = self.pk = str(auth.principal_identifier) + + @property + def is_anonymous(self): + return False + + @property + def is_authenticated(self): + return True + + def __str__(self): + return self.username diff --git a/runtests.py b/runtests.py index ed0ac74cc0984fe297d0469b2d045d94fe71465c..ab0a8f7fcc736b9afc194ff926f993b9f6c9342a 100644 --- a/runtests.py +++ b/runtests.py @@ -20,7 +20,12 @@ settings.configure( }, TIME_ZONE="Europe/London", USE_TZ=True, - INSTALLED_APPS=("apigatewayauth", "apigatewayauth.tests.mocks"), + INSTALLED_APPS=( + "django.contrib.contenttypes", + "django.contrib.auth", + "apigatewayauth", + "apigatewayauth.tests.mocks", + ), MIDDLEWARE_CLASSES=(), MIDDLEWARE=(), TEMPLATES=[],