diff --git a/README.md b/README.md index 3a976cc..7a1a3db 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,38 @@ Authentication required, returns the User Accepted fields: `email`, `username`, `password`, `image`, `bio` +### Password Reset +`POST /api/users/password/reset/` + +Example request body: + +```source-json +{ + "user":{ + "email":"codingbrian58@gmail.com" + } +} +``` + +No authentication required, returns a token + +Required fields: `email` + +### Password Reset Confirm +`POST /api/users/password/reset/confirm/` + +Example request body: + +```source-json +{ + "token":"bb57bb7c779d2c7872c8621d5735e3b8170d6105", + "password":"passroneggne2424", + "password_confirm":"passroneggne2424" +} +``` + +No authentication required, returns a success message + ### Get Profile `GET /api/profiles/:username` diff --git a/authors/apps/authentication/tests/basetests.py b/authors/apps/authentication/tests/basetests.py index 949dda0..34b8994 100644 --- a/authors/apps/authentication/tests/basetests.py +++ b/authors/apps/authentication/tests/basetests.py @@ -104,3 +104,58 @@ def get_user(self): A method to retrieve users from the database """ return self.client.get(self.get_url) + + +class PasswordResetBaseTest(BaseTest): + """ + Base test for testing passeord reset + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.email = self.user.email + self.password_reset_url = reverse("authentication:password_reset") + self.token = None + self.password_reset_confirm_url = reverse( + "authentication:password_reset_confirm") + self.reset_data = { + "user": { + "email": self.email + } + } + self.password_data = { + "token": self.token, + "password": "HenkDTestPAss!#", + "password_confirm": "HenkDTestPAss!#" + } + self.contains_error = lambda container, error: error in container + + def password_reset(self): + """ + Verifies user account and generates reset password + token + """ + response = self.client.post( + path=self.password_reset_url, + data=self.reset_data, + format="json" + ) + if response.data.get("data"): + for item in response.data.get("data"): + if item.get("token"): + self.token = item.get("token") + break + self.password_data["token"] = self.token + return response + + def password_reset_confirm(self): + """ + Confirms password reset by posting new password + """ + response = self.client.post( + path=self.password_reset_confirm_url, + data=self.password_data, + format="json" + ) + return response 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..35f15e8 --- /dev/null +++ b/authors/apps/authentication/tests/test_password_reset.py @@ -0,0 +1,204 @@ +from .basetests import PasswordResetBaseTest +from rest_framework import status + + +class TestPasswordReset(PasswordResetBaseTest): + """ + Tests password reset by user + """ + + def test_invalid_email_address(self): + """ + Tests posting of invalid email address + """ + self.reset_data["user"]["email"] = "johnsoon.com" + response = self.password_reset() + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("email"), + "Enter a valid email address." + ), True + ) + + def test_missing_email(self): + """ + Tests password reset without a mail + """ + self.reset_data["user"]["email"] = None + response = self.password_reset() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("email"), + "this field is required" + ), True + ) + + def test_unexisting_ccount(self): + """ + Tests unexisting account + """ + self.reset_data["user"]["email"] = "jeff@gmail.com" + response = self.password_reset() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("email"), + "no account with that email address" + ), True + ) + + def test_unmatching_password(self): + """ + Tests if passwords match + """ + self.password_data["password_confirm"] = "SomeTestPassword#!2" + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "passwords did not match" + ), True + ) + + def test_invalid_password(self): + """ + Tests invalid password + """ + self.password_data["password"] = "123" + self.password_data["password_confirm"] = "123" + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "This password is too short. It must contain at least 8 characters." + ), True + ) + + def test_invalid_token(self): + """ + Tests changing of password with invalid token + """ + self.password_data["token"] = "abcd898adwhi3454asddwhfwh" + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("token"), + "invalid token" + ), True + ) + + def test_missing_token(self): + """ + Tests password reset without a token + """ + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("token"), + "this field is required" + ), True + ) + + def test_missing_password(self): + """ + Tests missing password + """ + self.password_data["password"] = None + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "this field is required" + ), True + ) + + def test_missing_password_confirm(self): + """ + Tests missing password confirm + """ + self.password_data["password_confirm"] = None + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_406_NOT_ACCEPTABLE + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password_confirm"), + "this field is required" + ), True + ) + + def test_successful_password_reset(self): + """ + Tests successful password rese + """ + self.password_reset() + response = self.password_reset_confirm() + message = None + for item in response.data.get("data"): + if item.get("message"): + message = item.get("message") + break + self.assertEqual( + response.status_code, + status.HTTP_200_OK + ) + self.assertEqual( + message, + "you have successfully reset your password" + ) + + def test_token_reuse(self): + """ + Tests if a user can use token generated more than once to + reset password + """ + self.password_reset() + self.password_reset_confirm() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("token"), + "invalid token" + ), True + ) diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index ea4d870..253c3cc 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -1,7 +1,7 @@ from django.conf.urls import url from .views import ( - LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView + LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, PasswordResetView, PasswordResetConfirmView ) app_name = 'authentication' @@ -10,4 +10,8 @@ url(r'^user/?$', UserRetrieveUpdateAPIView.as_view(), name='update_get'), url(r'^users/?$', RegistrationAPIView.as_view(), name='registration'), url(r'^users/login/?$', LoginAPIView.as_view(), name='login'), + url(r'^users/password/reset/?$', + PasswordResetView.as_view(), name='password_reset'), + url(r'^users/password/reset/confirm/?$', + PasswordResetConfirmView.as_view(), name='password_reset_confirm'), ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index 039e113..21bd687 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -8,6 +8,24 @@ from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer ) +from django.core.validators import EmailValidator +from django.contrib.auth import get_user_model +from django.core.mail import send_mail, EmailMultiAlternatives +from rest_framework.authtoken.models import Token +from rest_framework.authentication import ( + TokenAuthentication, + get_authorization_header +) +from rest_framework.exceptions import AuthenticationFailed +import datetime +from rest_framework import exceptions +import pytz +from rest_framework.authtoken.views import ObtainAuthToken +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.utils import timezone + +User = get_user_model() class RegistrationAPIView(APIView): @@ -73,3 +91,141 @@ def update(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) + +class PasswordResetView(ObtainAuthToken): + """ + Sends password reset email with id token + """ + permission_classes = (AllowAny,) + + def post(self, request): + response = None + response_status = None + errors = {} + data = request.data.get("user") + email = data.get("email") + if not email: + errors["email"] = ["this field is required"] + response_status = status.HTTP_406_NOT_ACCEPTABLE + else: + mail_validator = EmailValidator() + try: + mail_validator(email) + except ValidationError as error: + errors["email"] = error + response_status = status.HTTP_400_BAD_REQUEST + else: + users = User.objects.filter(email=email) + if not users: + errors["email"] = ["no account with that email address"] + response_status = status.HTTP_406_NOT_ACCEPTABLE + else: + user = users[0] + token, created = Token.objects.get_or_create(user=user) + if not created: + token.created = timezone.now() + token.save() + html_content = """ +
Hi {}
To change your Authors Heaven password, click the link below
This link will expire in 24 hours, so be sure to use it right away.
Thank you for using Authors Heaven!
+The Authors Heaven Team
+ """.format( + user.username, + token + ) + message = EmailMultiAlternatives( + subject="Password Reset", + body="This is a mail to reset your password", + from_email="noreply@authorsheaven.com", + to=[email] + ) + message.attach_alternative(html_content, "text/html") + message.send() + response = Response( + data={"data": [{ + "token": token.key + }]}, + status=status.HTTP_200_OK + ) + if errors: + response = Response( + data={ + "errors": errors + }, + status=response_status + ) + return response + + +class PasswordResetConfirmView(APIView): + """ + Confirms password reset after retrieving token embedded in mail + """ + permission_classes = (AllowAny,) + + def post(self, request): + response = None + response_status = None + missing_fields = False + errors = {} + data = request.data + token, password, password_confirm = ( + data.get("token"), + data.get("password"), + data.get("password_confirm") + ) + if not token: + errors["token"] = ["this field is required"] + missing_fields = True + if not password: + errors["password"] = ["this field is required"] + missing_fields = True + if not password_confirm: + errors["password_confirm"] = ["this field is required"] + missing_fields = True + if not missing_fields: + token_list = Token.objects.filter(key=token) + utc_now = timezone.now() + + def invalid_token(auth_token): + return auth_token.created < utc_now - timezone.timedelta(hours=24) + if not token_list: + errors["token"] = ["invalid token"] + response_status = status.HTTP_401_UNAUTHORIZED + elif invalid_token(token_list[0]): + errors["token"] = ["invalid token"] + response_status = status.HTTP_401_UNAUTHORIZED + else: + user = token_list[0].user + if password != password_confirm: + errors["password"] = ["passwords did not match"] + response_status = status.HTTP_406_NOT_ACCEPTABLE + else: + try: + validate_password(password) + except ValidationError as error: + errors["password"] = error + response_status = status.HTTP_406_NOT_ACCEPTABLE + else: + user.set_password(password) + user.save() + token_list[0].delete() + response = Response( + data={"data": [{ + "message": "you have successfully reset your password" + }]}, + status=status.HTTP_200_OK + ) + if errors: + if missing_fields: + response_status = status.HTTP_406_NOT_ACCEPTABLE + response = Response( + data={ + "errors": errors + }, + status=response_status + ) + return response diff --git a/authors/settings.py b/authors/settings.py index 3c26a36..584c8d5 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -40,6 +40,7 @@ 'corsheaders', 'django_extensions', 'rest_framework', + 'rest_framework.authtoken', 'authors.apps.authentication', 'authors.apps.core', @@ -148,4 +149,10 @@ # ), } +EMAIL_BACKEND = os.getenv('EMAIL_BACKEND') +EMAIL_HOST = os.getenv('EMAIL_HOST') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +EMAIL_PORT = os.getenv('EMAIL_PORT') +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS') django_heroku.settings(locals())