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/exceptions.py b/cadasta/accounts/exceptions.py index 2f9de0043..3ed72b4b6 100644 --- a/cadasta/accounts/exceptions.py +++ b/cadasta/accounts/exceptions.py @@ -1,2 +1,25 @@ -class EmailNotVerifiedError(BaseException): - pass +from django.utils.translation import ugettext as _ + + +class EmailNotVerifiedError(Exception): + + def __init__(self, msg=None): + if not msg: + self.msg = _("The email has not been verified.") + super().__init__(msg) + + +class PhoneNotVerifiedError(Exception): + + def __init__(self, msg=None): + if not msg: + self.msg = _("The phone has not been verified.") + super().__init__(msg) + + +class AccountInactiveError(Exception): + + def __init__(self, msg=None): + if not msg: + self.msg = _("User account is disabled.") + super().__init__(msg) diff --git a/cadasta/accounts/forms.py b/cadasta/accounts/forms.py index 90c2cb19d..a2a5f7b91 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 . import utils +from .models import User, VerificationDevice +from .validators import check_username_case_insensitive, phone_validator +from . import messages 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': messages.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,23 @@ 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 and 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 +83,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']) @@ -69,11 +111,16 @@ def save(self, *args, **kwargs): class ProfileForm(SanitizeFieldsForm, forms.ModelForm): + email = forms.EmailField(required=False) + + phone = forms.RegexField(regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': messages.phone_format}, + required=False) password = forms.CharField(widget=forms.PasswordInput()) class Meta: model = User - fields = ['username', 'email', 'full_name', 'language', + fields = ['username', 'email', 'phone', 'full_name', 'language', 'measurement', 'avatar'] class Media: @@ -84,6 +131,16 @@ def __init__(self, *args, **kwargs): self.request = kwargs.pop('request', None) super().__init__(*args, **kwargs) self.current_email = self.instance.email + self.current_phone = self.instance.phone + + def clean(self): + super(ProfileForm, 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.")) def clean_username(self): username = self.data.get('username') @@ -100,33 +157,83 @@ def clean_password(self): raise forms.ValidationError( _("Please provide the correct password for your account.")) + def clean_phone(self): + phone = self.data.get('phone') + if phone: + if (phone != self.current_phone and + VerificationDevice.objects.filter(unverified_phone=phone + ).exists()): + raise forms.ValidationError( + _("User with this Phone number already exists.")) + else: + phone = None + return phone + def clean_email(self): email = self.data.get('email') - if self.instance.email != email: - if User.objects.filter(email=email).exists(): + if email: + email = email.casefold() + if (email != self.current_email and + EmailAddress.objects.filter(email=email).exists()): raise forms.ValidationError( - _("Another user with this email already exists")) + _("User with this Email address already exists.")) + else: + email = None return email def save(self, *args, **kwargs): user = super().save(commit=False, *args, **kwargs) + email_update_message = None + if self.current_email != user.email: current_email_set = self.instance.emailaddress_set.all() if current_email_set.exists(): current_email_set.delete() - send_email_confirmation(self.request, user) - send_email_update_notification(self.current_email) - user.email = self.current_email - + if user.email: + send_email_confirmation(self.request, user) + email_update_message = messages.email_change + + if self.current_email: + utils.send_email_update_notification(self.current_email) + user.email = self.current_email + else: + user.email_verified = False + email_update_message = messages.email_delete + utils.send_email_deleted_notification(self.current_email) + + if self.current_phone != user.phone: + current_phone_set = VerificationDevice.objects.filter( + user=self.instance) + if current_phone_set.exists(): + current_phone_set.delete() + + if user.phone: + device = VerificationDevice.objects.create( + user=self.instance, unverified_phone=user.phone) + device.generate_challenge() + if self.current_phone: + utils.send_sms(self.current_phone, messages.phone_change) + user.phone = self.current_phone + if user.email: + utils.send_phone_update_notification(user.email) + else: + user.phone_verified = False + utils.send_sms(self.current_phone, messages.phone_delete) + if user.email: + utils.send_phone_deleted_notification(user.email) + + if user.phone and email_update_message: + utils.send_sms(user.phone, email_update_message) user.save() return user class ChangePasswordMixin: + def clean_password(self): - if not self.user.change_pw: + if not self.user or not self.user.change_pw: raise forms.ValidationError(_("The password for this user can not " "be changed.")) @@ -138,6 +245,12 @@ def clean_password(self): raise forms.ValidationError( _("The password is too similar to the username.")) + phone = self.user.phone + if phone and phone_validator(phone): + phone = str(parse_phone(phone).national_number) + if phone in password: + raise forms.ValidationError( + _("Passwords cannot contain your phone.")) return password def save(self): @@ -170,7 +283,86 @@ def __init__(self, *args, **kwargs): class ResetPasswordForm(allauth_forms.ResetPasswordForm): + email = forms.EmailField(required=False) + + phone = forms.RegexField(regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': messages.phone_format}, + required=False) + + def clean(self): + if not self.data.get('email') and not self.data.get('phone'): + raise forms.ValidationError(_( + "You cannot leave both phone and email empty.")) + def clean_email(self): email = self.cleaned_data.get('email') - self.users = User.objects.filter(email=email) + if email: + email = email.casefold() + self.users = User.objects.filter(email=email) + else: + email = None return email + + def clean_phone(self): + phone = self.data.get('phone') + if not phone: + phone = None + return phone + + def save(self, request, **kwargs): + phone = self.data.get('phone') + if phone: + request.session['phone'] = phone + try: + user = User.objects.get(phone=phone) + device = user.verificationdevice_set.get_or_create( + unverified_phone=phone, label='password_reset') + device[0].generate_challenge() + except User.DoesNotExist: + pass + return phone + else: + super().save(request, **kwargs) + + +class TokenVerificationForm(forms.Form): + token = forms.CharField(label=_("Token"), max_length=settings.TOTP_DIGITS) + + def __init__(self, *args, **kwargs): + self.device = kwargs.pop('device', None) + super().__init__(*args, **kwargs) + + def clean_token(self): + token = self.data.get('token') + device = self.device + if not device: + raise forms.ValidationError( + _("The token could not be verified." + " Please click on 'here' to try again.")) + try: + token = int(token) + except ValueError: + raise forms.ValidationError(_("Token must be a number.")) + + if device.verify_token(token): + return token + elif device.verify_token(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.") + + +class ResendTokenForm(forms.Form): + email = forms.EmailField(required=False) + + phone = forms.RegexField(regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': messages.phone_format}, + required=False) + + def clean(self): + if not self.data.get('email') and not self.data.get('phone'): + raise forms.ValidationError( + _("You cannot leave both phone and email empty.")) diff --git a/cadasta/accounts/gateways.py b/cadasta/accounts/gateways.py new file mode 100644 index 000000000..9217a819f --- /dev/null +++ b/cadasta/accounts/gateways.py @@ -0,0 +1,38 @@ +import random +import logging + +from django.conf import settings +from twilio.rest import Client, TwilioException + +fake_logger = logging.getLogger(name='accounts.FakeGateway') + + +class TwilioGateway(object): + + def __init__(self, + account_sid=settings.TWILIO_ACCOUNT_SID, + auth_token=settings.TWILIO_AUTH_TOKEN, + from_phone_number_list=[]): + + self.client = Client(account_sid, auth_token) + self.from_phone_number_list = from_phone_number_list + + def send_sms(self, to, body): + try: + # create and send a message with client + message = self.client.messages.create( + to=to, + from_=random.choice(self.from_phone_number_list), + body=body) + + except TwilioException as e: + message = e + + return message + + +class FakeGateway(object): + + def send_sms(self, to, body): + fake_logger.debug("Message sent to: {phone}. Message: {body}".format( + phone=to, body=body)) diff --git a/cadasta/accounts/manager.py b/cadasta/accounts/manager.py index f7558586f..020cb536a 100644 --- a/cadasta/accounts/manager.py +++ b/cadasta/accounts/manager.py @@ -4,8 +4,9 @@ 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: @@ -13,11 +14,11 @@ def get_from_username_or_email(self, identifier=None): 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) diff --git a/cadasta/accounts/messages.py b/cadasta/accounts/messages.py new file mode 100644 index 000000000..fce476f5c --- /dev/null +++ b/cadasta/accounts/messages.py @@ -0,0 +1,51 @@ +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." +) + +account_inactive = _( + "Your account has been set inactive. We request you to verify" + " your registered phone or email in order to access your account.") + + +unverified_identifier = _( + "You have not verified your phone or email. We request you to verify" + " your registered phone or email in order to access your account.") + +# send an sms to the user's phone(if any) notifying the removal of email +email_delete = _( + "You are receiving this message because a user at Cadasta Platform removed" + " the email address from the account linked to this phone number." + " If it wasn't you, please contact us immediately at security@cadasta.org") + +# send an sms to the user's phone(if any) notifying the change in email +email_change = _( + "You are receiving this message because a user at Cadasta Platform updated" + " the email address for the account linked to this phone" + " number." + " If it wasn't you, please contact us immediately at security@cadasta.org") + +# send an sms to the user's old phone(if any) notifying the removal of phone +phone_delete = _( + "You are receiving this message because a user at Cadasta Platform removed" + " the phone number from the account previously linked to this" + " phone number." + " If it wasn't you, please contact us immediately at security@cadasta.org") + +# send an sms to the user's old phone(if any) notifying the change in phone +phone_change = _( + "You are receiving this message because a user at Cadasta Platform changed" + " the phone number for the account previously linked to this" + " phone number." + " If it wasn't you, please contact us immediately at security@cadasta.org") + +# send an sms to the user's phone notifying the change in password +password_change_or_reset = _( + "You are receiving this message because a user at Cadasta Platform has" + " changed or reset the password for your account linked to this phone" + " number." + " If it wasn't you, please contact us immediately at security@cadasta.org") diff --git a/cadasta/accounts/migrations/0010_phone_and_verification_device.py b/cadasta/accounts/migrations/0010_phone_and_verification_device.py new file mode 100644 index 000000000..0d451392a --- /dev/null +++ b/cadasta/accounts/migrations/0010_phone_and_verification_device.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-09-07 10:05 +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', '0009_add_audit_fields'), + ] + + 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)), + ('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.')), + ('label', models.CharField(choices=[('phone_verify', 'Verify Phone'), ('password_reset', 'Reset Password')], default='phone_verify', max_length=20)), + ('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), + ), + ] diff --git a/cadasta/accounts/models.py b/cadasta/accounts/models.py index 53a17b95f..bd54041de 100644 --- a/cadasta/accounts/models.py +++ b/cadasta/accounts/models.py @@ -11,10 +11,17 @@ 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 time from simple_history.models import HistoricalRecords from .manager import UserManager - +from .utils import send_sms +from . import messages as account_message PERMISSIONS_DIR = settings.BASE_DIR + '/permissions/' @@ -33,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, @@ -84,7 +97,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): @@ -117,10 +131,79 @@ def assign_default_policy(sender, instance, **kwargs): def password_changed_reset(sender, request, user, **kwargs): msg_body = render_to_string( 'accounts/email/password_changed_notification.txt') - send_mail( - _("Change of password at Cadasta Platform"), - msg_body, - settings.DEFAULT_FROM_EMAIL, - [user.email], - fail_silently=False + if user.email: + send_mail( + _("Change of password at Cadasta Platform"), + msg_body, + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False + ) + if user.phone: + send_sms(user.phone, account_message.password_change_or_reset) + + +def default_key(): + return random_hex(20).decode() + + +class VerificationDevice(Device): + unverified_phone = models.CharField(max_length=16) + 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) + label = models.CharField(max_length=20, + choices=(('phone_verify', _("Verify Phone")), + ('password_reset', _("Reset Password"))), + default='phone_verify') + 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) + + send_sms(to=self.unverified_phone, body=message) + + return token + + def verify_token(self, token, tolerance=0): + totp = self.totp_obj() + if ((totp.t() > self.last_verified_counter) and + (totp.verify(token, tolerance=tolerance))): + self.last_verified_counter = totp.t() + self.verified = True + self.save() + else: + self.verified = False + return self.verified diff --git a/cadasta/accounts/serializers.py b/cadasta/accounts/serializers.py index 1efe422b6..6ac305d63 100644 --- a/cadasta/accounts/serializers.py +++ b/cadasta/accounts/serializers.py @@ -1,27 +1,38 @@ from buckets.serializers import S3Field 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 +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 .exceptions import EmailNotVerifiedError +from .models import User, VerificationDevice +from .validators import check_username_case_insensitive, phone_validator +from . import exceptions +from .messages import phone_format class RegistrationSerializer(SanitizeFieldSerializer, djoser_serializers.UserRegistrationSerializer): - email = EmailField( + email = serializers.EmailField( + 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=_("Another user is already registered with this " - "email address") - )] + message=_("User with this Phone number already exists.") + )], + allow_blank=True, + allow_null=True, + required=False ) class Meta: @@ -30,21 +41,58 @@ 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() or + User.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 @@ -52,9 +100,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') @@ -62,22 +111,52 @@ 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( + 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=_("Another user is already registered with this " - "email address") - )] + message=_("User with this Phone number already exists.") + )], + allow_blank=True, + allow_null=True, + required=False ) 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 @@ -85,28 +164,86 @@ class Meta: 'username', 'full_name', 'email', - 'email_verified', + 'phone', 'last_login', 'language', 'measurement', 'avatar', + 'email_verified', + 'phone_verified', ) extra_kwargs = { - 'email': {'required': True, 'unique': True}, - 'email_verified': {'read_only': True} + 'email': {'required': False, 'unique': True, + 'allow_null': True, 'allow_blank': True}, + 'phone': {'required': False, 'unique': True, + 'allow_null': True, 'allow_blank': True}, + 'email_verified': {'read_only': True}, + 'phone_verified': {'read_only': True} } + def validate(self, data): + data = super(UserSerializer, self).validate(data) + instance = self.instance + if instance: + email = self.initial_data.get('email', instance.email) + phone = self.initial_data.get('phone', instance.phone) + if (not phone) and (not email): + raise serializers.ValidationError( + _("You cannot leave both phone and email empty.")) + return data + + def validate_email(self, email): + instance = self.instance + if (instance and email != instance.email and + self.context['request'].user != instance): + raise serializers.ValidationError(_("Cannot update email")) + + if instance and instance.email == email: + return email + + if email: + email = email.casefold() + # make sure that the new email updated by a user is not a duplicate + # of an unverified email already linked to a different user + if (EmailAddress.objects.filter(email=email).exists() or + User.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): + instance = self.instance + if (instance and phone != instance.phone and + self.context['request'].user != instance): + raise serializers.ValidationError(_("Cannot update phone")) + + if instance and instance.phone == phone: + return phone + + if phone: + # make sure that the new phone updated by a user is not a duplicate + # of an unverified phone already linked to a different user + 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): instance = self.instance if instance is not None: 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 @@ -115,27 +252,39 @@ 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 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): - raise EmailNotVerifiedError + if (attrs['username'] == self.user.username and + not self.user.email_verified and + not self.user.phone_verified): + raise exceptions.AccountInactiveError + + if (attrs['username'] == self.user.email and + not self.user.email_verified): + raise exceptions.EmailNotVerifiedError + + if (attrs['username'] == self.user.phone and + not self.user.phone_verified): + raise exceptions.PhoneNotVerifiedError return attrs 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): @@ -144,7 +293,54 @@ 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.")) + phone = user.phone + if phone and phone_validator(phone): + phone = str(parse_phone(phone).national_number) + if phone in password: + raise serializers.ValidationError( + _("Passwords cannot contain your phone.")) + return password + + +class PhoneVerificationSerializer(serializers.Serializer, + SanitizeFieldSerializer): + phone = serializers.RegexField( + regex=r'^\+(?:[0-9]?){6,14}[0-9]$', + error_messages={'invalid': phone_format}, + required=True + ) + token = serializers.CharField(label=_("Token"), + max_length=settings.TOTP_DIGITS, + required=True) + + def validate_token(self, token): + phone = self.initial_data.get('phone') + try: + token = int(token) + device = VerificationDevice.objects.get( + unverified_phone=phone, verified=False) + except ValueError: + raise serializers.ValidationError(_("Token must be a number.")) + except VerificationDevice.DoesNotExist: + raise serializers.ValidationError( + "Phone is already verified or not linked to any user account.") + + user = device.user + if device.verify_token(token): + if user.phone != phone: + user.phone = phone + user.phone_verified = True + user.is_active = True + user.save() + device.delete() + return token + elif device.verify_token(token=token, tolerance=5): + raise serializers.ValidationError( + _("The token has expired.")) + else: + raise serializers.ValidationError( + _("Invalid Token. Enter a valid token.")) diff --git a/cadasta/accounts/tests/factories.py b/cadasta/accounts/tests/factories.py index 80bf5cd03..17a049b82 100644 --- a/cadasta/accounts/tests/factories.py +++ b/cadasta/accounts/tests/factories.py @@ -10,7 +10,10 @@ class Meta: username = factory.Sequence(lambda n: "testuser%s" % n) email = factory.Sequence(lambda n: "email_%s@example.com" % n) + phone = factory.Sequence(lambda n: "+123456789%s" % n) password = '' + language = 'en' + measurement = 'metric' @classmethod def _build(cls, model_class, *args, **kwargs): diff --git a/cadasta/accounts/tests/test_adapter.py b/cadasta/accounts/tests/test_adapter.py index f5328fe70..30cb81b95 100644 --- a/cadasta/accounts/tests/test_adapter.py +++ b/cadasta/accounts/tests/test_adapter.py @@ -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'} @@ -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'} @@ -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'} @@ -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())) 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 a7db2303b..44f311546 100644 --- a/cadasta/accounts/tests/test_forms.py +++ b/cadasta/accounts/tests/test_forms.py @@ -9,21 +9,26 @@ from django.db import IntegrityError from allauth.account.models import EmailAddress -from allauth.account.utils import send_email_confirmation +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 class RegisterFormTest(UserTestCase, TestCase): + def test_valid_data(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', 'language': 'fr', @@ -46,6 +51,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 +64,7 @@ def test_case_insensitive_username(self): data = { 'username': 'johnLennon', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', } @@ -70,6 +77,7 @@ def test_password_contains_username(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Letsimagine71things?', 'full_name': 'John Lennon', } @@ -84,6 +92,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 +107,7 @@ def test_password_contains_blank_username(self): data = { 'username': '', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Letsimagine71things?', 'full_name': 'John Lennon', } @@ -111,6 +121,7 @@ def test_password_contains_email(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'IsJOHNreallythebest34?', 'full_name': 'John Lennon', } @@ -121,23 +132,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 +152,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 +169,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 +188,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 +204,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 +219,7 @@ def test_sanitize(self): data = { 'username': '😛😛😛😛', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Iloveyoko68!', 'full_name': 'John Lennon' } @@ -225,6 +229,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): @@ -238,11 +476,14 @@ def test_update_user(self): email='john@beatles.uk', email_verified=True, password='sgt-pepper', + language='en', measurement='metric', - language='en') + phone='+919327768250', + phone_verified=True) data = { 'username': 'imagine71', 'email': 'john2@beatles.uk', + 'phone': '+12345678990', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en', @@ -266,20 +507,25 @@ def test_update_user(self): assert user.language == 'en' assert user.measurement == 'imperial' assert user.email_verified is True - assert len(mail.outbox) == 2 + assert user.phone == '+919327768250' + assert user.phone_verified is True + assert len(mail.outbox) == 3 assert 'john2@beatles.uk' in mail.outbox[0].to assert 'john@beatles.uk' in mail.outbox[1].to + assert 'john@beatles.uk' in mail.outbox[2].to def test_display_name(self): user = UserFactory.create(username='imagine71', email='john@beatles.uk', password='sgt-pepper', - measurement='metric') + measurement='metric', + phone='+919327768250') assert user.get_display_name() == 'imagine71' data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en', @@ -295,10 +541,12 @@ def test_update_user_with_existing_username(self): UserFactory.create(username='existing') user = UserFactory.create(username='imagine71', email='john@beatles.uk', + phone='+919327768250', password='sgt-pepper') data = { 'username': 'existing', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en' @@ -316,6 +564,7 @@ def test_case_insensitive_username(self): data = { 'username': user.username.lower(), 'email': '%s@beatles.uk' % user.username, + 'phone': '+919327768250', 'full_name': 'John Lennon', 'language': 'en' } @@ -329,10 +578,12 @@ def test_case_insensitive_username(self): user = UserFactory.create(username='JohNlEnNoN', email='john@beatles.uk', password='sgt-pepper', - measurement='metric') + measurement='metric', + phone='+919327768250') data = { 'username': 'johnLennon', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en', @@ -349,10 +600,12 @@ def test_update_user_with_existing_email(self): UserFactory.create(email='existing@example.com') user = UserFactory.create(username='imagine71', email='john@beatles.uk', + phone='+919327768250', password='sgt-pepper') data = { 'username': 'imagine71', 'email': 'existing@example.com', + 'phone': '+919327768250', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en' @@ -367,6 +620,7 @@ def test_update_user_with_restricted_username(self): data = { 'username': random.choice(invalid_usernames), 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'full_name': 'John Lennon', 'password': 'sgt-pepper', 'language': 'en' @@ -377,6 +631,7 @@ def test_update_user_with_restricted_username(self): def test_signup_with_released_email(self): user = UserFactory.create(username='user1', email='user1@example.com', + phone='+919327768250', email_verified=True, password='sgt-pepper', measurement='metric') @@ -386,9 +641,10 @@ def test_signup_with_released_email(self): data = { 'username': 'user1', 'email': 'user1_email_change@example.com', - 'password': 'sgt-pepper', 'language': 'en', - 'measurement': 'metric' + 'measurement': 'metric', + 'phone': '+919327768250', + 'password': 'sgt-pepper' } request = HttpRequest() @@ -400,22 +656,23 @@ def test_signup_with_released_email(self): form = forms.ProfileForm(data, request=request, instance=user) form.save() + assert EmailAddress.objects.filter( + email="user1@example.com").exists() is False - user = UserFactory.create(username='user2', - email='user1@example.com') - try: - send_email_confirmation(request, user) - except IntegrityError: - assert False - else: - assert True + with pytest.raises(IntegrityError): + user = UserFactory.create(username='user2', + email='user1@example.com') def test_update_email_with_incorrect_password(self): user = UserFactory.create(email='john@beatles.uk', + phone='+919327768250', password='imagine71') data = { 'username': 'imagine71', 'email': 'john2@beatles.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', 'full_name': 'John Lennon', 'password': 'stg-pepper' } @@ -427,10 +684,12 @@ def test_update_email_with_incorrect_password(self): def test_sanitize(self): user = UserFactory.create(email='john@beatles.uk', + phone='+919327768250', password='imagine71') data = { 'username': '😛😛😛😛', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'Iloveyoko68!', 'full_name': 'John Lennon' } @@ -439,8 +698,488 @@ def test_sanitize(self): assert form.is_valid() is False assert SANITIZE_ERROR in form.errors.get('username') + def test_update_phone_only(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + VerificationDevice.objects.create(user=user, + unverified_phone=user.phone) + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+12345678990', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + } + form = forms.ProfileForm(data, instance=user) + form.save() + + user.refresh_from_db() + assert form.is_valid() is True + assert user.phone == '+919327768250' + assert user.phone_verified is True + assert VerificationDevice.objects.filter( + unverified_phone='+919327768250').exists() is False + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_email_only(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + EmailAddress.objects.create(user=user, email=user.email, verified=True) + + data = { + 'username': 'sherlock', + 'email': 'john.watson@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + } + + request = HttpRequest() + setattr(request, 'session', 'session') + self.messages = FallbackStorage(request) + setattr(request, '_messages', self.messages) + request.META['SERVER_NAME'] = 'testserver' + request.META['SERVER_PORT'] = '80' + + form = forms.ProfileForm(data, request=request, instance=user) + form.save() + + user.refresh_from_db() + assert user.email == 'sherlock.holmes@bbc.uk' + assert user.email_verified is True + assert len(mail.outbox) == 2 + assert 'john.watson@bbc.uk' in mail.outbox[0].to + assert 'sherlock.holmes@bbc.uk' in mail.outbox[1].to + assert EmailAddress.objects.filter( + email="sherlock.holmes@bbc.uk").exists() is False + # sms must be sent about email change to phone '+919327768250' + + def test_update_with_duplicate_phone(self): + UserFactory.create(phone='+12345678990') + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + email_verified=True, + phone_verified=True, + phone='+919327768250', + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+12345678990', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + } + form = forms.ProfileForm(data, instance=user) + assert form.is_valid() is False + assert (_("User with this Phone number already exists.") + in form.errors.get('phone')) + + def test_update_add_phone(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone=None, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.phone == '+919327768250' + assert user.phone_verified is False + assert VerificationDevice.objects.count() == 1 + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_add_email(self): + user = UserFactory.create(username='sherlock', + phone='+919327768250', + email=None, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = HttpRequest() + setattr(request, 'session', 'session') + self.messages = FallbackStorage(request) + setattr(request, '_messages', self.messages) + request.META['SERVER_NAME'] = 'testserver' + request.META['SERVER_PORT'] = '80' + + form = forms.ProfileForm(data, request=request, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.email == 'sherlock.holmes@bbc.uk' + assert user.email_verified is False + assert len(mail.outbox) == 1 + + def test_udpate_with_invalid_phone(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': 'Test Number', + 'password': '221B@bakerstreet', + 'language': 'en', + 'measurement': 'metric', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is False + assert (phone_format in form.errors.get('phone')) + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '9327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is False + assert (phone_format in form.errors.get('phone')) + + def test_update_remove_both_phone_and_email(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is False + assert (_("You cannot leave both phone and email empty.") + in form.errors.get('__all__')) + + def test_update_remove_phone(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + VerificationDevice.objects.create(user=user, + unverified_phone=user.phone) + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert not user.phone + assert user.phone_verified is False + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_remove_email(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + EmailAddress.objects.create(user=user, + email=user.email) + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert not user.email + assert user.email_verified is False + assert EmailAddress.objects.count() == 0 + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_add_phone_and_remove_email(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone=None, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + EmailAddress.objects.create(user=user, email=user.email) + + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.phone == '+919327768250' + assert user.phone_verified is False + assert user.email is None + assert user.email_verified is False + assert EmailAddress.objects.count() == 0 + assert VerificationDevice.objects.count() == 1 + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_add_email_and_remove_phone(self): + user = UserFactory.create(username='sherlock', + email=None, + phone='+919327768250', + phone_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + VerificationDevice.objects.create(user=user, + unverified_phone=user.phone) + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = HttpRequest() + setattr(request, 'session', 'session') + self.messages = FallbackStorage(request) + setattr(request, '_messages', self.messages) + request.META['SERVER_NAME'] = 'testserver' + request.META['SERVER_PORT'] = '80' + + form = forms.ProfileForm(data, request=request, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.phone is None + assert user.phone_verified is False + assert user.email == 'sherlock.holmes@bbc.uk' + assert user.email_verified is False + assert EmailAddress.objects.count() == 1 + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 2 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_phone_and_remove_email(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+12345678990', + phone_verified=True, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + EmailAddress.objects.create(user=user, email=user.email) + VerificationDevice.objects.create(user=user, + unverified_phone=user.phone) + + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.phone == '+12345678990' + assert user.phone_verified is True + assert user.email is None + assert user.email_verified is False + assert EmailAddress.objects.count() == 0 + assert VerificationDevice.objects.filter( + unverified_phone='+12345678990').exists() is False + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + # send sms to user's phone '+12345678990' about email removal + # send sms to user's phone '+12345678990' about phone change + + def test_update_email_and_remove_phone(self): + user = UserFactory.create(username='sherlock', + email='john.watson@bbc.uk', + phone='+919327768250', + phone_verified=True, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + EmailAddress.objects.create(user=user, email=user.email) + VerificationDevice.objects.create(user=user, + unverified_phone=user.phone) + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = HttpRequest() + setattr(request, 'session', 'session') + self.messages = FallbackStorage(request) + setattr(request, '_messages', self.messages) + request.META['SERVER_NAME'] = 'testserver' + request.META['SERVER_PORT'] = '80' + + form = forms.ProfileForm(data, request=request, instance=user) + assert form.is_valid() is True + form.save() + + user.refresh_from_db() + assert user.phone is None + assert user.phone_verified is False + assert user.email == 'john.watson@bbc.uk' + assert user.email_verified is True + assert EmailAddress.objects.filter( + email='john.watson@bbc.uk').exists() is False + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 3 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + assert 'john.watson@bbc.uk' in mail.outbox[1].to + assert 'john.watson@bbc.uk' in mail.outbox[2].to + + def test_update_with_existing_email_in_EmailAddress(self): + user = UserFactory.create() + EmailAddress.objects.create(email='sherlock.holmes@bbc.uk', user=user) + user1 = UserFactory.create(username='sherlock', + email='john.watson@bbc.uk', + phone='+919327768250', + phone_verified=True, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user1) + assert form.is_valid() is False + assert (_("User with this Email address already exists.") + in form.errors.get('email')) + + def test_update_with_existing_phone_in_VerificationDevice(self): + user = UserFactory.create() + VerificationDevice.objects.create(unverified_phone='+919327768250', + user=user) + user1 = UserFactory.create(username='sherlock', + email='john.watson@bbc.uk', + phone='+12345678990', + phone_verified=True, + email_verified=True, + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + form = forms.ProfileForm(data=data, instance=user1) + assert form.is_valid() is False + assert (_("User with this Phone number already exists.") + in form.errors.get('phone')) + class ChangePasswordFormTest(UserTestCase, TestCase): + def test_valid_data(self): user = UserFactory.create(password='beatles4Lyfe!') @@ -555,8 +1294,22 @@ def test_user_not_allowed_change_password(self): assert (_("The password for this user can not be changed.") in form.errors.get('password')) + def test_password_contains_phone(self): + user = UserFactory.create(password='221B@bakerstreet', + phone='+919327768250') + data = { + 'oldpassword': '221B@bakerstreet', + 'password': '9327768250@bakerstreet' + } + + form = forms.ChangePasswordForm(user, data) + assert form.is_valid() is False + assert (_("Passwords cannot contain your phone.") + in form.errors.get('password')) + class ResetPasswordKeyFormTest(UserTestCase, TestCase): + def test_valid_data(self): user = UserFactory.create(password='beatles4Lyfe!') @@ -651,8 +1404,29 @@ def test_user_not_allowed_change_password(self): assert (_("The password for this user can not be changed.") in form.errors.get('password')) + def test_password_contains_phone(self): + user = UserFactory.create(password='221B@bakerstreet', + phone='+919327768250') + data = { + 'password': '9327768250@bakerstreet' + } + + form = forms.ResetPasswordKeyForm(data, user=user) + assert form.is_valid() is False + assert (_("Passwords cannot contain your phone.") + in form.errors.get('password')) + + def test_password_change_without_user(self): + data = {'password': 'iloveyoko79!'} + form = forms.ResetPasswordKeyForm(data) + assert form.is_valid() is False + assert (_( + "The password for this user can not be changed.") + in form.errors.get('password')) + class ResetPasswordFormTest(UserTestCase, TestCase): + def test_email_not_sent_reset(self): data = { 'email': 'john@thebeatles.uk' @@ -671,3 +1445,138 @@ def test_email_sent_reset(self): form = forms.ResetPasswordForm(data) assert form.is_valid() is True + + def test_msg_sent_reset_with_phone(self): + UserFactory.create( + password='221B@bakerstreet', phone='+919327768250') + + data = { + 'phone': '+919327768250' + } + form = forms.ResetPasswordForm(data) + request = HttpRequest() + setattr(request, 'session', {}) + form.save(request) + + assert form.is_valid() is True + assert VerificationDevice.objects.filter( + unverified_phone='+919327768250', + label='password_reset').exists() is True + + def test_msg_not_sent_reset_with_phone(self): + data = { + 'phone': '+919327768250' + } + + form = forms.ResetPasswordForm(data) + assert form.is_valid() is True + request = HttpRequest() + setattr(request, 'session', {}) + form.save(request) + assert VerificationDevice.objects.filter( + unverified_phone='+919327768250', + label='password_reset').exists() is False + + def test_empty_submit(self): + data = { + 'phone': '', + } + + form = forms.ResetPasswordForm(data) + assert form.is_valid() is False + assert (_("You cannot leave both phone and email empty.") + in form.errors.get('__all__')) + + data = { + 'email': '', + } + + form = forms.ResetPasswordForm(data) + assert form.is_valid() is False + assert (_("You cannot leave both phone and email empty.") + in form.errors.get('__all__')) + + +class TokenVerificationFormTest(UserTestCase, TestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + def test_valid_token(self): + token = self.device.generate_challenge() + data = {'token': token} + form = forms.TokenVerificationForm(data=data, device=self.device) + assert form.is_valid() is True + + def test_invalid_token(self): + token = self.device.generate_challenge() + token = str(int(token) - 1) + data = {'token': token} + form = forms.TokenVerificationForm(data=data, device=self.device) + 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 + with mock.patch('time.time', return_value=now): + token = self.device.generate_challenge() + data = {'token': token} + with mock.patch('time.time', return_value=( + now + settings.TOTP_TOKEN_VALIDITY + 1)): + form = forms.TokenVerificationForm(data=data, device=self.device) + 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_invalid_token_format(self): + data = {'token': 'TOKEN'} + form = forms.TokenVerificationForm(data=data, device=self.device) + assert form.is_valid() is False + assert (_("Token must be a number.") in form.errors.get('token')) + + def test_token_without_device(self): + data = {'token': '123456'} + form = forms.TokenVerificationForm(data=data, device=None) + assert form.is_valid() is False + assert (_("The token could not be verified." + " Please click on 'here' to try again.") + in form.errors.get('token')) + + +class ResendTokenFormTest(UserTestCase, TestCase): + + def test_invalid_phone(self): + data = {'phone': '12345678990'} + form = forms.ResendTokenForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + data = {'phone': 'Test Number'} + form = forms.ResendTokenForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + data = {'phone': '+1 2345678990'} + form = forms.ResendTokenForm(data) + assert form.is_valid() is False + assert phone_format in form.errors.get('phone') + + def test_submit_empty(self): + data = {'email': ''} + form = forms.ResendTokenForm(data) + assert form.is_valid() is False + assert ( + _("You cannot leave both phone and email empty.") + in form.errors.get('__all__')) + + data = {'phone': ''} + form = forms.ResendTokenForm(data) + assert form.is_valid() is False + assert ( + _("You cannot leave both phone and email empty.") + in form.errors.get('__all__')) diff --git a/cadasta/accounts/tests/test_gateways.py b/cadasta/accounts/tests/test_gateways.py new file mode 100644 index 000000000..ac526ecb7 --- /dev/null +++ b/cadasta/accounts/tests/test_gateways.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from django.conf import settings +from accounts.gateways import TwilioGateway, FakeGateway +from django.test.utils import override_settings +from unittest import mock + + +class TwilioGatewayTest(TestCase): + + @override_settings( + TWILIO_ACCOUNT_SID='SID', + TWILIO_AUTH_TOKEN='TOKEN', + TWILIO_PHONE_NUMBER_LIST=['+123']) + @mock.patch('accounts.gateways.Client') + def test_gateway(self, mock_client): + twilio = TwilioGateway( + account_sid=settings.TWILIO_ACCOUNT_SID, + auth_token=settings.TWILIO_AUTH_TOKEN, + from_phone_number_list=settings.TWILIO_PHONE_NUMBER_LIST + ) + mock_client.assert_called_with('SID', 'TOKEN') + body = 'Testing Twilio SMS gateway!' + to = '+456' + twilio.send_sms(to, body) + mock_client.return_value.messages.create.assert_called_with( + to=to, + body=body, + from_='+123') + + def test_gateway_exception(self): + twilio = TwilioGateway(account_sid='SID', + auth_token='TOKEN', + from_phone_number_list=['+123']) + body = 'Testing Twilio SMS gateway!' + to = '+456' + response = twilio.send_sms(to, body) + assert response.status == 404 + assert 'Unable to create record' in response.msg + + +class FakeGatewayTest(TestCase): + @mock.patch('accounts.gateways.fake_logger') + def test_gateway(self, mock_logger): + fake_gateway = FakeGateway() + to = '+456' + body = 'Testing Fake SMS gateway!' + fake_gateway.send_sms(to, body) + mock_logger.debug.assert_called_with( + "Message sent to: {phone}. Message: {body}".format( + phone=to, body=body)) diff --git a/cadasta/accounts/tests/test_manager.py b/cadasta/accounts/tests/test_manager.py index dd3e0cbc6..dac53cea2 100644 --- a/cadasta/accounts/tests/test_manager.py +++ b/cadasta/accounts/tests/test_manager.py @@ -8,9 +8,9 @@ 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 ) @@ -18,19 +18,28 @@ def test_get_from_usernamel(self): 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='user@example.com') UserFactory.create(email='user@example.com') with raises(User.MultipleObjectsReturned): - User.objects.get_from_username_or_email( + User.objects.get_from_username_or_email_or_phone( identifier='user@example.com' ) diff --git a/cadasta/accounts/tests/test_models.py b/cadasta/accounts/tests/test_models.py index 836dbba3c..1bc4df4a0 100644 --- a/cadasta/accounts/tests/test_models.py +++ b/cadasta/accounts/tests/test_models.py @@ -2,6 +2,9 @@ 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 ..models import VerificationDevice class UserTest(TestCase): @@ -11,12 +14,14 @@ def test_repr(self): full_name='John Lennon', email='john@beatles.uk', email_verified=True, - verify_email_by=date) + phone='+12025550111', + phone_verified=True) assert repr(user) == ('').format(date) + ' phone=+12025550111' + ' phone_verified=True>').format(date) def test_avatar_url_property_with_avatar_field_empty(self): user = UserFactory.build(username='John', @@ -33,3 +38,119 @@ def test_avatar_url_property_with_avatar_field_set(self): avatar=test_avatar_path, ) assert user.avatar_url == user.avatar.url + + +class VerificationDeviceTest(UserTestCase, TestCase): + def setUp(self): + super().setUp() + + self.sherlock = UserFactory.create(username='sherlock') + VerificationDevice.objects.create( + label='phone_verify', + user=self.sherlock, + unverified_phone=self.sherlock.phone) + + self.john = UserFactory.create(username='john') + VerificationDevice.objects.create( + label='phone_verify', + user=self.john, + unverified_phone=self.john.phone) + + self.TOTP_TOKEN_VALIDITY = settings.TOTP_TOKEN_VALIDITY + self._now = 1497657600 + + def test_instant(self): + """Verify token as soon as it is created""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + + with mock.patch('time.time', return_value=self._now): + token = device.generate_challenge() + verified = device.verify_token(int(token)) + + assert verified is True + + def test_barely_made_it(self): + """Verify token 1 second before it expires""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + + with mock.patch('time.time', return_value=self._now): + token = device.generate_challenge() + with mock.patch('time.time', + return_value=self._now + self.TOTP_TOKEN_VALIDITY - 1): + verified = device.verify_token(int(token)) + + assert verified is True + + def test_too_late(self): + """Verify token 1 second after it expires""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + + with mock.patch('time.time', return_value=self._now): + token = device.generate_challenge() + with mock.patch('time.time', + return_value=self._now + self.TOTP_TOKEN_VALIDITY + 1): + verified = device.verify_token(int(token)) + + assert verified is False + + def test_future(self): + """Verify token from the future. Time Travel!!""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + + with mock.patch('time.time', return_value=self._now + 1): + token = device.generate_challenge() + with mock.patch('time.time', return_value=self._now - 1): + verified = device.verify_token(int(token)) + + assert verified is False + + def test_code_reuse(self): + """Verify same token twice""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + + token = device.generate_challenge() + verified_once = device.verify_token(int(token)) + verified_twice = device.verify_token(int(token)) + + assert verified_once is True + assert verified_twice is False + + def test_cross_user(self): + """Verify token generated by one device with that of another""" + device_sherlock = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + device_john = self.john.verificationdevice_set.get( + unverified_phone=self.john.phone) + + token = device_sherlock.generate_challenge() + verified = device_john.verify_token(int(token)) + + assert verified is False + + def test_token_tolerance(self): + """Test tolerance of a token""" + device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone) + with mock.patch('time.time', return_value=( + self._now + (settings.TOTP_TOKEN_VALIDITY * 2))): + token = device.generate_challenge() + with mock.patch('time.time', return_value=self._now): + verified = device.verify_token(token=int(token), tolerance=2) + + assert verified is True + + def test_verify_token_with_different_label(self): + phone_device = self.sherlock.verificationdevice_set.get( + unverified_phone=self.sherlock.phone, + label='phone_verify') + password_device = self.sherlock.verificationdevice_set.create( + unverified_phone=self.sherlock.phone, + label='password_reset') + token = phone_device.generate_challenge() + verified = password_device.verify_token(int(token)) + assert verified is False diff --git a/cadasta/accounts/tests/test_serializers.py b/cadasta/accounts/tests/test_serializers.py index e2471351e..f173d720f 100644 --- a/cadasta/accounts/tests/test_serializers.py +++ b/cadasta/accounts/tests/test_serializers.py @@ -1,25 +1,28 @@ import random import pytest -from datetime import datetime +from django.conf import settings from django.utils.translation import gettext as _ from django.test import TestCase +from allauth.account.models import EmailAddress from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.request import Request +from unittest import mock from core.messages import SANITIZE_ERROR from core.tests.utils.cases import UserTestCase, FileStorageTestCase from .. import serializers -from ..models import User -from ..exceptions import EmailNotVerifiedError +from ..models import User, VerificationDevice from core.tests.utils.files import make_dirs # noqa - +from accounts import exceptions +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', @@ -28,10 +31,12 @@ class RegistrationSerializerTest(UserTestCase, TestCase): + 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): @@ -45,6 +50,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'] @@ -69,6 +75,8 @@ def test_create_without_email(self): data = { 'username': 'imagine71', + 'language': 'en', + 'measurement': 'metric', 'password': 'iloveyoko79!', 'password_repeat': 'iloveyoko79!', 'full_name': 'John Lennon', @@ -78,11 +86,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': '😀😃😄😁😆😅', @@ -101,6 +111,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', @@ -108,7 +121,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): @@ -116,6 +129,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', @@ -130,6 +146,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', @@ -143,6 +162,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', @@ -156,6 +178,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', @@ -168,6 +193,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', @@ -181,18 +209,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', @@ -207,6 +241,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', @@ -219,13 +256,260 @@ 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 False + assert (_("User with this Email address already exists.") + in serializer.errors['email']) + + 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): + def test_field_serialization(self): user = UserFactory.build() serializer = serializers.UserSerializer(user) assert 'email_verified' in serializer.data + assert 'phone_verified' in serializer.data assert 'password' not in serializer.data assert serializer.data['avatar'] == user.avatar.url @@ -236,6 +520,7 @@ def test_create_with_valid_data(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79', 'full_name': 'John Lennon', 'last_login': '2016-01-01 23:00:00', @@ -252,6 +537,7 @@ def test_create_with_valid_data(self): user_obj = User.objects.first() assert user_obj.is_active assert not user_obj.email_verified + assert not user_obj.phone_verified def test_update_username_fails(self): serializer = serializers.UserSerializer(data=BASIC_TEST_DATA) @@ -271,6 +557,7 @@ def test_case_insensitive_username(self): data = { 'username': 'USERtwO', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79', 'full_name': 'John Lennon' } @@ -293,6 +580,7 @@ def test_update_last_login_fails(self): user = serializer.save() update_data1 = {'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'last_login': '2016-01-01 23:00:00'} serializer2 = serializers.UserSerializer(user, data=update_data1) assert serializer2.is_valid() is False @@ -306,6 +594,7 @@ def test_update_with_restricted_username(self): data = { 'username': random.choice(invalid_usernames), 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'full_name': 'John Lennon', } request = APIRequestFactory().patch('/user/imagine71', data) @@ -363,24 +652,332 @@ def test_sanitize(self): assert serializer.is_valid() is False assert SANITIZE_ERROR in serializer.errors['full_name'] + def test_update_with_blank_phone_and_email(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + password='221B@bakerstreet', + full_name='Sherlock Holmes') + data = { + 'username': 'sherlock', + 'email': '', + 'phone': '', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user) + serializer = serializers.UserSerializer( + user, data=data, context={'request': Request(request)}, + partial=True + ) + assert serializer.is_valid() is False + assert (_("You cannot leave both phone and email empty.") + in serializer.errors['non_field_errors']) + + def test_update_email_fails(self): + serializer = serializers.UserSerializer(data=BASIC_TEST_DATA) + assert serializer.is_valid() is True + + user = serializer.save() + other_user = UserFactory.create() + + update_data = {'email': 'sherlock.holmes@bbc.uk'} + request = APIRequestFactory().patch('/user/imagine71', update_data) + force_authenticate(request, user=other_user) + + serializer2 = serializers.UserSerializer( + user, update_data, context={'request': Request(request)} + ) + assert serializer2.is_valid() is False + assert (_("Cannot update email") in serializer2.errors['email']) + + def test_update_phone_fails(self): + serializer = serializers.UserSerializer(data=BASIC_TEST_DATA) + assert serializer.is_valid() is True + + user = serializer.save() + other_user = UserFactory.create() + + update_data = {'phone': '+919067439937'} + request = APIRequestFactory().patch('/user/imagine71', update_data) + force_authenticate(request, user=other_user) + + serializer2 = serializers.UserSerializer( + user, update_data, context={'request': Request(request)} + ) + assert serializer2.is_valid() is False + assert (_("Cannot update phone") in serializer2.errors['phone']) + + def test_update_with_invalid_phone(self): + user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + password='221B@bakerstreet', + full_name='Sherlock Holmes') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': 'Test Number', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user) + serializer = serializers.UserSerializer( + user, data=data, context={'request': Request(request)}, + partial=True + ) + 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' + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user) + serializer = serializers.UserSerializer( + user, data=data, context={'request': Request(request)}, + partial=True + ) + 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' + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user) + serializer = serializers.UserSerializer( + user, data=data, context={'request': Request(request)}, + partial=True + ) + assert serializer.is_valid() is False + assert (phone_format in serializer._errors['phone']) + + def test_update_with_existing_phone_in_VerificationDevice(self): + user = UserFactory.create() + VerificationDevice.objects.create(user=user, + unverified_phone='+919327768250') + user1 = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user1) + serializer = serializers.UserSerializer( + user1, data=data, context={'request': Request(request)}, + partial=True + ) + assert serializer.is_valid() is False + assert (_("User with this Phone number already exists.") + in serializer.errors['phone']) + + def test_update_with_existing_email_in_EmailAddress(self): + user = UserFactory.create() + EmailAddress.objects.create(user=user, + email='sherlock.holmes@bbc.uk') + user1 = UserFactory.create(username='sherlock', + phone='+919327768250', + password='221B@bakerstreet', + full_name='Sherlock Holmes') + + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes' + + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user1) + serializer = serializers.UserSerializer( + user1, data=data, context={'request': Request(request)}, + partial=True + ) + assert serializer.is_valid() is False + assert (_("User with this Email address already exists.") + in serializer.errors['email']) + + def test_create_with_existing_email_in_EmailAddress_without_instance( + self): + user1 = UserFactory.create() + EmailAddress.objects.create(user=user1, + email='sherlock.holmes@bbc.uk') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'John Lennon', + 'last_login': '2016-01-01 23:00:00', + 'language': 'en', + 'measurement': 'metric', + } + serializer = serializers.UserSerializer(data=data) + assert serializer.is_valid() is False + assert (_("User with this Email address already exists.") + in serializer.errors['email']) + + def test_create_with_existing_phone_in_VerificationDevice_without_instance( + self): + user1 = UserFactory.create() + VerificationDevice.objects.create(user=user1, + unverified_phone='+919327768250') + data = { + 'username': 'sherlock', + 'email': 'sherlock.holmes@bbc.uk', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'Sherlock Holmes', + 'last_login': '2016-01-01 23:00:00', + 'language': 'en', + 'measurement': 'metric', + } + serializer = serializers.UserSerializer(data=data) + assert serializer.is_valid() is False + assert (_("User with this Phone number already exists.") + in serializer.errors['phone']) + + def test_create_with_phone_only(self): + data = { + 'username': 'imagine71', + 'email': '', + 'phone': '+919327768250', + 'password': 'iloveyoko79', + 'full_name': 'John Lennon', + 'last_login': '2016-01-01 23:00:00', + 'language': 'en', + 'measurement': 'metric', + } + serializer = serializers.UserSerializer(data=data) + assert serializer.is_valid() is True + + serializer.save() + assert User.objects.count() == 1 + + user_obj = User.objects.first() + assert user_obj.is_active + assert not user_obj.phone_verified + assert user_obj.email is None + + def test_create_with_email_only(self): + data = { + 'username': 'imagine71', + 'email': 'john@beatles.uk', + 'phone': '', + 'password': 'iloveyoko79', + 'full_name': 'John Lennon', + 'last_login': '2016-01-01 23:00:00', + 'language': 'en', + 'measurement': 'metric', + } + serializer = serializers.UserSerializer(data=data) + assert serializer.is_valid() is True + + serializer.save() + assert User.objects.count() == 1 + + user_obj = User.objects.first() + assert user_obj.is_active + assert not user_obj.email_verified + assert user_obj.phone is None + + def test_update_insensitive_email_check(self): + UserFactory.create(email='sherlock.holmes@bbc.uk') + user = UserFactory.create(username='sherlock', + phone='+919327768250', + password='221B@bakerstreet') + data = { + 'username': 'sherlock', + 'email': 'SHERLOCK.HOLMES@BBC.UK', + 'phone': '+919327768250', + 'password': '221B@bakerstreet', + 'full_name': 'John Lennon', + 'last_login': '2016-01-01 23:00:00', + 'language': 'en', + 'measurement': 'metric', + } + request = APIRequestFactory().patch('/user/sherlock', data) + force_authenticate(request, user=user) + + serializer = serializers.UserSerializer( + user, data=data, context={'request': Request(request)}) + assert serializer.is_valid() is False + assert (_("User with this Email address already exists.") + in serializer.errors['email']) + class AccountLoginSerializerTest(UserTestCase, TestCase): + def test_unverified_account(self): - """Serializer should raise EmailNotVerifiedError exeception when the - user has not verified their email address within 48 hours""" + """Serializer should raise exceptions.EmailNotVerifiedError exeception + when the user has not verified their email address""" UserFactory.create(username='sgt_pepper', password='iloveyoko79', - verify_email_by=datetime.now()) - - with pytest.raises(EmailNotVerifiedError): + email='john@beatles.uk', + email_verified=False) + with pytest.raises(exceptions.EmailNotVerifiedError): serializers.AccountLoginSerializer().validate(attrs={ - 'username': 'sgt_pepper', + 'username': 'john@beatles.uk', 'password': 'iloveyoko79' }) + def test_login_account_with_unverified_phone(self): + UserFactory.create(username='sherlock', + password='221B@bakerstreet', + phone='+919327768250', + phone_verified=False) + with pytest.raises(exceptions.PhoneNotVerifiedError): + serializers.AccountLoginSerializer().validate(attrs={ + 'username': '+919327768250', + 'password': '221B@bakerstreet' + }) + + def test_login_account_with_both_unverified_phone_and_email(self): + UserFactory.create(username='sherlock', + password='221B@bakerstreet', + phone='+919327768250', + phone_verified=False) + with pytest.raises(exceptions.AccountInactiveError): + serializers.AccountLoginSerializer().validate(attrs={ + 'username': 'sherlock', + 'password': '221B@bakerstreet' + }) + class ChangePasswordSerializerTest(UserTestCase, TestCase): + def test_user_can_change_pw(self): user = UserFactory.create(password='beatles4Lyfe!', change_pw=True) request = APIRequestFactory().patch('/user/imagine71', {}) @@ -518,3 +1115,148 @@ def test_password_does_not_meet_unique_character_requirements(self): "lowercase characters, uppercase characters," " special characters, and/or numerical character.\n" ) in serializer._errors['new_password']) + + def test_password_contains_phone(self): + user = UserFactory.create( + username='sherlock', + password='221B@bakerstreet', + phone='+919327768250') + request = APIRequestFactory().patch('/user/sherlock', {}) + force_authenticate(request, user=user) + + data = { + 'current_password': '221B@bakerstreet', + 'new_password': '9327768250@bakerstreet', + 're_new_password': '9327768250@bakerstreet', + } + + serializer = serializers.ChangePasswordSerializer( + user, data=data, context={'request': Request(request)}) + + assert serializer.is_valid() is False + assert (_("Passwords cannot contain your phone.") + in serializer._errors['new_password']) + + +class PhoneVerificationSerializerTest(UserTestCase, TestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory.create(phone='+919327768250') + + def test_valid_token_and_phone(self): + self.user.is_active = False + self.user.save() + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + data = { + 'phone': self.user.phone, + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is True + self.user.refresh_from_db() + assert self.user.phone_verified is True + assert self.user.is_active is True + assert VerificationDevice.objects.count() == 0 + + def test_update_phone(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone='+12345678990') + token = device.generate_challenge() + data = { + 'phone': '+12345678990', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is True + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + assert VerificationDevice.objects.count() == 0 + + def test_invalid_token(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + token = str(int(token) - 1) + data = { + 'phone': '+919327768250', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert(_("Invalid Token. Enter a valid token.") + in serializer._errors.get('token')) + + def test_expired_token(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + now = 1497657600 + with mock.patch('time.time', return_value=now): + token = device.generate_challenge() + data = { + 'phone': self.user.phone, + 'token': token + } + with mock.patch('time.time', + return_value=(now + settings.TOTP_TOKEN_VALIDITY + 1)): + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("The token has expired.") in serializer._errors.get('token')) + + def test_invalid_token_format(self): + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + data = { + 'phone': '+12345678990', + 'token': 'token' + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert(_("Token must be a number.") + in serializer._errors.get('token')) + + def test_unlinked_phone(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + data = { + 'phone': '+12345678990', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert ( + _("Phone is already verified or not linked to any user account.") + in serializer._errors.get('token')) + + def test_invalid_phone_format(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + data = { + 'phone': '9327768250', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert phone_format in serializer._errors.get('phone') + + data = { + 'phone': '+91 9327768250', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert phone_format in serializer._errors.get('phone') + + data = { + 'phone': 'Test Number', + 'token': token + } + serializer = serializers.PhoneVerificationSerializer(data=data) + assert serializer.is_valid() is False + assert phone_format in serializer._errors.get('phone') diff --git a/cadasta/accounts/tests/test_urls_api.py b/cadasta/accounts/tests/test_urls_api.py index 000339070..4fa47a9ff 100644 --- a/cadasta/accounts/tests/test_urls_api.py +++ b/cadasta/accounts/tests/test_urls_api.py @@ -6,6 +6,7 @@ class UserUrlsTest(TestCase): + def test_account_user(self): assert reverse(version_ns('accounts:user')) == version_url('/account/') @@ -32,3 +33,10 @@ def test_account_password(self): resolved = resolve(version_url('/account/password/')) assert resolved.func.__name__ == api.SetPasswordView.__name__ + + def test_account_verify_phone(self): + assert (reverse(version_ns('accounts:verify_phone')) == + version_url('/account/verify/phone/')) + + resolved = resolve(version_url('/account/verify/phone/')) + assert resolved.func.__name__ == api.ConfirmPhoneView.__name__ diff --git a/cadasta/accounts/tests/test_urls_default.py b/cadasta/accounts/tests/test_urls_default.py index 83c0edf97..7c859501b 100644 --- a/cadasta/accounts/tests/test_urls_default.py +++ b/cadasta/accounts/tests/test_urls_default.py @@ -1,6 +1,6 @@ from django.test import TestCase from django.core.urlresolvers import reverse, resolve - +import allauth.account.views as allauth_views from ..views import default @@ -24,3 +24,73 @@ 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__ + + def test_resend_token(self): + assert reverse('account:resend_token') == '/account/resendtokenpage/' + resolved = resolve('/account/resendtokenpage/') + assert resolved.func.__name__ == default.ResendTokenView.__name__ + + def test_change_password(self): + assert reverse( + 'account:account_change_password') == '/account/password/change/' + + resolved = resolve('/account/password/change/') + assert resolved.func.__name__ == default.PasswordChangeView.__name__ + + def test_reset_password(self): + assert reverse( + 'account:account_reset_password') == '/account/password/reset/' + + resolved = resolve('/account/password/reset/') + assert resolved.func.__name__ == default.PasswordResetView.__name__ + + def test_reset_password_from_key(self): + assert reverse( + 'account:account_reset_password_from_key', + kwargs={'uidb36': 'ABC', 'key': '123'}) == ( + '/account/password/reset/key/ABC-123/') + + resolved = resolve('/account/password/reset/key/ABC-123/') + assert (resolved.func.__name__ == + default.PasswordResetFromKeyView.__name__) + assert resolved.kwargs['uidb36'] == 'ABC' + assert resolved.kwargs['key'] == '123' + + def test_reset_password_from_phone(self): + assert reverse( + 'account:account_reset_password_from_phone') == ( + '/account/password/reset/phone/') + + resolved = resolve('/account/password/reset/phone/') + assert (resolved.func.__name__ == + default.PasswordResetFromPhoneView.__name__) + + def test_reset_password_done(self): + assert reverse( + 'account:account_reset_password_done') == ( + '/account/password/reset/done/') + + resolved = resolve('/account/password/reset/done/') + assert resolved.func.__name__ == default.PasswordResetDoneView.__name__ + + def test_reset_password_from_key_done(self): + assert reverse( + 'account:account_reset_password_from_key_done') == ( + '/account/password/reset/key/done/') + + resolved = resolve('/account/password/reset/key/done/') + assert (resolved.func.__name__ == + allauth_views.PasswordResetFromKeyDoneView.__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 7d02258f6..da8d6d88b 100644 --- a/cadasta/accounts/tests/test_views_api.py +++ b/cadasta/accounts/tests/test_views_api.py @@ -1,12 +1,14 @@ -from datetime import datetime - from django.core import mail from django.test import TestCase +from django.conf import settings +from unittest import mock + +from allauth.account.models import EmailAddress 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 @@ -18,7 +20,30 @@ class AccountUserTest(APITestCase, UserTestCase, TestCase): def setup_models(self): self.user = UserFactory.create(username='imagine71', email='john@beatles.uk', - email_verified=True) + phone='+12345678990', + email_verified=True, + phone_verified=True) + + def test_update_profile(self): + data = {'email': 'boss@beatles.uk', + 'phone': '+919327768250', + 'username': 'imagine71'} + response = self.request(method='PUT', post_data=data, user=self.user) + assert response.status_code == 200 + + assert len(mail.outbox) == 3 + assert VerificationDevice.objects.count() == 1 + assert VerificationDevice.objects.filter( + unverified_phone='+12345678990').exists() is False + assert 'boss@beatles.uk' in mail.outbox[0].to + assert 'john@beatles.uk' in mail.outbox[1].to + assert 'john@beatles.uk' in mail.outbox[2].to + + self.user.refresh_from_db() + assert self.user.email_verified is True + assert self.user.phone_verified is True + assert self.user.email == 'john@beatles.uk' + assert self.user.phone == '+12345678990' def test_update_email_address(self): """Service should send a verification email when the user updates their @@ -26,9 +51,11 @@ def test_update_email_address(self): data = {'email': 'boss@beatles.uk', 'username': 'imagine71'} response = self.request(method='PUT', post_data=data, user=self.user) assert response.status_code == 200 + assert len(mail.outbox) == 2 assert 'boss@beatles.uk' in mail.outbox[0].to assert 'john@beatles.uk' in mail.outbox[1].to + self.user.refresh_from_db() assert self.user.email_verified is True @@ -39,6 +66,7 @@ def test_keep_email_address(self): response = self.request(method='PUT', post_data=data, user=self.user) assert response.status_code == 200 assert len(mail.outbox) == 0 + self.user.refresh_from_db() assert self.user.email_verified is True @@ -46,16 +74,18 @@ def test_update_with_existing_email(self): UserFactory.create(email='boss@beatles.uk') data = {'email': 'boss@beatles.uk', 'username': self.user.username} response = self.request(method='PUT', post_data=data, user=self.user) - self.user.refresh_from_db() assert response.status_code == 400 + + self.user.refresh_from_db() assert self.user.email == 'john@beatles.uk' assert self.user.email_verified is True def test_update_username(self): data = {'email': self.user.email, 'username': 'john'} response = self.request(method='PUT', post_data=data, user=self.user) - self.user.refresh_from_db() assert response.status_code == 200 + + self.user.refresh_from_db() assert self.user.username == 'john' assert self.user.email_verified is True @@ -63,11 +93,137 @@ def test_update_with_existing_username(self): UserFactory.create(username='boss') data = {'email': self.user.email, 'username': 'boss'} response = self.request(method='PUT', post_data=data, user=self.user) - self.user.refresh_from_db() assert response.status_code == 400 + + self.user.refresh_from_db() assert self.user.username == 'imagine71' assert self.user.email_verified is True + def test_update_phone_number(self): + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + data = {'phone': '+919327768250', 'username': 'imagine71'} + response = self.request(method='PUT', post_data=data, user=self.user) + assert response.status_code == 200 + assert VerificationDevice.objects.filter( + unverified_phone='+12345678990').exists() is False + + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + assert len(mail.outbox) == 1 + assert self.user.email in mail.outbox[0].to + + def test_keep_phone_number(self): + data = {'phone': self.user.phone, 'username': 'imagine71'} + response = self.request(method='PUT', post_data=data, user=self.user) + assert response.status_code == 200 + + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + assert VerificationDevice.objects.filter( + user=self.user, unverified_phone=self.user.phone).exists() is False + + def test_update_with_existing_phone(self): + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + user2 = UserFactory.create(phone='+919327768250') + data = {'phone': user2.phone, 'username': 'imagine71'} + response = self.request(method='PUT', post_data=data, user=self.user) + assert response.status_code == 400 + + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + + def test_update_add_phone_and_remove_email(self): + user1 = UserFactory.create(username='sherlock', + phone=None, + email='sherlock.holmes@bbc.uk', + email_verified=True) + EmailAddress.objects.create(user=user1, email=user1.email) + + data = { + 'phone': '+919327768250', + 'email': '', + 'username': 'sherlock' + } + response = self.request(method='PUT', post_data=data, user=user1) + assert response.status_code == 200 + + user1.refresh_from_db() + assert user1.phone == '+919327768250' + assert user1.phone_verified is False + assert VerificationDevice.objects.count() == 1 + assert user1.email is None + assert user1.email_verified is False + assert VerificationDevice.objects.filter( + unverified_phone=user1.phone).exists() is True + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_update_add_email_and_remove_phone(self): + user1 = UserFactory.create(username='sherlock', + phone='+919327768250', + email=None, + phone_verified=True) + VerificationDevice.objects.create(user=user1, + unverified_phone=user1.phone) + data = { + 'phone': '', + 'email': 'sherlock.holmes@bbc.uk', + 'username': 'sherlock' + } + response = self.request(method='PUT', post_data=data, user=user1) + assert response.status_code == 200 + + user1.refresh_from_db() + assert user1.phone is None + assert user1.phone_verified is False + assert user1.email == 'sherlock.holmes@bbc.uk' + assert user1.email_verified is False + assert VerificationDevice.objects.count() == 0 + assert len(mail.outbox) == 2 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + assert 'sherlock.holmes@bbc.uk' in mail.outbox[1].to + + def test_remove_phone_email_unverified(self): + user1 = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + email_verified=False, + phone='+919327768250', + phone_verified=True, + password='221B@bakerstreet') + + data = {'phone': '', 'username': 'sherlock'} + response = self.request(method='PUT', post_data=data, user=user1) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + user1.refresh_from_db() + assert user1.phone is None + assert user1.phone_verified is False + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_remove_phone_email_verified(self): + user1 = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + email_verified=True, + phone_verified=True, + password='221B@bakerstreet') + + data = {'phone': '', 'username': 'sherlock'} + response = self.request(method='PUT', post_data=data, user=user1) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + user1.refresh_from_db() + assert user1.phone is None + assert user1.phone_verified is False + class AccountSignupTest(APITestCase, UserTestCase, TestCase): view_class = api_views.AccountRegister @@ -76,6 +232,7 @@ def test_user_signs_up(self): data = { 'username': 'imagine71', 'email': 'john@beatles.uk', + 'phone': '+919327768250', 'password': 'iloveyoko79!', 'full_name': 'John Lennon', } @@ -96,6 +253,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): @@ -104,32 +288,89 @@ class AccountLoginTest(APITestCase, UserTestCase, TestCase): def setup_models(self): self.user = UserFactory.create(username='imagine71', email='john@beatles.uk', + phone='+919327768250', password='iloveyoko79!') + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) def test_successful_login(self): """The view should return a token to authenticate API calls""" + self.user.email_verified = True + self.user.save() data = {'username': 'imagine71', 'password': 'iloveyoko79!'} response = self.request(method='POST', post_data=data) assert response.status_code == 200 assert 'auth_token' in response.content - def test_unsuccessful_login(self): - """The view should return a token to authenticate API calls""" + def test_unsuccessful_login_incorrect_password(self): + """The view should not return a token to authenticate API calls""" data = {'username': 'imagine71', 'password': 'iloveyoko78!'} response = self.request(method='POST', post_data=data) assert response.status_code == 401 def test_login_with_unverified_email(self): - """The view should return an error message if the User.verify_email_by - is exceeded. An email with a verification link should be have been - sent to the user.""" - self.user.verify_email_by = datetime.now() + """The view should return an error message if the User email + has not been verified.""" + data = {'username': 'john@beatles.uk', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data, user=self.user) + assert response.status_code == 401 + assert 'auth_token' not in response.content + assert ( + response.content['detail'] == "The email has not been verified.") + + def test_unsuccessful_login_with_username_both_unverified(self): + data = {'username': 'imagine71', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 401 + assert ( + response.content['detail'] == "User account is disabled.") + + def test_successful_login_with_username_both_verified(self): + self.user.email_verified = True + self.user.phone_verified = True self.user.save() data = {'username': 'imagine71', 'password': 'iloveyoko79!'} - response = self.request(method='POST', post_data=data, user=self.user) + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'auth_token' in response.content + + def test_successful_login_with_username_only_phone_verified(self): + self.user.phone_verified = True + self.user.save() + + data = {'username': 'imagine71', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'auth_token' in response.content + + def test_successful_login_with_verified_email(self): + self.user.email_verified = True + self.user.save() + + data = {'username': 'john@beatles.uk', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'auth_token' in response.content + + def test_successful_login_with_verified_phone(self): + self.user.phone_verified = True + self.user.save() + + data = {'username': '+919327768250', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'auth_token' in response.content + + def test_unsuccessful_login_with_unverified_phone(self): + self.user.email_verified = True + self.user.phone_verified = False + self.user.save() + data = {'username': '+919327768250', 'password': 'iloveyoko79!'} + response = self.request(method='POST', post_data=data) assert response.status_code == 401 assert 'auth_token' not in response.content - assert len(mail.outbox) == 1 + assert ( + response.content['detail'] == "The phone has not been verified.") class AccountSetPasswordViewTest(APITestCase, UserTestCase, TestCase): @@ -154,3 +395,78 @@ def test_change_password(self): assert 'john@beatles.uk' in mail.outbox[0].to self.user.refresh_from_db() assert self.user.check_password('iloveyoko80!') is True + + +class ConfirmPhoneViewTest(APITestCase, UserTestCase, TestCase): + view_class = api_views.ConfirmPhoneView + + def setup_models(self): + self.user = UserFactory.create(phone='+919327762850', + password='221B@bakerstreet') + self.device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + def test_successful_phone_verification(self): + token = self.device.generate_challenge() + data = { + 'phone': self.user.phone, + 'token': token + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'Phone successfully verified.' in response.content['detail'] + self.user.refresh_from_db() + assert self.user.phone_verified is True + + def test_successful_new_phone_verification(self): + self.device.unverified_phone = '+12345678990' + self.device.save() + token = self.device.generate_challenge() + data = { + 'phone': '+12345678990', + 'token': token + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 200 + assert 'Phone successfully verified.' in response.content['detail'] + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + + def test_unsuccessful_phone_verification_invalid_token(self): + token = self.device.generate_challenge() + token = str(int(token) - 1) + data = { + 'phone': self.user.phone, + 'token': token + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 400 + assert ( + "Invalid Token. Enter a valid token." in response.content['token']) + + def test_unsuccessful_phone_verification_expired_token(self): + now = 1497657600 + with mock.patch('time.time', return_value=now): + token = self.device.generate_challenge() + data = { + 'phone': self.user.phone, + 'token': token + } + with mock.patch('time.time', + return_value=(now + settings.TOTP_TOKEN_VALIDITY + 1)): + response = self.request(method='POST', post_data=data) + assert response.status_code == 400 + assert ( + "The token has expired." in response.content['token']) + + def test_unsuccessful_phone_verification_non_existent_phone(self): + token = self.device.generate_challenge() + data = { + 'phone': '+12345678990', + 'token': token + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 400 + assert ( + "Phone is already verified or not linked to any user account." + in response.content['token']) diff --git a/cadasta/accounts/tests/test_views_default.py b/cadasta/accounts/tests/test_views_default.py index 18ae72f06..cf2569380 100644 --- a/cadasta/accounts/tests/test_views_default.py +++ b/cadasta/accounts/tests/test_views_default.py @@ -10,8 +10,80 @@ 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 ..messages import account_inactive, unverified_identifier +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): @@ -21,7 +93,8 @@ class ProfileTest(ViewTestCase, UserTestCase, TestCase): def setup_template_context(self): return { 'form': ProfileForm(instance=self.user), - 'emails_to_verify': False + 'emails_to_verify': False, + 'phones_to_verify': False } def test_get_profile(self): @@ -63,10 +136,11 @@ def test_update_profile(self): post_data = { 'username': 'John', 'email': user.email, + 'phone': user.phone, + 'language': 'en', + 'measurement': 'metric', 'full_name': 'John Lennon', 'password': 'sgt-pepper', - 'language': 'en', - 'measurement': 'metric' } response = self.request(method='POST', post_data=post_data, user=user) response.status_code == 200 @@ -91,12 +165,81 @@ def test_update_profile_duplicate_email(self): post_data = { 'username': 'Bill', 'email': user1.email, + 'phone': user2.phone, + 'language': 'en', + 'measurement': 'metric', 'full_name': 'Bill Bloggs', } response = self.request(method='POST', user=user2, post_data=post_data) assert 'Failed to update profile information' in response.messages + def test_get_profile_with_verified_phone(self): + self.user = UserFactory.create(phone_verified=True, + email_verified=True) + response = self.request(user=self.user) + + assert response.status_code == 200 + assert response.content == self.expected_content + + def test_get_profile_with_unverified_phone(self): + self.user = UserFactory.create() + VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + + response = self.request(user=self.user) + + assert response.status_code == 200 + assert response.content == self.render_content(phones_to_verify=True) + + def test_update_profile_with_duplicate_phone(self): + user1 = UserFactory.create(phone='+919327768250') + user2 = UserFactory.create(password='221B@bakerstreet') + post_data = { + 'username': user2.username, + 'email': user2.email, + 'phone': user1.phone, + 'language': 'en', + 'measurement': 'metric', + 'full_name': 'Sherlock Holmes', + 'password': '221B@bakerstreet' + } + response = self.request(method='POST', post_data=post_data, user=user2) + + assert response.status_code == 200 + assert 'Failed to update profile information' in response.messages + + def test_update_profile_with_phone(self): + user = UserFactory.create(password='221B@bakerstreet') + post_data = { + 'username': user.username, + 'email': user.email, + 'phone': '+919327768250', + 'language': 'en', + 'measurement': 'metric', + 'full_name': 'Sherlock Holmes', + 'password': '221B@bakerstreet' + } + response = self.request(method='POST', post_data=post_data, user=user) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + + def test_update_keep_phone(self): + user = UserFactory.create( + password='221B@bakerstreet') + post_data = { + 'username': user.username, + 'email': user.email, + 'phone': user.phone, + 'language': 'en', + 'measurement': 'metric', + 'full_name': 'Sherlock Holmes', + 'password': '221B@bakerstreet' + } + response = self.request(method='POST', post_data=post_data, user=user) + assert response.status_code == 302 + assert '/account/profile' in response.location + class PasswordChangeTest(ViewTestCase, UserTestCase, TestCase): view_class = default.PasswordChangeView @@ -124,26 +267,84 @@ class LoginTest(ViewTestCase, UserTestCase, TestCase): def setup_models(self): self.user = UserFactory.create(username='imagine71', email='john@beatles.uk', + phone='+919327768250', password='iloveyoko79') def test_successful_login(self): + self.user.email_verified = True + self.user.save() + data = {'login': 'imagine71', 'password': 'iloveyoko79'} response = self.request(method='POST', post_data=data) assert response.status_code == 302 assert 'dashboard' in response.location def test_successful_login_with_unverified_user(self): - self.user.verify_email_by = datetime.datetime.now() + self.user.email_verified = False + self.user.phone_verified = False self.user.save() data = {'login': 'imagine71', 'password': 'iloveyoko79'} response = self.request(method='POST', post_data=data) assert response.status_code == 302 - assert 'account/inactive' in response.location - assert len(mail.outbox) == 1 + assert '/account/resendtokenpage/' in response.location + assert account_inactive in response.messages + self.user.refresh_from_db() assert self.user.is_active is False + def test_unsuccessful_login_with_email(self): + + data = {'login': 'john@beatles.uk', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/resendtokenpage/' in response.location + assert unverified_identifier in response.messages + + def test_unsuccessful_login_with_phone(self): + data = {'login': '+919327768250', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/resendtokenpage/' in response.location + assert unverified_identifier in response.messages + + def test_successful_login_with_username_both_verified(self): + self.user.email_verified = True + self.user.phone_verified = True + self.user.save() + + data = {'login': 'imagine71', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert 'dashboard' in response.location + + def test_successful_login_with_username_only_phone_verified(self): + self.user.phone_verified = True + self.user.save() + + data = {'login': 'imagine71', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert 'dashboard' in response.location + + def test_successful_login_with_email(self): + self.user.email_verified = True + self.user.save() + + data = {'login': 'john@beatles.uk', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert 'dashboard' in response.location + + def test_successful_login_with_phone(self): + self.user.phone_verified = True + self.user.save() + + data = {'login': '+919327768250', 'password': 'iloveyoko79'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert 'dashboard' in response.location + class ConfirmEmailTest(ViewTestCase, UserTestCase, TestCase): view_class = default.ConfirmEmail @@ -212,7 +413,11 @@ class PasswordResetViewTest(ViewTestCase, UserTestCase, TestCase): view_class = default.PasswordResetView def setup_models(self): - self.user = UserFactory.create(email='john@example.com') + self.user = UserFactory.create(email='john@example.com', + phone='+919327762850') + self.user.verificationdevice_set.create( + unverified_phone=self.user.phone, + label='password_reset') def test_mail_sent(self): data = {'email': 'john@example.com'} @@ -225,3 +430,227 @@ def test_mail_not_sent(self): response = self.request(method='POST', post_data=data) assert response.status_code == 302 assert len(mail.outbox) == 0 + + def test_text_msg_sent(self): + data = {'phone': '+919327768250'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + + def test_text_msg_not_sent(self): + data = {'phone': '+12345678990'} + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + + +class PasswordResetDoneViewTest(UserTestCase, TestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory.create(phone='+919327768250', + email='sherlock.holmes@bbc.uk', + phone_verified=True, + email_verified=True) + self.factory = RequestFactory() + self.device = VerificationDevice.objects.create( + user=self.user, + unverified_phone=self.user.phone, + label='password_reset') + + def test_successful_token_verification(self): + token = self.device.generate_challenge() + data = {'token': token} + request = self.factory.post('/account/password/reset/done/', data=data) + request.session = {"phone": self.user.phone} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.PasswordResetDoneView.as_view()(request) + assert response.status_code == 302 + assert '/account/password/reset/phone/' in response.url + assert VerificationDevice.objects.filter( + user=self.user, label='password_reset').exists() is False + assert 'phone' not in request.session + + def test_without_phone(self): + token = self.device.generate_challenge() + data = {'token': token} + request = self.factory.post('/account/password/reset/done/', data=data) + setattr(request, 'session', {}) + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.PasswordResetDoneView.as_view()(request) + assert response.status_code == 200 + + def test_with_unknown_phone(self): + token = self.device.generate_challenge() + data = {'token': token} + request = self.factory.post('/account/password/reset/done/', data=data) + request.session = {"phone": '+12345678990'} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.PasswordResetDoneView.as_view()(request) + assert response.status_code == 200 + + +class PasswordResetFromPhoneViewTest(UserTestCase, TestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory.create(password='221B@bakerstreet') + self.factory = RequestFactory() + + def test_password_successfully_set(self): + data = {'password': 'i@msher!0cked'} + request = self.factory.post( + '/account/password/reset/phone/', data=data) + request.session = {"password_reset_id": self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + + response = default.PasswordResetFromPhoneView.as_view()(request) + assert response.status_code == 302 + self.user.refresh_from_db() + assert self.user.check_password('i@msher!0cked') is True + assert len(mail.outbox) == 1 + assert self.user.email in mail.outbox[0].to + + def test_password_set_without_password_reset_id(self): + data = {'password': 'i@msher!0cked'} + request = self.factory.post( + '/account/password/reset/phone/', data=data) + setattr(request, 'session', {}) + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + + response = default.PasswordResetFromPhoneView.as_view()(request) + assert response.status_code == 200 + self.user.refresh_from_db() + assert self.user.check_password('i@msher!0cked') is False + + +class ConfirmPhoneViewTest(UserTestCase, TestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory.create(phone='+919327768250') + EmailAddress.objects.create(user=self.user, email=self.user.email) + self.factory = RequestFactory() + + def test_successful_phone_verification(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + + data = {'token': token} + request = self.factory.post('/account/accountverification/', data=data) + request.session = {'phone_verify_id': self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.ConfirmPhone.as_view()(request) + assert response.status_code == 302 + + self.user.refresh_from_db() + assert self.user.phone_verified is True + assert VerificationDevice.objects.filter( + user=self.user, + unverified_phone=self.user.phone, + label='phone_verify').exists() is False + + def test_successful_new_phone_verification(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone='+12345678990') + token = device.generate_challenge() + + data = {'token': token} + request = self.factory.post('/account/accountverification/', data=data) + request.session = {'phone_verify_id': self.user.id} + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.ConfirmPhone.as_view()(request) + assert response.status_code == 302 + + self.user.refresh_from_db() + assert self.user.phone == '+12345678990' + assert self.user.phone_verified is True + assert VerificationDevice.objects.filter( + user=self.user, + unverified_phone='+12345678990', + label='phone_verify').exists() is False + + def test_phone_verification_without_phone_verify_id(self): + device = VerificationDevice.objects.create( + user=self.user, unverified_phone=self.user.phone) + token = device.generate_challenge() + + data = {'token': token} + request = self.factory.post('/account/accountverification/', data=data) + setattr(request, 'session', {}) + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + response = default.ConfirmPhone.as_view()(request) + assert response.status_code == 200 + + +class ResendTokenViewTest(ViewTestCase, UserTestCase, TestCase): + view_class = default.ResendTokenView + + def setup_models(self): + self.user = UserFactory.create(username='sherlock', + email='sherlock.holmes@bbc.uk', + phone='+919327768250', + password='221B@bakerstreet', + ) + + def test_phone_send_token(self): + VerificationDevice.objects.create(user=self.user, + unverified_phone=self.user.phone) + data = { + 'phone': '+919327768250', + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + + def test_email_send_link(self): + EmailAddress.objects.create(user=self.user, email=self.user.email) + data = { + 'email': 'sherlock.holmes@bbc.uk', + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + assert len(mail.outbox) == 1 + assert 'sherlock.holmes@bbc.uk' in mail.outbox[0].to + + def test_updated_email_send_link(self): + EmailAddress.objects.create(user=self.user, email='john.watson@bbc.uk') + data = { + 'email': 'john.watson@bbc.uk', + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + assert len(mail.outbox) == 1 + assert 'john.watson@bbc.uk' in mail.outbox[0].to + + def test_already_verified_email(self): + EmailAddress.objects.create( + user=self.user, verified=True, email=self.user.email) + data = { + 'email': 'john.watson@bbc.uk', + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + assert len(mail.outbox) == 0 + + def test_already_verified_phone(self): + data = { + 'phone': '+919327768250', + } + response = self.request(method='POST', post_data=data) + assert response.status_code == 302 + assert '/account/accountverification/' in response.location + assert VerificationDevice.objects.filter( + user=self.user, + unverified_phone=self.user.phone, + label='phone_verify').exists() is False diff --git a/cadasta/accounts/urls/api.py b/cadasta/accounts/urls/api.py index 167649dce..1f05d6e00 100644 --- a/cadasta/accounts/urls/api.py +++ b/cadasta/accounts/urls/api.py @@ -7,4 +7,6 @@ url(r'^register/$', api.AccountRegister.as_view(), name='register'), url(r'^login/$', api.AccountLogin.as_view(), name='login'), url(r'^password/$', api.SetPasswordView.as_view(), name='password'), + url(r'^verify/phone/$', api.ConfirmPhoneView.as_view(), + name='verify_phone') ] diff --git a/cadasta/accounts/urls/default.py b/cadasta/accounts/urls/default.py index 256aa42a1..675f08ba2 100644 --- a/cadasta/accounts/urls/default.py +++ b/cadasta/accounts/urls/default.py @@ -1,8 +1,9 @@ from django.conf.urls import url - +import allauth.account.views as allauth_views 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,17 @@ 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'), + url(r'^resendtokenpage/$', + default.ResendTokenView.as_view(), name='resend_token'), + url(r'^password/reset/done/$', + default.PasswordResetDoneView.as_view(), + name='account_reset_password_done'), + url(r'^password/reset/phone/$', + default.PasswordResetFromPhoneView.as_view(), + name='account_reset_password_from_phone'), + url(r'password/reset/key/done/$', + allauth_views.PasswordResetFromKeyDoneView.as_view(), + name='account_reset_password_from_key_done') ] diff --git a/cadasta/accounts/utils.py b/cadasta/accounts/utils.py index 30a212408..276f85f98 100644 --- a/cadasta/accounts/utils.py +++ b/cadasta/accounts/utils.py @@ -2,6 +2,7 @@ from django.utils.translation import ugettext as _ from django.core.mail import send_mail from django.template.loader import render_to_string +from django.utils.module_loading import import_string def send_email_update_notification(email): @@ -14,3 +15,45 @@ def send_email_update_notification(email): [email], fail_silently=False, ) + + +def send_email_deleted_notification(email): + msg_body = render_to_string( + 'allauth/account/messages/email_deleted.txt', {'email': email} + ) + send_mail( + _("Deleted email at Cadasta Platform"), + msg_body, + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=False, + ) + + +def send_phone_update_notification(email): + msg_body = render_to_string( + 'accounts/email/phone_changed_notification.txt') + send_mail( + _("Change of phone at Cadasta Platform"), + msg_body, + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=False, + ) + + +def send_phone_deleted_notification(email): + msg_body = render_to_string( + 'accounts/email/phone_deleted_notification.txt') + send_mail( + _("Deleted phone at Cadasta Platform"), + msg_body, + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=False, + ) + + +def send_sms(to, body): + twilioobj = (import_string(settings.SMS_GATEWAY))() + twilioobj.send_sms(to, body) diff --git a/cadasta/accounts/validators.py b/cadasta/accounts/validators.py index a377fecd7..be610af7f 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 = [ @@ -13,6 +14,7 @@ class CharacterTypePasswordValidator(object): + def __init__(self, character_types=DEFAULT_CHARACTER_TYPES, unique_types=3): self.character_types = character_types @@ -40,8 +42,9 @@ def validate(self, password, user=None): class EmailSimilarityValidator(object): + def validate(self, password, user=None): - if not user: + if not user or not user.email: return None email = user.email.split('@') @@ -59,3 +62,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..1c16f6636 100644 --- a/cadasta/accounts/views/api.py +++ b/cadasta/accounts/views/api.py @@ -1,36 +1,71 @@ -from django.utils.translation import ugettext as _ from allauth.account.utils import send_email_confirmation from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny from djoser import views as djoser_views from djoser import signals from allauth.account.signals import password_changed from .. import serializers -from ..utils import send_email_update_notification -from ..exceptions import EmailNotVerifiedError +from .. import utils +from .. import messages +from ..models import VerificationDevice +from accounts import exceptions class AccountUser(djoser_views.UserView): serializer_class = serializers.UserSerializer def perform_update(self, serializer): - user = self.get_object() - old_email = user.email - new_email = serializer.validated_data.get('email', user.email) - - if user.email != new_email: - updated = serializer.save(email=old_email) - updated.email = new_email - - send_email_confirmation(self.request._request, updated) - send_email_update_notification(old_email) + instance = self.get_object() + current_email, current_phone = instance.email, instance.phone + new_email = serializer.validated_data.get('email', instance.email) + new_phone = serializer.validated_data.get('phone', instance.phone) + email_update_message = None + user = serializer.save() - else: - serializer.save() + if current_email != new_email: + email_set = instance.emailaddress_set.all() + if email_set.exists(): + email_set.delete() + if new_email: + send_email_confirmation(self.request._request, user) + email_update_message = messages.email_change + if current_email: + user.email = current_email + utils.send_email_update_notification(current_email) + else: + user.email_verified = False + utils.send_email_deleted_notification(current_email) + email_update_message = messages.email_delete + + if current_phone != new_phone: + phone_set = VerificationDevice.objects.filter(user=instance) + if phone_set.exists(): + phone_set.delete() + if new_phone: + device = VerificationDevice.objects.create( + user=instance, + unverified_phone=new_phone) + device.generate_challenge() + if current_phone: + user.phone = current_phone + utils.send_sms(current_phone, messages.phone_change) + if user.email: + utils.send_phone_update_notification(user.email) + else: + user.phone_verified = False + utils.send_sms(current_phone, messages.phone_delete) + if user.email: + utils.send_phone_deleted_notification(user.email) + + if user.phone and email_update_message: + utils.send_sms(to=user.phone, body=email_update_message) + user.save() class AccountRegister(djoser_views.RegistrationView): @@ -41,7 +76,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): @@ -52,28 +93,55 @@ def post(self, request): try: serializer.is_valid(raise_exception=True) return self._action(serializer) + except ValidationError: - return Response( - data=serializer.errors, - status=status.HTTP_401_UNAUTHORIZED, - ) - except EmailNotVerifiedError: + error = serializer.errors + + except exceptions.AccountInactiveError as e: user = serializer.user user.is_active = False user.save() + error = e.msg + except exceptions.EmailNotVerifiedError as e: + user = serializer.user + error = e.msg send_email_confirmation(self.request._request, user) - return Response( - data={'detail': _("The email has not been verified.")}, - status=status.HTTP_401_UNAUTHORIZED, - ) + except exceptions.PhoneNotVerifiedError as e: + user = serializer.user + error = e.msg + device = user.verificationdevice_set.get(label='phone_verify') + device.generate_challenge() + + return Response( + data={'detail': error}, + status=status.HTTP_401_UNAUTHORIZED) class SetPasswordView(djoser_views.SetPasswordView): + def _action(self, serializer): response = super()._action(serializer) password_changed.send(sender=self.request.user.__class__, request=self.request._request, user=self.request.user) return response + + +class ConfirmPhoneView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.PhoneVerificationSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except ValidationError: + return Response( + data=serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + data={'detail': 'Phone successfully verified.'}, + status=status.HTTP_200_OK) diff --git a/cadasta/accounts/views/default.py b/cadasta/accounts/views/default.py index 63e6b90aa..716406de0 100644 --- a/cadasta/accounts/views/default.py +++ b/cadasta/accounts/views/default.py @@ -2,18 +2,51 @@ from django.utils.translation import ugettext_lazy as _ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.utils import timezone +from django.views.generic import FormView +from django.shortcuts import redirect +from django.utils.html import format_html -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 from allauth.account.views import ConfirmEmailView, LoginView from allauth.account.utils import send_email_confirmation from allauth.account.models import EmailAddress +from allauth.account import signals -from ..models import User +from ..models import User, VerificationDevice from .. import forms +from ..messages import account_inactive, unverified_identifier + + +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['phone_verify_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) class PasswordChangeView(LoginRequiredMixin, @@ -28,11 +61,76 @@ class PasswordResetView(SuperUserCheckMixin, form_class = forms.ResetPasswordForm +class PasswordResetDoneView(FormView, allauth_views.PasswordResetDoneView): + """ If the user opts to reset password with phone, this view will display + a form to verify the password reset token. """ + form_class = forms.TokenVerificationForm + success_url = reverse_lazy('account:account_reset_password_from_phone') + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['phone'] = self.request.session.get('phone', None) + return context + + def get_form_kwargs(self, *args, **kwargs): + form_kwargs = super().get_form_kwargs(*args, **kwargs) + phone = self.request.session.get('phone', None) + try: + form_kwargs['device'] = VerificationDevice.objects.get( + unverified_phone=phone, label='password_reset') + except VerificationDevice.DoesNotExist: + pass + return form_kwargs + + def form_valid(self, form): + device = form.device + message = _("Successfully Verified Token." + " You can now reset your password.") + messages.add_message(self.request, messages.SUCCESS, message) + self.request.session.pop('phone', None) + self.request.session['password_reset_id'] = device.user_id + device.delete() + return super().form_valid(form) + + class PasswordResetFromKeyView(SuperUserCheckMixin, allauth_views.PasswordResetFromKeyView): form_class = forms.ResetPasswordKeyForm +class PasswordResetFromPhoneView(FormView, SuperUserCheckMixin): + """ This view will allow user to reset password once a user has + successfully verified the password reset token. """ + form_class = forms.ResetPasswordKeyForm + template_name = 'account/password_reset_from_key.html' + success_url = reverse_lazy("account:account_reset_password_from_key_done") + + def get_form_kwargs(self, *args, **kwargs): + form_kwargs = super().get_form_kwargs(*args, **kwargs) + try: + user_id = self.request.session['password_reset_id'] + user = User.objects.get(id=user_id) + form_kwargs['user'] = user + except KeyError: + message = _( + "You must first verify your token before resetting password." + " Click here to get the password reset" + " verification token. ") + message = format_html(message.format( + url=reverse_lazy('account:account_reset_password'))) + messages.add_message(self.request, messages.ERROR, message) + + return form_kwargs + + def form_valid(self, form): + form.save() + self.request.session.pop('password_reset_id', None) + signals.password_reset.send(sender=form.user.__class__, + request=self.request, + user=form.user) + return super().form_valid(form) + + class AccountProfile(LoginRequiredMixin, UpdateView): model = User form_class = forms.ProfileForm @@ -40,13 +138,19 @@ class AccountProfile(LoginRequiredMixin, UpdateView): success_url = reverse_lazy('account:profile') def get_object(self, *args, **kwargs): + self.instance_phone = self.request.user.phone return self.request.user def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) + emails_to_verify = EmailAddress.objects.filter( user=self.object, verified=False).exists() + phones_to_verify = VerificationDevice.objects.filter( + user=self.object, verified=False).exists() + context['emails_to_verify'] = emails_to_verify + context['phones_to_verify'] = phones_to_verify return context def get_form_kwargs(self, *args, **kwargs): @@ -55,8 +159,17 @@ def get_form_kwargs(self, *args, **kwargs): return form_kwargs def form_valid(self, form): + phone = form.data.get('phone') messages.add_message(self.request, messages.SUCCESS, _("Successfully updated profile information")) + + if (phone != self.instance_phone and phone): + message = _("Verification Token sent to {phone}") + message = message.format(phone=phone) + messages.add_message(self.request, messages.INFO, message) + self.request.session['phone_verify_id'] = self.object.id + self.success_url = reverse_lazy('account:verify_phone') + return super().form_valid(form) def form_invalid(self, form): @@ -66,17 +179,31 @@ def form_invalid(self, form): class AccountLogin(LoginView): + def form_valid(self, form): + login = form.cleaned_data['login'] user = form.user - if not user.email_verified and timezone.now() > user.verify_email_by: + + if (login == user.username and + not user.phone_verified and + not user.email_verified): user.is_active = False user.save() - send_email_confirmation(self.request, user) + messages.add_message( + self.request, messages.ERROR, account_inactive) + return redirect(reverse_lazy('account:resend_token')) - return super().form_valid(form) + if(login == user.email and not user.email_verified or + login == user.phone and not user.phone_verified): + messages.add_message( + self.request, messages.ERROR, unverified_identifier) + return redirect(reverse_lazy('account:resend_token')) + else: + return super().form_valid(form) class ConfirmEmail(ConfirmEmailView): + def post(self, *args, **kwargs): response = super().post(*args, **kwargs) @@ -87,3 +214,90 @@ def post(self, *args, **kwargs): user.save() return response + + +class ConfirmPhone(FormView): + template_name = 'accounts/account_verification.html' + form_class = forms.TokenVerificationForm + success_url = reverse_lazy('account:login') + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + user_id = self.request.session.get('phone_verify_id', None) + + emails_to_verify = EmailAddress.objects.filter( + user_id=user_id, verified=False).exists() + phones_to_verify = VerificationDevice.objects.filter( + user_id=user_id, label='phone_verify').exists() + context['phone'] = phones_to_verify + context['email'] = emails_to_verify + + return context + + def get_form_kwargs(self, *args, **kwargs): + form_kwargs = super().get_form_kwargs(*args, **kwargs) + user_id = self.request.session.get('phone_verify_id', None) + try: + form_kwargs['device'] = VerificationDevice.objects.get( + user_id=user_id, label='phone_verify') + except VerificationDevice.DoesNotExist: + pass + return form_kwargs + + def form_valid(self, form): + device = form.device + user = device.user + if user.phone != device.unverified_phone: + user.phone = device.unverified_phone + user.phone_verified = True + user.is_active = True + user.save() + device.delete() + message = _("Successfully verified {phone}") + message = message.format(phone=user.phone) + messages.add_message(self.request, messages.SUCCESS, message) + self.request.session.pop('phone_verify_id', None) + return super().form_valid(form) + + +class ResendTokenView(FormView): + form_class = forms.ResendTokenForm + template_name = 'accounts/resend_token_page.html' + success_url = reverse_lazy('account:verify_phone') + + def form_valid(self, form): + phone = form.data.get('phone') + email = form.data.get('email') + if phone: + try: + phone_device = VerificationDevice.objects.get( + unverified_phone=phone, verified=False) + phone_device.generate_challenge() + self.request.session['phone_verify_id'] = phone_device.user_id + except VerificationDevice.DoesNotExist: + pass + message = _( + "Your phone number has been submitted." + " If it matches your account on Cadasta Platform, you will" + " receive a verification token to confirm your phone.") + messages.add_message(self.request, messages.SUCCESS, message) + + if email: + email = email.casefold() + try: + email_device = EmailAddress.objects.get( + email=email, verified=False) + user = email_device.user + if not user.email_verified: + user.email = email + send_email_confirmation(self.request, user) + self.request.session['phone_verify_id'] = email_device.user.id + except EmailAddress.DoesNotExist: + pass + message = _( + "Your email address has been submitted." + " If it matches your account on Cadasta Platform, you will" + " receive a verification link to confirm your email.") + 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 4671d7bd6..202ef5be6 100644 --- a/cadasta/config/settings/default.py +++ b/cadasta/config/settings/default.py @@ -73,6 +73,7 @@ 'simple_history', 'jsonattrs', 'compressor', + 'django_otp', ) MIDDLEWARE_CLASSES = ( @@ -89,6 +90,7 @@ 'audit_log.middleware.UserLoggingMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', 'accounts.middleware.UserLanguageMiddleware', + 'django_otp.middleware.OTPMiddleware', ) REST_FRAMEWORK = { @@ -137,7 +139,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' @@ -543,7 +546,7 @@ } MIME_LOOKUPS = { - 'gpx': 'application/gpx+xml' + 'gpx': 'application/gpx+xml' } FILE_UPLOAD_HANDLERS = [ @@ -570,3 +573,11 @@ ES_HOST = 'localhost' ES_PORT = '9200' ES_MAX_RESULTS = 10000 + +TOTP_TOKEN_VALIDITY = 3600 +TOTP_DIGITS = 6 + +SMS_GATEWAY = 'accounts.gateways.FakeGateway' + +TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID') +TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN') diff --git a/cadasta/config/settings/dev.py b/cadasta/config/settings/dev.py index 9b680018d..fa373e884 100644 --- a/cadasta/config/settings/dev.py +++ b/cadasta/config/settings/dev.py @@ -89,6 +89,10 @@ 'filename': '/var/log/django/debug.log', 'formatter': 'simple' }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + } }, 'loggers': { 'django': { @@ -99,7 +103,11 @@ 'xform.submissions': { 'handlers': ['file'], 'level': 'DEBUG' - } + }, + 'accounts.FakeGateway': { + 'handlers': ['console'], + 'level': 'DEBUG' + }, }, } diff --git a/cadasta/config/settings/production.py b/cadasta/config/settings/production.py index 8d911d248..1efadc3aa 100644 --- a/cadasta/config/settings/production.py +++ b/cadasta/config/settings/production.py @@ -137,3 +137,5 @@ }, }, } + +SMS_GATEWAY = 'accounts.gateways.TwilioGateway' diff --git a/cadasta/config/settings/travis.py b/cadasta/config/settings/travis.py index e27e425c2..227965bd7 100644 --- a/cadasta/config/settings/travis.py +++ b/cadasta/config/settings/travis.py @@ -49,6 +49,11 @@ 'level': 'DEBUG', 'class': 'logging.NullHandler', }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.NullHandler', + } + }, 'loggers': { 'django': { @@ -59,6 +64,10 @@ 'xform.submissions': { 'handlers': ['file'], 'level': 'DEBUG' - } - }, + }, + 'accounts.FakeGateway': { + 'handlers': ['console'], + 'level': 'DEBUG' + }, + } } diff --git a/cadasta/core/fixtures/__init__.py b/cadasta/core/fixtures/__init__.py index 71cbe3a08..7a6d906c3 100644 --- a/cadasta/core/fixtures/__init__.py +++ b/cadasta/core/fixtures/__init__.py @@ -24,9 +24,9 @@ def add_test_users(self): # the first two named users will have superuser access named_users = [ {'username': 'superuser1', 'email': 'superuser1@cadasta.org', - 'full_name': 'Super User1', 'is_superuser': True}, + 'phone': '+15125550115', 'full_name': 'Super User1'}, {'username': 'superuser2', 'email': 'superuser2@cadasta.org', - 'full_name': 'Super User2', 'is_superuser': True}] + 'phone': '+15125550138', 'full_name': 'Super User2'}] # add users with names in languages that need to be tested. languages = ['el_GR', 'ja_JP', 'hi_IN', 'hr_HR', 'lt_LT'] named_users.append({ diff --git a/cadasta/core/form_mixins.py b/cadasta/core/form_mixins.py index 1e6c7b6dc..db69a60bb 100644 --- a/cadasta/core/form_mixins.py +++ b/cadasta/core/form_mixins.py @@ -68,7 +68,7 @@ def set_standard_field(self, name, empty_choice=None, field_name=None): try: question = Question.objects.get(name=name, questionnaire=q) self.fields[field_name].labels_xlang = template_xlang_labels( - question.label_xlat) + question.label_xlat) if question.has_options: choices = QuestionOption.objects.filter( @@ -77,7 +77,7 @@ def set_standard_field(self, name, empty_choice=None, field_name=None): try: choices, xlang_labels = zip( *[((c[0], c[1].get(default_lang)), - (c[0], c[1])) for c in choices]) + (c[0], c[1])) for c in choices]) except AttributeError: choices = choices xlang_labels = '' diff --git a/cadasta/organization/forms.py b/cadasta/organization/forms.py index 9e26119dc..96152d5de 100644 --- a/cadasta/organization/forms.py +++ b/cadasta/organization/forms.py @@ -184,7 +184,7 @@ def __init__(self, *args, **kwargs): def clean_identifier(self): identifier = self.data.get('identifier') try: - self.user = User.objects.get_from_username_or_email( + self.user = User.objects.get_from_username_or_email_or_phone( identifier=identifier) except (User.DoesNotExist, User.MultipleObjectsReturned) as e: raise forms.ValidationError(e) diff --git a/cadasta/organization/serializers.py b/cadasta/organization/serializers.py index 6fa913473..c544aaa64 100644 --- a/cadasta/organization/serializers.py +++ b/cadasta/organization/serializers.py @@ -150,13 +150,17 @@ def validate_username(self, value): if self.instance: self.user = self.instance else: - users = User.objects.filter(Q(username=value) | Q(email=value)) + users = User.objects.filter( + Q(username=value) | Q(email=value) | Q(phone=value)) users_count = len(users) if users_count == 0: - error = _("User with username or email {} does not exist") + error = _( + "User with username or email or phone {} does not exist") elif users_count > 1: - error = _("More than one user found for username or email {}") + error = _( + "More than one user found for username or email or" + " phone {}") else: self.user = users[0] diff --git a/cadasta/organization/tests/test_serializers.py b/cadasta/organization/tests/test_serializers.py index 9d3969f35..4a8498ad4 100644 --- a/cadasta/organization/tests/test_serializers.py +++ b/cadasta/organization/tests/test_serializers.py @@ -510,14 +510,14 @@ def test_set_roles_for_user_that_does_not_exist(self): with pytest.raises(ValidationError): serializer.is_valid(raise_exception=True) - assert (_('User with username or email {username} ' + assert (_('User with username or email or phone {username} ' 'does not exist').format(username='some-user') in serializer.errors['username']) def test_set_roles_for_duplicate_username(self): org = OrganizationFactory.create() user1 = UserFactory.create(email='some-user@some.com') - UserFactory.create(email='some-user@some.com') + UserFactory.create(username='some-user@some.com') data = {'username': user1.email, 'admin': 'true'} serializer = serializers.OrganizationUserSerializer( data=data, context={'organization': org} @@ -525,9 +525,10 @@ def test_set_roles_for_duplicate_username(self): with pytest.raises(ValidationError): serializer.is_valid(raise_exception=True) - assert (_('More than one user found for username or email ' - '{email}').format(email='some-user@some.com') - in serializer.errors['username']) + assert ( + _('More than one user found for username or email or' + ' phone {email}').format(email='some-user@some.com') + in serializer.errors['username']) def test_set_role_when_role_exists(self): user = UserFactory.create() @@ -648,7 +649,7 @@ def test_set_roles_for_user_that_does_not_exist(self): with pytest.raises(ValidationError): serializer.is_valid(raise_exception=True) - assert (_('User with username or email {username} ' + assert (_('User with username or email or phone {username} ' 'does not exist').format(username='some-user') in serializer.errors['username']) diff --git a/cadasta/organization/tests/test_views_api_organizations.py b/cadasta/organization/tests/test_views_api_organizations.py index 7b71376f0..b0c63d24b 100644 --- a/cadasta/organization/tests/test_views_api_organizations.py +++ b/cadasta/organization/tests/test_views_api_organizations.py @@ -126,9 +126,9 @@ def test_permission_filter(self): 'clause': [ clause('allow', ['org.list']), clause('allow', ['org.view', 'project.create'], - ['organization/*']), + ['organization/*']), clause('deny', ['project.create'], - ['organization/unauthorized']) + ['organization/unauthorized']) ] } policy = Policy.objects.create(name='deny', body=json.dumps(clauses)) @@ -166,8 +166,8 @@ def test_create_valid_organization(self): assert response.status_code == 201 assert Organization.objects.count() == 1 assert OrganizationRole.objects.get( - organization_id=response.content['id'], user=self.user - ).admin is True + organization_id=response.content['id'], user=self.user + ).admin is True def test_create_invalid_organization(self): data = {'description': 'Org description'} @@ -442,8 +442,8 @@ def test_add_user_that_does_not_exist(self): post_data={'username': 'some_username'}) assert response.status_code == 400 assert self.org.users.count() == 2 - assert ("User with username or email some_username does not exist" - in response.content['username']) + assert ("User with username or email or phone some_username does not" + " exist" in response.content['username']) def test_add_user_to_organization_that_does_not_exist(self): new_user = UserFactory.create() diff --git a/cadasta/organization/tests/test_views_api_projects.py b/cadasta/organization/tests/test_views_api_projects.py index 3ba3ebdb2..ae7bd5eda 100644 --- a/cadasta/organization/tests/test_views_api_projects.py +++ b/cadasta/organization/tests/test_views_api_projects.py @@ -125,7 +125,7 @@ def test_add_user_with_invalid_data(self): user=self.user) assert response.status_code == 400 assert self.project.users.count() == 0 - assert ('User with username or email some-user does not exist' + assert ('User with username or email or phone some-user does not exist' in response.content['username']) def test_add_user_to_archived_project(self): diff --git a/cadasta/resources/tests/test_migrations.py b/cadasta/resources/tests/test_migrations.py index b05d61190..52a6ea94c 100644 --- a/cadasta/resources/tests/test_migrations.py +++ b/cadasta/resources/tests/test_migrations.py @@ -29,16 +29,16 @@ def setUp(self): # Reverse to migrate_from call_command('migrate', 'accounts', - '0002_activate_users_20161014_0846') + '0002_activate_users_20161014_0846', '--noinput') call_command('migrate', 'organization', - '0004_remove_Pb_project_roles') - call_command('migrate', self.app, self.migrate_from) + '0004_remove_Pb_project_roles', '--noinput') + call_command('migrate', self.app, self.migrate_from, '--noinput') # setup pre-migration test data self.setUpBeforeMigration(apps_before) # Run the migration to test - call_command('migrate', self.app, self.migrate_to) + call_command('migrate', self.app, self.migrate_to, '--noinput') # get application state post-migration self.apps_after = self._get_apps_for_migration( diff --git a/cadasta/templates/accounts/account_verification.html b/cadasta/templates/accounts/account_verification.html new file mode 100644 index 000000000..bc273c333 --- /dev/null +++ b/cadasta/templates/accounts/account_verification.html @@ -0,0 +1,42 @@ +{% extends "core/base.html" %} + +{% load i18n %} + +{% load widget_tweaks %} + +{% block head_title %} {% trans "Account Verification" %}{% endblock %} + +{% block content %} +
+

