sil2100//vx developer log

Welcome to my personal web page

Django. Restricting user login

For a Django-based sub-project I'm working on, I had the need to restrict user login to only one session active and logged-in at once. As currently I am almost a complete newbie in this framework, I tried finding a ready solution around the web and failed, as nothing really fit my needs. After gathering some bits and pieces of information from around the internet I wrote a quick and very simple piece of auth code to do the login restriction I wanted.

2016-10-05 21:52

In this post I will cover the essentials, so there might be some more fine grained tweaks you might want to add later on yourself. In overall my requirements were simple: once a user logs into the webpage, another login with the same credentials should be rejected with an error message. I also wanted to use as many bits from the regular django auth module without the need of writing too many things from scratch.

First step: extending the authentication backend. The authentication backend is used whenever an user wants to log in and her/his username and password need to be checked. The django documentation shows some examples how to write your own backend if needed, but we can actually just take the existing default and extend it by modifying the one method we're interested in - as every backend is simply a Python class. For this we create an auth.py file in the directory of our django application (not the project but the app itself). In this post I am assuming 'app' as the application name and 'project' as the overall django project name.

# app/auth.py

# Some of these imports will be used later
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import User
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.sessions.models import Session
from django.utils import timezone
from django.core.exceptions import ValidationError

class CheckIfLoggedOnBackend(ModelBackend):
    """
    Authenticate
    """

    # We overwrite the authenticate method to do what we want
    def authenticate(self, username=None, password=None, **kwargs):
        # Query all non-expired sessions
        sessions = Session.objects.filter(expire_date__gte=timezone.now())
        uid_list = []

        # Build a list of user ids from that query
        for session in sessions:
            data = session.get_decoded()
            uid_list.append(data.get('_auth_user_id', None))

        # Query all logged in users based on id list
        logged_in = User.objects.filter(id__in=uid_list)
        print(logged_in)

        # Check if the supplied username has an active session
        try:
            user = logged_in.get(username=username)
        except User.DoesNotExist:
            # If not, then just proceed to 'standard login'
            return super().authenticate(username, password, **kwargs)

        # If we're here, this means the user is already logged in, reject the login attempt

        # [1]
        # We print out the custom error message if possible - more about this in a moment
        if 'errors' in kwargs:
            kwargs['errors'].append('Already logged in.')

        return None

# (...)

The comments are self explanatory. Ok, this will work and the user will be rejected if already logged in. Now we need to enable the new auth backend we have created. It would also be useful if user sessions would be terminated on browser close - but by default such functionality is disabled in case you're not interested in that.

# project/settings.py

# (...)

AUTHENTICATION_BACKENDS = ['app.auth.CheckIfLoggedOnBackend']

SESSION_EXPIRE_AT_BROWSER_CLOSE = True

# (...)

Ok, so that now works as expected. If you have your login screen properly configured, you should no longer be able to log twice on the same account credentials until logout. But it would be nice if the login screen would inform us somehow about why the login failed. This is also very easy, although there's one small part I don't like.

To get custom error messages we will need a custom AuthenticationForm - or rather, its custom clear() method. The problem is (the thing I don't like), with the way how the default method has been defined it's hard to 'extend' it without copy-pasting code. The good thing is: the default method is really simple so there's not much harm in simply re-implementing it to do things the same way.
Now, for the changes that are needed. Remember our CheckIfLoggedOnBackend authentication method? We supplied kwargs there after the default two required arguments. In the almost last line of code (marked as [1]) we check if an errors additional parameter is supplied that we can use to supply an error message. Let's see how we use that:

# app/auth.py

# (...)

class CustomErrorAuthenticationForm(AuthenticationForm):
    def clean(self):
        # Most of this code is taken from django.contrib.auth.forms
        username = self.cleaned_data.get('username')
        password = self.cleaned_data.get('password')

        if username and password:
            errors = []
            # We now call the current authenticate method, supplying an additional argument - an errors list reference
            self.user_cache = authenticate(username=username,
                                           password=password,
                                           errors=errors)
            if self.user_cache is None:
                # Ok, the authentication failed - either due to login/password missmatch or...
                if errors:
                    # Looks like it's a custom error message (from [1])
                    # Let's use that as our error
                    raise ValidationError(
                        ' '.join(errors)
                    )
                else:
                    # Default validation error
                    raise ValidationError(
                        self.error_messages['invalid_login'],
                        code='invalid_login',
                        params={'username': self.username_field.verbose_name},
                    )
            else:
                self.confirm_login_allowed(self.user_cache)

        return self.cleaned_data

Another thing we need to do is to enable the new AuthenticationForm to be used by our login screen. For this, we tweak the project/urls.py file. The default login object looks at the authentication_form argument to define which auth form to use - let's use that for our purpose.

# project/urls.py

# (...)

import app.auth

urlpatterns = [
# (...)
    url(r'^accounts/login/', login, name='login', kwargs={'authentication_form': app.auth.CustomErrorAuthenticationForm}),
# (...)
]

Let's now take a quick look how to print this message in our login template. This is the final step to get everything working:

<!-- project/templates/registration.html -->

<!-- (...) -->

  {% if form.errors %}
  <p> Error: </p>
  <p>
    {% for errors in form.non_field_errors %}
      {{ errors }}
    {% endfor %}
  </p>
  {% endif %}

<!-- (...) -->

This is of course just the base, since for normal full usage some session management tweaks are needed (by default sessions expire after a longer while). But it's a good place to start off from. Hope this helps!