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 @@ - Authosr Heaven + Authors Heaven @@ -193,20 +195,20 @@

Hi {{name}},

-
+
-

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.

@@ -226,7 +228,7 @@

Hi {{name}},