DEV Community

Cover image for Guide to Building a Complete Blog App with Django using TDD Methodology and PostgreSQL (Part 3): Secure User Authentication
AVLESSI Matchoudi
AVLESSI Matchoudi

Posted on • Originally published at Medium

Guide to Building a Complete Blog App with Django using TDD Methodology and PostgreSQL (Part 3): Secure User Authentication

Welcome back, everyone! In the previous part, we established a secure user registration process for our Django blog application. However, after successful registration, we were redirected to the homepage. This behaviour will be modified once we implement user authentication. User authentication ensures that only authorized users can access certain functionalities and protects sensitive information.
Entity-Relationship Diagram(ERD)
In this series, we are building a complete blog application, guided by the following Entity-Relationship Diagram (ERD). For this time, our focus will be on setting up a secure user authentication process. If you find this content helpful, please like, comment, and subscribe to stay updated when the next part is released.
login preview
This is a preview of how our login page will look after we’ve implemented the login functionality. If you haven’t read the previous parts of the series, I recommend doing so, as this tutorial is a continuation of the previous steps.

Okay, let’s get started !!

Django comes with a built-in app called contrib.auth, which simplifies handling user authentication for us. You can check the blog_env/settings.py file, under the INSTALLED_APPS, you’ll see that auth is already listed.

# django_project/settings.py
INSTALLED_APPS = [
    # "django.contrib.admin",
    "django.contrib.auth",  # <-- Auth app
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]
Enter fullscreen mode Exit fullscreen mode

The auth app provides us with multiple authentication views for handling login, logout, password change, password reset, etc. This means that the essential authentication functionality, such as user login, registration, and permissions, is ready to use without needing to build everything from scratch.

In this tutorial, we’ll focus solely on the login and logout views, and cover the rest of the views in later parts of the series.

1. Create a login form

Following our TDD approach, let’s begin by creating tests for the login form. Since we haven’t created a login form yet, navigate to the users/forms.py file and create a new class inheriting from AuthenticationForm.

# users/forms.py
from django.contrib.auth import AuthenticationForm

class LoginForm(AuthenticationForm):


Enter fullscreen mode Exit fullscreen mode

Once the form is defined, we can add test cases in users/tests/test_forms.py to verify its functionality.

# users/tests/test_forms.py

#   --- other code

class LoginFormTest(TestCase):
  def setUp(self):
    self.user = User.objects.create_user(
      full_name= 'Tester User',
      email= 'tester@gmail.com',
      bio= 'new bio for tester',
      password= 'password12345'
    )

  def test_valid_credentials(self):
    """
    With valid credentials, the form should be valid
    """
    credentials = {
      'email': 'tester@gmail.com',
      'password': 'password12345',
      'remember_me': False
    }

    form = LoginForm(data = credentials)
    self.assertTrue(form.is_valid())

  def test_wrong_credentials(self):
    """
    With wrong credentials, the form should raise Invalid email or password error
    """
    credentials = {
      'email': 'tester@gmail.com',
      'password': 'wrongpassword',
      'remember_me': False
    }
    form = LoginForm(data = credentials)
    self.assertIn('Invalid email or password', str(form.errors['__all__']))

  def test_credentials_with_empty_email(self):
    """
    Should raise an error when the email field is empty
    """
    credentials = {
      'email': '',
      'password': 'password12345',
      'remember_me': False
    }
    form = LoginForm(data = credentials)
    self.assertFalse(form.is_valid())
    self.assertIn('This field is required', str(form.errors['email']))

  def test_credentials_with_empty_password(self):
    """
    Should raise error when the password field is empty
    """
    credentials = {
      'email': 'tester@gmail.com',
      'password': '',
      'remember_me': False
    }
    form = LoginForm(data = credentials)
    self.assertFalse(form.is_valid())
    self.assertIn('This field is required', str(form.errors['password']))
Enter fullscreen mode Exit fullscreen mode

These tests cover scenarios like successful login with valid credentials, failed login with invalid credentials, and handling error messages appropriately.

The AuthenticationForm class provides some basic validation by default. However, with our LoginForm, we can tailor its behaviour and add any necessary validation rules to meet our specific requirements.

# users/forms.py

# -- other code
from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm # new line
from django.contrib.auth import get_user_model, authenticate # new line


# --- other code

