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

[BUG] CSRF Fails with Django Auth

Open benjamin-lawson opened this issue 11 months ago • 16 comments

Describe the bug When using django_auth (SessionAuth), the CSRF check fails from the Swagger docs page. The login endpoint returns a 200 and seems to be successful. However, when calling the update profile endpoint, it returns a 403 and says the CSRF check failed. This is from a brand new project and the settings are all default.

api.py

from django.contrib.auth import get_user_model, authenticate, login as django_login
from ninja import Schema, Field, NinjaAPI
from django.shortcuts import HttpResponse
from ninja.security import django_auth
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie

api = NinjaAPI(csrf=True, auth=None)
UserModel = get_user_model()

class UserIn(Schema):
    email: str = Field(..., pattern=r'^\S+@\S+\.\S+$')
    password: str = Field(..., min_length=8)

@api.post('/login/', response={200: str, 401: str})
@csrf_exempt
@ensure_csrf_cookie
def login(request, user_in: UserIn):
    user = authenticate(request, username=user_in.email, password=user_in.password)
    if user is not None:
        django_login(request, user)
        return HttpResponse('Login successful', status=200)
    else:
        return 401, 'Invalid login'


class UserProfileIn(Schema):
    first_name: str
    last_name: str


class UserProfileOut(Schema):
    email: str
    first_name: str
    last_name: str

@api.post('/profile/', auth=django_auth, response=UserProfileOut)
def update_profile(request, payload: UserProfileIn):
    for attr, value in payload.dict().items():
        setattr(request.user, attr, value)
    request.user.save()
    return request.user

Versions (please complete the following information):

  • Python version: 3.10.11
  • Django version: 4.2.10
  • Django-Ninja version: 1.1.0
  • Pydantic version: 2.6.3

benjamin-lawson avatar Mar 03 '24 21:03 benjamin-lawson

+1 same issue here

chrisJuresh avatar Mar 03 '24 22:03 chrisJuresh

when calling the update profile endpoint, csrf_token should be included in request body

yzongyue avatar Mar 05 '24 06:03 yzongyue

when calling the update profile endpoint, csrf_token should be included in request body

I'm a beginner. Could you please elaborate on how to include CSRF in request body ?

naveencom avatar Mar 05 '24 06:03 naveencom

when calling the update profile endpoint, csrf_token should be included in request body

I'm a beginner. Could you please elaborate on how to include CSRF in request body ?

https://stackoverflow.com/questions/5100539/django-csrf-check-failing-with-an-ajax-post-request this may help.

yzongyue avatar Mar 05 '24 06:03 yzongyue

when calling the update profile endpoint, csrf_token should be included in request body

I'm a beginner. Could you please elaborate on how to include CSRF in request body ?

https://stackoverflow.com/questions/5100539/django-csrf-check-failing-with-an-ajax-post-request this may help.

Okay.. But how do we pass csrf_token value to api consuming client (Eg: Mobile app) ?

naveencom avatar Mar 05 '24 07:03 naveencom

when calling the update profile endpoint, csrf_token should be included in request body

I'm a beginner. Could you please elaborate on how to include CSRF in request body ?

https://stackoverflow.com/questions/5100539/django-csrf-check-failing-with-an-ajax-post-request this may help.

Okay.. But how do we pass csrf_token value to api consuming client (Eg: Mobile app) ?

Correct me if I'm wrong but it should be in the HttpResponse (header?) automatically if you are using django authentication.

chrisJuresh avatar Mar 05 '24 12:03 chrisJuresh

when calling the update profile endpoint, csrf_token should be included in request body

I've verified, via a custom Middleware, that both the CSRF header is being (X-CSRFToken) and the cookie are being passed to the server. When those aren't present, I get a different error.

benjamin-lawson avatar Mar 05 '24 13:03 benjamin-lawson

To add on, I've figured out a workaround for this issue. It seems like the value contained in the X-CSRFToken header is incorrect when the request is coming from the Swagger docs. I wrote a middleware to take the unencrypted cookie value and replace the X-CSRFToken header with that value instead. That allows the request to go through and pass the CSRF check.

class CsrfInterceptMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request.META['HTTP_X_CSRFTOKEN'] = request.COOKIES.get('csrftoken', request.META['HTTP_X_CSRFTOKEN'])
        response = self.get_response(request)
        return response

benjamin-lawson avatar Mar 05 '24 14:03 benjamin-lawson

did anyone get this to work? I tried your solution @benjamin-lawson but no success for me. I get a KeyError:

backend_web-1                 | Traceback (most recent call last):
backend_web-1                 |   File "/usr/local/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55, in inner
backend_web-1                 |     response = get_response(request)
backend_web-1                 |                ^^^^^^^^^^^^^^^^^^^^^
backend_web-1                 |   File "/usr/src/app/core/middleware.py", line 12, in __call__
backend_web-1                 |     "csrftoken", request.META["HTTP_X_CSRFTOKEN"]
backend_web-1                 |                  ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
backend_web-1                 | KeyError: 'HTTP_X_CSRFTOKEN'

Thoughts?

rafrasenberg avatar Apr 09 '24 21:04 rafrasenberg

This is example code that worksï¼› django-ninja use https://github.com/django/django/blob/main/django/middleware/csrf.py#L349 check csrf



import requests


"""
python manage.py migrate
python manage.py shell -c "from django.contrib.auth import get_user_model; get_user_model().objects.create_superuser('[email protected]', 'adminadmin', 'adminadmin')"
"""


def main():
    base_url = 'http://127.0.0.1:8000'
    
    session = requests.Session()
    resp = session.post(base_url + '/login/', json={'email': '[email protected]', 'password': 'adminadmin'})
    print(resp.status_code)
    print(resp.text)
    print(resp.cookies)
    csrf_token = resp.cookies['csrftoken']
    print(csrf_token)

    resp = session.post(base_url + '/profile/', json={'first_name': 'John', 'last_name': 'Doe'}, headers={"X-CSRFTOKEN": csrf_token})
    print(resp.status_code)
    print(resp.text)


if __name__ == '__main__':
    main()

yzongyue avatar Apr 10 '24 03:04 yzongyue

Yeah for me the CSRF works fine when making requests from my frontend. I'm specifically talking about the Swagger docs, that isn't working with the following setup:

api = NinjaAPI(....stuff, csrf=True)

api.add_router(
    "/v1",
    base_router,
    auth=django_auth,
)

# Some route
@router.post(
    "/",
    summary="Ping pong!",
    auth=None,
)
@ensure_csrf_cookie
def ping(request):
    return {"ping": "pong!"}

That will fail when making a request from Swagger. I want to protect my route with CSRF but not don't require a user to be logged in. @yzongyue

rafrasenberg avatar Apr 10 '24 14:04 rafrasenberg

ensure_csrf_cookie may not required or just put it before @router.get, my test code:

@ensure_csrf_cookie
@api.get(
    "/pingpong",
    summary="Ping pong!",
    auth=None,
)
def ping(request):
    return {"ping": "pong!"}


@api.post(
    "/postpingpong",
    summary="Ping pong!",
    auth=None,
    # auth=django_auth
)
def postping(request, code: int):
    return {"ping": "pong!"}

and ensure django.middleware.csrf.CsrfViewMiddleware exist in settings.MIDDLEWARE @rafrasenberg

yzongyue avatar Apr 11 '24 09:04 yzongyue

Thanks @yzongyue

The .get works indeed. However .post gets me CSRF verification failed. Request aborted. from Swagger.

rafrasenberg avatar Apr 11 '24 11:04 rafrasenberg

This is how I solved this problem.

https://django-ninja.dev/reference/csrf/

How to protect against CSRF with Django Ninja

Use an authentication method not automatically embedded in the request CSRF attacks rely on authentication methods that are automatically included in requests started from another site, like cookies or Basic access authentication. Using an authentication method that does not automatically gets embedded, such as the Authorization: Bearer header for exemple, mitigates this attack.

