Skip to content

Commit

Permalink
Feat: Password Reset with Email
Browse files Browse the repository at this point in the history
- email with password reset link is sent to user
- user clicks reset link
- user updates password
[Maintains #164798202]
  • Loading branch information
dorothyas committed Apr 15, 2019
1 parent 5d5314d commit bd2a95e
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 6 deletions.
34 changes: 34 additions & 0 deletions authors/apps/authentication/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
)

confirm_password = serializers.CharField(
max_length=128,
min_length=8,
write_only=True
)
class Meta:
model = User
fields = ('password','confirm_password')


class SocialAuthSerializer(serializers.Serializer):
"""
Social Authentication parent class
Expand Down
21 changes: 21 additions & 0 deletions authors/apps/authentication/tests/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"confirm_password":"asiimweD1"

}
}
unmatched_password = {
"user":{
"password":"asiimweD1",
"password2":"asiim"

}
}
62 changes: 62 additions & 0 deletions authors/apps/authentication/tests/test_password_reset.py
Original file line number Diff line number Diff line change
@@ -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")


11 changes: 9 additions & 2 deletions authors/apps/authentication/tests/test_serializer.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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')
6 changes: 5 additions & 1 deletion authors/apps/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from .views import (
LoginAPIView, RegistrationAPIView,
UserRetrieveUpdateAPIView, FacebookAuthApiView,
GoogleAuthApiView, TwitterAuthApiView, VerifyUserAPIView, FavoritesList
GoogleAuthApiView, TwitterAuthApiView, VerifyUserAPIView, FavoritesList,
PasswordChangeView,PasswordResetView
)

urlpatterns = [
Expand All @@ -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/<str:token>/change/', PasswordChangeView.as_view(), name='password-change')

]
65 changes: 62 additions & 3 deletions authors/apps/authentication/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -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)


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.get('password') == data.get('confirm_password'):
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
)

0 comments on commit bd2a95e

Please sign in to comment.