class LoginForm(AuthenticationForm):
  email = forms.EmailField(
    required=True,
    widget=forms.EmailInput(attrs={'placeholder': 'Email','class': 'form-control',})
  )
  password = forms.CharField(
    required=True,
    widget=forms.PasswordInput(attrs={
                                'placeholder': 'Password',
                                'class': 'form-control',
                                'data-toggle': 'password',
                                'id': 'password',
                                'name': 'password',
                                })
  )
  remember_me = forms.BooleanField(required=False)

  def __init__(self, *args, **kwargs):
    super(LoginForm, self).__init__(*args, **kwargs)
    # Remove username field

    if 'username' in self.fields:
      del self.fields['username']

  def clean(self):
    email = self.cleaned_data.get('email')
    password = self.cleaned_data.get('password')

    # Authenticate using email and password
    if email and password:
      self.user_cache = authenticate(self.request, email=email, password=password)
      if self.user_cache is None:
        raise forms.ValidationError("Invalid email or password")
      else:
        self.confirm_login_allowed(self.user_cache)
    return self.cleaned_data

  class Meta:
    model = User
    fields = ('email', 'password', 'remember_me')
Enter fullscreen mode Exit fullscreen mode

We’ve created a custom login form that includes the following fields: email, password, and remember_me. The remember_me checkbox allows users to maintain their login session across browser sessions.

Since our form extends the AuthenticationForm, we've overridden some default behaviour:

  • ** __init__ method**: We've removed the default username field from the form to align with our email-based authentication.
  • clean() method: This method validates the email and password fields. If the credentials are valid, we authenticate the user using Django's built-in authentication mechanism.
  • confirm_login_allowed() method: This built-in method provides an opportunity for additional verification before login. You can override this method to implement custom checks if needed. Now our tests should pass:
(.venv)$ python3 manage.py test users.tests.test_forms
Found 9 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.........
----------------------------------------------------------------------
Ran 9 tests in 3.334s
OK
Destroying test database for alias 'default'...
Enter fullscreen mode Exit fullscreen mode

2. Create our login view

2.1 Create tests for the login view

Since we do not have the view for the login yet, let's navigate to the users/views.py file and create a new class inheriting from the auth app’s LoginView

# -- other code 
from .forms import CustomUserCreationForm, LoginForm
from django.contrib.auth import get_user_model, views
# -- other code

class CustomLoginView(views.LoginForm):


Enter fullscreen mode Exit fullscreen mode

At the bottom of the users/tests/test_views.py file add these test cases

# users/tests/test_views.py

# -- other code

class LoginTests(TestCase):
  def setUp(self):
    User.objects.create_user(
      full_name= 'Tester User',
      email= 'tester@gmail.com',
      bio= 'new bio for tester',
      password= 'password12345'
    )
    self.valid_credentials = {
      'email': 'tester@gmail.com',
      'password': 'password12345',
      'remember_me': False
    }

  def test_login_url(self):
    """User can navigate to the login page"""
    response = self.client.get(reverse('users:login'))
    self.assertEqual(response.status_code, 200)

  def test_login_template(self):
    """Login page render the correct template"""
    response = self.client.get(reverse('users:login'))
    self.assertTemplateUsed(response, template_name='registration/login.html')
    self.assertContains(response, '<a class="btn btn-outline-dark text-white" href="/users/sign_up/">Sign Up</a>')

  def test_login_with_valid_credentials(self):
    """User should be log in when enter valid credentials"""
    response = self.client.post(reverse('users:login'), self.valid_credentials, follow=True)
    self.assertEqual(response.status_code, 200)
    self.assertRedirects(response, reverse('home'))
    self.assertTrue(response.context['user'].is_authenticated)
    self.assertContains(response, '<button type="submit" class="btn btn-danger"><i class="bi bi-door-open-fill"></i> Log out</button>')

  def test_login_with_wrong_credentials(self):
    """Get error message when enter wrong credentials"""
    credentials = {
      'email': 'tester@gmail.com',
      'password': 'wrongpassword',
      'remember_me': False
    }

    response = self.client.post(reverse('users:login'), credentials, follow=True)
    self.assertEqual(response.status_code, 200)
    self.assertContains(response, 'Invalid email or password')
    self.assertFalse(response.context['user'].is_authenticated)
Enter fullscreen mode Exit fullscreen mode

We need to ensure that these tests are failing at this stage.

2.2 Create a login view

In the users/views.py file at the bottom of the file add the code below:

# -- other code 
from .forms import CustomUserCreationForm, LoginForm
from django.contrib.auth import get_user_model, views
# -- other code

class CustomLoginView(views.LoginView):
  form_class = LoginForm
  redirect_authenticated_user = True
  authentication_form = LoginForm
  template_name = 'registration/login.html'

  def form_valid(self, form):
    remember_me = form.cleaned_data.get('remember_me')

    if not remember_me:
      # set session expiry to 0 seconds. So it will automatically close the session after the browser is closed.
      self.request.session.set_expiry(0)
      # Set session as modified to force data updates/cookie to be saved.
      self.request.session.modified = True
    return super(CustomLoginView, self).form_valid(form)
Enter fullscreen mode Exit fullscreen mode

