From fc91e3b0d5960504cf6bbb897962c660ecefda95 Mon Sep 17 00:00:00 2001 From: Paul Balitema Date: Mon, 4 Feb 2019 16:53:58 +0300 Subject: [PATCH] feat(password-reset): reset password via email -send password-reset link to user's registered mailbox -reset user's account password to newly provided one [Starts: #163383177] --- app.json | 6 + authors/apps/authentication/serializers.py | 21 ++++ .../apps/authentication/tests/base_class.py | 2 +- .../tests/test_data/password_reset_data.py | 51 ++++++++ .../tests/test_reset_password.py | 109 ++++++++++++++++++ .../apps/authentication/tests/test_update.py | 2 +- authors/apps/authentication/urls.py | 13 ++- authors/apps/authentication/views.py | 74 ++++++++++-- authors/apps/core/password_reset_manager.py | 75 ++++++++++++ .../core/templates/password_reset_email.txt | 15 +++ authors/settings/base.py | 7 ++ requirements.txt | 1 + 12 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 authors/apps/authentication/tests/test_data/password_reset_data.py create mode 100644 authors/apps/authentication/tests/test_reset_password.py create mode 100644 authors/apps/core/password_reset_manager.py create mode 100644 authors/apps/core/templates/password_reset_email.txt diff --git a/app.json b/app.json index 0b845a8..bb85bbd 100644 --- a/app.json +++ b/app.json @@ -10,6 +10,12 @@ }, "SECRET_KEY": { "required": true + }, + "USER_EMAIL": { + "required": true + }, + "USER_PASSWORD": { + "required": true } }, "formation": {}, diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index 682d909..bbae12e 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -154,3 +154,24 @@ class TwitterAuthSerializer(serializers.Serializer): class GoogleFacebookAuthSerializer(serializers.Serializer): """Handle serialization of Google and Facebook access tokens""" access_token = serializers.CharField(max_length=2000) + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField(max_length=255, required=True) + + def validate(self,data): + email = data.get('email', None) + if email is None: + raise serializers.ValidationError("Email field is required") + return email + +class PasswordResetSerializer(serializers.Serializer): + new_password = serializers.CharField( + max_length=128, allow_blank=False, write_only=True, required=True, + min_length=6 + ) + def validate(self, data): + new_password = data.get('new_password', None) + if not re.compile(r'^[0-9a-zA-Z]*$').match(new_password): + raise ValidationError( + "Password must contain alphanumeric characters only") + return new_password diff --git a/authors/apps/authentication/tests/base_class.py b/authors/apps/authentication/tests/base_class.py index 97071fd..0536971 100644 --- a/authors/apps/authentication/tests/base_class.py +++ b/authors/apps/authentication/tests/base_class.py @@ -16,7 +16,7 @@ def setUp(self): self.url_register = reverse('authentication:register') self.url_login = reverse('authentication:login') - self.url_user_detail = reverse('authentication:retrieve_update') + self.url_user_detail = reverse('authentication:retrieve-update') self.url_twitter = reverse('authentication:twitter-auth') self.url_google = reverse('authentication:google-auth') self.url_facebook = reverse('authentication:facebook-auth') diff --git a/authors/apps/authentication/tests/test_data/password_reset_data.py b/authors/apps/authentication/tests/test_data/password_reset_data.py new file mode 100644 index 0000000..a6c1851 --- /dev/null +++ b/authors/apps/authentication/tests/test_data/password_reset_data.py @@ -0,0 +1,51 @@ +"""Test data for password reset feature""" +import jwt +from django.conf import settings + +from .login_data import valid_login_data + +registered_email = { + "user": { + "email": valid_login_data["user"]["email"] + } +} + +unregistered_email = { + "user": { + "email": "jklm@mno.org" + } +} + +new_valid_password = { + "user": { + "new_password": "xml123XML" + } +} + +new_short_password = { + "user": { + "new_password": "ab2" + } +} + +new_invalid_password = { + "user": { + "new_password": "abcd@efgh" + } +} + +new_blank_password = { + "user": { + "new_passord":"" + } +} + +token = jwt.encode({ + "email":valid_login_data["user"]["email"], + }, + settings.SECRET_KEY, + algorithm='HS256' + ) +reset_link = '/api/v1/users/reset-password/'+ token.decode('utf-8') + '/' + +invalid_reset_link = '/api/v1/users/reset-password/'+ token.decode('utf-8') + 'wrongL!NK/' diff --git a/authors/apps/authentication/tests/test_reset_password.py b/authors/apps/authentication/tests/test_reset_password.py new file mode 100644 index 0000000..3bd6b77 --- /dev/null +++ b/authors/apps/authentication/tests/test_reset_password.py @@ -0,0 +1,109 @@ +""" +This file contains tests for the password reset feature +where the user who forgets their password can reset it +via a verification link sent directly into their registerd +mailbox. +""" +from .base_class import BaseTest +import json +from rest_framework import status +from .test_data.password_reset_data import (reset_link, invalid_reset_link, + registered_email, unregistered_email, new_valid_password, + new_blank_password, new_invalid_password, new_short_password +) +from django.urls import reverse + + +class ResetPassword(BaseTest): + + def setUp(self): + super().setUp() + self.register_test_user() + self.password_reset_request_url = reverse( + 'authentication:request-password-reset' + ) + + def test_request_password_reset_registered_user(self): + """test user with valid account requests password reset""" + response = self.client.post( + self.password_reset_request_url, + data=json.dumps( + registered_email + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_request_password_reset_unregistered_user(self): + """test user with no account requests for a password reset""" + response = self.client.post( + self.password_reset_request_url, + data=json.dumps( + unregistered_email + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_request_password_reset_valid_link(self): + """ + test user uses valid reset link sent to their email + to access password reset endpoint + """ + response = self.client.get( + reset_link, + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_request_password_reset_invalid_link(self): + """test user uses invalid reset link to access password reset endpoint""" + response = self.client.get( + invalid_reset_link, + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_reset_valid_password(self): + """test user tries to reset to a valid password""" + response = self.client.post( + reset_link, + data=json.dumps( + new_valid_password + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_password_reset_short_password(self): + """test user tries to supply an undesirable short password""" + response = self.client.post( + reset_link, + data=json.dumps( + new_short_password + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_reset_invalid_password(self): + """test user tries to supply a password with forbidden characters""" + response = self.client.post( + reset_link, + data=json.dumps( + new_invalid_password + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_reset_blank_password(self): + """test user tries to supply an empty password""" + response = self.client.post( + reset_link, + data=json.dumps( + new_blank_password + ), + content_type='application/json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/authors/apps/authentication/tests/test_update.py b/authors/apps/authentication/tests/test_update.py index fbab75f..8ed8129 100644 --- a/authors/apps/authentication/tests/test_update.py +++ b/authors/apps/authentication/tests/test_update.py @@ -12,7 +12,7 @@ class UpdateTest(BaseTest): def setUp(self): super().setUp() - self.url_update = reverse('authentication:retrieve_update') + self.url_update = reverse('authentication:retrieve-update') def test_user_can_successfully_update_email(self): """Test that an existing user can successfully update their email""" diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index 8565d46..fd73506 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -2,14 +2,23 @@ from .views import ( LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, - TwitterAuthAPIView, GoogleAuthAPIView, FacebookAuthAPIView + TwitterAuthAPIView, GoogleAuthAPIView, FacebookAuthAPIView, + PasswordResetRequestAPIView, PasswordResetAPIView ) app_name = "authentication" urlpatterns = [ - path('user/', UserRetrieveUpdateAPIView.as_view(), name='retrieve_update'), + path('user/', UserRetrieveUpdateAPIView.as_view(), name='retrieve-update'), path('users/', RegistrationAPIView.as_view(), name='register'), path('users/login/', LoginAPIView.as_view(), name='login'), path('users/login/twitter/', TwitterAuthAPIView.as_view(), name='twitter-auth'), path('users/login/google/', GoogleAuthAPIView.as_view(), name='google-auth'), path('users/login/facebook/', FacebookAuthAPIView.as_view(), name='facebook-auth'), + path( + 'users/request-password-reset/', PasswordResetRequestAPIView.as_view(), + name='request-password-reset' + ), + path( + 'users/reset-password//', PasswordResetAPIView.as_view(), + name='reset-password' + ), ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index 186bdae..2f95e1f 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -3,23 +3,21 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from ..core.password_reset_manager import PasswordResetManager from .renderers import UserJSONRenderer from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer, + PasswordResetRequestSerializer, PasswordResetSerializer, TwitterAuthSerializer, GoogleFacebookAuthSerializer ) - import os import twitter - import facebook - from google.oauth2 import id_token from google.auth.transport import requests - from .social_login import(login_or_register_social_user) - +from .models import User class RegistrationAPIView(GenericAPIView): # Allow any user (authenticated or not) to hit this endpoint. @@ -59,7 +57,6 @@ def post(self, request): return Response(serializer.data, status=status.HTTP_200_OK) - class UserRetrieveUpdateAPIView(RetrieveUpdateAPIView): permission_classes = (IsAuthenticated,) renderer_classes = (UserJSONRenderer,) @@ -87,7 +84,6 @@ def update(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) - class TwitterAuthAPIView(GenericAPIView): """ Handle login of a Twitter user via the Twitter Api. @@ -127,7 +123,6 @@ def post(self, request): return login_or_register_social_user(twitter_user) - class GoogleAuthAPIView(GenericAPIView): """ Handle login of a Google user via the Google oauth2. @@ -157,7 +152,6 @@ def post(self, request): return login_or_register_social_user(google_user) - class FacebookAuthAPIView(GenericAPIView): """ Handle login of a Facebook user via the Facebook Graph API. @@ -183,3 +177,65 @@ def post(self, request): facebook_user = graph.get_object(id='me', fields='email, name') return login_or_register_social_user(facebook_user) + +class PasswordResetRequestAPIView(GenericAPIView): + """ + This handles receiving the requester's email address and sends + a password reset email + """ + permission_classes = (AllowAny,) + renderer_classes = (UserJSONRenderer,) + serializer_class = PasswordResetRequestSerializer + + def post(self, request): + user = request.data.get( + 'user', {}) if 'user' in request.data else request.data + serializer = self.serializer_class(data=user) + serializer.is_valid(raise_exception=True) + user_email = user.get("email", None) + PasswordResetManager(request).send_password_reset_email(user_email) + return Response( + { + "message":"An email has been sent to your mailbox with instructions to reset your password", + }, + status=status.HTTP_200_OK + ) + +class PasswordResetAPIView(GenericAPIView): + """ + This handles verification of reset link and updates password field + with new password + """ + permission_classes = (AllowAny,) + renderer_classes = (UserJSONRenderer,) + serializer_class = PasswordResetSerializer + + def get(self, request, token): + user = PasswordResetManager(request).get_user_from_encoded_token(token) + if user is None: + return Response({"message":"Invalid token!"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + { + "message":"Token is valid. OK to send new password information" + }, + status=status.HTTP_200_OK + ) + + def post(self, request, token): + data = request.data.get( + 'user', {}) if 'user' in request.data else request.data + print(data) + user = PasswordResetManager(request).get_user_from_encoded_token(token) + if user is None: + return Response( + { + "message":"Your token has expired. Please start afresh." + }, + status=status.HTTP_400_BAD_REQUEST + ) + email = user.get('email') + serializer = self.serializer_class(data=data) + serializer.is_valid(raise_exception=True) + new_password = data.get("new_password") + PasswordResetManager(request).update_password(email, new_password) + return Response({"message":"Your password has been reset"} , status=status.HTTP_200_OK) \ No newline at end of file diff --git a/authors/apps/core/password_reset_manager.py b/authors/apps/core/password_reset_manager.py new file mode 100644 index 0000000..3b7357f --- /dev/null +++ b/authors/apps/core/password_reset_manager.py @@ -0,0 +1,75 @@ +from ..authentication.models import UserManager, User +from rest_framework import serializers +import jwt, datetime +from django.conf import settings +from django.template.loader import render_to_string +from django.core.mail import send_mail + +class PasswordResetManager: + """ + This class handles the operations involved in the email-based + password-reset feature including preparing the email message, + reset link and verification of link and updating password + """ + + def __init__(self, request): + self.sender_email = "teamabasama@gmail.com" + self.subject = "You requested a password reset" + self.password_reset_url = request.build_absolute_uri('/api/v1/users/reset-password/') + + def get_user_by_email(self, email): + email = UserManager.normalize_email(email) + try: + user = User.objects.get(email=email) + return user + except: + return None + + def prepare_password_reset_email(self, email): + user = self.get_user_by_email(email) + + if user is None: + raise serializers.ValidationError("There is no user with this email address!") + + #parameters for password reset email to be sent + self.requester_email = user.email + self.encoded_token = jwt.encode({ + "email":self.requester_email, + "exp":datetime.datetime.utcnow() + datetime.timedelta(minutes=30) + }, + settings.SECRET_KEY, + algorithm='HS256' + ) + self.context = { + 'username' : user.username, + 'reset_link' : self.password_reset_url + self.encoded_token.decode('utf-8')+ '/' + } + self.email_body = render_to_string('password_reset_email.txt', self.context) + + return self.email_body + + def send_password_reset_email(self, email): + self.prepare_password_reset_email(email) + send_mail( + self.subject, + self.email_body, + self.sender_email, + [self.requester_email], + fail_silently=False + ) + return self.encoded_token.decode('utf-8') + + def update_password(self, email, new_password): + user = self.get_user_by_email(email) + user.set_password(new_password) + user.save() + + def get_user_from_encoded_token(self, token): + try: + payload_data = jwt.decode(token,settings.SECRET_KEY,algorithm='HS256') + return payload_data + except : + return None + + + diff --git a/authors/apps/core/templates/password_reset_email.txt b/authors/apps/core/templates/password_reset_email.txt new file mode 100644 index 0000000..68f1fff --- /dev/null +++ b/authors/apps/core/templates/password_reset_email.txt @@ -0,0 +1,15 @@ +Hello {{username}}, + +You have requested a password reset for your Authors Haven account. +Please click and follow the link below to reset your password. + +Click to reset your password: {{reset_link}} + +If the link above does not redirect you to our password-reset page, copy +the entire URL directly into the address bar of your web-browser. + +Please ignore this email if you did NOT request a password reset. + +Thank you. + +The Authors Haven Team. \ No newline at end of file diff --git a/authors/settings/base.py b/authors/settings/base.py index 803a6a3..5475e06 100644 --- a/authors/settings/base.py +++ b/authors/settings/base.py @@ -38,6 +38,7 @@ 'django_extensions', 'rest_framework', 'drf_yasg', + 'mailer', 'authors.apps.authentication', 'authors.apps.core', @@ -133,3 +134,9 @@ 'authors.apps.authentication.backends.JWTAuthentication', ), } +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "smtp.gmail.com" +EMAIL_USE_TLS = True +EMAIL_PORT = 587 +EMAIL_HOST_USER = os.environ.get('USER_EMAIL') +EMAIL_HOST_PASSWORD = os.environ.get('USER_PASSWORD') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 27afaa9..794e36c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ idna==2.8 inflection==0.3.1 itypes==1.1.0 Jinja2==2.10 +mailer==0.8.1 MarkupSafe==1.1.0 psycopg2==2.7.7 PyJWT==1.7.1