+ {% trans "Account Verification" %} +

+ {% if email %} +

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

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

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

+
+ {% csrf_token %} +
+ + {% render_field form.token class+="form-control input-lg" data-parsley-required="true" data-parsley-sanitize="1" %} +
+ {{ form.token.errors }} +
+
+ +
+ {% endif %} + + {% url 'account:resend_token' as resend_url %} +

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

+
+{% endblock %} diff --git a/cadasta/templates/accounts/email/phone_changed_notification.txt b/cadasta/templates/accounts/email/phone_changed_notification.txt new file mode 100644 index 000000000..7ae97a94e --- /dev/null +++ b/cadasta/templates/accounts/email/phone_changed_notification.txt @@ -0,0 +1,11 @@ +{% load i18n %}{% autoescape off %} + +{% blocktrans %} +You are receiving this email because a user at Cadasta Platform changed the phone number for the account linked to this email address. + +If it wasn't you who changed the phone number, please contact us immediately under security@cadasta.org. +{% endblocktrans %} + +{% blocktrans %}The Cadasta Team. {% endblocktrans %} + +{% endautoescape %} diff --git a/cadasta/templates/accounts/email/phone_deleted_notification.txt b/cadasta/templates/accounts/email/phone_deleted_notification.txt new file mode 100644 index 000000000..bea6c1596 --- /dev/null +++ b/cadasta/templates/accounts/email/phone_deleted_notification.txt @@ -0,0 +1,11 @@ +{% load i18n %}{% autoescape off %} + +{% blocktrans %} +You are receiving this email because a user at Cadasta Platform removed the phone number for the account linked to this email address. + +If it wasn't you who removed the phone number, please contact us immediately under security@cadasta.org. +{% endblocktrans %} + +{% blocktrans %}The Cadasta Team. {% endblocktrans %} + +{% endautoescape %} diff --git a/cadasta/templates/accounts/profile.html b/cadasta/templates/accounts/profile.html index dbcdfb57a..c1b5d92c0 100644 --- a/cadasta/templates/accounts/profile.html +++ b/cadasta/templates/accounts/profile.html @@ -36,11 +36,24 @@