In the code above, we accomplish the following:

  • Set the form_class Attribute: We specify our custom LoginForm as the form_class attribute since we are no longer using the default AuthenticationForm.
  • Override the form_valid Method: We override the form_valid method, which is called when valid form data has been posted. This allows us to implement custom behaviour after the user has successfully logged in.
  • Handle Session Expiration: If the user does not check the remember_me box, the session will expire automatically when the browser is closed. However, if the remember_me box is checked, the session will last for the duration defined in settings.py. The default session length is two weeks, but we can modify this using the SESSION_COOKIE_AGE variable in settings.py. For example, to set the cookie age to 7 days, we can add the following line to our settings:
# blog_app/settings.py
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7
Enter fullscreen mode Exit fullscreen mode

To connect your custom login functionality and allow users to access the login page, we’ll define URL patterns in the users/urls.py file. This file will map specific URLs (/log_in/ in this case) to the corresponding views (CustomLoginView). Additionally, we'll include a path for the logout functionality using Django's built-in LogoutView.

# users/urls.py

# -- other code
from django.contrib.auth import views as auth_views
from . import views

app_name = 'users'
urlpatterns = [
  path('log_in/', views.CustomLoginView.as_view(), name='login' ), # new line
  path('sign_up/', views.SignUpView.as_view(), name='signup'),
  path('log_out/', auth_views.LogoutView.as_view(), name='logout'),# new line
]
Enter fullscreen mode Exit fullscreen mode

Everything seems to be in order, but we should specify where to redirect users upon successful login and logout. To do this, we will use the LOGIN_REDIRECT_URL and LOGOUT_REDIRECT_URL settings. At the bottom of your blog_app/settings.py file, add the following lines to redirect users to the homepage:

# django_project/settings.py
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
Enter fullscreen mode Exit fullscreen mode

Now that we have the login URL let’s update our SignUpView in the users/views.py file to redirect to the login page when sign-up is successful.

# users/views.py
class SignUpView(CreateView):
  form_class = CustomUserCreationForm
  model = User
  success_url = reverse_lazy('users:login') # Updated line
  template_name = 'registration/signup.html'
Enter fullscreen mode Exit fullscreen mode

We will also update our SignUpTexts, specifically the test_signup_correct_data(self), to reflect the new behaviour and ensure that our changes are properly tested.

# users/tests/test_views.py
  def test_signup_correct_data(self):
    """User should be saved when a correct data is provided"""
    response = self.client.post(reverse('users:signup'), data={
      'full_name': self.full_name,
      'email': self.email,
      'bio': self.bio,
      'password1': self.password,
      'password2': self.password
    })

    self.assertRedirects(response, reverse('users:login')) # Updated line
    users = User.objects.all()
    self.assertEqual(users.count(), 1)
    self.assertNotEqual(users[0].password, self.password)
Enter fullscreen mode Exit fullscreen mode

2.3 Create a template for Login

Then create a users/templates/registration/login.html file with your text editor and include the following code:

{% extends 'layout.html' %}
{% block page %}
  Login
{% endblock %}
{% block content %}
<div class="container mt-3">
  <div class="row justify-content-center">
    <div class="col-lg-5">
      <div class="card shadow-lg border-0 rounded-lg mt-3 mb-3">
        <div class="card-header justify-content-center">
          <h3 class="font-weight-light my-1 text-center">Sign In</h3>
        </div>
        {% if form.errors %}
          {% for field, message in form.errors.items %}
            <div class="alert alert-danger alert-dismissible fade show" role="alert">
              {{message|first}}
              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
            </div>
          {% endfor %}
        {% endif %}
        <form method="POST" class="card-body">
          <div class="mb-3">
            <label for="id_email" class="form-label">Email address</label>
            {{form.email}}
          </div>
          <div class="mb-3">
            <label for="password" class="form-label">Password</label>
            {{form.password}}
          </div>
          <div class="mb-3">
            {{form.remember_me}}
            <label>Remember me</label>
          </div>
          <div class="mb-3">
            <button name="login" class="col-md-12 btn bg-secondary bg-gradient text-white">Sign in</button>
          </div>
        </form>
        <div class="card-footer text-center">
          <div class="small">
            <a href="{% url 'users:signup' %}">Don't have an account yet? Go to signup</a><br>
            <a href="#"><i>Forgot Password?</i></a>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

