vitalik / django-ninja

💨 Fast, Async-ready, Openapi, type hints based framework for building APIs
https://django-ninja.dev
MIT License
6.97k stars 420 forks source link

[BUG] CSRF Fails with Django Auth #1101

Open benjamin-lawson opened 6 months ago

benjamin-lawson commented 6 months ago

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):

chrisJuresh commented 6 months ago

+1 same issue here

yzongyue commented 6 months ago

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

naveencom commented 6 months ago

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 ?

yzongyue commented 6 months ago

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.

naveencom commented 6 months ago

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) ?

chrisJuresh commented 6 months ago

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.

benjamin-lawson commented 6 months ago

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 commented 6 months ago

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
rafrasenberg commented 5 months ago

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?

yzongyue commented 5 months ago

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('admin@admin.com', 'adminadmin', 'adminadmin')"
"""

def main():
    base_url = 'http://127.0.0.1:8000'

    session = requests.Session()
    resp = session.post(base_url + '/login/', json={'email': 'admin@admin.com', '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()
rafrasenberg commented 5 months ago

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

yzongyue commented 5 months ago

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

rafrasenberg commented 5 months ago

Thanks @yzongyue

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

naveencom commented 5 months ago

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

benjamin-lawson commented 5 months ago

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.

pizzapanther commented 4 months ago

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?