{% trans "Update your profile" %}

{{ form.username.errors }}
+ {% url 'account:resend_token' as resend_url %}
- {% render_field form.email class+="form-control input-lg" data-parsley-required="true" data-parsley-sanitize="1" %} - {% if emails_to_verify %}

{% trans "The email for this account has been changed recently, but the new email address has not yet been verified. You should have received an email with a link to verify the new email address." %}

{% endif %} -
{{ form.email.errors }}
+ {% render_field form.email class+="form-control input-lg" data-parsley-sanitize="1" %} + {% if emails_to_verify %} +

{% blocktrans %} The email for this account has been changed recently, but the new email address has not yet been verified. You should have received an email with a link to verify the new email address. To manually verify the new email address, click here.{% endblocktrans %}

+ {% endif %} +
{{ form.email.errors }}
+
+ +
+ + {% render_field form.phone class+='form-control input-lg' %} + {% if phones_to_verify %} +

{% blocktrans %} The phone for this account has been changed recently, but the new phone number has not yet been verified. To verify the new phone number, click here.{% endblocktrans %}

+ {% endif %} +
{{ form.phone.errors }}
+
@@ -89,3 +102,4 @@
{% trans "Password options" %}
{% endblock %} + diff --git a/cadasta/templates/accounts/resend_token_page.html b/cadasta/templates/accounts/resend_token_page.html new file mode 100644 index 000000000..2b83a08cd --- /dev/null +++ b/cadasta/templates/accounts/resend_token_page.html @@ -0,0 +1,55 @@ +{% extends "core/base.html" %} + +{% load i18n %} + +{% load widget_tweaks %} + +{% block top-nav %}verify{% endblock %} + +{% block title %} | {% trans "Verify Account" %}{% endblock %} + +{% block content %} +
+

