Skip to content

Commit

Permalink
VerificationDevice model and Removal of 48hr email verification period (
Browse files Browse the repository at this point in the history
#1606)

Rebased master, and created new migration file to avoid migration merge conflicts

Rebased master with new changes

Rebased master, and created new migration file to avoid migration merge conflicts

VerificationDevice model and Removal of 48hr email verification period (#1606)

Solve migration merge conflicts
  • Loading branch information
valaparthvi committed Jul 19, 2017
1 parent f960306 commit 5bf21df
Show file tree
Hide file tree
Showing 23 changed files with 366 additions and 69 deletions.
9 changes: 5 additions & 4 deletions cadasta/accounts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@


class UserManager(DjangoUserManager):
def get_from_username_or_email(self, identifier=None):
users = self.filter(Q(username=identifier) | Q(email=identifier))
def get_from_username_or_email_or_phone(self, identifier=None):
users = self.filter(Q(username=identifier) | Q(
email=identifier) | Q(phone=identifier))
users_count = len(users)

if users_count == 1:
return users[0]

if users_count == 0:
error = _(
"User with username or email {} does not exist"
"User with username or email or phone {} does not exist"
).format(identifier)
raise self.model.DoesNotExist(error)
else:
error = _(
"More than one user found for username or email {}"
"More than one user found for username or email or phone {}"
).format(identifier)
raise self.model.MultipleObjectsReturned(error)
78 changes: 78 additions & 0 deletions cadasta/accounts/migrations/0007_phone_and_verification_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-07-17 16:50
from __future__ import unicode_literals

import accounts.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_otp.util


class Migration(migrations.Migration):

dependencies = [
('accounts', '0006_add_measurement_field'),
]

operations = [
migrations.CreateModel(
name='VerificationDevice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)),
('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')),
('unverified_phone', models.CharField(max_length=16, unique=True)),
('secret_key', models.CharField(default=accounts.models.default_key, help_text='Hex-encoded secret key to generate totp tokens.', max_length=40, unique=True, validators=[django_otp.util.hex_validator])),
('last_verified_counter', models.BigIntegerField(default=-1, help_text='The counter value of the latest verified token.The next token must be at a higher counter value.It makes sure a token is used only once.')),
('verified', models.BooleanField(default=False)),
],
options={
'verbose_name': 'Verification Device',
'abstract': False,
},
),
migrations.RemoveField(
model_name='historicaluser',
name='verify_email_by',
),
migrations.RemoveField(
model_name='user',
name='verify_email_by',
),
migrations.AddField(
model_name='historicaluser',
name='phone',
field=models.CharField(blank=True, db_index=True, default=None, max_length=16, null=True, verbose_name='phone number'),
),
migrations.AddField(
model_name='historicaluser',
name='phone_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='phone',
field=models.CharField(blank=True, default=None, max_length=16, null=True, unique=True, verbose_name='phone number'),
),
migrations.AddField(
model_name='user',
name='phone_verified',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='historicaluser',
name='email',
field=models.EmailField(blank=True, db_index=True, default=None, max_length=254, null=True, verbose_name='email address'),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True, verbose_name='email address'),
),
migrations.AddField(
model_name='verificationdevice',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
84 changes: 81 additions & 3 deletions cadasta/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@
from allauth.account.signals import password_changed, password_reset
from tutelary.models import Policy
from tutelary.decorators import permissioned_model
from django_otp.models import Device
from django_otp.oath import TOTP
from django_otp.util import random_hex, hex_validator
from binascii import unhexlify

import logging
import time

from simple_history.models import HistoricalRecords
from .manager import UserManager

logger = logging.getLogger("accounts.token")

PERMISSIONS_DIR = settings.BASE_DIR + '/permissions/'

Expand All @@ -32,12 +40,18 @@ def abstract_user_field(name):
class User(auth_base.AbstractBaseUser, auth.PermissionsMixin):
username = abstract_user_field('username')
full_name = models.CharField(_('full name'), max_length=130, blank=True)
email = abstract_user_field('email')
email = models.EmailField(
_('email address'), blank=True, null=True, default=None, unique=True
)
phone = models.CharField(
_('phone number'), max_length=16, null=True,
blank=True, default=None, unique=True
)
is_staff = abstract_user_field('is_staff')
is_active = abstract_user_field('is_active')
date_joined = abstract_user_field('date_joined')
email_verified = models.BooleanField(default=False)
verify_email_by = models.DateTimeField(default=now_plus_48_hours)
phone_verified = models.BooleanField(default=False)
change_pw = models.BooleanField(default=True)
language = models.CharField(max_length=10,
choices=settings.LANGUAGES,
Expand Down Expand Up @@ -76,7 +90,8 @@ def __repr__(self):
' full_name={obj.full_name}'
' email={obj.email}'
' email_verified={obj.email_verified}'
' verify_email_by={obj.verify_email_by}>')
' phone={obj.phone}'
' phone_verified={obj.phone_verified}>')
return repr_string.format(obj=self)

def get_display_name(self):
Expand Down Expand Up @@ -112,3 +127,66 @@ def password_changed_reset(sender, request, user, **kwargs):
[user.email],
fail_silently=False
)


def default_key():
return random_hex(20).decode()


