Skip to content

Commit

Permalink
fix #197 : feat(backend): Added mail verification (#205)
Browse files Browse the repository at this point in the history
* feat(backend): Added mail verification

* feat: Added a frontend page to activate and redirect

* feat: added tests for mail verification

* fix: nits

* fix: minor mistake

* feat: limit to post

* fix: tests

* fix: test assert inactive acc
  • Loading branch information
Om-Thorat authored Jul 10, 2024
1 parent cfc0efd commit 52d1e78
Show file tree
Hide file tree
Showing 12 changed files with 183 additions and 12 deletions.
1 change: 1 addition & 0 deletions backend/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DJANGO_ADMINS=
FRONTEND_URL=
18 changes: 18 additions & 0 deletions backend/accounts/migrations/0002_alter_account_is_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2 on 2024-07-05 08:18

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='account',
name='is_active',
field=models.BooleanField(default=False),
),
]
2 changes: 1 addition & 1 deletion backend/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Account(AbstractBaseUser):
is_admin = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False)
is_superadmin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
is_active = models.BooleanField(default=False)

USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['first_name', 'last_name']
Expand Down
7 changes: 7 additions & 0 deletions backend/accounts/templates/activation_mail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% autoescape off %}
Hi {{ user.username }},

Please click on the link below to confirm your registration:

{{ protocol }}://{{ domain }}/activate?uuid={{uuid}}&token={{token}}
{% endautoescape %}
42 changes: 40 additions & 2 deletions backend/accounts/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import pytest
from rest_framework.test import APIClient
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework import status
from rest_framework.test import APIClient

from .models import Account
from .token import account_activation_token

account_data = {
"email": "[email protected]",
Expand Down Expand Up @@ -40,6 +44,9 @@ def testCreateAccountView_validAccountDetails_accountCreationSuccesful():
assert 'password' not in response.data
assert Account.objects.get().password != account_data['password']

# check that the account is inactive initially
assert not Account.objects.get().is_active


@pytest.mark.django_db
@pytest.mark.parametrize('field', ['email', 'password', 'first_name', 'last_name'])
Expand Down Expand Up @@ -72,6 +79,37 @@ def testCreateAccountView_invalidOrDuplicateEmail_returnsBadRequest(new_email):
assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
def testActivate_invalidTokenOrUid_returnsBadRequest():
# Arrange
client = APIClient()
account = Account.objects.create(**account_data)
token = account_activation_token.make_token(account)
uid = urlsafe_base64_encode(force_bytes(account.email))

# Act
response = client.post(f'/api/accounts/activate/{uid}/malformed{token}')
print(response.content,response)
# Assert
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Account.objects.get().is_active


@pytest.mark.django_db
def testActivate_validTokenAndUid_accountActivated():
# Arrange
client = APIClient()
account = Account.objects.create(**account_data)
token = account_activation_token.make_token(account)
uid = urlsafe_base64_encode(force_bytes(account.email))

# Act
response = client.post(f'/api/accounts/activate/{uid}/{token}')

# Assert
assert response.status_code == status.HTTP_200_OK
assert Account.objects.get().is_active

@pytest.mark.django_db
def testRetrieveLoggedInAccountView_userLoggedIn_returnsAccountDetails(api_client):
# Act
Expand Down Expand Up @@ -119,4 +157,4 @@ def testUpdateLogInAccountView_userNotLoggedIn_returnsUnauthorized():
response = client.put('/api/accounts/me/', {}, format='json')

# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.status_code == status.HTTP_401_UNAUTHORIZED
9 changes: 9 additions & 0 deletions backend/accounts/token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator

class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp):
return (
str(user.email) + str(timestamp) + str(user.is_active)
)

account_activation_token = AccountActivationTokenGenerator()
3 changes: 2 additions & 1 deletion backend/accounts/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.urls import path
from .views import CreateAccountView, RetrieveUpdateLoggedInAccountView
from .views import CreateAccountView, RetrieveUpdateLoggedInAccountView,ActivateAccountView

urlpatterns = [
path('', CreateAccountView.as_view()),
path('me/', RetrieveUpdateLoggedInAccountView.as_view()),
path('activate/<uidb64>/<token>', ActivateAccountView.as_view(), name='activate'),
]
51 changes: 45 additions & 6 deletions backend/accounts/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
import logging