We will add the Forgot Password functionality later in this series but now it’s just a dead link.
Login page
Now, let us update our layout.html template to include the login, sign-up and logout links.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block page %}{% endblock %} | Blog App</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
  <nav class="navbar navbar-expand navbar-dark bg-primary bg-gradient">
    <div class="container-fluid">
      <a class="navbar-brand" href="{% url 'home' %}">Blog App</a>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarScroll" aria-controls="navbarScroll" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarScroll">
        <ul class="navbar-nav me-auto my-2 my-lg-0 navbar-nav-scroll" style="--bs-scroll-height: 100px;">
          <li class="nav-item">
            <a class="nav-link active" aria-current="page" href="{% url 'home' %}">Home</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="{% url 'about' %}">About</a>
          </li>
        </ul>
        {% if user.is_authenticated %}
        <ul class="navbar-nav">
          <li class="nav-item me-3" style="cursor: pointer">
            <i class="bi bi-person-circle fs-2 text-white" title="{{user.full_name}}"></i>
          </li>
          <li class="nav-item">
            <form class="mt-1" action="{% url 'users:logout' %}" method="post">
              {% csrf_token %}
              <button type="submit" class="btn btn-danger"><i class="bi bi-door-open-fill"></i> Log out</button>
            </form>
          </li>
        </ul>
        {% else %}
        <ul class="navbar-nav">
          <li class="nav-item me-2">
            <a class="btn btn-outline-dark text-white" href="{% url 'users:signup' %}">Sign Up</a>
          </li>
          <li class="nav-item">
            <a class="btn btn-outline-dark text-white" href="{% url 'users:login' %}">Sign In</a>
          </li>
        </ul>
        {% endif %}
      </div>
    </div>
  </nav>
  {% block content %}
  {% endblock %}
  <footer></footer>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

In our template, we check whether the user is authenticated. If the user is logged in, we display the log-out link and the user's full name. Otherwise, we show the sign-in and sign-up links.
Now let's run all the tests

(.venv)$ python3 manage.py test
Found 22 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......................
----------------------------------------------------------------------
Ran 22 tests in 9.942s
OK
Destroying test database for alias 'default'...
Enter fullscreen mode Exit fullscreen mode

3. Test if everything is working as it should in our browser

Now that we've configured the login and logout functionality, it's time to test everything in our web browser. Let's start the development server

(.venv)$ python3 manage.py runserver
Enter fullscreen mode Exit fullscreen mode

Navigate to the registration page and enter valid credentials. After a successful registration, you should be redirected to the login page. Enter the user information in the login form, and once logged in, click the logout button. You should then be logged out and redirected to the homepage. Finally, verify that you're no longer logged in and that the sign-up and sign-in links are displayed again.
Everything works perfectly, but I noticed that when a user is logged in and visits the registration page at http://127.0.0.1:8000/users/sign_up/, they still have access to the registration form. Ideally, once a user is logged in, they shouldn't be able to access the sign-up page.
Description page
This behaviour can introduce several security vulnerabilities into our project. To address this, we need to update the SignUpView to redirect any logged-in user to the home page.
But first, let's update our LoginTest to add a new test that covers the scenario. So in the users/tests/test_views.py add this code.

# users/tests/test_views.py
class LoginTests(TestCase):
  # -- other test cases

  def test_visiting_registration_after_logged_in(self):
    """
    Logged in user should be redirected when visiting the registration page
    """
    response = self.client.post(reverse('users:login'), self.valid_credentials, follow=True)
    self.assertTrue(response.context['user'].is_authenticated)

    sign_up_resp = self.client.get(reverse('users:signup'))
    self.assertRedirects(sign_up_resp, reverse('home'))
Enter fullscreen mode Exit fullscreen mode

Now, we can update our SignUpView

# users/views.py
from django.conf import settings # new line
...

class SignUpView(CreateView):
  form_class = CustomUserCreationForm
  redirect_authenticated_user = True
  model = User
  success_url = reverse_lazy('users:login')
  template_name = 'registration/signup.html'

  def dispatch(self, request, *args, **kwargs):
    # Check if a user is already authenticated
    if request.user.is_authenticated:
      # Redirect the user to the login redirect url
      return redirect(f'{settings.LOGIN_REDIRECT_URL}')
    return super().dispatch(request, *args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

In the code above, we override the dispatch() method of our SignUpView to redirect any user who is already logged in and tries to access the registration page. This redirect will use the LOGIN_REDIRECT_URL set in our settings.py file, which in this case, points to the home page.
Okay! Once again, let's run all our tests to confirm that our updates are working as expected

(.venv)$ python3 manage.py test
Found 23 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......................
----------------------------------------------------------------------
Ran 23 tests in 11.215s

OK
Destroying test database for alias 'default'...
Enter fullscreen mode Exit fullscreen mode

I know there's much more to accomplish, but let's take a moment to appreciate what we've accomplished so far. Together, we've set up our project environment, connected a PostgreSQL database, and implemented a secure user registration and login system for our Django blog application. In the next part, we'll dive into creating a user profile page, enabling users to edit their information, and password reset! Stay tuned for more exciting developments as we continue our Django blog app journey!

Your feedback is always valued. Please share your thoughts, questions, or suggestions in the comments below. Don't forget to like, leave a comment, and subscribe to stay updated on the latest developments!

Top comments (0)