diff --git a/cadasta/accounts/backends.py b/cadasta/accounts/backends.py index 7c2c68f46..f74eb1ba1 100644 --- a/cadasta/accounts/backends.py +++ b/cadasta/accounts/backends.py @@ -1,5 +1,7 @@ from allauth.account.auth_backends import AuthenticationBackend as Backend +from django.contrib.auth.backends import ModelBackend from .models import User +from .validators import phone_validator class AuthenticationBackend(Backend): @@ -18,3 +20,16 @@ def _authenticate_by_email(self, **credentials): pass return None + + +class PhoneAuthenticationBackend(ModelBackend): + def authenticate(self, **credentials): + phone = credentials.get('phone', credentials.get('username')) + if phone_validator(phone): + try: + user = User.objects.get(phone__iexact=phone) + if user.check_password(credentials["password"]): + return user + except User.DoesNotExist: + pass + return None diff --git a/cadasta/accounts/forms.py b/cadasta/accounts/forms.py index 90c2cb19d..71af5c8e8 100644 --- a/cadasta/accounts/forms.py +++ b/cadasta/accounts/forms.py @@ -2,31 +2,50 @@ from django.conf import settings from django.utils.translation import ugettext as _ from django.contrib.auth.password_validation import validate_password + from allauth.account.utils import send_email_confirmation from allauth.account import forms as allauth_forms +from allauth.account.models import EmailAddress from core.form_mixins import SanitizeFieldsForm from .utils import send_email_update_notification -from .models import User -from .validators import check_username_case_insensitive +from .models import User, VerificationDevice +from .validators import check_username_case_insensitive, phone_validator +from .messages import phone_format from parsley.decorators import parsleyfy +from phonenumbers import parse as parse_phone @parsleyfy class RegisterForm(SanitizeFieldsForm, forms.ModelForm): - email = forms.EmailField(required=True) + email = forms.EmailField(required=False) + + phone = forms.RegexField(regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': phone_format}, + required=False) password = forms.CharField(widget=forms.PasswordInput()) MIN_LENGTH = 10 class Meta: model = User - fields = ['username', 'email', 'password', + fields = ['username', 'email', 'phone', 'password', 'full_name', 'language'] class Media: js = ('js/sanitize.js', ) + def clean(self): + super(RegisterForm, self).clean() + + email = self.data.get('email') + phone = self.data.get('phone') + + if (not phone) and (not email): + raise forms.ValidationError( + _("You cannot leave both phone and email empty." + " Signup with either phone or email or both.")) + def clean_username(self): username = self.data.get('username') check_username_case_insensitive(username) @@ -40,15 +59,24 @@ def clean_password(self): validate_password(password) errors = [] - email = self.data.get('email').split('@') - if len(email[0]) and email[0].casefold() in password.casefold(): - errors.append(_("Passwords cannot contain your email.")) + email = self.data.get('email') + if email: + email = email.split('@') + if email[0].casefold() in password.casefold(): + errors.append(_("Passwords cannot contain your email.")) username = self.data.get('username') if len(username) and username.casefold() in password.casefold(): errors.append( _("The password is too similar to the username.")) + phone = self.data.get('phone') + if phone: + if phone_validator(phone): + phone = str(parse_phone(phone).national_number) + if phone in password: + errors.append(_("Passwords cannot contain your phone.")) + if errors: raise forms.ValidationError(errors) @@ -56,11 +84,26 @@ def clean_password(self): def clean_email(self): email = self.data.get('email') - if User.objects.filter(email=email).exists(): - raise forms.ValidationError( - _("Another user with this email already exists")) + if email: + email = email.casefold() + if EmailAddress.objects.filter(email=email).exists(): + raise forms.ValidationError( + _("User with this Email address already exists.")) + else: + email = None return email + def clean_phone(self): + phone = self.data.get('phone') + if phone: + if VerificationDevice.objects.filter( + unverified_phone=phone).exists(): + raise forms.ValidationError( + _("User with this Phone number already exists.")) + else: + phone = None + return phone + def save(self, *args, **kwargs): user = super().save(*args, **kwargs) user.set_password(self.cleaned_data['password']) @@ -174,3 +217,33 @@ def clean_email(self): email = self.cleaned_data.get('email') self.users = User.objects.filter(email=email) return email + + +class PhoneVerificationForm(forms.Form): + token = forms.CharField(label=_("Token"), max_length=settings.TOTP_DIGITS) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + def clean_token(self): + token = self.data.get('token') + try: + token = int(token) + device = self.user.verificationdevice + if device.verify_token(token): + if self.user.phone != device.unverified_phone: + self.user.phone = device.unverified_phone + self.user.phone_verified = True + self.user.is_active = True + self.user.save() + elif device.verify_token(token, tolerance=5): + raise forms.ValidationError( + _("The token has expired." + " Please click on 'here' to receive the new token.")) + else: + raise forms.ValidationError( + "Invalid Token. Enter a valid token.") + except ValueError: + raise forms.ValidationError(_("Token must be a number.")) + return token diff --git a/cadasta/accounts/messages.py b/cadasta/accounts/messages.py new file mode 100644 index 000000000..dd0f7ac64 --- /dev/null +++ b/cadasta/accounts/messages.py @@ -0,0 +1,8 @@ +from django.utils.translation import ugettext as _ + + +phone_format = _( + "Phone numbers must be provided in the format +9999999999." + " Up to 15 digits allowed. Do not include hyphen or" + " blank spaces in between, at the beginning or at the end." +) diff --git a/cadasta/accounts/migrations/0008_phone_and_verification_device.py b/cadasta/accounts/migrations/0008_phone_and_verification_device.py index fac367f66..9b569bcb2 100644 --- a/cadasta/accounts/migrations/0008_phone_and_verification_device.py +++ b/cadasta/accounts/migrations/0008_phone_and_verification_device.py @@ -28,8 +28,8 @@ class Migration(migrations.Migration): ('verified', models.BooleanField(default=False)), ], options={ - 'abstract': False, 'verbose_name': 'Verification Device', + 'abstract': False, }, ), migrations.RemoveField( @@ -73,6 +73,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='verificationdevice', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ] diff --git a/cadasta/accounts/models.py b/cadasta/accounts/models.py index 5556b3d74..f0c44680f 100644 --- a/cadasta/accounts/models.py +++ b/cadasta/accounts/models.py @@ -156,7 +156,7 @@ class VerificationDevice(Device): "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) + user = models.OneToOneField(User, on_delete=models.CASCADE) verified = models.BooleanField(default=False) step = settings.TOTP_TOKEN_VALIDITY @@ -183,18 +183,18 @@ def generate_challenge(self): 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) + logger.debug("Token has been sent to %s " % self.unverified_phone) + logger.debug("%s" % message) return token - def verify_token(self, token): + def verify_token(self, token, tolerance=0): totp = self.totp_obj() if ((totp.t() > self.last_verified_counter) and - (totp.token() == token)): + (totp.verify(token, tolerance=tolerance))): self.last_verified_counter = totp.t() - verified = True + self.verified = True self.save() else: - verified = False - return verified + self.verified = False + return self.verified diff --git a/cadasta/accounts/serializers.py b/cadasta/accounts/serializers.py index 7626e9c17..2bb4dbe5f 100644 --- a/cadasta/accounts/serializers.py +++ b/cadasta/accounts/serializers.py @@ -2,25 +2,41 @@ from django.conf import settings from django.utils.translation import ugettext as _ from django.contrib.auth.password_validation import validate_password +from allauth.account.models import EmailAddress -from rest_framework.serializers import EmailField, ValidationError +from rest_framework import serializers from rest_framework.validators import UniqueValidator from djoser import serializers as djoser_serializers +from phonenumbers import parse as parse_phone from core.serializers import SanitizeFieldSerializer -from .models import User -from .validators import check_username_case_insensitive +from .models import User, VerificationDevice +from .validators import check_username_case_insensitive, phone_validator from .exceptions import EmailNotVerifiedError +from .messages import phone_format class RegistrationSerializer(SanitizeFieldSerializer, djoser_serializers.UserRegistrationSerializer): - email = EmailField( + email = serializers.EmailField( validators=[UniqueValidator( queryset=User.objects.all(), - message=_("Another user is already registered with this " - "email address") - )] + message=_("User with this Email address already exists.") + )], + allow_blank=True, + allow_null=True, + required=False + ) + phone = serializers.RegexField( + regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': phone_format}, + validators=[UniqueValidator( + queryset=User.objects.all(), + message=_("User with this Phone number already exists.") + )], + allow_blank=True, + allow_null=True, + required=False ) class Meta: @@ -29,21 +45,57 @@ class Meta: 'username', 'full_name', 'email', + 'phone', 'password', - 'email_verified', 'language', 'measurement', 'avatar', + 'email_verified', + 'phone_verified' ) extra_kwargs = { 'password': {'write_only': True}, - 'email': {'required': True, 'unique': True} + 'email': {'required': False, 'unique': True, + 'allow_null': True, 'allow_blank': True}, + 'phone': {'required': False, 'unique': True, + 'allow_null': True, 'allow_blank': True}, } + def validate(self, data): + data = super(RegistrationSerializer, self).validate(data) + + email = self.initial_data.get('email') + phone = self.initial_data.get('phone') + if (not phone) and (not email): + raise serializers.ValidationError( + _("You cannot leave both phone and email empty." + " Signup with either phone or email or both.")) + return data + + def validate_email(self, email): + if email: + email = email.casefold() + if EmailAddress.objects.filter(email=email).exists(): + raise serializers.ValidationError( + _("User with this Email address already exists.")) + else: + email = None + return email + + def validate_phone(self, phone): + if phone: + if VerificationDevice.objects.filter( + unverified_phone=phone).exists(): + raise serializers.ValidationError( + _("User with this Phone number already exists.")) + else: + phone = None + return phone + def validate_username(self, username): check_username_case_insensitive(username) if username.lower() in settings.CADASTA_INVALID_ENTITY_NAMES: - raise ValidationError( + raise serializers.ValidationError( _('Username cannot be “add” or “new”.')) return username @@ -51,9 +103,10 @@ def validate_password(self, password): validate_password(password) errors = [] - if self.initial_data.get('email'): - email = self.initial_data.get('email').split('@') - if len(email[0]) and email[0].casefold() in password.casefold(): + email = self.initial_data.get('email') + if email: + email = email.split('@') + if email[0].casefold() in password.casefold(): errors.append(_("Passwords cannot contain your email.")) username = self.initial_data.get('username') @@ -61,15 +114,21 @@ def validate_password(self, password): errors.append( _("The password is too similar to the username.")) + phone = self.initial_data.get('phone') + if phone: + if phone_validator(phone): + phone = str(parse_phone(phone).national_number) + if phone in password: + errors.append(_("Passwords cannot contain your phone.")) if errors: - raise ValidationError(errors) + raise serializers.ValidationError(errors) return password class UserSerializer(SanitizeFieldSerializer, djoser_serializers.UserSerializer): - email = EmailField( + email = serializers.EmailField( validators=[UniqueValidator( queryset=User.objects.all(), message=_("Another user is already registered with this " @@ -77,6 +136,20 @@ class UserSerializer(SanitizeFieldSerializer, )] ) avatar = S3Field(required=False) + language = serializers.ChoiceField( + choices=settings.LANGUAGES, + default=settings.LANGUAGE_CODE, + error_messages={ + 'invalid_choice': _('Language invalid or not available') + } + ) + measurement = serializers.ChoiceField( + choices=settings.MEASUREMENTS, + default=settings.MEASUREMENT_DEFAULT, + error_messages={ + 'invalid_choice': _('Measurement system invalid or not available') + } + ) class Meta: model = User @@ -101,11 +174,11 @@ def validate_username(self, username): if (username is not None and username != instance.username and self.context['request'].user != instance): - raise ValidationError('Cannot update username') + raise serializers.ValidationError('Cannot update username') if instance.username.casefold() != username.casefold(): check_username_case_insensitive(username) if username.lower() in settings.CADASTA_INVALID_ENTITY_NAMES: - raise ValidationError( + raise serializers.ValidationError( _('Username cannot be “add” or “new”.')) return username @@ -114,7 +187,8 @@ def validate_last_login(self, last_login): if instance is not None: if (last_login is not None and last_login != instance.last_login): - raise ValidationError(_('Cannot update last_login')) + raise serializers.ValidationError( + _('Cannot update last_login')) return last_login @@ -132,8 +206,8 @@ class ChangePasswordSerializer(djoser_serializers.SetPasswordRetypeSerializer): def validate(self, attrs): if not self.context['request'].user.change_pw: - raise ValidationError(_("The password for this user can not " - "be changed.")) + raise serializers.ValidationError( + _("The password for this user can not be changed.")) return super().validate(attrs) def validate_new_password(self, password): @@ -142,7 +216,7 @@ def validate_new_password(self, password): username = user.username if len(username) and username.casefold() in password.casefold(): - raise ValidationError( + raise serializers.ValidationError( _("The password is too similar to the username.")) return password diff --git a/cadasta/accounts/tests/test_backend.py b/cadasta/accounts/tests/test_backend.py index e0162f2ce..66bb90b01 100644 --- a/cadasta/accounts/tests/test_backend.py +++ b/cadasta/accounts/tests/test_backend.py @@ -1,7 +1,7 @@ from django.test import TestCase from allauth.account.models import EmailAddress from core.tests.utils.cases import UserTestCase -from ..backends import AuthenticationBackend +from ..backends import AuthenticationBackend, PhoneAuthenticationBackend from .factories import UserFactory @@ -35,3 +35,29 @@ def test_auth_in_username_field(self): credentials = {'username': 'miles@davis.co', 'password': 'PlayTh3Trumpet!'} assert self.backend._authenticate_by_email(**credentials) == self.user + + +class PhoneAuthBackendTest(UserTestCase, TestCase): + def setUp(self): + super().setUp() + self.user = UserFactory.create( + phone='+912345678990', password='PlayTh3Trumpet!' + ) + self.backend = PhoneAuthenticationBackend() + + def test_login_with_verified_phone(self): + credentials = {'phone': '+912345678990', + 'password': 'PlayTh3Trumpet!'} + assert self.backend.authenticate(**credentials) == self.user + + def test_login_for_inactive_account(self): + self.user.is_active = False + self.user.save() + credentials = {'phone': '+912345678990', + 'password': 'PlayTh3Trumpet!'} + assert self.backend.authenticate(**credentials) == self.user + + def test_login_with_non_existent_phone(self): + credentials = {'phone': '+912345612345', + 'password': 'PlayTh3Trumpet!'} + assert self.backend.authenticate(**credentials) is None diff --git a/cadasta/accounts/tests/test_forms.py b/cadasta/accounts/tests/test_forms.py index d1c28809e..84ffb790c 100644 --- a/cadasta/accounts/tests/test_forms.py +++ b/cadasta/accounts/tests/test_forms.py @@ -9,13 +9,16 @@ from django.db import IntegrityError from allauth.account.models import EmailAddress +from django.conf import settings from django.test import TestCase from django.utils.translation import gettext as _ from core.tests.utils.files import make_dirs # noqa +from unittest import mock from .. import forms -from ..models import User +from ..models import User, VerificationDevice +from ..messages import phone_format from .factories import UserFactory @@ -24,6 +27,7 @@ def test_valid_data(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', 'language': 'fr', @@ -46,6 +50,7 @@ def test_case_insensitive_username(self): data = { 'username': user.username.lower(), 'email': '%s@beatles.uk' % user.username, + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', } @@ -58,6 +63,7 @@ def test_case_insensitive_username(self): data = { 'username': 'johnLennon', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', } @@ -70,6 +76,7 @@ def test_password_contains_username(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Letsimagine71things?', 'full_name': 'John Lennon', } @@ -84,6 +91,7 @@ def test_password_contains_username_case_insensitive(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'LetsIMAGINE71things?', 'full_name': 'John Lennon', } @@ -98,6 +106,7 @@ def test_password_contains_blank_username(self): data = { 'username': '', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Letsimagine71things?', 'full_name': 'John Lennon', } @@ -111,6 +120,7 @@ def test_password_contains_email(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'IsJOHNreallythebest34?', 'full_name': 'John Lennon', } @@ -121,23 +131,11 @@ def test_password_contains_email(self): form.errors.get('password')) assert User.objects.count() == 0 - def test_password_contains_blank_email(self): - data = { - 'username': 'imagine71', - 'email': '', - 'password': 'Isjohnreallythebest34?', - 'full_name': 'John Lennon', - } - form = forms.RegisterForm(data) - - assert form.is_valid() is False - assert (form.errors.get('password') is None) - assert User.objects.count() == 0 - def test_password_contains_less_than_min_characters(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': '<3yoko', 'full_name': 'John Lennon', } @@ -153,6 +151,7 @@ def test_password_does_not_meet_unique_character_requirements(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'yokoisjustthebest', 'full_name': 'John Lennon', } @@ -169,6 +168,7 @@ def test_password_does_not_meet_unique_character_requirements(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'YOKOISJUSTTHEBEST', 'full_name': 'John Lennon', } @@ -187,13 +187,14 @@ def test_signup_with_existing_email(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79', 'full_name': 'John Lennon', } form = forms.RegisterForm(data) assert form.is_valid() is False - assert (_("Another user with this email already exists") + assert (_("User with this Email address already exists.") in form.errors.get('email')) assert User.objects.count() == 1 @@ -202,6 +203,7 @@ def test_signup_with_restricted_username(self): data = { 'username': random.choice(invalid_usernames), 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Iloveyoko68!', 'full_name': 'John Lennon' } @@ -216,6 +218,7 @@ def test_sanitize(self): data = { 'username': '😛😛😛😛', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Iloveyoko68!', 'full_name': 'John Lennon' } @@ -225,6 +228,240 @@ def test_sanitize(self): assert SANITIZE_ERROR in form.errors.get('username') assert User.objects.count() == 0 + def test_password_contains_blank_email(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + form = forms.RegisterForm(data) + assert form.is_valid() is True + assert (form.errors.get('password') is None) + + def test_password_contains_blank_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + form = forms.RegisterForm(data) + assert form.is_valid() is True + assert (form.errors.get('password') is None) + + def test_password_contains_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': 'holmes@9327768250', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("Passwords cannot contain your phone.") + in form.errors.get('password')) + assert User.objects.count() == 0 + + def test_signup_with_existing_phone(self): + UserFactory.create(phone='+919327768250') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("User with this Phone number already exists.") + in form.errors.get('phone')) + assert User.objects.count() == 1 + + def test_signup_with_invalid_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': 'Invalid Number', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+91-9067439937', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '9327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+91 9327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': ' +919327768250 ', + 'password': '221B@bakertstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': ' +919327768250137284721', + 'password': '221B@bakertstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + assert User.objects.count() == 0 + + def test_signup_with_blank_phone_and_email(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("You cannot leave both phone and email empty." + " Signup with either phone or email or both.") + in form.errors.get('__all__')) + + assert User.objects.count() == 0 + + def test_signup_with_phone_only(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + form = forms.RegisterForm(data) + form.save() + assert form.is_valid() is True + assert User.objects.count() == 1 + + user = User.objects.first() + assert user.email is None + assert user.check_password('221B@bakerstreet') is True + + def test_signup_with_email_only(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + form = forms.RegisterForm(data) + form.save() + assert form.is_valid() is True + assert User.objects.count() == 1 + + user = User.objects.first() + assert user.phone is None + assert user.check_password('221B@bakerstreet') is True + + def test_case_insensitive_email_check(self): + UserFactory.create(email='sherlock.holmes@bbc.uk') + data = { + 'username': 'sherlock', + 'email': 'SHERLOCK.HOLMES@BBC.UK', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("User with this Email address already exists.") + in form.errors.get('email')) + + assert User.objects.count() == 1 + + def test_signup_with_existing_email_in_EmailAddress(self): + user = UserFactory.create() + EmailAddress.objects.create(email='sherlock.holmes@bbc.uk', user=user) + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("User with this Email address already exists.") + in form.errors.get('email')) + + def test_signup_with_existing_phone_in_VerificationDevice(self): + user = UserFactory.create() + VerificationDevice.objects.create(unverified_phone='+919327768250', + user=user) + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.RegisterForm(data) + assert form.is_valid() is False + assert (_("User with this Phone number already exists.") + in form.errors.get('phone')) + @pytest.mark.usefixtures('make_dirs') class ProfileFormTest(UserTestCase, FileStorageTestCase, TestCase): @@ -668,3 +905,76 @@ def test_email_sent_reset(self): form = forms.ResetPasswordForm(data) assert form.is_valid() is True + + +class PhoneVerificationFormTest(UserTestCase, TestCase): + def setUp(self): + super().setUp() + self.user = UserFactory.create(username='sherlock', + phone='+919327768250', + ) + + def test_valid_token(self): + self.user.is_active = False + self.user.save() + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = self.user.verificationdevice.generate_challenge() + + data = { + 'token': token + } + form = forms.PhoneVerificationForm(data, user=self.user) + assert form.is_valid() is True + self.user.refresh_from_db() + assert self.user.phone_verified is True + assert self.user.is_active is True + + def test_invalid_token(self): + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = self.user.verificationdevice.generate_challenge() + token = str(int(token) - 1) + data = { + 'token': token + } + form = forms.PhoneVerificationForm(data, user=self.user) + assert form.is_valid() is False + assert (_("Invalid Token. Enter a valid token.") + in form.errors.get('token')) + + def test_expired_token(self): + _now = 1497657600 + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + with mock.patch('time.time', return_value=_now): + token = self.user.verificationdevice.generate_challenge() + data = {'token': token} + + with mock.patch('time.time', return_value=( + _now + settings.TOTP_TOKEN_VALIDITY + 1)): + form = forms.PhoneVerificationForm(data, user=self.user) + assert form.is_valid() is False + assert (_("The token has expired." + " Please click on 'here' to receive the new token.") + in form.errors.get('token')) + + def test_valid_token_update_phone(self): + VerificationDevice.objects.create( + user=self.user, unverified_phone='+12345678990') + token = self.user.verificationdevice.generate_challenge() + + data = {'token': token} + form = forms.PhoneVerificationForm(data, user=self.user) + assert form.is_valid() is True + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + + def test_invalid_token_format(self): + VerificationDevice.objects.create(user=self.user, + unverified_phone=self.user.phone) + data = {'token': 'TOKEN'} + form = forms.PhoneVerificationForm(data, user=self.user) + assert form.is_valid() is False + assert (_("Token must be a number.") in form.errors.get('token')) diff --git a/cadasta/accounts/tests/test_models.py b/cadasta/accounts/tests/test_models.py index 12da3784e..46e221540 100644 --- a/cadasta/accounts/tests/test_models.py +++ b/cadasta/accounts/tests/test_models.py @@ -1,9 +1,13 @@ +import pytest + from datetime import datetime from django.conf import settings from django.test import TestCase from .factories import UserFactory from core.tests.utils.cases import UserTestCase from unittest import mock +from django.db import IntegrityError +from ..models import VerificationDevice class UserTest(TestCase): @@ -43,12 +47,14 @@ class VerificationDeviceTest(UserTestCase, TestCase): def setUp(self): super().setUp() - self.sherlock = UserFactory.create() - self.sherlock.verificationdevice_set.create( + self.sherlock = UserFactory.create(username='sherlock') + VerificationDevice.objects.create( + user=self.sherlock, unverified_phone=self.sherlock.phone) - self.john = UserFactory.create() - self.john.verificationdevice_set.create( + self.john = UserFactory.create(username='john') + VerificationDevice.objects.create( + user=self.john, unverified_phone=self.john.phone) self.TOTP_TOKEN_VALIDITY = settings.TOTP_TOKEN_VALIDITY @@ -56,7 +62,7 @@ def setUp(self): def test_instant(self): """Verify token as soon as it is created""" - device = self.sherlock.verificationdevice_set.get() + device = self.sherlock.verificationdevice with mock.patch('time.time', return_value=self._now): token = device.generate_challenge() verified = device.verify_token(int(token)) @@ -65,7 +71,7 @@ def test_instant(self): def test_barely_made_it(self): """Verify token 1 second before it expires""" - device = self.sherlock.verificationdevice_set.get() + device = self.sherlock.verificationdevice with mock.patch('time.time', return_value=self._now): token = device.generate_challenge() @@ -77,7 +83,7 @@ def test_barely_made_it(self): def test_too_late(self): """Verify token 1 second after it expires""" - device = self.sherlock.verificationdevice_set.get() + device = self.sherlock.verificationdevice with mock.patch('time.time', return_value=self._now): token = device.generate_challenge() @@ -89,7 +95,7 @@ def test_too_late(self): def test_future(self): """Verify token from the future. Time Travel!!""" - device = self.sherlock.verificationdevice_set.get() + device = self.sherlock.verificationdevice with mock.patch('time.time', return_value=self._now + 1): token = device.generate_challenge() @@ -100,7 +106,7 @@ def test_future(self): def test_code_reuse(self): """Verify same token twice""" - device = self.sherlock.verificationdevice_set.get() + device = self.sherlock.verificationdevice with mock.patch('time.time', return_value=self._now): token = device.generate_challenge() @@ -112,8 +118,8 @@ def test_code_reuse(self): def test_cross_user(self): """Verify token generated by one device with that of another""" - device_sherlock = self.sherlock.verificationdevice_set.get() - device_john = self.john.verificationdevice_set.get() + device_sherlock = self.sherlock.verificationdevice + device_john = self.john.verificationdevice with mock.patch('time.time', return_value=self._now): token = device_sherlock.generate_challenge() @@ -121,30 +127,32 @@ def test_cross_user(self): assert verified is False - def test_token_invalid(self): - """Verify an invalid token""" - device = self.sherlock.verificationdevice_set.get() - - with mock.patch('time.time', return_value=self._now): + def test_create_two_devices_for_same_user(self): + """Try to create 2 device for same user""" + self.moriarty = UserFactory.create(username="jim") + VerificationDevice.objects.create(user=self.moriarty, + unverified_phone=self.moriarty.phone) + with pytest.raises(IntegrityError): + VerificationDevice.objects.create( + user=self.moriarty, + unverified_phone='+919067439937') + self.sherlock.save() + + def test_create_two_devices_with_same_number(self): + """Try to create 2 devices with same phone number""" + self.moriarty = UserFactory.create(username='jim') + with pytest.raises(IntegrityError): + VerificationDevice.objects.create( + user=self.moriarty, + unverified_phone=self.sherlock.phone) + + def test_token_tolerance(self): + """Test tolerance of a token""" + device = self.sherlock.verificationdevice + with mock.patch('time.time', return_value=( + self._now + (settings.TOTP_TOKEN_VALIDITY * 2))): token = device.generate_challenge() - verified_invalid_token = device.verify_token('ABCDEF') - verified_valid_token = device.verify_token(int(token)) - - assert verified_invalid_token is False - assert verified_valid_token is True - - def test_two_unverified_phone(self): - """Verify token generated by device 1 with device 2 of user""" - self.sherlock.verificationdevice_set.create( - unverified_phone='+919067439937') - - device_1 = self.sherlock.verificationdevice_set.get( - unverified_phone=self.sherlock.phone) - device_2 = self.sherlock.verificationdevice_set.get( - unverified_phone='+919067439937') - with mock.patch('time.time', return_value=self._now): - token_device_1 = device_1.generate_challenge() - verified_device_2 = device_2.verify_token(token_device_1) + verified = device.verify_token(token=int(token), tolerance=2) - assert verified_device_2 is False + assert verified is True diff --git a/cadasta/accounts/tests/test_serializers.py b/cadasta/accounts/tests/test_serializers.py index 7d96c5218..c1b9b8e0c 100644 --- a/cadasta/accounts/tests/test_serializers.py +++ b/cadasta/accounts/tests/test_serializers.py @@ -3,22 +3,25 @@ from django.utils.translation import gettext as _ from django.test import TestCase +from django.db import IntegrityError +from allauth.account.models import EmailAddress from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.request import Request from core.messages import SANITIZE_ERROR from core.tests.utils.cases import UserTestCase, FileStorageTestCase from .. import serializers -from ..models import User +from ..models import User, VerificationDevice from ..exceptions import EmailNotVerifiedError from core.tests.utils.files import make_dirs # noqa - +from ..messages import phone_format from .factories import UserFactory BASIC_TEST_DATA = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', 'language': 'en', @@ -31,6 +34,7 @@ def test_field_serialization(self): user = UserFactory.build() serializer = serializers.RegistrationSerializer(user) assert 'email_verified' in serializer.data + assert 'phone_verified' in serializer.data assert 'password' not in serializer.data def test_create_with_valid_data(self): @@ -44,6 +48,7 @@ def test_create_with_valid_data(self): assert user_obj.check_password(BASIC_TEST_DATA['password']) assert user_obj.is_active assert not user_obj.email_verified + assert not user_obj.phone_verified def test_case_insensitive_username(self): usernames = ['UsErOne', 'useRtWo', 'uSERthReE'] @@ -68,6 +73,8 @@ def test_create_without_email(self): data = { 'username': 'imagine71', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -77,11 +84,13 @@ def test_create_without_email(self): assert serializer.is_valid() is False def test_sanitize(self): - """Serialiser should be invalid when no email address is provided.""" data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': '😀😃😄😁😆😅', @@ -100,6 +109,9 @@ def test_create_with_existing_email(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -107,7 +119,7 @@ def test_create_with_existing_email(self): serializer = serializers.RegistrationSerializer(data=data) assert serializer.is_valid() is False - assert (_("Another user is already registered with this email address") + assert (_("User with this Email address already exists.") in serializer._errors['email']) def test_create_with_restricted_username(self): @@ -115,6 +127,9 @@ def test_create_with_restricted_username(self): data = { 'username': random.choice(invalid_usernames), 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -129,6 +144,9 @@ def test_password_contains_username(self): data = { 'username': 'yoko79', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -142,6 +160,9 @@ def test_password_contains_username_case_insensitive(self): data = { 'username': 'yoko79', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveYOKO79!', 'password_repeat': 'iloveYOKO79!', 'full_name': 'John Lennon', @@ -155,6 +176,9 @@ def test_password_contains_blank_username(self): data = { 'username': '', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -167,6 +191,9 @@ def test_password_contains_email(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'johnisjustheBest!!', 'password_repeat': 'johnisjustheBest!!', 'full_name': 'John Lennon', @@ -180,18 +207,24 @@ def test_password_contains_blank_email(self): data = { 'username': 'imagine71', 'email': '', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'johnisjustheBest!!', 'password_repeat': 'johnisjustheBest!!', 'full_name': 'John Lennon', } serializer = serializers.RegistrationSerializer(data=data) - assert serializer.is_valid() is False + assert serializer.is_valid() assert ('password' not in serializer._errors) def test_password_contains_less_than_min_characters(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'yoko<3', 'password_repeat': 'yoko<3', 'full_name': 'John Lennon', @@ -206,6 +239,9 @@ def test_password_does_not_meet_unique_character_requirements(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko', 'password_repeat': 'iloveyoko', 'full_name': 'John Lennon', @@ -218,6 +254,252 @@ def test_password_does_not_meet_unique_character_requirements(self): " special characters, and/or numerical character.\n" ) in serializer._errors['password']) + def test_password_contains_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': 'holmes@9327768250', + 'password_repeat': 'holmes@9327768250', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert (_("Passwords cannot contain your phone.") + in serializer._errors['password']) + + def test_password_contains_blank_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is True + assert ('password' not in serializer._errors) + + def test_signup_with_phone_only(self): + data = { + 'username': 'sherlock', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'language': 'en', + 'measurement': 'metric', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is True + serializer.save() + assert User.objects.count() == 1 + + user_obj = User.objects.first() + assert user_obj.check_password(data['password']) + assert user_obj.is_active + assert not user_obj.phone_verified + + def test_signup_with_email_only(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is True + serializer.save() + assert User.objects.count() == 1 + + user_obj = User.objects.first() + assert user_obj.check_password(data['password']) + assert user_obj.is_active + assert not user_obj.email_verified + + def test_signup_with_existing_phone(self): + UserFactory.create(phone='+919327768250') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert (_("User with this Phone number already exists.") + in serializer._errors['phone']) + + def test_signup_with_blank_phone_and_email(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("You cannot leave both phone and email empty." + " Signup with either phone or email or both.") + in serializer._errors['non_field_errors']) + + def test_signup_without_phone_and_email(self): + data = { + 'username': 'sherlock', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("You cannot leave both phone and email empty." + " Signup with either phone or email or both.") + in serializer._errors['non_field_errors']) + + def test_signup_with_invalid_phone(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': 'Test Number', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'password_repeat': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert phone_format in serializer._errors['phone'] + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+91-9067439937', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + + assert phone_format in serializer._errors['phone'] + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '9067439937', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + + assert phone_format in serializer._errors['phone'] + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '9067439937', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + + assert phone_format in serializer._errors['phone'] + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+91 9067439937', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + + assert phone_format in serializer._errors['phone'] + + def test_insensitive_email_check(self): + UserFactory.create(email='sherlock.holmes@bbc.uk') + data = { + 'username': 'sherlock', + 'email': 'SHERLOCK.HOLMES@BBC.UK', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is True + + with pytest.raises(IntegrityError): + serializer.save() + + def test_signup_with_existing_email_in_EmailAddress(self): + user = UserFactory.create() + EmailAddress.objects.create(user=user, email='sherlock.holmes@bbc.uk') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("User with this Email address already exists.") + in serializer.errors['email']) + + def test_signup_with_existing_phone_in_VerificationDevice(self): + user = UserFactory.create() + VerificationDevice.objects.create(user=user, + unverified_phone='+919327768250') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + serializer = serializers.RegistrationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("User with this Phone number already exists.") + in serializer.errors['phone']) + @pytest.mark.usefixtures('make_dirs') class UserSerializerTest(UserTestCase, FileStorageTestCase, TestCase): diff --git a/cadasta/accounts/tests/test_urls_default.py b/cadasta/accounts/tests/test_urls_default.py index 83c0edf97..80302df60 100644 --- a/cadasta/accounts/tests/test_urls_default.py +++ b/cadasta/accounts/tests/test_urls_default.py @@ -24,3 +24,14 @@ def test_verify_email(self): resolved = resolve('/account/confirm-email/123/') assert resolved.func.__name__ == default.ConfirmEmail.__name__ assert resolved.kwargs['key'] == '123' + + def test_signup(self): + assert reverse('account:register') == '/account/signup/' + resolved = resolve('/account/signup/') + assert resolved.func.__name__ == default.AccountRegister.__name__ + + def test_verify_phone(self): + assert reverse( + 'account:verify_phone') == '/account/accountverification/' + resolved = resolve('/account/accountverification/') + assert resolved.func.__name__ == default.ConfirmPhone.__name__ diff --git a/cadasta/accounts/tests/test_validators.py b/cadasta/accounts/tests/test_validators.py new file mode 100644 index 000000000..375301795 --- /dev/null +++ b/cadasta/accounts/tests/test_validators.py @@ -0,0 +1,20 @@ +from ..validators import phone_validator +from unittest import TestCase + + +class PhoneValidatorTest(TestCase): + def test_valid_phone(self): + phone = '+91937768250' + assert phone_validator(phone) is True + + def test_invalid_phone(self): + phone = 'Test Number' + assert phone_validator(phone) is False + + def test_invalid_phone_without_country_code(self): + phone = '9327768250' + assert phone_validator(phone) is False + + def test_invalid_phone_with_white_spaces(self): + phone = '+91 9327768250' + assert phone_validator(phone) is False diff --git a/cadasta/accounts/tests/test_views_api.py b/cadasta/accounts/tests/test_views_api.py index c4bf0b168..bf6a7b066 100644 --- a/cadasta/accounts/tests/test_views_api.py +++ b/cadasta/accounts/tests/test_views_api.py @@ -4,7 +4,7 @@ from skivvy import APITestCase from core.tests.utils.cases import UserTestCase -from ..models import User +from ..models import User, VerificationDevice from ..views import api as api_views from .factories import UserFactory @@ -74,6 +74,7 @@ def test_user_signs_up(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', } @@ -94,6 +95,33 @@ def test_user_signs_up_with_invalid(self): assert response.status_code == 400 assert User.objects.count() == 0 assert len(mail.outbox) == 0 + assert VerificationDevice.objects.count() == 0 + + def test_user_signs_up_with_phone_only(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 201 + assert User.objects.count() == 1 + assert VerificationDevice.objects.count() == 1 + + def test_user_signs_up_with_email_only(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 201 + assert User.objects.count() == 1 + assert len(mail.outbox) == 1 class AccountLoginTest(APITestCase, UserTestCase, TestCase): diff --git a/cadasta/accounts/tests/test_views_default.py b/cadasta/accounts/tests/test_views_default.py index 1032f4483..96bb5b648 100644 --- a/cadasta/accounts/tests/test_views_default.py +++ b/cadasta/accounts/tests/test_views_default.py @@ -2,7 +2,9 @@ from django.core.urlresolvers import reverse_lazy from django.test import TestCase from django.core import mail +from django.conf import settings from skivvy import ViewTestCase +from unittest import mock from accounts.tests.factories import UserFactory from core.tests.utils.cases import UserTestCase @@ -10,8 +12,79 @@ from allauth.account.models import EmailConfirmation, EmailAddress from allauth.account.forms import ChangePasswordForm +from accounts.models import User, VerificationDevice from ..views import default from ..forms import ProfileForm +from django.test import RequestFactory +from django.contrib.messages.storage.fallback import FallbackStorage + + +class RegisterTest(ViewTestCase, UserTestCase, TestCase): + view_class = default.AccountRegister + template = 'accounts/signup.html' + + def test_user_signs_up(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert User.objects.count() == 1 + assert VerificationDevice.objects.count() == 1 + assert len(mail.outbox) == 1 + user = User.objects.first() + assert user.check_password('221B@bakerstreet') is True + assert '/account/accountverification/' in response.location + + def test_signs_up_with_invalid(self): + data = { + 'username': 'sherlock', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert User.objects.count() == 0 + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 0 + + def test_signs_up_with_phone_only(self): + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert User.objects.count() == 1 + assert VerificationDevice.objects.count() == 1 + assert len(mail.outbox) == 0 + assert 'account/accountverification/' in response.location + + def test_signs_up_with_email_only(self): + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'language': 'fr' + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert User.objects.count() == 1 + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 1 + assert 'account/accountverification/' in response.location class ProfileTest(ViewTestCase, UserTestCase, TestCase): @@ -228,3 +301,95 @@ def test_mail_not_sent(self): response = self.request(method='POST', post_data=data) assert response.status_code == 302 assert len(mail.outbox) == 0 + + +class ConfirmPhoneViewTest(UserTestCase, TestCase): + def setUp(self): + super().setUp() + + self.user = UserFactory.create() + EmailAddress.objects.create( + user=self.user, email=self.user.email) + self.factory = RequestFactory() + + def test_successful_phone_verification(self): + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + token = self.device.generate_challenge() + data = {'token': token} + + request = self.factory.post('/account/accountverification/', data=data) + request.session = {"user_id": self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.ConfirmPhone.as_view()(request) + + self.user.refresh_from_db() + + assert response.status_code == 302 + assert self.user.phone_verified is True + assert self.user.is_active is True + + def test_unsuccessful_phone_verification_with_invalid_token(self): + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + token = self.device.generate_challenge() + token = str(int(token) - 1) + data = {'token': token} + + request = self.factory.post('/account/accountverification/', data=data) + request.session = {"user_id": self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + + response = default.ConfirmPhone.as_view()(request) + + self.user.refresh_from_db() + + assert response.status_code == 200 + assert self.user.phone_verified is False + + def test_unsuccessful_phone_verification_with_expired_token(self): + self._now = 1497657600 + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + with mock.patch('time.time', return_value=self._now): + token = self.device.generate_challenge() + + data = {'token': token} + request = self.factory.post( + '/account/accountverification/', data=data) + request.session = {"user_id": self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + + with mock.patch('time.time', return_value=( + self._now + settings.TOTP_TOKEN_VALIDITY + 1)): + response = default.ConfirmPhone.as_view()(request) + + assert response.status_code == 200 + self.user.refresh_from_db() + assert self.user.phone_verified is False + + def test_successful_phone_verification_new_phone(self): + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone='+919327768250') + + token = self.device.generate_challenge() + data = {'token': token} + + request = self.factory.post('/account/accountverification/', data=data) + request.session = {"user_id": self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.ConfirmPhone.as_view()(request) + + self.user.refresh_from_db() + + assert response.status_code == 302 + assert self.user.phone == '+919327768250' + assert self.user.phone_verified is True + assert self.user.is_active is True diff --git a/cadasta/accounts/urls/default.py b/cadasta/accounts/urls/default.py index 256aa42a1..6d9cf25f9 100644 --- a/cadasta/accounts/urls/default.py +++ b/cadasta/accounts/urls/default.py @@ -3,6 +3,7 @@ from ..views import default urlpatterns = [ + url(r'^signup/$', default.AccountRegister.as_view(), name='register'), url(r'^profile/$', default.AccountProfile.as_view(), name='profile'), url(r'^login/$', default.AccountLogin.as_view(), name='login'), url(r'^confirm-email/(?P[-:\w]+)/$', default.ConfirmEmail.as_view(), @@ -14,4 +15,6 @@ name="account_reset_password_from_key"), url(r'^password/reset/$', default.PasswordResetView.as_view(), name="account_reset_password"), + url(r'^accountverification/$', + default.ConfirmPhone.as_view(), name='verify_phone'), ] diff --git a/cadasta/accounts/validators.py b/cadasta/accounts/validators.py index a377fecd7..e6494ead5 100644 --- a/cadasta/accounts/validators.py +++ b/cadasta/accounts/validators.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ +import re from .models import User DEFAULT_CHARACTER_TYPES = [ @@ -59,3 +60,11 @@ def check_username_case_insensitive(username): raise ValidationError( _("A user with that username already exists") ) + + +def phone_validator(phone): + pattern = r'^\+(?:[0-9]?){6,14}[0-9]$' + if re.match(pattern=pattern, string=str(phone)): + return True + else: + return False diff --git a/cadasta/accounts/views/api.py b/cadasta/accounts/views/api.py index d596e9fdd..956088db2 100644 --- a/cadasta/accounts/views/api.py +++ b/cadasta/accounts/views/api.py @@ -12,6 +12,7 @@ from .. import serializers from ..utils import send_email_update_notification from ..exceptions import EmailNotVerifiedError +from ..models import VerificationDevice class AccountUser(djoser_views.UserView): @@ -41,7 +42,13 @@ def perform_create(self, serializer): signals.user_registered.send(sender=self.__class__, user=user, request=self.request) - send_email_confirmation(self.request._request, user) + if user.email: + send_email_confirmation(self.request._request, user) + if user.phone: + verification_device = VerificationDevice.objects.create( + user=user, + unverified_phone=user.phone) + verification_device.generate_challenge() class AccountLogin(djoser_views.LoginView): diff --git a/cadasta/accounts/views/default.py b/cadasta/accounts/views/default.py index a28d62a3e..31cc42da2 100644 --- a/cadasta/accounts/views/default.py +++ b/cadasta/accounts/views/default.py @@ -2,8 +2,9 @@ from django.utils.translation import ugettext_lazy as _ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import FormView -from core.views.generic import UpdateView +from core.views.generic import UpdateView, CreateView from core.views.mixins import SuperUserCheckMixin import allauth.account.views as allauth_views @@ -11,10 +12,43 @@ from allauth.account.utils import send_email_confirmation from allauth.account.models import EmailAddress -from ..models import User +from ..models import User, VerificationDevice from .. import forms +class AccountRegister(CreateView): + model = User + form_class = forms.RegisterForm + template_name = 'account/signup.html' + success_url = reverse_lazy('account:verify_phone') + + def form_valid(self, form): + user = form.save(self.request) + + if user.email: + send_email_confirmation(self.request, user) + + if user.phone: + device = VerificationDevice.objects.create( + user=user, unverified_phone=user.phone) + device.generate_challenge() + + message = _("Verification Token sent to {phone}") + message = message.format(phone=user.phone) + messages.add_message(self.request, messages.INFO, message) + + self.request.session['user_id'] = user.id + + message = _("We have created your account. You should have" + " received an email or a text to verify your account.") + messages.add_message(self.request, messages.SUCCESS, message) + + return super().form_valid(form) + + def form_invalid(self, form): + return super().form_invalid(form) + + class PasswordChangeView(LoginRequiredMixin, SuperUserCheckMixin, allauth_views.PasswordChangeView): @@ -86,3 +120,37 @@ def post(self, *args, **kwargs): user.save() return response + + +class ConfirmPhone(FormView): + template_name = 'accounts/account_verification.html' + form_class = forms.PhoneVerificationForm + success_url = reverse_lazy('account:login') + + def get_user(self): + user_id = self.request.session['user_id'] + user = User.objects.get(id=user_id) + return user + + def get_form_kwargs(self, *args, **kwargs): + form_kwargs = super().get_form_kwargs(*args, **kwargs) + form_kwargs["user"] = self.get_user() + return form_kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user = self.get_user() + if user.emailaddress_set.filter(verified=False).exists(): + context['email'] = user.email + if VerificationDevice.objects.filter( + user=user, verified=False).exists(): + context['phone'] = user.phone + return context + + def form_valid(self, form): + user = self.get_user() + user.refresh_from_db() + message = _("Successfully verified {phone}") + message = message.format(phone=user.phone) + messages.add_message(self.request, messages.SUCCESS, message) + return super().form_valid(form) diff --git a/cadasta/config/settings/default.py b/cadasta/config/settings/default.py index f31b89307..7bd339970 100644 --- a/cadasta/config/settings/default.py +++ b/cadasta/config/settings/default.py @@ -137,7 +137,8 @@ AUTHENTICATION_BACKENDS = [ 'core.backends.Auth', 'django.contrib.auth.backends.ModelBackend', - 'accounts.backends.AuthenticationBackend' + 'accounts.backends.AuthenticationBackend', + 'accounts.backends.PhoneAuthenticationBackend' ] ACCOUNT_AUTHENTICATION_METHOD = 'username_email' diff --git a/cadasta/templates/accounts/account_verification.html b/cadasta/templates/accounts/account_verification.html new file mode 100644 index 000000000..3362d613e --- /dev/null +++ b/cadasta/templates/accounts/account_verification.html @@ -0,0 +1,27 @@ +{% extends "core/base.html" %} +{% load i18n %} +{% load widget_tweaks %} +{% block head_title %} {% trans "Account Verification" %}{% endblock %} +{% block content %} +
+

{% trans "Account Verification" %}

+ {% if email %} +

{% blocktrans %}To verify your email address, click on the verification link sent to your registered email address.{% endblocktrans %}

+ {% endif %} + {% if phone %} +

{% blocktrans %}To verify your phone number, enter the one-time password sent to your registered phone number.{% endblocktrans%}

+ +
+ {% csrf_token %} +
+ + {% render_field form.token class+="form-control input-lg" data-parsley-required="true" data-parsley-sanitize="1" %} +
{{ form.token.errors }}
+
+ +
+ {% endif %} +

{% blocktrans %} Click here if you did not receive any email or text.{% endblocktrans %}

+
+{% endblock %} + diff --git a/cadasta/templates/allauth/account/signup.html b/cadasta/templates/allauth/account/signup.html index a68d2114e..a5aeb51da 100644 --- a/cadasta/templates/allauth/account/signup.html +++ b/cadasta/templates/allauth/account/signup.html @@ -56,6 +56,12 @@

{% trans "Register for a free account" %}

{{ form.email.errors }}
+
+ + {% render_field form.phone class+='form-control input-lg' data-parsley-santize="1" %} +
{{form.phone.errors}}
+
+
@@ -66,7 +72,7 @@

{% trans "Register for a free account" %}

- +
{{ form.password.errors }}
diff --git a/requirements/common.txt b/requirements/common.txt index f0c1087a1..c7cf0a9ca 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -39,4 +39,5 @@ django-compressor==2.1.1 beautifulsoup4==4.6.0 gpxpy==1.1.2 django-otp==0.3.11 -twilio==6.3.0 \ No newline at end of file +twilio==6.3.0 +phonenumbers==8.5.2