From 85f7d47ca575947fc8f70e220675109302903e83 Mon Sep 17 00:00:00 2001 From: Nikola Spalevic Date: Thu, 18 Mar 2021 23:08:29 +0100 Subject: [PATCH] Account verification process --- requirements/dev.txt | 1 + src/common/helpers/token.py | 112 ++++++++++++++++++ src/common/{helpers.py => helpers/urls.py} | 0 src/config/common.py | 2 + src/config/local.py | 2 +- src/notifications/notifications.py | 17 +++ src/notifications/services.py | 12 +- .../migrations/0005_auto_20210318_2132.py | 18 +++ src/users/models.py | 33 +++++- src/users/serializers.py | 11 +- .../templates/emails/user_reset_password.html | 2 +- .../templates/emails/verify_account.html | 3 + src/users/views.py | 15 ++- 13 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 src/common/helpers/token.py rename src/common/{helpers.py => helpers/urls.py} (100%) create mode 100644 src/notifications/notifications.py create mode 100644 src/users/migrations/0005_auto_20210318_2132.py create mode 100644 src/users/templates/emails/verify_account.html diff --git a/requirements/dev.txt b/requirements/dev.txt index 6867df8..1c17b07 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,6 +4,7 @@ ipdb==0.13.3 flake8==3.8.3 yapf==0.30.0 +django-extensions==3.1.1 # Testing factory-boy==2.12.0 diff --git a/src/common/helpers/token.py b/src/common/helpers/token.py new file mode 100644 index 0000000..2a6285e --- /dev/null +++ b/src/common/helpers/token.py @@ -0,0 +1,112 @@ +""" +Copyright (c) Django Software Foundation and individual contributors. +All rights reserved. +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of Django nor the names of its contributors may be used + to endorse or promote products derived from this software without + specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +from datetime import datetime + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.http import base36_to_int, int_to_base36, urlsafe_base64_decode, urlsafe_base64_encode + + +class EmailVerificationTokenGenerator: + """ + Strategy object used to generate and check tokens for the password + reset mechanism. + """ + key_salt = "django-email-verification.token" + algorithm = None + secret = settings.SECRET_KEY + + def make_token(self, user, expiry=None): + """ + Return a token that can be used once to do a password reset + for the given user. + Args: + user (Model): the user + expiry (datetime): optional forced expiry date + Returns: + (tuple): tuple containing: + token (str): the token + expiry (datetime): the expiry datetime + """ + if expiry is None: + return self._make_token_with_timestamp(user, self._num_seconds(self._now())) + return self._make_token_with_timestamp(user, self._num_seconds(expiry) - settings.EMAIL_TOKEN_LIFE) + + def check_token(self, token): + """ + Check that a password reset token is correct. + Args: + token (str): the token from the url + Returns: + (tuple): tuple containing: + valid (bool): True if the token is valid + user (Model): the user model if the token is valid + """ + + try: + email_b64, ts_b36, _ = token.split("-") + email = urlsafe_base64_decode(email_b64).decode() + user = get_user_model().objects.get(email=email) + ts = base36_to_int(ts_b36) + except (ValueError, get_user_model().DoesNotExist): + return False, None + + if not constant_time_compare(self._make_token_with_timestamp(user, ts)[0], token): + return False, None + + now = self._now() + if (self._num_seconds(now) - ts) > settings.EMAIL_TOKEN_LIFE: + return False, None + + return True, user + + def _make_token_with_timestamp(self, user, timestamp): + email_b64 = urlsafe_base64_encode(user.email.encode()) + ts_b36 = int_to_base36(timestamp) + hash_string = salted_hmac( + self.key_salt, + self._make_hash_value(user, timestamp), + secret=self.secret, + ).hexdigest() + return f'{email_b64}-{ts_b36}-{hash_string}', \ + datetime.fromtimestamp(timestamp + settings.EMAIL_TOKEN_LIFE) + + @staticmethod + def _make_hash_value(user, timestamp): + login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None) + return str(user.pk) + user.password + str(login_timestamp) + str(timestamp) + + @staticmethod + def _num_seconds(dt): + return int((dt - datetime(2001, 1, 1)).total_seconds()) + + @staticmethod + def _now(): + return datetime.now() + + +default_token_generator = EmailVerificationTokenGenerator() diff --git a/src/common/helpers.py b/src/common/helpers/urls.py similarity index 100% rename from src/common/helpers.py rename to src/common/helpers/urls.py diff --git a/src/config/common.py b/src/config/common.py index 110d175..d9c537c 100755 --- a/src/config/common.py +++ b/src/config/common.py @@ -83,6 +83,8 @@ EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost') EMAIL_PORT = os.getenv('EMAIL_PORT', 1025) EMAIL_FROM = os.getenv('EMAIL_FROM', 'noreply@somehost.local') +# the lifespan of the email link - verify account token (in seconds) +EMAIL_TOKEN_LIFE = os.getenv('EMAIL_TOKEN_LIFE', 60 * 60) # Celery BROKER_URL = os.getenv('BROKER_URL', 'redis://redis:6379') diff --git a/src/config/local.py b/src/config/local.py index 92fa3df..2f45dbf 100755 --- a/src/config/local.py +++ b/src/config/local.py @@ -1,6 +1,6 @@ from src.config.common import * # noqa # Testing -INSTALLED_APPS += ('django_nose', ) # noqa +INSTALLED_APPS += ('django_nose', 'django_extensions', ) # noqa TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' NOSE_ARGS = ['-s', '--nologcapture', '--with-progressive', '--with-fixture-bundling'] diff --git a/src/notifications/notifications.py b/src/notifications/notifications.py new file mode 100644 index 0000000..fbe67df --- /dev/null +++ b/src/notifications/notifications.py @@ -0,0 +1,17 @@ +ACTIVITY_USER_CREATED = 'new user registered' +ACTIVITY_USER_RESETS_PASS = 'started password reset process' + +NOTIFICATIONS = { + ACTIVITY_USER_CREATED: { + 'email': { + 'email_subject': 'Email Confirmation', + 'email_html_template': 'emails/verify_account.html', + } + }, + ACTIVITY_USER_RESETS_PASS: { + 'email': { + 'email_subject': 'Password Reset', + 'email_html_template': 'emails/user_reset_password.html', + } + } +} diff --git a/src/notifications/services.py b/src/notifications/services.py index b7f47e4..a0b3652 100644 --- a/src/notifications/services.py +++ b/src/notifications/services.py @@ -2,20 +2,10 @@ from actstream import action from src.notifications.channels.email import EmailChannel +from src.notifications.notifications import NOTIFICATIONS logger = logging.getLogger(__name__) -ACTIVITY_USER_RESETS_PASS = 'started password reset process' - -NOTIFICATIONS = { - ACTIVITY_USER_RESETS_PASS: { - 'email': { - 'email_subject': 'Password Reset', - 'email_html_template': 'emails/user_reset_password.html', - } - } -} - def _send_email(email_notification_config, context, to): email_html_template = email_notification_config.get('email_html_template') diff --git a/src/users/migrations/0005_auto_20210318_2132.py b/src/users/migrations/0005_auto_20210318_2132.py new file mode 100644 index 0000000..1d94c11 --- /dev/null +++ b/src/users/migrations/0005_auto_20210318_2132.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2021-03-18 21:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_auto_20210317_0720'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='is_active', + field=models.BooleanField(default=False, help_text='Designates whether this user should be treated as active.'), + ), + ] diff --git a/src/users/models.py b/src/users/models.py index a09d4cc..a5c5eab 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -1,7 +1,8 @@ import uuid +from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models.signals import post_save from django.dispatch import receiver -from django.contrib.auth.models import AbstractUser from rest_framework_simplejwt.tokens import RefreshToken from easy_thumbnails.fields import ThumbnailerImageField from django.urls import reverse @@ -9,8 +10,10 @@ from easy_thumbnails.signals import saved_file from easy_thumbnails.signal_handlers import generate_aliases_global -from src.common.helpers import build_absolute_uri -from src.notifications.services import notify, ACTIVITY_USER_RESETS_PASS +from src.common.helpers.token import default_token_generator +from src.common.helpers.urls import build_absolute_uri +from src.notifications.services import notify +from src.notifications.notifications import ACTIVITY_USER_CREATED, ACTIVITY_USER_RESETS_PASS @receiver(reset_password_token_created) @@ -23,7 +26,7 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, context = { 'username': reset_password_token.user.username, 'email': reset_password_token.user.email, - 'reset_password_url': build_absolute_uri(f'{reset_password_path}?token={reset_password_token.key}'), + 'link': build_absolute_uri(f'{reset_password_path}?token={reset_password_token.key}'), } notify(ACTIVITY_USER_RESETS_PASS, context=context, email_to=[reset_password_token.user.email]) @@ -32,6 +35,8 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, class User(AbstractUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) profile_picture = ThumbnailerImageField('ProfilePicture', upload_to='profile_pictures/', blank=True, null=True) + is_active = models.BooleanField(default=False, + help_text='Designates whether this user should be treated as active.') def get_tokens(self): refresh = RefreshToken.for_user(self) @@ -46,3 +51,23 @@ def __str__(self): saved_file.connect(generate_aliases_global) + + +@receiver(post_save, sender=User) +def user_created(sender, instance, created, **kwargs): + if not created: + return + + """ + Handles user created verification process + """ + # you can pass additional expiry param to make_token method + token, _ = default_token_generator.make_token(instance) + verify_account_path = reverse('user-verify_account') + context = { + 'username': instance.username, + 'email': instance.email, + 'link': build_absolute_uri(f'{verify_account_path}?token={token}'), + } + + notify(ACTIVITY_USER_CREATED, context=context, email_to=[instance.email]) diff --git a/src/users/serializers.py b/src/users/serializers.py index 005edc1..cc10e6b 100644 --- a/src/users/serializers.py +++ b/src/users/serializers.py @@ -16,16 +16,13 @@ class Meta: 'first_name', 'last_name', 'profile_picture', + 'is_active', ) - read_only_fields = ('username', ) + read_only_fields = ('username', 'is_active', ) class CreateUserSerializer(serializers.ModelSerializer): profile_picture = ThumbnailerJSONSerializer(required=False, allow_null=True, alias_target='src.users') - tokens = serializers.SerializerMethodField() - - def get_tokens(self, user): - return user.get_tokens() def create(self, validated_data): # call create_user on user object. Without this @@ -42,8 +39,8 @@ class Meta: 'first_name', 'last_name', 'email', - 'tokens', 'profile_picture', + 'is_active', ) - read_only_fields = ('tokens', ) + read_only_fields = ('is_active', ) extra_kwargs = {'password': {'write_only': True}} diff --git a/src/users/templates/emails/user_reset_password.html b/src/users/templates/emails/user_reset_password.html index 404af39..aeba3c8 100644 --- a/src/users/templates/emails/user_reset_password.html +++ b/src/users/templates/emails/user_reset_password.html @@ -1,6 +1,6 @@

