django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

Authentication is never awaited in AsyncOperation

Open dozer133 opened this issue 4 years ago • 6 comments

Example

from asgiref.sync import sync_to_async
from ninja import NinjaAPI, Schema
from ninja.security import APIKeyQuery
from .models import Client

api = NinjaAPI()


class ApiKey(APIKeyQuery):
    param_name = "key"

    async def authenticate(self, request, key):
        try:
            return await sync_to_async(Client.objects.get)(key=key)
        except Client.DoesNotExist:
            pass


class Test(Schema):
    msg: str


@api.get("", response=Test, auth=ApiKey())
async def test(request):
    return 200, {"msg": "hello!"}

Scenario

The API is called with a non-existing API key. What should happen: Ninja should return a 401 response. What actually happens: Returns 200.

Possible solution

Implement _run_checks and _run_authentication as async methods in AsyncOperation.

class AsyncOperation(Operation):
    def __init__(self, *args, **kwargs):
        if django.VERSION < (3, 1):  # pragma: no cover
            raise Exception("Async operations are supported only with Django 3.1+")
        super().__init__(*args, **kwargs)
        self.is_async = True

    async def _run_checks(self, request):
        "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

    async def _run_authentication(self, request):
        for callback in self.auth_callbacks:
            result = await callback(request)
            if result is not None:
                request.auth = result
                return
        return Response({"detail": "Unauthorized"}, status=401)

    async def run(self, request, **kw):
        error = await self._run_checks(request)
        if error:
            return error

        values, errors = self._get_values(request, kw)
        if errors:
            return Response({"detail": errors}, status=422)
        result = await self.view_func(request, **values)
        return self._create_response(result)

Above example will then return 401 on an invalid key as expected.

dozer133 avatar Dec 04 '20 12:12 dozer133

Yes, in nutshell this is the code I'm targeting for... but there are few things to deal with - like if you have async auth, but not-async operation or vice-versa

vitalik avatar Dec 04 '20 14:12 vitalik

@vitalik I'd like to help with this. I don't understand what's wrong with #202 though. It seems that it's very similar to the code above plus taking care of async auth on not-async operation and vice versa. If I understand what needs to be changed compared to #202, I'd be happy to prepare PR with code, tests and docs changes.

One of the main reasons I chose django-ninja was it's support for async views and I'd like to broaden the async experience even further. Right now, I can't use built-in auth at all and I'm doomed to calling my async auth function in each view, which is repetitive and prone to errors.

stinovlas avatar Feb 17 '22 16:02 stinovlas

Is there any chance for this issue to be resolved soon? I'm quite fond of django-ninja, it's just this issue that I find a bit unsettling.

richardbinder avatar Jun 09 '22 18:06 richardbinder

any news on this ?

rednaks avatar Jun 16 '22 07:06 rednaks

Not sure if this helps, but.. try adding an await statement

@api.get("", response=Test, auth=ApiKey())
async def test(request):
    await request.auth  <<<
    return 200, {"msg": "hello!"}

changhyun-an avatar Aug 04 '22 01:08 changhyun-an

Not sure if this helps, but.. try adding an await statement

@api.get("", response=Test, auth=ApiKey())
async def test(request):
    await request.auth  <<<
    return 200, {"msg": "hello!"}

ah, that's the magic of the event loop. Thank you, it really works.

alexg0mel avatar Aug 25 '22 13:08 alexg0mel

For the full copypasta

@api.get("", response=Test, auth=ApiKey())
async def test(request):
    api_key = await request.auth
    if not api_key:
        raise AuthenticationError()
    return 200, {"msg": "hello!"}

maxmorlocke avatar Sep 20 '22 01:09 maxmorlocke

Thanks @changhyun-an and @maxmorlocke! I build a decorator from your code :) This should reduce some boilerplate in case of many endpoints

