diff --git a/.env-example b/.env-example index 0db2f1b..2619b7f 100644 --- a/.env-example +++ b/.env-example @@ -36,4 +36,3 @@ export EMAIL_HOST_PASSWORD='your email host password' export EMAIL_PORT='your email port' export EMAIL_HOST_USER='your email host user' export EMAIL_USE_TLS=True -export DOMAIN_NAME='your domain name' diff --git a/authors/apps/authentication/renderers.py b/authors/apps/authentication/renderers.py index 11aff4d..0e54607 100644 --- a/authors/apps/authentication/renderers.py +++ b/authors/apps/authentication/renderers.py @@ -1,5 +1,4 @@ import json - from rest_framework.renderers import JSONRenderer @@ -18,12 +17,12 @@ def render(self, data, media_type=None, renderer_context=None): # rendering errors. return super(UserJSONRenderer, self).render(data) - # Finally, we can render our data under the "user" namespace. return json.dumps({ 'user': data }) + class SignupUserJSONRenderer(JSONRenderer): charset = 'utf-8' @@ -39,7 +38,6 @@ def render(self, data, media_type=None, renderer_context=None): # rendering errors. return super(SignupUserJSONRenderer, self).render(data) - # Finally, we can render our data under the "user" namespace. return json.dumps({ 'user': data, diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index 0661834..5bd3eaf 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -1,8 +1,6 @@ from django.contrib.auth import authenticate - from rest_framework import serializers from rest_framework.validators import UniqueValidator - from .models import User from django.core.validators import EmailValidator from django.core.exceptions import ValidationError @@ -10,6 +8,7 @@ from django.utils import timezone from rest_framework.authtoken.models import Token from django.contrib.auth.password_validation import validate_password +from password_strength import PasswordStats class RegistrationSerializer(serializers.ModelSerializer): @@ -226,55 +225,138 @@ class SocialAuthSerializer(serializers.Serializer): allow_blank=True, default="" ) + + class PasswordResetSerializer(serializers.Serializer): """ Serializer for password reset """ - email = serializers.EmailField(required=True) + email = serializers.EmailField( + required=False, + allow_blank=True, + allow_null=True, + write_only=True + ) - def validate_email(self, value): + def validate(self, data): + email = data.get("email") + if not email: + raise serializers.ValidationError({ + "email": "Please provide your email address" + }) try: - EmailValidator(value) + EmailValidator(email) except ValidationError as e: raise serializers.ValidationError(e) try: - User.objects.get(email=value) + User.objects.get(email=email) except ObjectDoesNotExist: - raise serializers.ValidationError( - "no account with that email address" - ) + raise serializers.ValidationError({ + "email": "No account with that email address" + }) + return data class PasswordResetConfirmSerializer(serializers.Serializer): """ Serializer class for password reset confirm """ - token = serializers.CharField(max_length=250, required=True) - password = serializers.CharField(max_length=250, required=True) - password_confirm = serializers.CharField(max_length=250, required=True) + token = serializers.CharField( + max_length=250, + write_only=True, + allow_null=True, + allow_blank=True, + required=False + ) + password = serializers.CharField( + max_length=250, + write_only=True, + allow_null=True, + allow_blank=True, + required=False + ) + password_confirm = serializers.CharField( + max_length=250, + write_only=True, + allow_null=True, + allow_blank=True, + required=False + ) def validate(self, data): + if not data.get("token"): + raise serializers.ValidationError({ + "token": "There is no token provided" + }) try: token_object = Token.objects.get(key=data.get("token")) except ObjectDoesNotExist: raise serializers.ValidationError({ - "token": "invalid token" + "token": "Invalid token" }) if self.expired_token(token_object): raise serializers.ValidationError({ - "token": "invalid token" + "token": "Expired token" }) + self.get_password_field_erros(data) try: validate_password(data.get("password")) except ValidationError as error: raise serializers.ValidationError({ "password": list(error) }) - if data["password"] != data["password_confirm"]: + self.get_password_policy_rrors(data.get("password")) + return data + + def get_password_policy_rrors(self, password): + """ + Captures password policy errors + """ + stats = PasswordStats(password) + password_errors = [] + if stats.letters_uppercase < 2: + password_errors.append( + "Your password should have a minimum of 2 uppercase letters" + ) + if stats.numbers < 2: + password_errors.append( + "Your password should have a minimum of 2 numbers" + ) + if stats.special_characters < 1: + password_errors.append( + "Your password should have a minimum of 1 special character" + ) + elif stats.letters_lowercase < 3: + password_errors.append( + "Your password should have a minimum of 3 lowercase letters" + ) + if password_errors: + raise serializers.ValidationError({ + "password": list(password_errors) + }) + + def get_password_field_erros(self, data): + """ + Captures null, blank and empty fields for password + and password confirm + """ + if not data.get("password") and not data.get("password_confirm"): raise serializers.ValidationError({ - "password": "passwords did not match" + "password": "Please provide your password", + "password_confirm": "Please confirm your password" + }) + elif not data.get("password"): + raise serializers.ValidationError({ + "password": "Please provide your password" + }) + elif not data.get("password_confirm"): + raise serializers.ValidationError({ + "password_confirm": "Please confirm your password" + }) + elif data["password"] != data["password_confirm"]: + raise serializers.ValidationError({ + "password": "Passwords did not match" }) - return data def expired_token(self, auth_token): """ diff --git a/authors/apps/authentication/tests/basetests.py b/authors/apps/authentication/tests/basetests.py index 8994ef8..e15126d 100644 --- a/authors/apps/authentication/tests/basetests.py +++ b/authors/apps/authentication/tests/basetests.py @@ -6,6 +6,7 @@ from rest_framework.test import APITestCase, APIClient from rest_framework.reverse import reverse from rest_framework.authtoken.models import Token +from django.utils import timezone User = get_user_model() @@ -30,7 +31,7 @@ def setUp(self): email="adam@gmail.com", password="@Us3r.com", ) - self.user.is_verified=True + self.user.is_verified = True self.user.save() self.user1 = User.objects.create_user( @@ -38,7 +39,7 @@ def setUp(self): email="ian@gmail.com", password="Maina9176", ) - self.user1.is_verified=True + self.user1.is_verified = True self.user1 = User.objects.get(username='ian') self.user1.is_active = False self.user1.save() @@ -61,10 +62,9 @@ def setUp(self): password="Maina9176", ) - self.user4.is_verified=True + self.user4.is_verified = True self.user4.save() - self.super_user = User.objects.create_superuser( username="admin", email="admin@authors.com", @@ -213,9 +213,8 @@ def setUp(self): } } self.password_data = { - "token": self.token, - "password": "HenkDTestPAss!#", - "password_confirm": "HenkDTestPAss!#" + "password": "HenkDTestPAss23!#", + "password_confirm": "HenkDTestPAss23!#" } self.contains_error = lambda container, error: error in container @@ -231,54 +230,31 @@ def password_reset(self): ) token, created = Token.objects.get_or_create(user=self.user) self.token = token.key - self.password_data["token"] = self.token + self.set_password_confirm_url() return response - def password_reset_confirm(self): + def generate_fake_token(self): """ - Confirms password reset by posting new password + Generates invalid token """ - return self.client.post(social_url, social_user) - -class PasswordResetBaseTest(BaseTest): - """ - Base test for testing passeord reset - """ + self.token = "fjeojfoeefubbfbwuebbyvyfvwd24" + self.set_password_confirm_url() - 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): + def generate_expired_token(self): """ - Verifies user account and generates reset password - token + Generates expired token """ - response = self.client.post( - path=self.password_reset_url, - data=self.reset_data, - format="json" - ) token, created = Token.objects.get_or_create(user=self.user) + token.created = timezone.now()-timezone.timedelta(hours=25) + token.save() self.token = token.key - self.password_data["token"] = self.token - return response + self.set_password_confirm_url() + + def set_password_confirm_url(self): + """ + Sets password confirm url as the token changes + """ + self.password_reset_confirm_url += '?token='+self.token def password_reset_confirm(self): """ diff --git a/authors/apps/authentication/tests/test_password_reset.py b/authors/apps/authentication/tests/test_password_reset.py index febc566..5bf8b0a 100644 --- a/authors/apps/authentication/tests/test_password_reset.py +++ b/authors/apps/authentication/tests/test_password_reset.py @@ -37,7 +37,7 @@ def test_missing_email_field(self): self.assertEqual( self.contains_error( response.data.get("errors").get("email"), - "This field is required." + "Please provide your email address" ), True ) @@ -54,7 +54,7 @@ def test_empty_email(self): self.assertEqual( self.contains_error( response.data.get("errors").get("email"), - "This field may not be blank." + "Please provide your email address" ), True ) @@ -71,10 +71,25 @@ def test_null_email(self): self.assertEqual( self.contains_error( response.data.get("errors").get("email"), - "This field may not be null." + "Please provide your email address" ), True ) + def test_successful_reset_link_sent(self): + """ + Tests for successful sending of the password + reset link + """ + response = self.password_reset() + self.assertEqual( + response.status_code, + status.HTTP_200_OK + ) + self.assertEqual( + response.data.get("data")[0].get("message"), + "A password reset message was sent to your email address. Please click the link in that message to reset your password" + ) + def test_unexisting_ccount(self): """ Tests unexisting account @@ -88,7 +103,7 @@ def test_unexisting_ccount(self): self.assertEqual( self.contains_error( response.data.get("errors").get("email"), - "no account with that email address" + "No account with that email address" ), True ) @@ -106,7 +121,7 @@ def test_unmatching_password(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password"), - "passwords did not match" + "Passwords did not match" ), True ) @@ -133,7 +148,7 @@ def test_invalid_token(self): """ Tests changing of password with invalid token """ - self.password_data["token"] = "abcd898adwhi3454asddwhfwh" + self.generate_fake_token() response = self.password_reset_confirm() self.assertEqual( response.status_code, @@ -142,13 +157,13 @@ def test_invalid_token(self): self.assertEqual( self.contains_error( response.data.get("errors").get("token"), - "invalid token" + "Invalid token" ), True ) def test_null_token(self): """ - Tests password reset without a null token + Tests for null token """ response = self.password_reset_confirm() self.assertEqual( @@ -158,15 +173,12 @@ def test_null_token(self): self.assertEqual( self.contains_error( response.data.get("errors").get("token"), - "This field may not be null." + "There is no token provided" ), True ) - def test_blank_token(self): - """ - Tests a blank token - """ - self.password_data["token"] = "" + def test_expired_token(self): + self.generate_expired_token() response = self.password_reset_confirm() self.assertEqual( response.status_code, @@ -175,15 +187,17 @@ def test_blank_token(self): self.assertEqual( self.contains_error( response.data.get("errors").get("token"), - "This field may not be blank." + "Expired token" ), True ) - def test_missing_token_field(self): + def test_token_reuse(self): """ - Tests missing token field + Tests if a user can use token generated more than once to + reset password """ - del self.password_data["token"] + self.password_reset() + self.password_reset_confirm() response = self.password_reset_confirm() self.assertEqual( response.status_code, @@ -192,7 +206,7 @@ def test_missing_token_field(self): self.assertEqual( self.contains_error( response.data.get("errors").get("token"), - "This field is required." + "Invalid token" ), True ) @@ -210,7 +224,7 @@ def test_null_password(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password"), - "This field may not be null." + "Please provide your password" ), True ) @@ -228,15 +242,92 @@ def test_blank_password(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password"), - "This field may not be blank." + "Please provide your password" ), True ) - def test_missing_password_field(self): + def test_missing_password(self): """ Tests missing password field """ del self.password_data["password"] + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "Please provide your password" + ), True + ) + + def test_less_than_2_uppercase_in_password(self): + """ + Tests a password that has less than two uppercase letters + """ + self.password_data["password"] = "Henkdestpass23!#" + self.password_data["password_confirm"] = "Henkdestpass23!#" + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "Your password should have a minimum of 2 uppercase letters" + ), True + ) + + def test_less_than_2_numbers_in_password(self): + """ + Tests for a password that has less than two integers from {0...9} + """ + self.password_data["password"] = "HenkDTestPAss2!#" + self.password_data["password_confirm"] = "HenkDTestPAss2!#" + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "Your password should have a minimum of 2 numbers" + ), True + ) + + def test_special_character_in_password(self): + """ + Tests for a password that has no special character + """ + self.password_data["password"] = "HenkDTestPAss23" + self.password_data["password_confirm"] = "HenkDTestPAss23" + self.password_reset() + response = self.password_reset_confirm() + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password"), + "Your password should have a minimum of 1 special character" + ), True + ) + + def test_less_than_3_lowercase_in_password(self): + """ + Tests for password with less than three lowercase letters + """ + self.password_data["password"] = "HENKKDTAKPAab23!#" + self.password_data["password_confirm"] = "HENKKDTAKPAab23!#" + self.password_reset() response = self.password_reset_confirm() self.assertEqual( response.status_code, @@ -245,7 +336,7 @@ def test_missing_password_field(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password"), - "This field is required." + "Your password should have a minimum of 3 lowercase letters" ), True ) @@ -263,7 +354,7 @@ def test_blank_password_confirm(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password_confirm"), - "This field may not be blank." + "Please confirm your password" ), True ) @@ -281,15 +372,16 @@ def test_null_password_confirm(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password_confirm"), - "This field may not be null." + "Please confirm your password" ), True ) - def test_missing_password_confirm_field(self): + def test_missing_password_confirm(self): """ Tests missing password field """ del self.password_data["password_confirm"] + self.password_reset() response = self.password_reset_confirm() self.assertEqual( response.status_code, @@ -298,45 +390,51 @@ def test_missing_password_confirm_field(self): self.assertEqual( self.contains_error( response.data.get("errors").get("password_confirm"), - "This field is required." + "Please confirm your password" ), True ) - def test_successful_password_reset(self): + def test_missing_password_and_password_confirm(self): """ - Tests successful password rese + Tests when a password and a confirmation password is missing """ + del self.password_data["password"] + del self.password_data["password_confirm"] 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 + status.HTTP_400_BAD_REQUEST ) self.assertEqual( - message, - "you have successfully reset your password" + self.contains_error( + response.data.get("errors").get("password"), + "Please provide your password" + ), True + ) + self.assertEqual( + self.contains_error( + response.data.get("errors").get("password_confirm"), + "Please confirm your password" + ), True ) - def test_token_reuse(self): + def test_successful_password_reset(self): """ - Tests if a user can use token generated more than once to - reset password + Tests successful password rese """ self.password_reset() - self.password_reset_confirm() 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_400_BAD_REQUEST + status.HTTP_200_OK ) self.assertEqual( - self.contains_error( - response.data.get("errors").get("token"), - "invalid token" - ), True + message, + "You have successfully reset your password" ) diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index 7f58c67..826f4a8 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -1,20 +1,15 @@ from .backends import JWTAuthentication from ..utils.mailer import ConfirmationMail import jwt - from django.conf import settings -from django.contrib.auth import get_user_model - from rest_framework import status from rest_framework.generics import RetrieveUpdateAPIView, GenericAPIView, CreateAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView - from social_django.utils import load_backend, load_strategy from social_core.backends.oauth import BaseOAuth1, BaseOAuth2 from social_core.exceptions import MissingBackend - from .renderers import UserJSONRenderer, SignupUserJSONRenderer from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer, @@ -28,15 +23,10 @@ TokenAuthentication, get_authorization_header ) -from rest_framework.generics import GenericAPIView from authors.utils.mailer import VerificationMail from django.utils import timezone -User = get_user_model() - - auth = JWTAuthentication() - User = get_user_model() @@ -183,7 +173,7 @@ def post(self, request): VerificationMail(user, token).send_mail() response = Response( data={"data": [{ - "message": "An email has been sent to your email address to reset your password" + "message": "A password reset message was sent to your email address. Please click the link in that message to reset your password" }]}, status=status.HTTP_200_OK ) @@ -198,12 +188,13 @@ class PasswordResetConfirmView(GenericAPIView): serializer_class = PasswordResetConfirmSerializer def post(self, request): - response = None + request.POST._mutable = True data = request.data token, password = ( - data.get("token"), + request.GET.get("token"), data.get("password") ) + data["token"] = token serializer = self.serializer_class(data=data) serializer.is_valid(raise_exception=True) token_object = Token.objects.get(key=token) @@ -213,7 +204,7 @@ def post(self, request): token_object.delete() response = Response( data={"data": [{ - "message": "you have successfully reset your password" + "message": "You have successfully reset your password" }]}, status=status.HTTP_200_OK ) diff --git a/authors/settings.py b/authors/settings.py index 76f187e..3bc18c6 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -66,9 +66,9 @@ ] cloudinary.config( - cloud_name = os.getenv('CLOUDINARY_NAME'), - api_key = os.getenv('CLOUDINARY_KEY'), - api_secret = os.getenv('CLOUDINARY_SECRET') + cloud_name=os.getenv('CLOUDINARY_NAME'), + api_key=os.getenv('CLOUDINARY_KEY'), + api_secret=os.getenv('CLOUDINARY_SECRET') ) ROOT_URLCONF = 'authors.urls' @@ -104,7 +104,8 @@ } SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY') -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET') +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv( + 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET') SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['email', 'username'] SOCIAL_AUTH_TWITTER_KEY = os.getenv('SOCIAL_AUTH_TWITTER_KEY') @@ -154,7 +155,8 @@ } SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY') -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET') +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv( + 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET') SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['email', 'username'] SOCIAL_AUTH_TWITTER_KEY = os.getenv('SOCIAL_AUTH_TWITTER_KEY') @@ -265,12 +267,13 @@ } } } -DOMAIN_NAME=os.getenv('DOMAIN_NAME') -PASSWORD_RESET_URL_PREFIX='https://{}/password/reset/?token='.format(DOMAIN_NAME) +PASSWORD_RESET_URL_PREFIX = '{}/api/users/password/reset/confirm/?token='.format( + DOMAIN) 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') +DEFAULT_EMAIL = "noreply@authorsheaven.com" django_heroku.settings(locals()) diff --git a/authors/templates/password_reset.html b/authors/templates/password_reset.html index 81965f8..0bb1014 100644 --- a/authors/templates/password_reset.html +++ b/authors/templates/password_reset.html @@ -16,12 +16,12 @@ height: 100%; margin: 0; line-height: 1.4; - background-color: #F5F7F9; - color: #839197; + background-color:white; + color:black; -webkit-text-size-adjust: none; } a { - color: #414EF9; + color: white; } /* Layout ------------------------------ */ @@ -94,7 +94,9 @@ .align-right { text-align: right; } - + .white-section a{ + color: white; + } /* Type ------------------------------ */ h1 { margin-top: 0; @@ -119,7 +121,7 @@ } p { margin-top: 0; - color: #839197; + color:black; font-size: 16px; line-height: 1.5em; text-align: left; @@ -135,7 +137,7 @@ .button { display: inline-block; width: 200px; - background-color: #414EF9; + background-color:white; border-radius: 3px; color: #ffffff; font-size: 15px; @@ -149,10 +151,10 @@ background-color: #28DB67; } .button--red { - background-color: rgb(226, 173, 185); + background-color:red; } .button--blue { - background-color: #414EF9; + background-color:dodgerblue; } /*Media Queries ------------------------------ */ @@ -177,7 +179,7 @@
- |
If you did not request a password reset, please ignore this email or reply to let us know. This password reset is only valid for the next 24 hours.
+If you did not request a password reset, please ignore this email. This password reset is only valid for the next 24 hours, so be sure to use it right away.
Thanks,
Authors Heaven team
P.S. We also love hearing from you and helping you with any issues you have. Please reply to this email if you want to ask a question or just say hi.
+P.S. We also love hearing from you and helping you with any issues you have. Please reach out to us if you want to ask a question or just say hi.
- © 2018 Authors Heaven. All rights reserved. +© 2019 Authors Heaven. All rights reserved.
Authors Heaven
|