From 6e45506807445b6ddbf5cabe4689535dbde36981 Mon Sep 17 00:00:00 2001 From: Dorothy Asiimwe Date: Thu, 11 Apr 2019 21:13:39 +0300 Subject: [PATCH] Feat: Password Reset with Email - email with password reset link is sent to user - user clicks reset link - user updates password [Maintains #164798202] --- authors/apps/authentication/serializers.py | 34 ++++++++++ .../apps/authentication/tests/test_data.py | 21 ++++++ .../tests/test_password_reset.py | 62 ++++++++++++++++++ .../authentication/tests/test_serializer.py | 11 +++- authors/apps/authentication/urls.py | 6 +- authors/apps/authentication/views.py | 65 ++++++++++++++++++- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 authors/apps/authentication/tests/test_password_reset.py diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index e13079e..68d85e1 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -11,6 +11,11 @@ from .models import User from .social_registration import register_social_user +from django.core.mail import EmailMessage +from django.contrib.sites.shortcuts import get_current_site +from django.shortcuts import get_object_or_404 + + class RegistrationSerializer(serializers.ModelSerializer): """Serializers registration requests and creates a new user.""" @@ -214,6 +219,35 @@ def update(self, instance, validated_data): return instance +class ResetPasswordSerializer(serializers.Serializer): + + email = serializers.EmailField(allow_blank=False) + + def validate_email(self, data): + email = data.get('email', None) + if email is None: + raise serializers.ValidationError( + 'Email is required' + ) + return data + +class ConfirmPasswordSerializer(serializers.ModelSerializer): + password = serializers.CharField( + max_length=128, + min_length=8, + write_only=True + ) + + password2 = serializers.CharField( + max_length=128, + min_length=8, + write_only=True + ) + class Meta: + model = User + fields = ('password','password2') + + class SocialAuthSerializer(serializers.Serializer): """ Social Authentication parent class diff --git a/authors/apps/authentication/tests/test_data.py b/authors/apps/authentication/tests/test_data.py index 5b571b5..81af623 100644 --- a/authors/apps/authentication/tests/test_data.py +++ b/authors/apps/authentication/tests/test_data.py @@ -153,3 +153,24 @@ 'email': 'test@testmail.com', 'username': 'testname' } +reset_data = { + "email": "testuser2@gmail.com", +} +wrong_reset_email ={ + "email": "test_user2@gmail.com" +} + +password_reset_data = { + "user":{ + "password":"asiimweD1", + "password2":"asiimweD1" + + } +} +unmatched_password = { + "user":{ + "password":"asiimweD1", + "password2":"asiim" + + } +} \ No newline at end of file diff --git a/authors/apps/authentication/tests/test_password_reset.py b/authors/apps/authentication/tests/test_password_reset.py new file mode 100644 index 0000000..6fb49ca --- /dev/null +++ b/authors/apps/authentication/tests/test_password_reset.py @@ -0,0 +1,62 @@ +""" +Module to test password reset +""" +from unittest.mock import patch +from rest_framework import status +from django.urls import reverse +from rest_framework.test import APITestCase +from .test_base import BaseTestCase +from .test_data import test_user_data, reset_data, login_data, password_reset_data, unmatched_password, wrong_reset_email + + +class TestPasswordReset(BaseTestCase): + """ + class to handle user password reset + """ + def test_reset_email_link_sent(self): + + self.client.post(reverse('register-user'), + data=test_user_data, + format='json') + response = self.client.post(reverse('reset-password'), + data=reset_data, + format='json') + self.assertEquals(response.status_code,status.HTTP_200_OK) + self.assertEquals(response.data['message'],'Password reset link has been sent to your Email') + + def test_reset_email_does_not_exist(self): + + self.client.post(reverse('register-user'), + data=test_user_data, + format='json') + response = self.client.post(reverse('reset-password'), + data=wrong_reset_email, + format='json') + self.assertEquals(response.status_code,status.HTTP_400_BAD_REQUEST) + self.assertEquals(response.data['message'],"Email does not exist") + + def test_password_reset(self): + + res = self.client.post(reverse('register-user'), + data=test_user_data, + format='json') + token = res.data["token"] + response = self.client.put(reverse('password-change', kwargs={'token':token}), + data=password_reset_data, + format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['message'], "Password has been reset") + + def test_password_not_matching(self): + + res = self.client.post(reverse('register-user'), + data=test_user_data, + format='json') + token = res.data["token"] + response = self.client.put(reverse('password-change', kwargs={'token':token}), + data=unmatched_password, + format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['error'], "Passwords do not match") + + diff --git a/authors/apps/authentication/tests/test_serializer.py b/authors/apps/authentication/tests/test_serializer.py index 51ec2cf..ebc0927 100644 --- a/authors/apps/authentication/tests/test_serializer.py +++ b/authors/apps/authentication/tests/test_serializer.py @@ -1,6 +1,6 @@ from .test_base import BaseTestCase from authors.apps.authentication.serializers import ( - LoginSerializer, UserSerializer + LoginSerializer, UserSerializer, ResetPasswordSerializer ) from unittest.mock import patch, MagicMock, PropertyMock @@ -109,4 +109,11 @@ def set_password(self, password): class_instance = UpdateUserInstance() self.assertEqual(UserSerializer().update(class_instance, data_to_update_user), class_instance) - + + def test_email_not_provided_for_password_reset(self): + no_email_data = { + "email":None + } + with self.assertRaises(ValidationError) as e: + ResetPasswordSerializer().validate_email(no_email_data) + self.assertEqual(e.exception.args[0], 'Email is required') diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index 57fe7f2..1839fa7 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -3,7 +3,8 @@ from .views import ( LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, FacebookAuthApiView, - GoogleAuthApiView, TwitterAuthApiView, VerifyUserAPIView, FavoritesList + GoogleAuthApiView, TwitterAuthApiView, VerifyUserAPIView, FavoritesList, + PasswordChangeView,PasswordResetView ) urlpatterns = [ @@ -15,4 +16,7 @@ path('users/login/twitter', TwitterAuthApiView.as_view(), name='twitter'), path('users/verify/', VerifyUserAPIView.as_view(), name='verify-user'), path('users/articles/favorites', FavoritesList.as_view(), name='favorites'), + path('users/password-reset/', PasswordResetView.as_view(), name='reset-password'), + path('users/reset//change/', PasswordChangeView.as_view(), name='password-change') + ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index a8c4afe..efe69df 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -1,5 +1,7 @@ import os import jwt +from django.contrib.sites.shortcuts import get_current_site +from django.shortcuts import reverse from rest_framework import status, generics from rest_framework.generics import RetrieveUpdateAPIView, GenericAPIView, ListCreateAPIView from rest_framework.permissions import AllowAny, IsAuthenticated @@ -10,12 +12,16 @@ from django.contrib.sites.shortcuts import get_current_site from .renderers import UserJSONRenderer from .serializers import ( -LoginSerializer, RegistrationSerializer, UserSerializer, FacebookAuthSerializer, GoogleAuthSerializer, TwitterAuthSerializer +LoginSerializer, RegistrationSerializer, UserSerializer, FacebookAuthSerializer, GoogleAuthSerializer, TwitterAuthSerializer, +ResetPasswordSerializer, ConfirmPasswordSerializer ) from .models import User from authors.apps.articles.permissions import IsOwnerOrReadOnly from authors.apps.articles.models import Article from authors.apps.articles import serializers +from authors import settings +from authors.settings import SECRET_KEY + class RegistrationAPIView(GenericAPIView): # Allow any user (authenticated or not) to hit this endpoint. @@ -33,7 +39,7 @@ def post(self, request): serializer.save() user_data = serializer.data - url = f"http://{get_current_site(request).domain}/api/users/verify?token={user_data.get('token')}" + url = f"http://{get_current_site(request).domain}/api/users/verify/?token={user_data.get('token')}" email = EmailMessage( subject='Authors Haven:Email-verification', @@ -146,4 +152,57 @@ def get(self, request): favorite_articles = user.favorite.all() serializer = self.serializer_class(favorite_articles, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - \ No newline at end of file + +class PasswordResetView(GenericAPIView): + """ Allow users to receive a password reset link intheir emails""" + + permission_classes = (AllowAny,) + renderer_classes = (UserJSONRenderer,) + serializer_class = ResetPasswordSerializer + + def post(self, request): + user_email = request.data.get('email') + user = User.objects.filter(email=user_email).first() + if user: + token = user._generate_jwt_token() + url = get_current_site(request).domain + reverse('password-change',kwargs={'token':token}) + send_email = EmailMessage( + subject='Authors Haven:Password Reset', + body='Click here to reset your password http://{}'.format(url), + from_email= settings.EMAIL_HOST_USER, + to=[user.email] + ) + send_email.send(fail_silently=False) + return Response({'message':'Password reset link has been sent to your Email'}, + status=status.HTTP_200_OK) + + return Response({'message':'Email does not exist'}, + status=status.HTTP_400_BAD_REQUEST) + +class PasswordChangeView(GenericAPIView): + + permission_classes = (AllowAny,) + renderer_classes = (UserJSONRenderer,) + serializer_class = ConfirmPasswordSerializer + + def put(self, request, *args, **kwargs): + token = kwargs.pop('token') + user = jwt.decode(token,SECRET_KEY) + + user_data = User.objects.get(pk=user['id']) + data = request.data.get('user', {}) + + if data['password'] == data['password2']: + serializer = self.serializer_class(user_data, data=data) + serializer.is_valid(raise_exception=True) + user_data.set_password(data['password']) + user_data.save() + return Response( + {"message": "Password has been reset"}, + status=status.HTTP_200_OK + ) + + return Response( + {"error": "Passwords do not match"}, + status=status.HTTP_400_BAD_REQUEST + )