from django.contrib.auth import authenticate, login
from django.views.decorators.csrf import csrf_exempt

from typing import List
from ninja import NinjaAPI, Schema, Form, Redoc
from ninja.security import HttpBearer 

from .schemas import *

class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        if token == "1kh@g%5#01_8d89*2bheymz#vxt+3c#z9-xbs_a7swg%*1":
            return token

        return None

class MultipleAuth(HttpBearer):

    def authenticate(self, request, token):
        if token == "1kh@g%5#01_8d89*2bheymz#vxt+3c#z9-xbs_a7swg%*1" and request.user.is_authenticated:
            return request.user

        return None

api = NinjaAPI( csrf = False, auth = MultipleAuth() )

@api.post("/login", auth = AuthBearer(), response = { 200: LoginSchema, 401: MessageSchema })
@csrf_exempt
def user_login( request, username: Form[str], password: Form[str] ):
    user = authenticate( request, username = username, password = password )

    if user is None:
        return 401, {"message": "Password is incorrect. If you have forgot, please reset your password."}

    elif not user.is_active:
        return 401, {"message": "Your account status is inactive."}

    login(request, user)

    return user
@api.post("/create-post")
def create_post(request, payload: SinglePostSchema):
    group_post = GroupPost.objects.create(**payload.dict()) 
    return { "id": group_post.id }

Django Ninja CRUD example

naveencom avatar Apr 11 '24 12:04 naveencom

This is how I solved this problem.

https://django-ninja.dev/reference/csrf/

How to protect against CSRF with Django Ninja

Use an authentication method not automatically embedded in the request CSRF attacks rely on authentication methods that are automatically included in requests started from another site, like cookies or Basic access authentication. Using an authentication method that does not automatically gets embedded, such as the Authorization: Bearer header for exemple, mitigates this attack.

from django.contrib.auth import authenticate, login
from django.views.decorators.csrf import csrf_exempt

from typing import List
from ninja import NinjaAPI, Schema, Form, Redoc
from ninja.security import HttpBearer 

from .schemas import *

class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        if token == "1kh@g%5#01_8d89*2bheymz#vxt+3c#z9-xbs_a7swg%*1":
            return token

        return None

class MultipleAuth(HttpBearer):

    def authenticate(self, request, token):
        if token == "1kh@g%5#01_8d89*2bheymz#vxt+3c#z9-xbs_a7swg%*1" and request.user.is_authenticated:
            return request.user

        return None

api = NinjaAPI( csrf = False, auth = MultipleAuth() )

@api.post("/login", auth = AuthBearer(), response = { 200: LoginSchema, 401: MessageSchema })
@csrf_exempt
def user_login( request, username: Form[str], password: Form[str] ):
    user = authenticate( request, username = username, password = password )

    if user is None:
        return 401, {"message": "Password is incorrect. If you have forgot, please reset your password."}

    elif not user.is_active:
        return 401, {"message": "Your account status is inactive."}

    login(request, user)

    return user
@api.post("/create-post")
def create_post(request, payload: SinglePostSchema):
    group_post = GroupPost.objects.create(**payload.dict()) 
    return { "id": group_post.id }

Django Ninja CRUD example

This is how we solved the issue as well, switch to JWT or another type of auth that doesn't rely on CSRF checks. But that isn't really a solution to the problem, but a work around.

benjamin-lawson avatar Apr 11 '24 14:04 benjamin-lawson

This issue is because APIKeyCookie which SessionAuth inherits from for django_auth defaults to csrf = True even if the NinjaAPI has CSRF off.

I fixed it with a custom django_auth

class CustomSessionAuth(SessionAuth):
  def __init__(self, csrf=True):
    super().__init__(csrf)
    self.csrf = False

django_auth = CustomSessionAuth()

I guess when using APIKeyCookie, CSRF On is best for security? However, for session auth that is not always the case because session auth has several mechanisms. Maybe there should be a setting to tweak CSRF on or off for SessionAuth?

pizzapanther avatar May 09 '24 19:05 pizzapanther