Repository: vitalik/django-ninja
Pull request #735 (2023-04-07 21:58:56)

Implement rudimentary support for async auth
This PR introduces initial support for asynchronous authentication by allowing users to pass in async callables through the `auth` kwarg. The built-in authentication classes provided by this project remain synchronous-only.

Doesn't fully solve https://github.com/vitalik/django-ninja/issues/44, but it might be a good first step towards full async auth support.


Patch:
diff --git a/docs/docs/guides/authentication.md b/docs/docs/guides/authentication.md
index a748655..04346b4 100644
--- a/docs/docs/guides/authentication.md
+++ b/docs/docs/guides/authentication.md
@@ -34,7 +34,7 @@ Now, when you click the **Authorize** button, you will get a prompt to input you
 When you do test calls, the Authorization header will be passed for every request.
 
 
-## Global authentication 
+## Global authentication
 
 In case you need to secure **all** methods of your API, you can pass the `auth` argument to the `NinjaAPI` constructor:
 
@@ -174,4 +174,20 @@ the same way an operation would:
 {!./src/tutorial/authentication/bearer02.py!}
 ```
 
+
+## Async authentication
+
+**Django Ninja** has basic support for asynchronous authentication. While the default authentication classes are not async-compatible, you can still define your custom asynchronous authentication callables and pass them in using `auth`.
+
+```python
+async def async_auth(request):
+    ...
+
+
+@api.get("/pets", auth=async_auth)
+def pets(request):
+    ...
+```
+
+
 See [Handling errors](errors.md) for more information.
diff --git a/ninja/operation.py b/ninja/operation.py
index 35b2a88..1749dfe 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -23,7 +23,7 @@ from ninja.params_models import TModels
 from ninja.schema import Schema
 from ninja.signature import ViewSignature, is_async
 from ninja.types import DictStrAny
-from ninja.utils import check_csrf
+from ninja.utils import check_csrf, is_async_callable
 
 if TYPE_CHECKING:
     from ninja import NinjaAPI, Router  # pragma: no cover
@@ -256,7 +256,7 @@ class AsyncOperation(Operation):
         self.is_async = True
 
     async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:  # type: ignore
-        error = self._run_checks(request)
+        error = await self._run_checks(request)
         if error:
             return error
         try:
@@ -267,6 +267,37 @@ class AsyncOperation(Operation):
         except Exception as e:
             return self.api.on_exception(request, e)
 
+    async def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]:  # type: ignore
+        "Runs security checks for each operation"
+        # auth:
+        if self.auth_callbacks:
+            error = await self._run_authentication(request)
+            if error:
+                return error
+
+        # csrf:
+        if self.api.csrf:
+            error = check_csrf(request, self.view_func)
+            if error:
+                return error
+
+        return None
+
+    async def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]:  # type: ignore
+        for callback in self.auth_callbacks:
+            try:
+                if is_async_callable(callback):
+                    result = await callback(request)
+                else:
+                    result = callback(request)
+            except Exception as exc:
+                return self.api.on_exception(request, exc)
+
+            if result:
+                request.auth = result  # type: ignore
+                return None
+        return self.api.on_exception(request, AuthenticationError())
+
 
 class PathView:
     def __init__(self) -> None:
diff --git a/ninja/utils.py b/ninja/utils.py
index e1a629e..2534e4d 100644
--- a/ninja/utils.py
+++ b/ninja/utils.py
@@ -33,3 +33,9 @@ def is_debug_server() -> bool:
         s.filename.endswith("runserver.py") and s.function == "run"
         for s in inspect.stack(0)[1:]
     )
+
+
+def is_async_callable(f: Callable) -> bool:
+    return inspect.iscoroutinefunction(f) or inspect.iscoroutinefunction(
+        getattr(f, "__call__", None)
+    )


Test patch:
diff --git a/tests/test_auth_async.py b/tests/test_auth_async.py
new file mode 100644
index 0000000..6e1a010
--- /dev/null
+++ b/tests/test_auth_async.py
@@ -0,0 +1,114 @@
+import asyncio
+
+import django
+import pytest
+
+from ninja import NinjaAPI
+from ninja.testing import TestAsyncClient
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_async_view_handles_async_auth_func():
+    api = NinjaAPI()
+
+    async def auth(request):
+        key = request.GET.get("key")
+        if key == "secret":
+            return key
+
+    @api.get("/async", auth=auth)
+    async def view(request):
+        await asyncio.sleep(0)
+        return {"key": request.auth}
+
+    client = TestAsyncClient(api)
+
+    # Actual tests --------------------------------------------------
+
+    # without auth:
+    res = await client.get("/async")
+    assert res.status_code == 401
+
+    # async successful
+    res = await client.get("/async?key=secret")
+    assert res.json() == {"key": "secret"}
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_async_view_handles_async_auth_cls():
+    api = NinjaAPI()
+
+    class Auth:
+        async def __call__(self, request):
+            key = request.GET.get("key")
+            if key == "secret":
+                return key
+
+    @api.get("/async", auth=Auth())
+    async def view(request):
+        await asyncio.sleep(0)
+        return {"key": request.auth}
+
+    client = TestAsyncClient(api)
+
+    # Actual tests --------------------------------------------------
+
+    # without auth:
+    res = await client.get("/async")
+    assert res.status_code == 401
+
+    # async successful
+    res = await client.get("/async?key=secret")
+    assert res.json() == {"key": "secret"}
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_async_view_handles_multi_auth():
+    api = NinjaAPI()
+
+    def auth_1(request):
+        return None
+
+    async def auth_2(request):
+        return None
+
+    async def auth_3(request):
+        key = request.GET.get("key")
+        if key == "secret":
+            return key
+
+    @api.get("/async", auth=[auth_1, auth_2, auth_3])
+    async def view(request):
+        await asyncio.sleep(0)
+        return {"key": request.auth}
+
+    client = TestAsyncClient(api)
+
+    res = await client.get("/async?key=secret")
+    assert res.json() == {"key": "secret"}
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_async_view_handles_auth_errors():
+    api = NinjaAPI()
+
+    async def auth(request):
+        raise Exception("boom")
+
+    @api.get("/async", auth=auth)
+    async def view(request):
+        await asyncio.sleep(0)
+        return {"key": request.auth}
+
+    @api.exception_handler(Exception)
+    def on_custom_error(request, exc):
+        return api.create_response(request, {"custom": True}, status=401)
+
+    client = TestAsyncClient(api)
+
+    res = await client.get("/async?key=secret")
+    assert res.json() == {"custom": True}
diff --git a/tests/test_csrf_async.py b/tests/test_csrf_async.py
new file mode 100644
index 0000000..7535ad9
--- /dev/null
+++ b/tests/test_csrf_async.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+import django
+import pytest
+from django.conf import settings
+
+from ninja import NinjaAPI
+from ninja.errors import ConfigError
+from ninja.security import APIKeyCookie
+from ninja.testing import TestAsyncClient as BaseTestAsyncClient
+
+
+class TestAsyncClient(BaseTestAsyncClient):
+    def _build_request(self, *args, **kwargs):
+        request = super()._build_request(*args, **kwargs)
+        request._dont_enforce_csrf_checks = False
+        return request
+
+
+TOKEN = "1bcdefghij2bcdefghij3bcdefghij4bcdefghij5bcdefghij6bcdefghijABCD"
+COOKIES = {settings.CSRF_COOKIE_NAME: TOKEN}
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_csrf_off():
+    csrf_OFF = NinjaAPI(urls_namespace="csrf_OFF")
+
+    @csrf_OFF.post("/post")
+    async def post_off(request):
+        return {"success": True}
+
+    client = TestAsyncClient(csrf_OFF)
+    response = await client.post("/post", COOKIES=COOKIES)
+    assert response.status_code == 200
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_csrf_on():
+    csrf_ON = NinjaAPI(urls_namespace="csrf_ON", csrf=True)
+
+    @csrf_ON.post("/post")
+    async def post_on(request):
+        return {"success": True}
+
+    client = TestAsyncClient(csrf_ON)
+
+    response = await client.post("/post", COOKIES=COOKIES)
+    assert response.status_code == 403
+
+    # check with token in formdata
+    response = await client.post(
+        "/post", {"csrfmiddlewaretoken": TOKEN}, COOKIES=COOKIES
+    )
+    assert response.status_code == 200
+
+    # check with headers
+    response = await client.post(
+        "/post", COOKIES=COOKIES, headers={"X-CSRFTOKEN": TOKEN}
+    )
+    assert response.status_code == 200
+
+
+@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
+@pytest.mark.asyncio
+async def test_raises_on_cookie_auth():
+    "It should raise if user picked Cookie based auth and csrf=False"
+
+    class Auth(APIKeyCookie):
+        def authenticate(self, request, key):
+            return request.COOKIES[key] == "foo"
+
+    api = NinjaAPI(auth=Auth(), csrf=False)
+
+    @api.get("/some")
+    async def some_method(request):
+        pass
+
+    with pytest.raises(ConfigError):
+        api._validate()
+
+    try:
+        import os
+
+        os.environ["NINJA_SKIP_REGISTRY"] = ""
+
+        # Check for wrong error reported
+        match = "Looks like you created multiple NinjaAPIs"
+        with pytest.raises(ConfigError, match=match):
+            api.urls
+
+        # django debug server can attempt to import the urls twice when errors exist
+        # verify we get the correct error reported
+        match = "Cookie Authentication must be used with CSRF"
+        with pytest.raises(ConfigError, match=match):
+            with mock.patch("ninja.main._imported_while_running_in_debug_server", True):
+                api.urls
+
+    finally:
+        os.environ["NINJA_SKIP_REGISTRY"] = "yes"