Skip to content

Commit

Permalink
Registration with Phone number (#1662)
Browse files Browse the repository at this point in the history
* Phone registration page, and authentication backend

* Upgrade django-skivvy to 0.1.8

* 100% test coverage

* Minor change to phone validator

* Changes to default.py and tests as addressed by Oliver

* Make all addressed changes

* allow user to authenticate even if the account status is inactive

* add changes addressed to PR

* Minor changes to the code

* Make VerificationDevice OneToOneField and make corresponding changes
Solve migration merge conflicts, and create a new migration file for phone and
 verification device
Pass all tests after rebasing
Make all tests pass

* change logger from info to debug
  • Loading branch information
valaparthvi committed Aug 28, 2017
1 parent 17b2d56 commit 1f5a6d2
Show file tree
Hide file tree
Showing 22 changed files with 1,246 additions and 104 deletions.
15 changes: 15 additions & 0 deletions cadasta/accounts/backends.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
93 changes: 83 additions & 10 deletions cadasta/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,50 @@
from django.conf import settings
from django.utils.translation import ugettext as _
from django.contrib.auth.password_validation import validate_password

from allauth.account.utils import send_email_confirmation
from allauth.account import forms as allauth_forms
from allauth.account.models import EmailAddress

from core.form_mixins import SanitizeFieldsForm
from .utils import send_email_update_notification
from .models import User
from .validators import check_username_case_insensitive
from .models import User, VerificationDevice
from .validators import check_username_case_insensitive, phone_validator
from .messages import phone_format

from parsley.decorators import parsleyfy
from phonenumbers import parse as parse_phone


@parsleyfy
class RegisterForm(SanitizeFieldsForm, forms.ModelForm):
email = forms.EmailField(required=True)
email = forms.EmailField(required=False)

phone = forms.RegexField(regex=r'^\+(?:[0-9]?){6,14}[0-9]$',
error_messages={'invalid': phone_format},
required=False)
password = forms.CharField(widget=forms.PasswordInput())
MIN_LENGTH = 10

class Meta:
model = User
fields = ['username', 'email', 'password',
fields = ['username', 'email', 'phone', 'password',
'full_name', 'language']

class Media:
js = ('js/sanitize.js', )

def clean(self):
super(RegisterForm, self).clean()

email = self.data.get('email')
phone = self.data.get('phone')

if (not phone) and (not email):
raise forms.ValidationError(
_("You cannot leave both phone and email empty."
" Signup with either phone or email or both."))

def clean_username(self):
username = self.data.get('username')
check_username_case_insensitive(username)
Expand All @@ -40,27 +59,51 @@ def clean_password(self):
validate_password(password)
errors = []

email = self.data.get('email').split('@')
if len(email[0]) and email[0].casefold() in password.casefold():
errors.append(_("Passwords cannot contain your email."))
email = self.data.get('email')
if email:
email = email.split('@')
if email[0].casefold() in password.casefold():
errors.append(_("Passwords cannot contain your email."))

username = self.data.get('username')
if len(username) and username.casefold() in password.casefold():
errors.append(
_("The password is too similar to the username."))

phone = self.data.get('phone')
if phone:
if phone_validator(phone):
phone = str(parse_phone(phone).national_number)
if phone in password:
errors.append(_("Passwords cannot contain your phone."))

if errors:
raise forms.ValidationError(errors)

return password

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'])
Expand Down Expand Up @@ -174,3 +217,33 @@ def clean_email(self):
email = self.cleaned_data.get('email')
self.users = User.objects.filter(email=email)
return email


class PhoneVerificationForm(forms.Form):
token = forms.CharField(label=_("Token"), max_length=settings.TOTP_DIGITS)

def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)

def clean_token(self):
token = self.data.get('token')
try:
token = int(token)
device = self.user.verificationdevice
if device.verify_token(token):
if self.user.phone != device.unverified_phone:
self.user.phone = device.unverified_phone
self.user.phone_verified = True
self.user.is_active = True
self.user.save()
elif device.verify_token(token, tolerance=5):
raise forms.ValidationError(
_("The token has expired."
" Please click on 'here' to receive the new token."))
else:
raise forms.ValidationError(
"Invalid Token. Enter a valid token.")
except ValueError:
raise forms.ValidationError(_("Token must be a number."))
return token
8 changes: 8 additions & 0 deletions cadasta/accounts/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.utils.translation import ugettext as _


phone_format = _(
"Phone numbers must be provided in the format +9999999999."
" Up to 15 digits allowed. Do not include hyphen or"
" blank spaces in between, at the beginning or at the end."
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ class Migration(migrations.Migration):
('verified', models.BooleanField(default=False)),
],
options={
'abstract': False,
'verbose_name': 'Verification Device',
'abstract': False,
},
),
migrations.RemoveField(
Expand Down Expand Up @@ -73,6 +73,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='verificationdevice',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
16 changes: 8 additions & 8 deletions cadasta/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ class VerificationDevice(Device):
"The next token must be at a higher counter value."
"It makes sure a token is used only once.")
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
user = models.OneToOneField(User, on_delete=models.CASCADE)
verified = models.BooleanField(default=False)

step = settings.TOTP_TOKEN_VALIDITY
Expand All @@ -183,18 +183,18 @@ def generate_challenge(self):
message = message.format(
token_value=token, time_validity=self.step // 60)

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

return token

def verify_token(self, token):
def verify_token(self, token, tolerance=0):
totp = self.totp_obj()
if ((totp.t() > self.last_verified_counter) and
(totp.token() == token)):
(totp.verify(token, tolerance=tolerance))):
self.last_verified_counter = totp.t()
verified = True
self.verified = True
self.save()
else:
verified = False
return verified
self.verified = False
return self.verified
Loading

0 comments on commit 1f5a6d2

Please sign in to comment.