Why is Django REST Framework lying to me?

rhymes

rhymes

Posted on June 21, 2018

Why is Django REST Framework lying to me?

One thing about frameworks is that they work well when you follow the tutorials and the conventions, they usually don't when you try to step a little bit out the tracks.

I've spent too much time trying to login from a VueJS SPA to a Django app, with the following requirements/constraints:

  • no jwt or tokens
  • the login should just send the credentials and the csrf cookie/header and get back the HttpOnly session cookie which is going to be automatically sent in the following requests
  • that's all

How hard should this be?

I've installed django-rest-framework and django-rest-auth but they seem to work only with token based authentication or other stuff, not with a plain old session based authentication. After a while I started placing breakpoints into django-rest-framework and django-rest-auth code bases to figure out what was going on.

This is what's happening now:

The frontend sends the credentials and the CSRF Token set by Django on the first request:

const CSRF_COOKIE_NAME = 'csrftoken'
const CSRF_HEADER_NAME = 'X-CSRFToken'

const client = axios.create({
  xsrfCookieName: CSRF_COOKIE_NAME,
  xsrfHeaderName: CSRF_HEADER_NAME,
})

export default {
  login(username, password) {
    return client.post('/auth/login/', { username, password })
  },
  logout() {
    return client.post('/auth/logout/')
  },
}
Enter fullscreen mode Exit fullscreen mode

/auth/ on the server is mapped to django rest auth like this:

path('auth/', include('rest_auth.urls'))
Enter fullscreen mode Exit fullscreen mode

The server (django-rest-auth) sees the login attempt and invokes the authenticator (in django-rest-framework) for SessionAuthentication:

    def authenticate(self, request):
        """
        Returns a `User` if the request session currently has a logged in user.
        Otherwise returns `None`.
        """

        # Get the session-based user from the underlying HttpRequest object
        user = getattr(request._request, 'user', None)

        # Unauthenticated, CSRF validation not required
        if not user or not user.is_active:
            return None

        self.enforce_csrf(request)

        # CSRF passed with authenticated user
        return (user, None)
Enter fullscreen mode Exit fullscreen mode

This is where it gets hairy and the documentation is not exactly clearing this up.

The only thing that this method does is checking if there's already an authenticated user on the server side (not 100% sure about this), which by logic, means that to pass you already need to be authenticated (!?!?) to use this via AJAX.

So, instead of authenticating you, it checks if the authentication you already have is correct and valid.

What I need is to authenticate the credentials, which this thing doesn't really do.

Why is it called SessionAuthentication if it doesn't actually authenticate you?

It's not like it's useless, it's just useful for server side rendered pages with traditional logins and an AJAX calls, not a SPA.

So much time wasted because of naming :D They should have called it CheckAlreadyExistingSessionAuthentication.

Anybody has any suggestions?

I'm thinking of implementing it myself (maybe as a custom class to django rest framework?). It seems like with all these fancy authentication mechanisms (tokens, jwt, oauth and so on) they forgot to cover the basics...

UPDATE June 25th

For now I solved (I think) by bending the frameworks a bit. It's ugly and as soon as I have a little more time I'll figure out how to do it properly, it's a pity that all of these framework neglect session based authentication.

This is what I did. On the server side I had to create a few custom things.

First I had to create a custom authentication class for Django REST Framework that simply get the credentials and uses Django's own auth layer to authenticate the user:

# adapted from https://bit.ly/2K80boT and https://bit.ly/2JV1iMK

from django.contrib import auth
from django.contrib.auth.models import User
from django.middleware.csrf import CsrfViewMiddleware
from django.utils.translation import ugettext_lazy as _
from rest_framework import authentication, exceptions


class CSRFCheck(CsrfViewMiddleware):
    def _reject(self, request, reason):
        # Return the failure reason instead of an HttpResponse
        return reason


class DRFSessionAuthentication(authentication.BaseAuthentication):
    'Session authentication against username/password for DRF'

    def authenticate(self, request):
        '''
        Returns a User if a correct username and password have been supplied
        using Django Session Authentication. Otherwise returns None.
        '''

        username = request.data.get('username')
        password = request.data.get('password')

        return self.authenticate_credentials(username, password, request)

    def authenticate_credentials(self, userid, password, request=None):
        '''
        Authenticate the userid and password against username and password
        with optional request for context.
        '''

        credentials = {
            auth.get_user_model().USERNAME_FIELD: userid,
            'password': password
        }
        user = auth.authenticate(request=request, **credentials)

        if user is None:
            raise exceptions.AuthenticationFailed(
                _('Invalid username/password.')
            )

        if not user.is_active:
            raise exceptions.AuthenticationFailed(
                _('User inactive or deleted.')
            )

        self.enforce_csrf(request)

        return (user, None)

    def authenticate_header(self, request):
        return 'Session'

    def enforce_csrf(self, request):
        'Enforce CSRF validation for session based authentication.'

        reason = CSRFCheck().process_view(request, None, (), {})
        if reason:
            # CSRF failed, bail with explicit error message
            raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)
Enter fullscreen mode Exit fullscreen mode

Django automatically creates the session cookies, sets it to http only and (if you tell it so) sets it secure on production.

Once you have the http only session cookie in your hand the user is actually authenticated, at each HTTP call the browser will send the session token to the server.

(fyi: a seession cookie is just a token in a HTTP header, basically the same thing as those fancy auth tokens)

The next step is to have the client check if the user is authenticated, which it can just do by calling the server which responds with something like this:

def user(request):
    'Returns a user object if authenticated, 401 otherwise'

    if request.user.is_authenticated:
        user = request.user
        return JsonResponse({
            'id': user.id,
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name
        })
    else:
        return JsonResponse({}, status=status.HTTP_401_UNAUTHORIZED)
Enter fullscreen mode Exit fullscreen mode

To make it all work I had to wire up the routes (again, leaving django-rest-auth aside a bit) like this:

from django.contrib.auth import views as auth_views
from app import views

path('auth/login/', views.SessionLoginView.as_view(), name='login'),
path('auth/logout/', auth_views.logout, {'next_page': '/'}, name='logout'),
path('auth/user/', views.user, name='user'),
Enter fullscreen mode Exit fullscreen mode

SessionLoginView is a custom class that I had to write to bypass all the token authentication machinery django-rest-auth puts in:

class SessionLoginView(LoginView):
    def login(self):
        self.user = self.serializer.validated_data['user']
        self.process_login()

    def get_response(self):
        return Response('', status=status.HTTP_204_NO_CONTENT)
Enter fullscreen mode Exit fullscreen mode

All of these is just ugly and untested and I wasted some time to solve something that should be super simple to do...

💖 💪 🙅 🚩
rhymes
rhymes

Posted on June 21, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related