+ {% trans "Verify Account" %} +

+ {{ form.non_field_errors }} + +
+ {% csrf_token %} +
+

+ +

+ {% render_field form.email class+="form-control input-lg" placeholder="Email" %} +
+ {{ form.email.errors }} +
+
+ +
+ +
+ {% csrf_token %} +
+

+ +

+ {% render_field form.phone class+="form-control input-lg" placeholder="Phone" %} +
+ {{ form.phone.errors }} +
+
+ +
+
+{% endblock %} + diff --git a/cadasta/templates/allauth/account/login.html b/cadasta/templates/allauth/account/login.html index 5edda137c..f1f069e0c 100644 --- a/cadasta/templates/allauth/account/login.html +++ b/cadasta/templates/allauth/account/login.html @@ -44,7 +44,7 @@

{% trans "Sign in to your account" %}

{% csrf_token %}
- + {% render_field form.login class+="form-control input-lg" placeholder="" data-parsley-required="true" %}
{{ form.login.errors }}
diff --git a/cadasta/templates/allauth/account/password_reset.html b/cadasta/templates/allauth/account/password_reset.html index b2803a3ca..a5a4c8b8d 100644 --- a/cadasta/templates/allauth/account/password_reset.html +++ b/cadasta/templates/allauth/account/password_reset.html @@ -8,24 +8,37 @@ {% block content %} - {% endblock %} diff --git a/cadasta/templates/allauth/account/password_reset_done.html b/cadasta/templates/allauth/account/password_reset_done.html index c58a91362..e364aee6f 100644 --- a/cadasta/templates/allauth/account/password_reset_done.html +++ b/cadasta/templates/allauth/account/password_reset_done.html @@ -2,6 +2,7 @@ {% load i18n %} {% load account %} +{% load widget_tweaks %} {% block head_title %}{% trans "Password Reset" %}{% endblock %} @@ -14,9 +15,24 @@

