Django REST-Auth Password Reset

Luckily, I found a nice library which made my life so easy today:

https://github.com/anx-ckreuzberger/django-rest-passwordreset

pip install django-rest-passwordreset

Got it working like this:

  1. Followed instructions on their website.

My accounts/urls.py now has the following paths:

# project/accounts/urls.py
from django.urls import path, include
from . import views as acc_views

app_name = 'accounts'
urlpatterns = [
    path('', acc_views.UserListView.as_view(), name='user-list'),
    path('login/', acc_views.UserLoginView.as_view(), name='login'),
    path('logout/', acc_views.UserLogoutView.as_view(), name='logout'),
    path('register/', acc_views.CustomRegisterView.as_view(), name='register'),
    # NEW: custom verify-token view which is not included in django-rest-passwordreset
    path('reset-password/verify-token/', acc_views.CustomPasswordTokenVerificationView.as_view(), name='password_reset_verify_token'),
    # NEW: The django-rest-passwordreset urls to request a token and confirm pw-reset
    path('reset-password/', include('django_rest_passwordreset.urls', namespace='password_reset')),
    path('<int:pk>/', acc_views.UserDetailView.as_view(), name='user-detail')
]

Then I also added a little TokenSerializer for my CustomTokenVerification:

# project/accounts/serializers.py
from rest_framework import serializers

class CustomTokenSerializer(serializers.Serializer):
    token = serializers.CharField()

Then I added a Signal Receiver in the previous derived CustomPasswordResetView, which now is no longer derived from rest_auth.views.PasswordResetView AND added a new view CustomPasswordTokenVerificationView:

# project/accounts/views.py
from django.dispatch import receiver
from django_rest_passwordreset.signals import reset_password_token_created
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from vuedj.constants import site_url, site_full_name, site_shortcut_name
from rest_framework.views import APIView
from rest_framework import parsers, renderers, status
from rest_framework.response import Response
from .serializers import CustomTokenSerializer
from django_rest_passwordreset.models import ResetPasswordToken
from django_rest_passwordreset.views import get_password_reset_token_expiry_time
from django.utils import timezone
from datetime import timedelta

class CustomPasswordResetView:
    @receiver(reset_password_token_created)
    def password_reset_token_created(sender, reset_password_token, *args, **kwargs):
        """
          Handles password reset tokens
          When a token is created, an e-mail needs to be sent to the user
        """
        # send an e-mail to the user
        context = {
            'current_user': reset_password_token.user,
            'username': reset_password_token.user.username,
            'email': reset_password_token.user.email,
            'reset_password_url': "{}/password-reset/{}".format(site_url, reset_password_token.key),
            'site_name': site_shortcut_name,
            'site_domain': site_url
        }

        # render email text
        email_html_message = render_to_string('email/user_reset_password.html', context)
        email_plaintext_message = render_to_string('email/user_reset_password.txt', context)

        msg = EmailMultiAlternatives(
            # title:
            "Password Reset for {}".format(site_full_name),
            # message:
            email_plaintext_message,
            # from:
            "noreply@{}".format(site_url),
            # to:
            [reset_password_token.user.email]
        )
        msg.attach_alternative(email_html_message, "text/html")
        msg.send()


class CustomPasswordTokenVerificationView(APIView):
    """
      An Api View which provides a method to verifiy that a given pw-reset token is valid before actually confirming the
      reset.
    """
    throttle_classes = ()
    permission_classes = ()
    parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,)
    renderer_classes = (renderers.JSONRenderer,)
    serializer_class = CustomTokenSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        token = serializer.validated_data['token']

        # get token validation time
        password_reset_token_validation_time = get_password_reset_token_expiry_time()

        # find token
        reset_password_token = ResetPasswordToken.objects.filter(key=token).first()

        if reset_password_token is None:
            return Response({'status': 'invalid'}, status=status.HTTP_404_NOT_FOUND)

        # check expiry date
        expiry_date = reset_password_token.created_at + timedelta(hours=password_reset_token_validation_time)

        if timezone.now() > expiry_date:
            # delete expired token
            reset_password_token.delete()
            return Response({'status': 'expired'}, status=status.HTTP_404_NOT_FOUND)

        # check if user has password to change
        if not reset_password_token.user.has_usable_password():
            return Response({'status': 'irrelevant'})

        return Response({'status': 'OK'})

Now my frontend will provide an option to request the pw-reset link, so the frontend will send a post request to django like this:

// urls.js
const SERVER_URL = 'http://localhost:8000/' // FIXME: change at production (https and correct IP and port)
const API_URL = 'api/v1/'
const API_AUTH = 'auth/'
API_AUTH_PASSWORD_RESET = API_AUTH + 'reset-password/'


// api.js
import axios from 'axios'
import urls from './urls'

axios.defaults.baseURL = urls.SERVER_URL + urls.API_URL
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.defaults.xsrfCookieName = 'csrftoken'

const api = {
    get,
    post,
    patch,
    put,
    head,
    delete: _delete
}

function post (url, request) {
    return axios.post(url, request)
        .then((response) => Promise.resolve(response))
        .catch((error) => Promise.reject(error))
}


// user.service.js
import api from '@/_api/api'
import urls from '@/_api/urls'

api.post(`${urls.API_AUTH_PASSWORD_RESET}`, email)
    .then( /* handle success */ )
    .catch( /* handle error */ )

And the created email will contain a link like this:

Click the link below to reset your password.

localhost:8000/password-reset/4873759c229f17a94546a63eb7c3d482e73983495fa40c7ec2a3d9ca1adcf017

... which is not defined in the django-urls by intention! Django will let every unknown url pass through and the vue router will decide if the url makes sense or not. Then I let the frontend send the token to see if it is valid, so the user can already see if the token is already used, expired, or whatever...

// urls.js
const API_AUTH_PASSWORD_RESET_VERIFY_TOKEN = API_AUTH + 'reset-password/verify-token/'

// users.service.js
api.post(`${urls.API_AUTH_PASSWORD_RESET_VERIFY_TOKEN}`, pwResetToken)
    .then( /* handle success */ )
    .catch( /* handle error */ )

Now the user will get an error message through Vue, or password-input fields, where they can finally reset the password, which will be sent by the frontend like this:

// urls.js
const API_AUTH_PASSWORD_RESET_CONFIRM = API_AUTH + 'reset-password/confirm/'

// users.service.js
api.post(`${urls.API_AUTH_PASSWORD_RESET_CONFIRM}`, {
    token: state[token], // (vuex state)
    password: state[password] // (vuex state)
})
.then( /* handle success */ )
.catch( /* handle error */ )

This is the main code. I used custom vue routes to decouple the django rest-endpoints from the frontend visible routes. The rest is done with api requests and handling their responses.

Hope this helps anybody who will have struggles like me in the future.