From 8eb33785388f1b6108ecbf29152451c087d189be Mon Sep 17 00:00:00 2001 From: skokado Date: Sat, 29 Apr 2023 23:10:25 +0900 Subject: [PATCH 1/8] #658: fix for async Auth --- ninja/operation.py | 14 +++++++++----- ninja/security/http.py | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ninja/operation.py b/ninja/operation.py index 35b2a8815..7cbf7a1e9 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -1,3 +1,4 @@ +import asyncio from typing import ( TYPE_CHECKING, Any, @@ -128,11 +129,11 @@ def _set_auth( if auth is not None and auth is not NOT_SET: # TODO: can it even happen ? self.auth_callbacks = isinstance(auth, Sequence) and auth or [auth] - def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: + async def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: "Runs security checks for each operation" # auth: if self.auth_callbacks: - error = self._run_authentication(request) + error = await self._run_authentication(request) if error: return error @@ -144,10 +145,13 @@ def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: return None - def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: + async def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: for callback in self.auth_callbacks: try: - result = callback(request) + if asyncio.iscoroutinefunction(callback.authenticate): + result = await callback(request) + else: + result = callback(request) except Exception as exc: return self.api.on_exception(request, exc) @@ -256,7 +260,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: 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: diff --git a/ninja/security/http.py b/ninja/security/http.py index 6a6729639..d49c6960e 100644 --- a/ninja/security/http.py +++ b/ninja/security/http.py @@ -1,3 +1,4 @@ +import asyncio import logging from abc import ABC, abstractmethod from base64 import b64decode @@ -24,7 +25,7 @@ class HttpBearer(HttpAuthBase, ABC): openapi_scheme: str = "bearer" header: str = "Authorization" - def __call__(self, request: HttpRequest) -> Optional[Any]: + async def __call__(self, request: HttpRequest) -> Optional[Any]: headers = get_headers(request) auth_value = headers.get(self.header) if not auth_value: @@ -36,6 +37,8 @@ def __call__(self, request: HttpRequest) -> Optional[Any]: logger.error(f"Unexpected auth - '{auth_value}'") return None token = " ".join(parts[1:]) + if asyncio.iscoroutinefunction(self.authenticate): + return await self.authenticate(request, token) return self.authenticate(request, token) @abstractmethod From 12a8f0ced01eacd865879238de6ec9734edd3acd Mon Sep 17 00:00:00 2001 From: skokado Date: Mon, 1 May 2023 22:27:54 +0900 Subject: [PATCH 2/8] fix AsyncOperation --- ninja/operation.py | 15 ++++++--------- ninja/security/http.py | 5 +---- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/ninja/operation.py b/ninja/operation.py index 7cbf7a1e9..88c4829e0 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -1,4 +1,3 @@ -import asyncio from typing import ( TYPE_CHECKING, Any, @@ -13,6 +12,7 @@ cast, ) +from asgiref.sync import sync_to_async import django import pydantic from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed @@ -129,11 +129,11 @@ def _set_auth( if auth is not None and auth is not NOT_SET: # TODO: can it even happen ? self.auth_callbacks = isinstance(auth, Sequence) and auth or [auth] - async def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: + def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: "Runs security checks for each operation" # auth: if self.auth_callbacks: - error = await self._run_authentication(request) + error = self._run_authentication(request) if error: return error @@ -145,13 +145,10 @@ async def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: return None - async def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: + def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: for callback in self.auth_callbacks: try: - if asyncio.iscoroutinefunction(callback.authenticate): - result = await callback(request) - else: - result = callback(request) + result = callback(request) except Exception as exc: return self.api.on_exception(request, exc) @@ -260,7 +257,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.is_async = True async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase: # type: ignore - error = await self._run_checks(request) + error = await sync_to_async(self._run_checks)(request) if error: return error try: diff --git a/ninja/security/http.py b/ninja/security/http.py index d49c6960e..6a6729639 100644 --- a/ninja/security/http.py +++ b/ninja/security/http.py @@ -1,4 +1,3 @@ -import asyncio import logging from abc import ABC, abstractmethod from base64 import b64decode @@ -25,7 +24,7 @@ class HttpBearer(HttpAuthBase, ABC): openapi_scheme: str = "bearer" header: str = "Authorization" - async def __call__(self, request: HttpRequest) -> Optional[Any]: + def __call__(self, request: HttpRequest) -> Optional[Any]: headers = get_headers(request) auth_value = headers.get(self.header) if not auth_value: @@ -37,8 +36,6 @@ async def __call__(self, request: HttpRequest) -> Optional[Any]: logger.error(f"Unexpected auth - '{auth_value}'") return None token = " ".join(parts[1:]) - if asyncio.iscoroutinefunction(self.authenticate): - return await self.authenticate(request, token) return self.authenticate(request, token) @abstractmethod From ec71f8d12d103aaf797603de7d93cdc8c2f67e17 Mon Sep 17 00:00:00 2001 From: skokado Date: Mon, 1 May 2023 22:55:04 +0900 Subject: [PATCH 3/8] fix sync Operation and add tests --- ninja/operation.py | 11 +++++-- tests/test_auth.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/ninja/operation.py b/ninja/operation.py index 88c4829e0..1c7e1b740 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -1,3 +1,4 @@ +import asyncio from typing import ( TYPE_CHECKING, Any, @@ -12,7 +13,7 @@ cast, ) -from asgiref.sync import sync_to_async +from asgiref.sync import async_to_sync, sync_to_async import django import pydantic from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed @@ -22,6 +23,7 @@ from ninja.errors import AuthenticationError, ConfigError, ValidationError from ninja.params_models import TModels from ninja.schema import Schema +from ninja.security.base import AuthBase from ninja.signature import ViewSignature, is_async from ninja.types import DictStrAny from ninja.utils import check_csrf @@ -148,7 +150,12 @@ def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: for callback in self.auth_callbacks: try: - result = callback(request) + if asyncio.iscoroutinefunction(callback): + result = async_to_sync(callback)(request) + elif isinstance(callback, AuthBase) and asyncio.iscoroutinefunction(callback.authenticate): + result = async_to_sync(callback)(request) + else: + result = callback(request) except Exception as exc: return self.api.on_exception(request, exc) diff --git a/tests/test_auth.py b/tests/test_auth.py index 01121bd78..17a910061 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -21,18 +21,34 @@ def callable_auth(request): return request.GET.get("auth") +async def async_callable_auth(request): + return request.GET.get("auth") + + class KeyQuery(APIKeyQuery): def authenticate(self, request, key): if key == "keyquerysecret": return key +class AsyncKeyQuery(APIKeyQuery): + async def authenticate(self, request, key): + if key == "keyquerysecret": + return key + + class KeyHeader(APIKeyHeader): def authenticate(self, request, key): if key == "keyheadersecret": return key +class AsyncKeyHeader(APIKeyHeader): + async def authenticate(self, request, key): + if key == "keyheadersecret": + return key + + class CustomException(Exception): pass @@ -44,24 +60,49 @@ def authenticate(self, request, key): return key +class AsyncKeyHeaderCustomException(APIKeyHeader): + async def authenticate(self, request, key): + if key != "keyheadersecret": + raise CustomException + return key + + class KeyCookie(APIKeyCookie): def authenticate(self, request, key): if key == "keycookiersecret": return key +class AsyncKeyCookie(APIKeyCookie): + async def authenticate(self, request, key): + if key == "keycookiersecret": + return key + + class BasicAuth(HttpBasicAuth): def authenticate(self, request, username, password): if username == "admin" and password == "secret": return username +class AsyncBasicAuth(HttpBasicAuth): + async def authenticate(self, request, username, password): + if username == "admin" and password == "secret": + return username + + class BearerAuth(HttpBearer): def authenticate(self, request, token): if token == "bearertoken": return token +class AsyncBearerAuth(HttpBearer): + async def authenticate(self, request, token): + if token == "bearertoken": + return token + + def demo_operation(request): return {"auth": request.auth} @@ -77,13 +118,20 @@ def on_custom_error(request, exc): for path, auth in [ ("django_auth", django_auth), ("django_auth_superuser", django_auth_superuser), + ("async_callable", async_callable_auth), ("callable", callable_auth), ("apikeyquery", KeyQuery()), + ("async_apikeyquery", AsyncKeyQuery()), ("apikeyheader", KeyHeader()), + ("async_apikeyheader", AsyncKeyHeader()), ("apikeycookie", KeyCookie()), + ("async_apikeycookie", AsyncKeyCookie()), ("basic", BasicAuth()), + ("async_basic", AsyncBasicAuth()), ("bearer", BearerAuth()), + ("async_bearer", AsyncBearerAuth()), ("customexception", KeyHeaderCustomException()), + ("async_customexception", AsyncKeyHeaderCustomException()), ]: api.get(f"/{path}", auth=auth, operation_id=path)(demo_operation) @@ -124,8 +172,12 @@ class MockSuperUser(str): ), ("/callable", {}, 401, BODY_UNAUTHORIZED_DEFAULT), ("/callable?auth=demo", {}, 200, dict(auth="demo")), + ("/async_callable", {}, 401, BODY_UNAUTHORIZED_DEFAULT), + ("/async_callable?auth=demo", {}, 200, dict(auth="demo")), ("/apikeyquery", {}, 401, BODY_UNAUTHORIZED_DEFAULT), ("/apikeyquery?key=keyquerysecret", {}, 200, dict(auth="keyquerysecret")), + ("/async_apikeyquery", {}, 401, BODY_UNAUTHORIZED_DEFAULT), + ("/async_apikeyquery?key=keyquerysecret", {}, 200, dict(auth="keyquerysecret")), ("/apikeyheader", {}, 401, BODY_UNAUTHORIZED_DEFAULT), ( "/apikeyheader", @@ -133,6 +185,13 @@ class MockSuperUser(str): 200, dict(auth="keyheadersecret"), ), + ("/async_apikeyheader", {}, 401, BODY_UNAUTHORIZED_DEFAULT), + ( + "/async_apikeyheader", + dict(headers={"key": "keyheadersecret"}), + 200, + dict(auth="keyheadersecret"), + ), ("/apikeycookie", {}, 401, BODY_UNAUTHORIZED_DEFAULT), ( "/apikeycookie", @@ -140,6 +199,13 @@ class MockSuperUser(str): 200, dict(auth="keycookiersecret"), ), + ("/async_apikeycookie", {}, 401, BODY_UNAUTHORIZED_DEFAULT), + ( + "/async_apikeycookie", + dict(COOKIES={"key": "keycookiersecret"}), + 200, + dict(auth="keycookiersecret"), + ), ("/basic", {}, 401, BODY_UNAUTHORIZED_DEFAULT), ( "/basic", @@ -185,6 +251,13 @@ class MockSuperUser(str): 200, dict(auth="keyheadersecret"), ), + ("/async_customexception", {}, 401, dict(custom=True)), + ( + "/async_customexception", + dict(headers={"key": "keyheadersecret"}), + 200, + dict(auth="keyheadersecret"), + ), ], ) def test_auth(path, kwargs, expected_code, expected_body, settings): @@ -199,11 +272,17 @@ def test_schema(): schema = api.get_openapi_schema() assert schema["components"]["securitySchemes"] == { "BasicAuth": {"scheme": "basic", "type": "http"}, + "AsyncBearerAuth": {"scheme": "bearer", "type": "http"}, "BearerAuth": {"scheme": "bearer", "type": "http"}, + "AsyncBasicAuth": {"scheme": "basic", "type": "http"}, "KeyCookie": {"in": "cookie", "name": "key", "type": "apiKey"}, + "AsyncKeyCookie": {"in": "cookie", "name": "key", "type": "apiKey"}, "KeyHeader": {"in": "header", "name": "key", "type": "apiKey"}, + "AsyncKeyHeader": {"in": "header", "name": "key", "type": "apiKey"}, "KeyHeaderCustomException": {"in": "header", "name": "key", "type": "apiKey"}, + "AsyncKeyHeaderCustomException": {"in": "header", "name": "key", "type": "apiKey"}, "KeyQuery": {"in": "query", "name": "key", "type": "apiKey"}, + "AsyncKeyQuery": {"in": "query", "name": "key", "type": "apiKey"}, "SessionAuth": {"in": "cookie", "name": "sessionid", "type": "apiKey"}, "SessionAuthSuperUser": {"in": "cookie", "name": "sessionid", "type": "apiKey"}, } From f34b7fe9bdd8370bf65f20e6e7f62216c15b0be5 Mon Sep 17 00:00:00 2001 From: skokado Date: Mon, 1 May 2023 22:57:34 +0900 Subject: [PATCH 4/8] little fix order --- tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 17a910061..562e1a756 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -118,8 +118,8 @@ def on_custom_error(request, exc): for path, auth in [ ("django_auth", django_auth), ("django_auth_superuser", django_auth_superuser), - ("async_callable", async_callable_auth), ("callable", callable_auth), + ("async_callable", async_callable_auth), ("apikeyquery", KeyQuery()), ("async_apikeyquery", AsyncKeyQuery()), ("apikeyheader", KeyHeader()), From fe683803bbda36ab46363359669fa4ef0bb7ccf1 Mon Sep 17 00:00:00 2001 From: skokado Date: Wed, 3 May 2023 12:38:18 +0900 Subject: [PATCH 5/8] update doc about async-auth --- docs/docs/guides/authentication.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/docs/guides/authentication.md b/docs/docs/guides/authentication.md index a74865512..761f6827a 100644 --- a/docs/docs/guides/authentication.md +++ b/docs/docs/guides/authentication.md @@ -174,4 +174,29 @@ 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 hl_lines="3 12" +from ninja.security import HttpBearer + +async def async_auth(request): + ... + +@api.get("/pets", auth=async_auth) +def pets(request): + ... + +# Also +class AsyncBearerAuth(HttpBearer): + def authenticate(self, request, token): + ... + +@api.get("/pets", auth=AsyncBearerAuth()) +def pets(request): + ... +``` + + See [Handling errors](errors.md) for more information. From 2885e9b3975f99c00e020c64a5200bd01ccdefd2 Mon Sep 17 00:00:00 2001 From: skokado Date: Wed, 3 May 2023 15:02:05 +0900 Subject: [PATCH 6/8] use asgiref.sync.iscoroutinefunction --- ninja/operation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ninja/operation.py b/ninja/operation.py index 1c7e1b740..43151efec 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -1,4 +1,3 @@ -import asyncio from typing import ( TYPE_CHECKING, Any, @@ -13,7 +12,7 @@ cast, ) -from asgiref.sync import async_to_sync, sync_to_async +from asgiref.sync import async_to_sync, sync_to_async, iscoroutinefunction import django import pydantic from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed @@ -150,9 +149,9 @@ def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: for callback in self.auth_callbacks: try: - if asyncio.iscoroutinefunction(callback): + if iscoroutinefunction(callback): result = async_to_sync(callback)(request) - elif isinstance(callback, AuthBase) and asyncio.iscoroutinefunction(callback.authenticate): + elif isinstance(callback, AuthBase) and iscoroutinefunction(callback.authenticate): result = async_to_sync(callback)(request) else: result = callback(request) From 15274a098fa4a84e091ebb9afad5ff2992a58cbe Mon Sep 17 00:00:00 2001 From: skokado Date: Fri, 5 May 2023 22:49:38 +0900 Subject: [PATCH 7/8] use utils.is_async --- ninja/operation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ninja/operation.py b/ninja/operation.py index 43151efec..91cafdc37 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -12,7 +12,7 @@ cast, ) -from asgiref.sync import async_to_sync, sync_to_async, iscoroutinefunction +from asgiref.sync import async_to_sync, sync_to_async import django import pydantic from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed @@ -149,9 +149,9 @@ def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]: def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]: for callback in self.auth_callbacks: try: - if iscoroutinefunction(callback): + if is_async(callback): result = async_to_sync(callback)(request) - elif isinstance(callback, AuthBase) and iscoroutinefunction(callback.authenticate): + elif isinstance(callback, AuthBase) and is_async(callback.authenticate): result = async_to_sync(callback)(request) else: result = callback(request) From dd181d96bd17dc293a7adbcc3f7ec1b663f4c317 Mon Sep 17 00:00:00 2001 From: skokado Date: Fri, 5 May 2023 22:49:46 +0900 Subject: [PATCH 8/8] black format --- tests/test_auth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 562e1a756..0688e95c2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -280,7 +280,11 @@ def test_schema(): "KeyHeader": {"in": "header", "name": "key", "type": "apiKey"}, "AsyncKeyHeader": {"in": "header", "name": "key", "type": "apiKey"}, "KeyHeaderCustomException": {"in": "header", "name": "key", "type": "apiKey"}, - "AsyncKeyHeaderCustomException": {"in": "header", "name": "key", "type": "apiKey"}, + "AsyncKeyHeaderCustomException": { + "in": "header", + "name": "key", + "type": "apiKey", + }, "KeyQuery": {"in": "query", "name": "key", "type": "apiKey"}, "AsyncKeyQuery": {"in": "query", "name": "key", "type": "apiKey"}, "SessionAuth": {"in": "cookie", "name": "sessionid", "type": "apiKey"},