from django.conf import settings
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from rest_framework.generics import CreateAPIView
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .serializers import AccountSerializer
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import Account
from django.shortcuts import get_object_or_404
from .serializers import AccountSerializer
from .token import account_activation_token

logger = logging.getLogger(__name__)

from django.core.mail import send_mail


def activateEmail(request, user, to_email):
mail_subject = 'Activate your user account.'
message = render_to_string('activation_mail.html', {
'user': user.first_name,
'domain': "localhost:3000" if settings.DEBUG else settings.FRONTEND_URL,
'uuid': urlsafe_base64_encode(force_bytes(user.email)),
'token': account_activation_token.make_token(user),
'protocol': 'https' if request.is_secure() else 'http'
})
email = send_mail(mail_subject, message,settings.EMAIL_HOST_USER ,[to_email])
logger.info(f"Email sent to {to_email} with response {email}")

class CreateAccountView(CreateAPIView):
""" View to create accounts """
queryset = Account.objects.all()
serializer_class = AccountSerializer

def perform_create(self, serializer):
instance = serializer.save()
activateEmail(self.request, instance, instance.email)


class RetrieveUpdateLoggedInAccountView(APIView):
""" View to retrieve and change logged in account """
Expand All @@ -30,7 +57,19 @@ def put(self, request):
return Response(serializer.data)
return Response(serializer.errors)

def get_account(email) :
return get_object_or_404(Account, email=email)


def get_account(email) :
return get_object_or_404(Account, email=email)

class ActivateAccountView(APIView):
def post(self, request, uidb64, token):
uid = force_str(urlsafe_base64_decode(uidb64))
account = get_account(uid)
if account and account_activation_token.check_token(account, token):
account.is_active = True
account.save()
return Response('Account activated successfully',status=200)
else:
return Response('Error while activating',status=400)

2 changes: 2 additions & 0 deletions backend/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

DEBUG = os.environ.get('DEBUG') != 'False'

FRONTEND_URL = os.environ.get('FRONTEND_URL')

ALLOWED_HOSTS = ['*']

CORS_ORIGIN_ALLOW_ALL = False
Expand Down
14 changes: 13 additions & 1 deletion frontend/pages/Signin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,12 @@
import { useAuthStore } from "../store/auth";
import { useRouter } from "vue-router";
import { extractPath } from "~/utils/url";
import { toast } from "vue3-toastify";
export default {
name: "Signin",
setup() {
const credentials = {
email: "",
Expand All @@ -111,7 +115,6 @@ export default {
if (store.isAuthenticated) {
router.push(last);
}
const submitForm = async (e) => {
e.preventDefault();
const data = await store.login(credentials);
Expand All @@ -127,6 +130,15 @@ export default {
handleSignup,
};
},
mounted() {
const router = useRouter();
if(router.currentRoute.value.query.msg){
toast.success(router.currentRoute.value.query.msg,{
autoClose: 2000,
position: toast.POSITION.BOTTOM_CENTER,
});
}
}
};
</script>
<style scoped>
Expand Down
44 changes: 44 additions & 0 deletions frontend/pages/activate.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col items-center justify-center h-[80svh] gap-4">
<p class="font-semibold text-4xl">{{ activationStatus }}</p>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from "vue-router";
const config = useRuntimeConfig();
const activationStatus = ref('');
const router = useRouter();
async function activateUser() {
const urlParams = new URLSearchParams(window.location.search);
const uuid = urlParams.get('uuid');
const token = urlParams.get('token');
let status = '';
try {
const response = await fetch(
`${config.public.API_BASE_URL}/api/accounts/activate/${uuid}/${token}`,
{ method: "POST" }
);
status = await response.text();
if (response.status === 200) {
router.push({ path: '/signin', query: { msg: 'account activated' } });
} else {
status = 'Activation failed';
}
} catch (error) {
console.error(error);
status = 'Activation failed';
}
return status;
}
onMounted(async () => {
const status = await activateUser();
activationStatus.value = status +' !!';
});
</script>
2 changes: 1 addition & 1 deletion frontend/store/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const useAuthStore = defineStore("auth", {
autoClose: 2000,
position: toast.POSITION.BOTTOM_CENTER,
});
await navigateTo(redirect);
await navigateTo({path:redirect,query:{msg:"Activation link sent to your mail"}});
} else {
Object.entries(error.value.data).forEach(([field, errorMessages]) => {
console.log(field, errorMessages);
Expand Down

0 comments on commit 52d1e78

Please sign in to comment.