Introduction
Having made the endpoints for users' logins and logouts, we'll look at registering users. But before then, we'll configure the admin page of our application to ensure that the required fields are shown there for managing users.
Assumption and Recommendation
It is assumed that you are familiar with Django. I also recommend you go through how we created the front end of the previous series as we'll only change a very few things there and will not delve much into how we pieced everything together. The APIs we'll build here mirror what we built in that series.
Source code
The source code for this series is hosted on GitHub via:
Sirneij / django-auth-backend
Django session-based authentication system with SvelteKit frontend
django-auth-backend
Django session-based authentication system with SvelteKit frontend and GitHub actions-based CI.
This app uses minimal dependencies (pure Django - no REST API framework) to build a secure, performant and reliable (with 100% automated test coverage, enforced static analysis using Python best uniform code standards) session-based authentication REST APIs which were then consumed by a SvelteKit-based frontend Application.
Users' profile images are uploaded directly to AWS S3 (in tests, we ditched S3 and used Django's InMemoryStorage for faster tests).
A custom password reset procedure was also incorporated, and Celery tasks did email sendings.
Run locally
-
To run the application, clone it:
git clone https://github.com/Sirneij/django-auth-backend.git
You can, if you want, grab its frontend counterpart.
-
Change the directory into the folder and create a virtual environment using either Python 3.9, 3.10 or 3.11 (tested against the three versions). Then activate it:
~django-auth-backend$ virtualenv -p python3.11 virtualenv ~django-auth-backend$ source virtualenv/bin/activate
…
Implementation
Step 1: Configure users/admin.py
for our custom User
model
At present, the admin page of our project will most likely configure our User
model incorrectly, if it's configured at all. We need to correct that. Open users/admin.py
and populate it with:
# src/users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from users.models import User, UserProfile
class CustomUserAdmin(UserAdmin):
model = User
list_display = (
'email',
'is_staff',
'is_active',
)
list_filter = (
'email',
'is_staff',
'is_active',
)
fieldsets = (
(None, {'fields': ('email', 'password', 'thumbnail')}),
('Permissions', {'fields': ('is_staff', 'is_active')}),
)
add_fieldsets = (
(
None,
{'classes': ('wide',), 'fields': ('email', 'password1', 'password2', 'thumbnail', 'is_staff', 'is_active')},
),
)
search_fields = ('email',)
ordering = ('email',)
admin.site.register(User, CustomUserAdmin)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin): # type:ignore
list_display = ('user_email', 'phone_number', 'github_link', 'birth_date')
@admin.display(ordering="user__email")
def user_email(self, obj: UserProfile) -> str:
"""Return the user's email."""
return obj.user.email # pragma: no cover
We are overriding the built-in UserAdmin
, providing our custom fields in the important subsections. I recommend reading through the documentation. We also register our second model, UserProfile
.
You can create a superuser now and access the admin site.
Step 2: Registering users
It's time to create a user registration endpoint. The flow will be just like that of the previous series:
Users register with email addresses, first and last names, and passwords. Then emails are sent. Clicking the links embedded in the emails activates such users.
Let's implement that in Django. As previously incepted, create register.py
file in users/views/
folder:
# src/users/views/register.py
import json
from datetime import timedelta
from typing import Any
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest, JsonResponse
from django.urls.base import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from users.tasks import send_email_message
from users.token import account_activation_token
from users.utils import validate_email
@method_decorator(csrf_exempt, name='dispatch')
class RegisterView(View):
async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Register users."""
data = json.loads(request.body.decode('utf-8'))
email = data.get('email')
first_name = data.get('first_name')
last_name = data.get('last_name')
password = data.get('password')
# Some validations
if email is None or password is None or first_name is None or last_name is None:
return JsonResponse(
{'error': 'All fields are required: email, first_name, last_name, password'}, status=400
)
is_valid, error_text = validate_email(email)
if not is_valid:
return JsonResponse({'error': error_text}, status=400)
user_exists = await get_user_model().objects.filter(email=email).aexists()
if user_exists:
return JsonResponse({'error': 'A user with that email address already exists'}, status=400)
user = await sync_to_async(get_user_model().objects.create_user)(
email=email,
password=password,
first_name=first_name,
last_name=last_name,
)
user.is_active = False
await user.asave(update_fields=['is_active'])
token = await sync_to_async(account_activation_token.make_token)(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
confirmation_link = (
f"{request.scheme}://{get_current_site(request)}"
f"{reverse('users:confirm', kwargs={'uidb64': uid, 'token': token})}",
)
subject = 'Please, verify your account'
ctx = {
'title': "(Django) RustAuth - Let's get you verified",
'domain': settings.FRONTEND_URL,
'confirmation_link': confirmation_link,
'expiration_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).minute,
'exact_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).strftime(
'%A %B %d, %Y at %r'
),
}
send_email_message.delay(
subject=subject,
template_name='verification_email.html',
user_id=user.id,
ctx=ctx,
)
return JsonResponse(
{
'message': 'Your account was created successfully. '
'Check your email address to activate your account as we '
'just sent you an activation link. Ensure you activate your '
'account before the link expires'
},
status=201,
)
As usual, we retrieved the data from our request object and made some validations. For email address validation, we used a custom-made util function, validate_email
, in users/utils.py
:
# src/users/utils.py
from django.core.exceptions import ValidationError
from django.core.validators import validate_email as django_validate_email
def validate_email(value: str) -> tuple[bool, str]:
"""Validate a single email."""
message_invalid = 'Enter a valid email address.'
if not value:
return False, message_invalid
# Check the regex, using the validate_email from django.
try:
django_validate_email(value)
except ValidationError:
return False, message_invalid
return True, ''
It is pretty basic. Uses Django's validate_email
to make such validations. If the email address is valid, we also checked whether or not a user with such an email address existed. If no such user existed, we proceeded to create a user with the provided credentials and deactivate such a user with is_active
set to False
. Next, we created a token for the user using the custom account_activation_token
defined in users/token.py
:
# src/users/token.py
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from six import text_type
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user: AbstractBaseUser, timestamp: int) -> str:
return text_type(user.pk) + text_type(timestamp) + text_type(user.is_active)
account_activation_token = AccountActivationTokenGenerator()
which simply subclasses Django's PasswordResetTokenGenerator
. We overrode the class's _make_hash_value
so that the user's specific details will be included in the generated token. Next, we encoded the user's id and formed a confirmation link out of the token and the encoded id. This link is the most crucial thing we sent to the user's email address using the celery task, send_email_message
:
# src/users/tasks.py
from typing import Any
from uuid import UUID
from celery import shared_task
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
@shared_task
def send_email_message(subject: str, template_name: str, user_id: UUID, ctx: dict[str, Any]) -> None:
"""Send email to users."""
html_message = render_to_string(template_name, ctx)
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[get_user_model().objects.get(id=user_id).email],
fail_silently=False,
html_message=html_message,
)
The task takes the user's id and other context data. From these data, we rendered the HTML template to be sent and used Django's send_mail
to dispatch it.
The confirmation link sent was timed for expiration using the PASSWORD_RESET_TIMEOUT
settings variable. When the user clicks the link, it redirects them to this view:
# src/users/views/confirm.py
from django.conf import settings
from django.contrib.auth import get_user_model
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
from django.views import View
from users.token import account_activation_token
class ConfirmEmailView(View):
async def get(self, request: HttpRequest, uidb64: str, token: str) -> HttpResponseRedirect:
"""Confirm and activate user emails and accounts."""
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = await get_user_model().objects.aget(pk=uid, is_active=False)
except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
user = None
if user is not None and account_activation_token.check_token(user, token):
user.is_active = True
await user.asave(update_fields=['is_active'])
return HttpResponseRedirect(f'{settings.FRONTEND_URL}/auth/confirmed')
return HttpResponseRedirect(
f'{settings.FRONTEND_URL}/auth/regenerate-token?reason=It appears that'
'your confirmation token has expired or previously used. Kindly generate a new token',
)
The view tries to retrieve the user's id from the uidb64
parameter. Remember that it was this parameter that we encoded the user's id in the RegisterView
. Having retrieved the id, we then tried to get a user with that id who has not been activated. If such a user exists and the user's token is valid, we activate such a user and redirect to the frontend page where a confirmation message is displayed. If otherwise, we prompted the user to a token regeneration page where an email is submitted and a token is regenerated for such a user if everything goes well:
# src/users/views/regenerate.py
import json
from datetime import timedelta
from typing import Any
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest, JsonResponse
from django.urls.base import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from users.tasks import send_email_message
from users.token import account_activation_token
from users.utils import validate_email
@method_decorator(csrf_exempt, name='dispatch')
class RegenerateTokenView(View):
async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Regenerate tokens to unverified users."""
data = json.loads(request.body.decode("utf-8"))
email = data.get('email')
if email is None:
return JsonResponse({'error': 'Email field is empty'}, status=400)
is_valid, error_text = validate_email(email)
if not is_valid:
return JsonResponse({'error': error_text}, status=400)
try:
user = await get_user_model().objects.filter(email=email, is_active=False).aget()
except get_user_model().DoesNotExist:
return JsonResponse(
{
'error': "A user with this e-mail address does not exist. "
"If you registered with this email, ensure you haven't activated it yet. "
"You can check by logging in"
},
status=404,
)
token = await sync_to_async(account_activation_token.make_token)(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
confirmation_link = (
f"{request.scheme}://{get_current_site(request)}"
f"{reverse('users:confirm', kwargs={'uidb64': uid, 'token': token})}",
)
subject = 'Please, verify your account'
ctx = {
'title': "(Django) RustAuth - Let's get you verified",
'domain': settings.FRONTEND_URL,
'confirmation_link': confirmation_link,
'expiration_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).minute,
'exact_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).strftime(
'%A %B %d, %Y at %r'
),
}
send_email_message.delay(
subject=subject,
template_name='verification_email.html',
user_id=user.id,
ctx=ctx,
)
return JsonResponse(
{
'message': 'Account activation link has been sent to your email address. '
'Kindly take action before its expiration'
},
status=200,
)
Token regeneration is almost like the registration page aside from the fact that the user ain't created but an inactive user with the provided email was retrieved from the database. The token was then generated and sent to the user.
It's time to register all these views now:
# src/users/urls.py
from users.views import (
confirm, # new
login,
logout,
regenerate, # new
register, # new
)
...
urlpatterns = [
path('register/', register.RegisterView.as_view(), name='register'), # new
path('register/confirm/<uidb64>/<token>/', confirm.ConfirmEmailView.as_view(), name='confirm'), # new
path('regenerate-token/', regenerate.RegenerateTokenView.as_view(), name='regenerate'), # new
...
]
You can now test your endpoints with Postman or, if you use VS Code, Thunder Client for VS Code.
Before we go, let's have another endpoint that retrieves the currently logged-in user:
# src/users/views/current_user.py
import json
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, JsonResponse
from django.views import View
from users.models import UserProfile
class CurrentUserView(View, LoginRequiredMixin):
def get(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
"""Get current user via session."""
if not request.user.is_authenticated:
return JsonResponse(
{'error': 'You are not logged in. Kindly ensure you are logged in and try again'}, status=401
)
user_details = UserProfile.objects.filter(user=request.user).select_related('user').get()
res_data = {
'id': str(user_details.user.pk),
'email': user_details.user.email,
'first_name': user_details.user.first_name,
'last_name': user_details.user.last_name,
'is_staff': user_details.user.is_staff,
'is_active': user_details.user.is_active,
'date_joined': str(user_details.user.date_joined),
'thumbnail': user_details.user.thumbnail.url if user_details.user.thumbnail else None,
'profile': {
'id': str(user_details.id),
'user_id': str(user_details.user.pk),
'phone_number': user_details.phone_number,
'github_link': user_details.github_link,
'birth_date': str(user_details.birth_date) if user_details.birth_date else None,
},
}
response_data = json.loads(json.dumps(res_data))
return JsonResponse(response_data, status=200)
It's a pretty simple view and it's our first synchronous view. I settled for that because request.user
is synchronous. In the finished code on GitHub, I used sync_to_async
decorator for the view. You can now add this view to your users/urls.y
file.
That's it for this article. In the next one, we'll talk about updating user data and changing users' passwords. See you then...
Outro
Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...
Top comments (0)