class VerificationDevice(Device):
unverified_phone = models.CharField(max_length=16, unique=True)
secret_key = models.CharField(
max_length=40,
default=default_key,
validators=[hex_validator],
help_text="Hex-encoded secret key to generate totp tokens.",
unique=True,
)
last_verified_counter = models.BigIntegerField(
default=-1,
help_text=("The counter value of the latest verified token."
"The next token must be at a higher counter value."
"It makes sure a token is used only once.")
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
verified = models.BooleanField(default=False)

step = settings.TOTP_TOKEN_VALIDITY
digits = settings.TOTP_DIGITS

class Meta(Device.Meta):
verbose_name = "Verification Device"

@property
def bin_key(self):
return unhexlify(self.secret_key.encode())

def totp_obj(self):
totp = TOTP(key=self.bin_key, step=self.step, digits=self.digits)
totp.time = time.time()
return totp

def generate_challenge(self):
totp = self.totp_obj()
token = str(totp.token()).zfill(self.digits)

message = _("Your token for Cadasta is {token_value}."
" It is valid for {time_validity} minutes.")
message = message.format(
token_value=token, time_validity=self.step // 60)

logger.info("Token has been sent to %s " % self.unverified_phone)
logger.info("%s" % message)

return token

def verify_token(self, token):
totp = self.totp_obj()
if ((totp.t() > self.last_verified_counter) and
(totp.token() == token)):
self.last_verified_counter = totp.t()
verified = True
self.save()
else:
verified = False
return verified
4 changes: 1 addition & 3 deletions cadasta/accounts/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from django.conf import settings
from django.utils import timezone
from django.utils.translation import ugettext as _
from django.contrib.auth.password_validation import validate_password

Expand Down Expand Up @@ -133,8 +132,7 @@ class AccountLoginSerializer(djoser_serializers.LoginSerializer):
def validate(self, attrs):
attrs = super(AccountLoginSerializer, self).validate(attrs)

if (not self.user.email_verified and
timezone.now() > self.user.verify_email_by):
if not self.user.email_verified:
raise EmailNotVerifiedError

return attrs
Expand Down
1 change: 1 addition & 0 deletions cadasta/accounts/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Meta:

username = factory.Sequence(lambda n: "testuser%s" % n)
email = factory.Sequence(lambda n: "email_%[email protected]" % n)
phone = factory.Sequence(lambda n: "+123456789%s" % n)
password = ''

@classmethod
Expand Down
8 changes: 4 additions & 4 deletions cadasta/accounts/tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
class AccountAdapterTests(UserTestCase, TestCase):
@override_settings(CACHES={
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
})
})
def test_pre_authenticate(self):
UserFactory.create(username='john_snow', password='Winteriscoming!')
credentials = {'username': 'john_snow', 'password': 'knowsnothing'}
Expand All @@ -29,7 +29,7 @@ def test_pre_authenticate(self):

@override_settings(CACHES={
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
})
})
def test_pre_authenticate_fails(self):
UserFactory.create(username='john_snow', password='Winteriscoming!')
credentials = {'username': 'john_snow', 'password': 'knowsnothing'}
Expand Down Expand Up @@ -57,7 +57,7 @@ def test_pre_authenticate_fails(self):

@override_settings(CACHES={
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}
})
})
def test_pre_authenticate_maxes_out(self):
UserFactory.create(username='john_snow', password='Winteriscoming!')
credentials = {'username': 'john_snow', 'password': 'knowsnothing'}
Expand All @@ -67,7 +67,7 @@ def test_pre_authenticate_maxes_out(self):
cache_key = all_auth()._get_login_attempts_cache_key(
request, **credentials)

data = [None]*1000
data = [None] * 1000
dt = timezone.now()

data.append(time.mktime(dt.timetuple()))
Expand Down
16 changes: 7 additions & 9 deletions cadasta/accounts/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import random
import pytest

from core.tests.utils.cases import UserTestCase
from core.messages import SANITIZE_ERROR
Expand All @@ -7,7 +8,7 @@
from django.http import HttpRequest
from django.db import IntegrityError
from allauth.account.models import EmailAddress
from allauth.account.utils import send_email_confirmation

from django.test import TestCase
from django.utils.translation import gettext as _

Expand Down Expand Up @@ -390,15 +391,12 @@ def test_signup_with_released_email(self):

form = forms.ProfileForm(data, request=request, instance=user)
form.save()
with pytest.raises(EmailAddress.DoesNotExist):
EmailAddress.objects.get(email="[email protected]")

user = UserFactory.create(username='user2',
email='[email protected]')
try:
send_email_confirmation(request, user)
except IntegrityError:
assert False
else:
assert True
with pytest.raises(IntegrityError):
user = UserFactory.create(username='user2',
email='[email protected]')

def test_update_email_with_incorrect_password(self):
user = UserFactory.create(email='[email protected]',
Expand Down
19 changes: 14 additions & 5 deletions cadasta/accounts/tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,38 @@


class UserManagerTest(UserTestCase, TestCase):
def test_get_from_usernamel(self):
def test_get_from_username(self):
user = UserFactory.create()
found = User.objects.get_from_username_or_email(
found = User.objects.get_from_username_or_email_or_phone(
identifier=user.username
)

assert found == user

def test_get_from_email(self):
user = UserFactory.create()
found = User.objects.get_from_username_or_email(identifier=user.email)
found = User.objects.get_from_username_or_email_or_phone(
identifier=user.email)

assert found == user

def test_get_from_phone(self):
user = UserFactory.create()
found = User.objects.get_from_username_or_email_or_phone(
identifier=user.phone
)
assert found == user

def test_user_not_found(self):
with raises(User.DoesNotExist):
User.objects.get_from_username_or_email(identifier='username')
User.objects.get_from_username_or_email_or_phone(
identifier='username')

def test_mulitple_users_found(self):
UserFactory.create(username='[email protected]')
UserFactory.create(email='[email protected]')

with raises(User.MultipleObjectsReturned):
User.objects.get_from_username_or_email(
User.objects.get_from_username_or_email_or_phone(
identifier='[email protected]'
)
Loading

0 comments on commit 5bf21df

Please sign in to comment.