From 296c776d565205011f2dc98e5c2cc42d8f3c8b8f Mon Sep 17 00:00:00 2001 From: NtaleGrey Date: Fri, 7 Dec 2018 09:21:13 +0300 Subject: [PATCH] feat(verification link): Send a verification link This commit delivers a test for the generate_activation_link function. It tests whether the send_mail function is called when testing [Fixes #162163264] --- .DS_Store | Bin 0 -> 8196 bytes .gitignore | 2 + README.md | 2 +- .../migrations/0002_user_is_verified.py | 18 ++++ authors/apps/authentication/models.py | 2 + authors/apps/authentication/serializers.py | 4 + .../test/test_authentication.py | 49 ++++++++- .../authentication/test/test_verification.py | 97 ++++++++++++++++++ authors/apps/authentication/urls.py | 7 +- authors/apps/authentication/views.py | 87 ++++++++++++++-- .../apps/profiles/test/test_create_profile.py | 21 +++- authors/settings.py | 7 +- 12 files changed, 273 insertions(+), 23 deletions(-) create mode 100644 .DS_Store create mode 100644 authors/apps/authentication/migrations/0002_user_is_verified.py create mode 100644 authors/apps/authentication/test/test_verification.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bf311e3d0e9b0b83cf307e6d1436256134ce0075 GIT binary patch literal 8196 zcmeHMTWl0n7(U-p=u9WTv{qWw&2A~T;3`{M+8_$cZmYKC(y%Qp*LHSigo)FcvNO8{ zYPBR9i5m4u1)@CoVnlrsZ%^JHB>E&Z2I7k$@$#T1_(Eb#{Lh@(rWa~_AcllF$(;Y3 z|91BO=R4=jIm;MBx~MlXR?ird=;2gVsJcPna{kOIk$_V{6lBkGqwYw~a&pwaI7d5V zgc%4k5N06EK$w9r1Ghp3XwT+FnrGh^(y$IQ5N6=E%m7~>;`DHu3h0EO{?b8}e*_@P zj{tt5w#pks;{i24+JA4IHABFo&3jr zb%vCnVI5{5%)s0X@aa>*?q`RY$-L?3_uPnOx?VE*Jt7M#7A~rkq$;Uuan-@>5oa{( zXb}9DI+c@B9r9+uapCns}yB%w5xmjj-9&(Rki3EyA4b8%#zWrd4@Zv zE_Er|&b(vy6pRu@)}ME*;;>DU>bz#YuIZVMy?4Mfj(J?h z9nBC8>~o!hm+_qqnB-q*)$lQPjc{Xkk5O=m%JTUob+MIe@7vJ!V5(zssajp5)GEvR zOQvh)ETeDK)V(8nH8*eAx@jNYM;Wy}^D)yX$kj;5y|y-NOxDydzw7RMqS4vfWiJE8 zoO!fpddJm9NfKi;L>p!Gf^gGV+b1Z&|#tg7e$mXhNtlip2aD=h*$9%&fqNG#5;Hw@8LsyiqG%` zzQiT`fS>R)e!;J}hQA^e5jj#LerKeN^`3GZ0l${$)MO^C2bXb?vp2?i?2$)1ySg*C zEY>eC;Q+s*_ReK>@m1^Aw`|&yo`S)wH$NSLKIjChe6*F(`-oR1Q7SUA>075t~AbY73vTtxF*nwEdQsp+O efKCWX94i0&MF4#nPJP0L`+vCq`zXBqs(%3bWh9mW literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index d8ac1da..0104cba 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,5 @@ db.sqlite3 # vscode directory .vscode/ +.DS_Store +*.swp diff --git a/README.md b/README.md index bc109c8..dda6fb2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Build Status](https://travis-ci.org/andela/ah-backend-athena.svg?branch=ch-travis-readme-badge-162163256)](https://travis-ci.org/andela/ah-backend-athena) -[![Coverage Status](https://coveralls.io/repos/github/andela/ah-backend-athena/badge.svg?branch=ch-coveralls-integration-162163259)](https://coveralls.io/github/andela/ah-backend-athena?branch=ch-coveralls-integration-162163259) +[![Coverage Status](https://coveralls.io/repos/github/andela/ah-backend-athena/badge.svg?branch=develop)](https://coveralls.io/github/andela/ah-backend-athena?branch=develop) Authors Haven - A Social platform for the creative at heart. ======= diff --git a/authors/apps/authentication/migrations/0002_user_is_verified.py b/authors/apps/authentication/migrations/0002_user_is_verified.py new file mode 100644 index 0000000..dee2b62 --- /dev/null +++ b/authors/apps/authentication/migrations/0002_user_is_verified.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-12-05 16:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_verified', + field=models.BooleanField(default=False), + ), + ] diff --git a/authors/apps/authentication/models.py b/authors/apps/authentication/models.py index c26a91b..0bc150a 100644 --- a/authors/apps/authentication/models.py +++ b/authors/apps/authentication/models.py @@ -69,6 +69,8 @@ class User(AbstractBaseUser, PermissionsMixin): # but we can still analyze the data. is_active = models.BooleanField(default=True) + is_verified = models.BooleanField(default=False) + # The `is_staff` flag is expected by Django to determine who can and cannot # log into the Django admin site. For most users, this flag will always be # falsed. diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index 6331bc3..e84f3c9 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -71,6 +71,10 @@ def validate(self, data): raise serializers.ValidationError( 'A user with this email and password was not found.' ) + if user.is_verified == False: + raise serializers.ValidationError( + 'Your email is not verified, Please check your email for a verification link' + ) # Django provides a flag on our `User` model called `is_active`. The # purpose of this flag to tell us whether the user has been banned diff --git a/authors/apps/authentication/test/test_authentication.py b/authors/apps/authentication/test/test_authentication.py index 11a37cb..969b791 100644 --- a/authors/apps/authentication/test/test_authentication.py +++ b/authors/apps/authentication/test/test_authentication.py @@ -1,7 +1,8 @@ import json from django.urls import reverse from rest_framework.views import status -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APITestCase, APIClient, APIRequestFactory +from ..views import VerifyAccount, RegistrationAPIView from ..models import UserManager, User @@ -20,6 +21,17 @@ def generate_user(self, username='', email='', password=''): } return user + def verify_account(self, token, uidb64): + request = APIRequestFactory().get( + reverse( + "activate_account", + kwargs={ + "token": token, + "uidb64": uidb64})) + verify_account = VerifyAccount.as_view() + response = verify_account(request, token=token, uidb64=uidb64) + return response + def create_user(self, username='', email='', password=''): user = self.generate_user(username, email, password) self.client.post('/api/users/', user, format='json') @@ -31,10 +43,23 @@ def test_user_registration(self): response = self.client.post('/api/users/', user, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual( - json.loads(response.content), - {"user": {"email": "athena@gmail.com", "username": "athena"}} - ) - + json.loads( + response.content), { + "user": { + "message": "A verification email has been sent to athena@gmail.com"}}) + + def test_cannot_login_without_verification(self): + self.create_user('athena', 'athena@gmail.com', 'password@user') + login_details = self.generate_user( + '', 'athena@gmail.com', 'password@user') + response = self.client.post( + '/api/users/login/', login_details, format='json') + self.assertEqual( + json.loads( + response.content), { + "errors": { + "error": ["Your email is not verified, Please check your email for a verification link"]}}) + def test_user_registration_empty_details(self): user = self.generate_user('', '', '') response = self.client.post('/api/users/', user, format='json') @@ -49,6 +74,13 @@ def test_user_login(self): self.create_user('athena', 'athena@gmail.com', 'password@user') login_details = self.generate_user( '', 'athena@gmail.com', 'password@user') + request = APIRequestFactory().post( + reverse("registration") + ) + user = User.objects.get() + token, uidb64 = RegistrationAPIView.generate_activation_link( + user, request, send=False) + self.verify_account(token, uidb64) response = self.client.post( '/api/users/login/', login_details, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -81,6 +113,13 @@ def test_user_with_valid_token_access_protected_endpoints(self): self.create_user('soko', 'athena@gmail.com', 'Password@user1') login_details = self.generate_user( '', 'athena@gmail.com', 'Password@user1') + request = APIRequestFactory().post( + reverse("registration") + ) + user = User.objects.get() + token, uidb64 = RegistrationAPIView.generate_activation_link( + user, request, send=False) + self.verify_account(token, uidb64) response = self.client.post( '/api/users/login/', login_details, format='json') token = response.data['token'] diff --git a/authors/apps/authentication/test/test_verification.py b/authors/apps/authentication/test/test_verification.py new file mode 100644 index 0000000..b48d4e7 --- /dev/null +++ b/authors/apps/authentication/test/test_verification.py @@ -0,0 +1,97 @@ +import json +from unittest.mock import patch + +from django.urls import reverse +from django.core import mail +from rest_framework.test import APITestCase, APIRequestFactory +from rest_framework import status +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.utils.encoding import force_bytes, force_text + +from ..models import User +from ..views import RegistrationAPIView, VerifyAccount + + +class VerificationTestCase(APITestCase): + def setUp(self): + self.data = { + "user": { + "username": "Ntale", + "email": "shadik.ntale@andela.com", + "password": "AthenaD0a" + } + } + self.url = reverse('registration') + self.client.post(self.url, self.data, format='json') + self.request = APIRequestFactory().post( + reverse("registration")) + + def verify_account(self, token, uidb64): + request = APIRequestFactory().get( + reverse( + "activate_account", + kwargs={ + "token": token, + "uidb64": uidb64})) + verify_account = VerifyAccount.as_view() + response = verify_account(request, token=token, uidb64=uidb64) + return response + + def test_sends_email(self): + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + "Author's Heaven account email verification") + + def test_account_verified(self): + user = User.objects.get() + req = self.request + token, uidb64 = RegistrationAPIView.generate_activation_link( + user, req, send=False) + response = self.verify_account(token, uidb64) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + user = User.objects.get() + self.assertTrue(user.is_verified) + + def test_generate_activation_link_function(self): + user = User.objects.get() + request = self.request + token, uid = RegistrationAPIView.generate_activation_link(user, request, send=False) + self.assertFalse(token==None) + self.assertFalse(uid==None) + self.assertFalse(len(mail.outbox)==2) + + def test_send_activation_link(self): + with patch('authors.apps.authentication.views.send_mail') as mock_mail: + user = User.objects.get() + request = self.request + + RegistrationAPIView.generate_activation_link( + user, request, send=False) + self.assertFalse(mock_mail.called) + + token, uid = RegistrationAPIView.generate_activation_link( + user, request, send=True) + self.assertTrue(mock_mail.called) + mock_mail.assert_called_with( + subject="Author's Heaven account email verification", + from_email="athenad0a@gmail.com", + recipient_list=['shadik.ntale@andela.com'], + message="Please follow the following link to activate your account \n https://testserver/api/activate/{}/{}/".format(token, uid), + fail_silently=False) + + def test_invalid_verification_link(self): + request = self.request + + user = User.objects.get() + token, uid = RegistrationAPIView.generate_activation_link(user, request, send=False) + + # create the uid from a different username + uid = urlsafe_base64_encode(force_bytes("invalid_username")).decode("utf-8") + + response = self.verify_account(token, uid) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + user = User.objects.get() + # Ensure the user is not verified + self.assertFalse(user.is_verified) diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index 6f2ae8b..8ae878c 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -3,13 +3,12 @@ from .views import ( LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, - PasswordResetView, PasswordResetConfirmView, -) - + PasswordResetView, PasswordResetConfirmView, VerifyAccount) urlpatterns = [ path('user/', UserRetrieveUpdateAPIView.as_view()), - path('users/', RegistrationAPIView.as_view()), + path('users/', RegistrationAPIView.as_view(), name="registration"), path('users/login/', LoginAPIView.as_view()), path('password_reset/', PasswordResetView.as_view()), path('password_reset_confirm/', PasswordResetConfirmView.as_view()), + path('activate///', VerifyAccount.as_view(), name="activate_account"), ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index e4a491b..58e2594 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -1,7 +1,9 @@ -from .serializers import ( - LoginSerializer, RegistrationSerializer, UserSerializer -) from .renderers import UserJSONRenderer +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.utils.encoding import force_bytes, force_text +from django.contrib.sites.shortcuts import get_current_site + from rest_framework import status from rest_framework import generics from rest_framework.permissions import AllowAny, IsAuthenticated @@ -11,32 +13,37 @@ from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer ) -from authors.apps.profiles.models import Profile +from ..profiles.models import Profile +from .renderers import UserJSONRenderer + from .models import User -from django.contrib.auth.tokens import default_token_generator -from django.utils.http import ( - urlsafe_base64_decode, urlsafe_base64_encode, force_bytes, -) +from django.utils.http import force_bytes from django.contrib.auth.hashers import make_password from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer ) from django.contrib.sites.shortcuts import get_current_site -from authors.apps.profiles.models import Profile from django.core.mail import send_mail from django.conf import settings from datetime import timedelta from django.core.signing import TimestampSigner +from .models import User +from .renderers import UserJSONRenderer +from .serializers import ( + LoginSerializer, RegistrationSerializer, UserSerializer +) + + class RegistrationAPIView(GenericAPIView): # Allow any user (authenticated or not) to hit this endpoint. permission_classes = (AllowAny,) renderer_classes = (UserJSONRenderer,) serializer_class = RegistrationSerializer - def post(self, request): + def post(self, request, *args, **kwargs): user = request.data.get('user', {}) # The create serializer, validate serializer, save serializer pattern @@ -45,8 +52,41 @@ def post(self, request): serializer = self.serializer_class(data=user) serializer.is_valid(raise_exception=True) serializer.save() + address = serializer.data['email'] + user = User.objects.filter(email=user['email']).first() - return Response(serializer.data, status=status.HTTP_201_CREATED) + RegistrationAPIView.generate_activation_link(user, request) + return Response({"message": "A verification email has been sent to {}".format( + address)}, status=status.HTTP_201_CREATED) + + @staticmethod + def generate_activation_link(user, request=None, send=True): + """ + This method creates a custom actvation link that will be used when verifying + a user's email containing a token and a unique id. The generated token bares the user's identity + and the unique id is just an encoded byte string of the user's username + + """ + token = default_token_generator.make_token(user) + uidb64 = urlsafe_base64_encode( + force_bytes(user.username)).decode() + domain = 'https://{}'.format(get_current_site(request)) + subject = 'Author\'s Heaven account email verification' + route = "api/activate" + url = "{}/{}/{}/{}/".format(domain, route, token, uidb64) + message = 'Please follow the following link to activate your account \n {}'.format( + url) + from_email = settings.EMAIL_HOST_USER + to_list = [user.email] + if send: + send_mail( + subject=subject, + from_email=from_email, + recipient_list=to_list, + message=message, + fail_silently=False) + + return token, uidb64 class LoginAPIView(GenericAPIView): @@ -176,3 +216,28 @@ def update(self, request, **kwargs): ] } }, status=status.HTTP_400_BAD_REQUEST) + +class VerifyAccount(GenericAPIView): + + def get(self, request, token, uidb64): + """ + Here, I am verifying both the token and encoded byte string embeded in the activation link + by checking the token against the bearer username and also the encoded byte string + + """ + username = force_text(urlsafe_base64_decode(uidb64)) + + user = User.objects.filter(username=username).first() + validate_token = default_token_generator.check_token(user, token) + + data = {"message": "Your account has been verified, You can login now!"} + stat = status.HTTP_200_OK + + if not validate_token: + data['message'] = "Your activation link is Invalid or has expired." + stat = status.HTTP_400_BAD_REQUEST + else: + user.is_verified = True + user.save() + + return Response(data, status=stat) diff --git a/authors/apps/profiles/test/test_create_profile.py b/authors/apps/profiles/test/test_create_profile.py index 8d12a23..67172c7 100644 --- a/authors/apps/profiles/test/test_create_profile.py +++ b/authors/apps/profiles/test/test_create_profile.py @@ -1,8 +1,10 @@ import json from django.urls import reverse from rest_framework.views import status -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APITestCase, APIClient, APIRequestFactory from ..models import Profile +from ...authentication.models import User +from ...authentication.views import VerifyAccount, RegistrationAPIView class TestProfileCreate(APITestCase): @@ -16,9 +18,26 @@ def setUp(self): 'password': 'sokosoko' } } + def verify_account(self, token, uidb64): + request = APIRequestFactory().get( + reverse( + "activate_account", + kwargs={ + "token": token, + "uidb64": uidb64})) + verify_account = VerifyAccount.as_view() + response = verify_account(request, token=token, uidb64=uidb64) + return response def create_testing_user(self): self.client.post('/api/users/', self.user, format='json') + request = APIRequestFactory().post( + reverse("registration") + ) + user = User.objects.get() + token, uidb64 = RegistrationAPIView.generate_activation_link( + user, request, send=False) + self.verify_account(token, uidb64) response = self.client.post( '/api/users/login/', self.user, format='json') token = response.data['token'] diff --git a/authors/settings.py b/authors/settings.py index 36d9cfe..7a62285 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -152,6 +152,7 @@ ), } + SWAGGER_SETTINGS = { 'SECURITY_DEFINITIONS': { 'api_key': { @@ -163,7 +164,6 @@ } # Activate Django-Heroku; must be at the very bottom -django_heroku.settings(locals()) EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = 'smtp.gmail.com' @@ -171,3 +171,8 @@ EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD', '') EMAIL_PORT = os.getenv('EMAIL_PORT', 587) EMAIL_USE_TLS = os.getenv('TLS', True) + + + +# Activate Django-Heroku; must be at the very bottom +django_heroku.settings(locals())