Hi {{ username }},

You asked for password reset for this email account {{ email }}. Click on this link {{ reset_password_url }} to reset + >. Click on this link {{ link }} to reset your password

diff --git a/src/users/templates/emails/verify_account.html b/src/users/templates/emails/verify_account.html new file mode 100644 index 0000000..1d5c4c1 --- /dev/null +++ b/src/users/templates/emails/verify_account.html @@ -0,0 +1,3 @@ +

Hey {{ username }},

+

You are almost there.


+

Please click here to confirm your account.

diff --git a/src/users/views.py b/src/users/views.py index 02c62c5..c4e2439 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework import status +from src.common.helpers.token import default_token_generator from src.users.models import User from src.users.permissions import IsUserOrReadOnly from src.users.serializers import CreateUserSerializer, UserSerializer @@ -21,7 +22,8 @@ class UserViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, } permissions = { 'default': (IsUserOrReadOnly,), - 'create': (AllowAny,) + 'create': (AllowAny,), + 'verify_account': (AllowAny,) } def get_serializer_class(self): @@ -37,3 +39,14 @@ def get_user_data(self, instance): return Response(UserSerializer(self.request.user, context={'request': self.request}).data, status=status.HTTP_200_OK) except Exception as e: return Response({'error': 'Wrong auth token' + e}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['get'], url_path='verify', url_name='verify_account') + def verify_account(self, request): + token = request.query_params.get('token') or '' + valid, user = default_token_generator.check_token(token) + if valid: + user.is_active = True + user.save() + return Response(UserSerializer(self.request.user, context={'request': self.request}).data, status=status.HTTP_200_OK) + + return Response({'error': 'Bad verify token provided'}, status=status.HTTP_400_BAD_REQUEST)