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.
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.
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",
]
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):
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']))
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')
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'...
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):
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)
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)
In the code above, we accomplish the following:
- Set the
form_class
Attribute: We specify our customLoginForm
as theform_class
attribute since we are no longer using the defaultAuthenticationForm
. -
Override the
form_valid
Method: We override theform_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 theremember_me
box is checked, the session will last for the duration defined insettings.py
. The default session length is two weeks, but we can modify this using theSESSION_COOKIE_AGE
variable insettings.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
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
]
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 = '/'
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'
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)
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 %}
We will add the Forgot Password functionality later in this series but now it’s just a dead link.
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>
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'...
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
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.
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'))
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)
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'...
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)