Django Expense Manager 6 - Fixing the Registration/Authentication System

Long time! I’m back into posting regularly. New job is fun and all, but I love working on these side projects and I want to keep going. Today’s post was initially going to be about implementing new endpoints to be able to read our Categories and to be able to add new ones. I even have half a draft for the post but…I realised that while the tests worked for the registration and email verification, doing it on the browser actually led to getting a ….

ImproperlyConfigured
TemplateResponseMixin requires either a definition of 'template_name' or an implementation of 'get_template_names()'

There’s a discussion here which links to similar discussion in some of the posts. Still, let’s fix it quickly so we can continue expanding. So what do we need?

Ok let’s start by having a quick look at our users/urls.py file

from django.urls import path, include, re_path
from dj_rest_auth.registration.views import VerifyEmailView, ConfirmEmailView

urlpatterns = [
    path('', include('dj_rest_auth.urls')),
    path('register/', include('dj_rest_auth.registration.urls')),
    path('verify-email/',
         VerifyEmailView.as_view(), name='rest_verify_email'),
    path('account-confirm-email/<str:key>/', ConfirmEmailView.as_view(), name="account_confirm_email"),
    path('account-confirm-email/',
         VerifyEmailView.as_view(), name='account_email_verification_sent'),
]

and at our expenses_backend/urls.py (i.e. the project URLs)

from django.urls import path, include, re_path
from dj_rest_auth.registration.views import VerifyEmailView, ConfirmEmailView

from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path('mysupersecretnotadmin/', admin.site.urls),
    path('users/', include('users.urls')),
    path("api/", include("expenses.api.urls")),
]

So we have all our dj_rest_auth urls accessible at: DOMAIN/users/. Then we have the registration endpoint at DOMAIN/users/register/. If we wanted to register for it, we would POST a JSON body to is such as:

{
    "email": "frodo@mordor.lava",
    "password1": "speakfriend",
    "password2": "speakfriend"
}

Ok but we’ve had this before, so it’s just a reminder. What else do we have in the users/urls.py? Well we have 3 endpoints that are necessary. Feel free to first read the comments here in the dj-rest-auth repository. Are you back? Ok so:

    path('verify-email/',
         VerifyEmailView.as_view(), name='rest_verify_email'),

is a view where you can POST the key you received in the email. For example, after registering, the email you get contains something like (I obviously haven’t configured any email settings yet…):

Hello from localhost!

You’re receiving this e-mail because user v11 has given your e-mail address to register an account on localhost:8000.

To confirm this is correct, go to http://localhost:8000/users/account-confirm-email/SUPERSECRETKEY/

Thank you for using localhost! localhost:8000

So then you can post a body like:

{"key": "SUPERSECRETKEY"}

and that will confirm your account. But wait, what’s the point of that link there? Well, we can use a GET request to verify the email. In order for that to work though, we need the following:

    path('account-confirm-email/<str:key>/', ConfirmEmailView.as_view(), name="account_confirm_email"),
    path('account-confirm-email/',
         VerifyEmailView.as_view(), name='account_email_verification_sent'),

Why? Well if we look at VerifyEmailView in dj-rest-auth we see that it inherits from allauth’s ConfirmEmailView. Fine, let’s check them out. Remember that in their documentation, it says that the verification email points to the “allauth.account.views.ConfirmEmailView” here. Two tricky steps I won’t go into detail (not important) but the important part is getting to the DefaultAccountAdapter which has a def get_login_redirect_url(self, request): method. If someone is not verified, it redirects them to the account confirm email view. For the account_confirm_email endpoint, that’s used in their get email confirmation url to build the link.

Ok now that we have both of these, we can actually just click on the link in the email and confirm our account.

Is that something we needed? Honestly, no. Why did I have this then? Well, some people might come across this problem and this (a quarter) explains how to fix it.

But why didn’t you catch that before Vlad? I thought you said you tested your code and added tests! Well, the tests only test what we ask them to test. Let’s add one to verify both these registration flows:

  1. POST registration data, get key from email, POST key to verify-email endpoint. THIS WE HAVE
  2. POST registration data, get link from email, GET request to that URL. -> This we did not. Let’s write the test.
@pytest.mark.django_db
def test_registration_verification_and_authentication_with_get_endpoint(api_client):
    current_user_count: int = User.objects.all().count()
    current_mail_count: int = len(mail.outbox)

    email: str = 'suchgoodemail2@aws.moon'
    password: str = 'amazingpassword2'
    registration_data: Dict[str, str] = {
        'email': email,
        'password1': password,
        'password2': password
    }

    register_url: str = reverse('rest_register')
    response: Response = api_client.post(
        register_url,
        data=registration_data
    )
    # check that the user was created successfully
    assert response.status_code == status.HTTP_201_CREATED
    # check that the user count increased by one
    assert User.objects.all().count() == (current_user_count + 1)
    assert len(mail.outbox) == (current_mail_count + 1)
    assert 'key' not in response.data

    # if we try to log in without verifying should get HTTP 400
    login_url: str = reverse('rest_login')
    login_response: Response = api_client.post(
        login_url,
        data={
            'email': email,
            'password': password,
        }
    )
    assert login_response.status_code == status.HTTP_400_BAD_REQUEST

    email_body: str = mail.outbox[-1].body
    start_of_link: int = email_body.find("http")  # not adding more in case we want to later add https
    link: str = email_body[start_of_link:].split("\n")[0]
    # now let's GET request that and we should have been redirected
    response: Response = api_client.get(link)
    assert response.status_code == 302

    redirect_url: str = response.url
    assert (redirect_url + "/").endswith(login_url)
    
    # now let's try logging in again
    login_response: Response = api_client.post(
        login_url,
        data={
            'email': email,
            'password': password,
        }
    )
    assert login_response.status_code == status.HTTP_200_OK

    # check if we got an access token
    assert 'access_token' in login_response.data

The first few parts are the same but now we get the whole link that’s in the email. We then perform a GET request to that link and assert we get a 302 Found response code. We check that we are redirected to the login page and we are. Finally, we try logging in again and since we just activated the account, life is good again :)

The end!

In the next post and I’m working on it we’ll look at how the categories serializers and validators will work. After that, we’ll look at some more basic Django stuff first, specifically how it works with JSON data in JSONFields.

The commit for this post is here