Skip to content

Commit

Permalink
Rebase Master
Browse files Browse the repository at this point in the history
VerificationDevice model and Removal of 48hr email verification period (#1606)
  • Loading branch information
valaparthvi committed Jul 20, 2017
1 parent f843b0a commit 12640f5
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 12640f5

Please sign in to comment.