diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b13db2f0..29ceecfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1637 Support for Django 6.0 +* #1373 Integration and docs for Django Ninja authentication ### Removed * #1636 Remove support for Python 3.8 and 3.9 diff --git a/docs/conf.py b/docs/conf.py index c32af9c03..2a7d40b2d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,9 @@ "sphinx_rtd_theme", ] +# Optional integrations that may not be installed in the docs build environment +autodoc_mock_imports = ["ninja"] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/index.rst b/docs/index.rst index 60934726f..f709b73b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,7 @@ Index getting_started tutorial/tutorial rest-framework/rest-framework + ninja views/views templates views/details diff --git a/docs/ninja.rst b/docs/ninja.rst new file mode 100644 index 000000000..b131338b7 --- /dev/null +++ b/docs/ninja.rst @@ -0,0 +1,123 @@ +Django Ninja +============ + +Django OAuth Toolkit provides a support layer for +`Django Ninja `_. + +This consists of a ``HttpOAuth2`` class, which will determine whether the +incoming HTTP request contains a valid OAuth2 access token issued +by Django OAuth Toolkit. Optionally, ``HttpOAuth2`` can also ensure that the +OAuth2 Access Token contains a defined set of scopes. + +Import ``HttpOAuth2`` as: + +.. code-block:: python + + from oauth2_provider.contrib.ninja import HttpOAuth2 + + +Basic Usage +----------- +``HttpOAuth2`` can be used anywhere that +`Django Ninja expects an authentication callable `_. + +For example, to ensure all requests are authenticated with OAuth2: + +.. code-block:: python + + from ninja import NinjaAPI + from oauth2_provider.contrib.ninja import HttpOAuth2 + + api = NinjaAPI(auth=HttpOAuth2()) + + +To require authentication on only a single endpoint: + +.. code-block:: python + + from ninja import NinjaAPI + from oauth2_provider.contrib.ninja import HttpOAuth2 + + api = NinjaAPI() + + @api.get("/private", auth=HttpOAuth2()) + def private_endpoint(request): + return {"message": "This is a private endpoint"} + + +Optional Authentication +----------------------- +``HttpOAuth2`` will always fail if the request is not authenticated. +However, many use cases require optional authentication (for example, where +additional private content is returned for authenticated users). + +Django Ninja's support for +`multiple authenticators `_ +can be used for optional authentication. Simply place ``HttpOAuth2`` at the +beginning of a list of authenticators (where it will be run first), with more +permissive authenticator functions near the end (as a fall-back). + +For example, to attempt OAuth2 authentication on all requests, +but allow access even for unauthenticated requests: + +.. code-block:: python + + from ninja import NinjaAPI + from oauth2_provider.contrib.ninja import HttpOAuth2 + + # Stricter authenticators must be placed first, + # as the first success terminates the chain + api = NinjaAPI(auth=[HttpOAuth2(), lambda _request: True]) + + +Scope Enforcement +----------------- +``HttpOAuth2`` can optionally enforce that the OAuth2 access token has certain +scopes (defined by the application). + +If a ``scopes`` argument is passed to ``HttpOAuth2``, then incoming access +tokens must contain all of the specified scopes to be considered valid. + +For example: + +.. code-block:: python + + from ninja import NinjaAPI + from oauth2_provider.contrib.ninja import HttpOAuth2 + + api = NinjaAPI() + + @api.post("/thing", auth=HttpOAuth2(scopes=["read", "write"])) + def create_endpoint(request): + ... + + +Custom Authorization Behavior +----------------------------- +``HttpOAuth2`` can be extended to provide custom authorization behaviors. + +Simply subclass it and override its ``authenticate`` method. + +.. autoclass:: oauth2_provider.contrib.ninja.HttpOAuth2 + :members: authenticate + +For example: + +.. code-block:: python + + from typing import Any + + from django.http import HttpRequest + from ninja import NinjaAPI + from oauth2_provider.contrib.ninja import HttpOAuth2 + from oauth2_provider.models import AbstractAccessToken + + class StaffOnlyOAuth2(HttpOAuth2): + def authenticate(self, request: HttpRequest, access_token: AbstractAccessToken) -> Any | None: + if not access_token.user.is_staff: + return None + + # Anything truthy can be returned, and will be available as `request.auth` + return access_token + + api = NinjaAPI(auth=StaffOnlyOAuth2()) diff --git a/oauth2_provider/contrib/ninja/__init__.py b/oauth2_provider/contrib/ninja/__init__.py new file mode 100644 index 000000000..4cb1659c8 --- /dev/null +++ b/oauth2_provider/contrib/ninja/__init__.py @@ -0,0 +1,4 @@ +from .security import HttpOAuth2 + + +__all__ = ["HttpOAuth2"] diff --git a/oauth2_provider/contrib/ninja/security.py b/oauth2_provider/contrib/ninja/security.py new file mode 100644 index 000000000..24f19814c --- /dev/null +++ b/oauth2_provider/contrib/ninja/security.py @@ -0,0 +1,45 @@ +from typing import Any + +from django.http import HttpRequest +from ninja.security.http import HttpAuthBase + +from ...models import AbstractAccessToken +from ...oauth2_backends import get_oauthlib_core + + +# Don't inherit from `HttpBearer`, since we have our own header extraction logic +class HttpOAuth2(HttpAuthBase): + """Perform OAuth2 authentication, for use with Django Ninja.""" + + openapi_scheme: str = "bearer" + + def __init__(self, *, scopes: list[str] | None = None) -> None: + super().__init__() + # Copy list, since it's mutable + self.scopes = list(scopes) if scopes is not None else [] + + def __call__(self, request: HttpRequest) -> Any | None: + oauthlib_core = get_oauthlib_core() + valid, r = oauthlib_core.verify_request(request, scopes=self.scopes) + + if not valid: + return None + + # Ninja doesn't automatically set `request.user`: https://github.com/vitalik/django-ninja/issues/76 + # However, Django's AuthenticationMiddleware (which does set this from a session cookie) is + # ubiquitous, and even Ninja's own tutorials assume that `request.user` will somehow be set, + # so ensure that authentication via OAuth2 doesn't violate expectations. + request.user = r.user + + return self.authenticate(request, r.access_token) + + def authenticate(self, request: HttpRequest, access_token: AbstractAccessToken) -> Any | None: + """ + Determine whether authentication succeeds. + + If this returns a truthy value, authentication will succeed. + Django Ninja will set the return value as `request.auth`. + + Subclasses may override this to implement additional authorization logic. + """ + return access_token diff --git a/tests/test_ninja.py b/tests/test_ninja.py new file mode 100644 index 000000000..e791a674a --- /dev/null +++ b/tests/test_ninja.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.test.utils import override_settings +from django.urls import include, path +from django.utils import timezone +from ninja import NinjaAPI + +from oauth2_provider.contrib.ninja import HttpOAuth2 +from oauth2_provider.models import get_access_token_model, get_application_model + +from .common_testing import OAuth2ProviderTestCase as TestCase + + +Application = get_application_model() +AccessToken = get_access_token_model() +UserModel = get_user_model() + +api = NinjaAPI() + + +@api.get("/private", auth=HttpOAuth2()) +def private_endpoint(request): + return {"message": "This is a private endpoint"} + + +@api.get("/write", auth=HttpOAuth2(scopes=["write"])) +def scoped_endpoint(request): + return {"message": "This requires 'write' scope"} + + +@api.get("/impossible", auth=HttpOAuth2(scopes=["impossible"])) +def scoped_impossible_endpoint(request): + return {"message": "This requires 'impossible' scope"} + + +@api.get("/request-attributes", auth=HttpOAuth2()) +def request_attributes_endpoint(request): + return { + "request_user_username": request.user.username, + "request_auth_token": request.auth.token, + } + + +urlpatterns = [ + path("oauth2/", include("oauth2_provider.urls")), + path("api/", api.urls), +] + + +@override_settings(ROOT_URLCONF=__name__) +@pytest.mark.nologinrequiredmiddleware +@pytest.mark.usefixtures("oauth2_settings") +class TestNinja(TestCase): + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( + name="Test Application", + redirect_uris="http://localhost http://example.com http://example.org", + user=cls.dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + cls.access_token = AccessToken.objects.create( + user=cls.test_user, + scope="read write", + expires=timezone.now() + timedelta(seconds=300), + token="secret-access-token-key", + application=cls.application, + ) + + def _create_authorization_header(self, token): + return "Bearer {0}".format(token) + + def test_valid_token(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/api/private", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_missing_token(self): + response = self.client.get("/api/private") + self.assertEqual(response.status_code, 401) + + def test_invalid_token(self): + auth = self._create_authorization_header("invalid") + response = self.client.get("/api/private", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + + def test_valid_token_with_scope(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/api/write", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_valid_token_absent_scope(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/api/impossible", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + + def test_request_attributes(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/api/request-attributes", HTTP_AUTHORIZATION=auth) + + self.assertEqual(response.status_code, 200) + response_data = response.json() + self.assertEqual(response_data["request_user_username"], self.test_user.username) + self.assertEqual(response_data["request_auth_token"], self.access_token.token) diff --git a/tox.ini b/tox.ini index 02eaba07e..d7d8302bf 100644 --- a/tox.ini +++ b/tox.ini @@ -47,6 +47,7 @@ deps = dj60: Django>=6.0,<6.1 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework + django-ninja oauthlib>=3.3.0 jwcrypto coverage