Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Account verification process #9

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ipdb==0.13.3
flake8==3.8.3
yapf==0.30.0
django-extensions==3.1.1
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added as dev dependency
./manage.py show_urls is quite useful


# Testing
factory-boy==2.12.0
Expand Down
112 changes: 112 additions & 0 deletions src/common/helpers/token.py
Original file line number Diff line number Diff line change
@@ -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()
File renamed without changes.
2 changes: 2 additions & 0 deletions src/config/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost')
EMAIL_PORT = os.getenv('EMAIL_PORT', 1025)
EMAIL_FROM = os.getenv('EMAIL_FROM', '[email protected]')
# 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')
Expand Down
2 changes: 1 addition & 1 deletion src/config/local.py
Original file line number Diff line number Diff line change
@@ -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']
17 changes: 17 additions & 0 deletions src/notifications/notifications.py
Original file line number Diff line number Diff line change
@@ -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',
}
}
}
12 changes: 1 addition & 11 deletions src/notifications/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
18 changes: 18 additions & 0 deletions src/users/migrations/0005_auto_20210318_2132.py
Original file line number Diff line number Diff line change
@@ -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.'),
),
]
33 changes: 29 additions & 4 deletions src/users/models.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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
from django_rest_passwordreset.signals import reset_password_token_created
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)
Expand All @@ -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])
Expand All @@ -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)
Expand All @@ -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])
11 changes: 4 additions & 7 deletions src/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}}
2 changes: 1 addition & 1 deletion src/users/templates/emails/user_reset_password.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<p>Hi {{ username }},</p>
<p>
You asked for password reset for this email account <strong>{{ email }}</strong
>. Click on this link <a href="{{ reset_password_url }}">{{ reset_password_url }}</a> to reset
>. Click on this link <a href="{{ link }}">{{ link }}</a> to reset
your password
</p>
3 changes: 3 additions & 0 deletions src/users/templates/emails/verify_account.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p>Hey {{ username }},</p>
<p>You are almost there.</p><br>
<p>Please click <a href="{{ link }}">here</a> to confirm your account.</p>
15 changes: 14 additions & 1 deletion src/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe throw exception from token generator if it's invalid

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)