def await_auth(f):
    @functools.wraps(f)
    async def decorator(*args, **kwargs):
        auth = await args[0].auth  # args[0] is always the request instance injected from ninja
        if not auth :
            raise AuthenticationError()
        args[0].auth = auth
        return await f(*args, **kwargs)
    return decorator

Usage:

api = NinjaAPI(auth=ApiKey(), ...)

@api.post(...)
@await_auth
async def create(request, data: Input):
    ....

Speedy1991 avatar Sep 23 '22 07:09 Speedy1991

While @changhyun-an 's answer works with a single authentication mechanism, I am having issues with multiple authentication classes. I have a JWTAuth and an APITokenAuth. Using the await request.auth technqiue, if the first auth class returns None, the second is not tried and auth fails. I could combine both authentication classes in one, but I prefer to keep them separate. Does anyone have an idea about this ?

GTorreil avatar Mar 04 '23 14:03 GTorreil

I tried async-auth with v1.0 beta2 but it didn't work, and I finally came across this issue...

I saw "async authentication fully supported on all layers" in the Release Note, but is the async-auth roadmap still in progress? https://github.com/vitalik/django-ninja/releases/tag/v1.0b2

If so, I'd be very happy to mention that in the documentation, also link to this PR :) Thank you

skokado avatar Nov 06 '23 10:11 skokado

@skokado

I tried async-auth with v1.0 beta2 but it didn't work, and I finally came across this issue...

could you provide your example code ?

vitalik avatar Nov 06 '23 10:11 vitalik

@vitalik Sorry 🙇 I was using 0.22.2, I broke my local environment without realizing. It works as I expected with 1.0b2.

But one more, when failed Authentication caused by no token provided, then occurs 500 error Here's my example code.

from ninja import NinjaAPI
from ninja.security import HttpBearer

api = NinjaAPI()


class MyAuth(HttpBearer):
    async def authenticate(self, request, token):
        if token == "secret":
            request.user = MyUser(id=1, name="Foo")
            return token
        return None


@api.get("/me", auth=MyAuth())
async def me_view(request):
    return {"hello": "world"}
curl -X 'GET' \
  'http://localhost:8000/me' \
  -H 'accept: */*'

Traceback:

Traceback (most recent call last):
  File "/home/skokado/workspace/django-ninja-tutorial/venv/lib/python3.11/site-packages/ninja/operation.py", line 304, in _run_authentication
    result = await callback(request)
             ^^^^^^^^^^^^^^^^^^^^^^^
TypeError: object NoneType can't be used in 'await' expression

It does not solve by registering @api.exception_handler By incorrect token then can get 401 response

curl -X 'GET' \
  'http://localhost:8000/api/me' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer dummy'

# => {"detail": "Unauthorized"}

That does not occur when using sync-auth

skokado avatar Nov 06 '23 11:11 skokado

@skokado please check with latest version

pip install django-ninja==1.0rc0

vitalik avatar Nov 07 '23 17:11 vitalik

@vitalik Works as expected, thank you for fixing.

skokado avatar Nov 08 '23 13:11 skokado

@vitalik Sorry for repetitive, but I found new Warning in case non-empty token request at only first time. It does not matter the authenticate attempt would be succeede or not.

Sample code is same as above https://github.com/vitalik/django-ninja/issues/44#issuecomment-1794583279

$ curl -X 'GET' \
  'http://localhost:8000/api/me' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer secret'
/home/skokado/workspace/django-ninja/ninja/operation.py:311: RuntimeWarning: coroutine 'MyAuth.authenticate' was never awaited
  result = await callback(request)
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
{"hello": "world"}

At here, callback is called twice

https://github.com/vitalik/django-ninja/blob/cd66ac8c25162f6723768eae613e7f6ca4c2e2ca/ninja/operation.py#L308-L310

skokado avatar Nov 10 '23 13:11 skokado

Created a PR for this https://github.com/vitalik/django-ninja/issues/44#issuecomment-1805733011

https://github.com/vitalik/django-ninja/pull/916

skokado avatar Nov 10 '23 13:11 skokado