{% trans "Password reset" %}

{% if user.is_authenticated %} {% include "account/snippets/already_logged_in.html" %} {% endif %} - -

{% blocktrans %}Your email address has been submitted. If it matches your account on the Cadasta Platform, you will receive an email shortly with instructions for resetting your password. Please contact us if you require assistance.{% endblocktrans %}

+ {% if phone %} +

{% blocktrans %}Your phone number has been submitted. If it matches your account on the Cadasta Platform, you will receive a token shortly. If the token matches, you will be further allowed to reset your password. Please contact us if you require assistance.{% endblocktrans %}

+
+ {% csrf_token %} +
+ + {% render_field form.token class+="form-control input-lg" data-parsley-required="true" data-parsley-sanitize="1" %} +
{{ form.token.errors }}
+ {{ form.non_field_errors }} +
+ +
+ {% url 'account:account_reset_password' as resend_url %} +

{% blocktrans %}Click here to try password reset again.{% endblocktrans %}

+ {% else %} +

{% blocktrans %}Your email address has been submitted. If it matches your account on the Cadasta Platform, you will receive an email shortly with instructions for resetting your password. Please contact us if you require assistance.{% endblocktrans %}

+ {% endif %}
{% 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/cadasta/templates/organization/organization_members_add.html b/cadasta/templates/organization/organization_members_add.html index 50cb31d87..e3f244c3f 100644 --- a/cadasta/templates/organization/organization_members_add.html +++ b/cadasta/templates/organization/organization_members_add.html @@ -22,7 +22,7 @@ {% csrf_token %} {{ form.non_field_errors }} {% render_field form.identifier class+="form-control input-lg" data-parsley-required="true" %}
{{ form.identifier.errors }}
diff --git a/provision/roles/cadasta/install/tasks/main.yml b/provision/roles/cadasta/install/tasks/main.yml index 4302282f3..02b0924f8 100644 --- a/provision/roles/cadasta/install/tasks/main.yml +++ b/provision/roles/cadasta/install/tasks/main.yml @@ -38,6 +38,9 @@ OPBEAT_APPID="{{ opbeat_appID }}" OPBEAT_TOKEN="{{ opbeat_token }}" SLACK_HOOK="{{ slack_hook }}" + TWILIO_ACCOUNT_SID="{{ twilio_account_sid }}" + TWILIO_AUTH_TOKEN="{{ twilio_auth_token }}" + - name: Enable environment variables for SSH become: yes @@ -64,6 +67,8 @@ Defaults env_keep += OPBEAT_APPID Defaults env_keep += OPBEAT_TOKEN Defaults env_keep += SLACK_HOOK + Defaults env_keep += TWILIO_ACCOUNT_SID + Defaults env_keep += TWILIO_AUTH_TOKEN # This is VERY ugly. The problem is that Ansible keeps SSH # connections open across tasks, so the environment variable changes diff --git a/requirements/common.txt b/requirements/common.txt index 06d4912f0..3a064e7d1 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -38,3 +38,6 @@ pyparsing==2.2.0 django-compressor==2.2 beautifulsoup4==4.6.0 gpxpy==1.1.2 +django-otp==0.3.11 +twilio==6.5.0 +phonenumbers==8.5.2 diff --git a/tox.ini b/tox.ini index 199e2b040..d09a3556c 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ envlist = py35-functional [testenv] -passenv = DJANGO_SETTINGS_MODULE +passenv = DJANGO_SETTINGS_MODULE TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN setenv = PYTHONDONTWRITEBYTECODE=1 CPLUS_INCLUDE_PATH=/usr/include/gdal