Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modified phone number validation regexp to account for '+' character … #17

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ cover/
coverage.*
htmlcov/
.vagrant/
venv/
2 changes: 1 addition & 1 deletion deux/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
'version_info_t', ('major', 'minor', 'micro', 'releaselevel', 'serial'),
)

VERSION = version_info = version_info_t(1, 2, 0, '', '')
VERSION = version_info = version_info_t(1, 2, 1, '', '')

__version__ = '{0.major}.{0.minor}.{0.micro}{0.releaselevel}'.format(VERSION)
__author__ = 'Robinhood Markets'
Expand Down
15 changes: 12 additions & 3 deletions deux/abstract_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
from django.utils.crypto import constant_time_compare

from deux.app_settings import mfa_settings
from deux.constants import CHALLENGE_TYPES, DISABLED, SMS
from deux.constants import CHALLENGE_TYPES, DISABLED, SMS, EMAIL
from deux.services import generate_key
from deux.validators import phone_number_validator
from deux.validators import phone_number_validator, email_address_validator


class AbstractMultiFactorAuth(models.Model):
Expand All @@ -24,6 +24,7 @@ class AbstractMultiFactorAuth(models.Model):
CHALLENGE_CHOICES = (
(SMS, "SMS"),
(DISABLED, "Off"),
(EMAIL, "EMAIL"),
)

#: User this MFA object represents.
Expand All @@ -37,6 +38,12 @@ class AbstractMultiFactorAuth(models.Model):
max_length=15, default="", blank=True,
validators=[phone_number_validator])

#: User's email.
email = models.EmailField(
blank=True,
validators=[email_address_validator]
)

#: Challenge type used for MFA.
challenge_type = models.CharField(
max_length=16, default=DISABLED,
Expand Down Expand Up @@ -85,7 +92,8 @@ def get_bin_key(self, challenge_type):
challenge=challenge_type)
)
return {
SMS: self.sms_bin_key
SMS: self.sms_bin_key,
EMAIL: self.sms_bin_key,
}.get(challenge_type, None)

def enable(self, challenge_type):
Expand Down Expand Up @@ -118,6 +126,7 @@ def disable(self):
self.challenge_type = DISABLED
self.backup_key = ""
self.phone_number = ""
self.email = ""
self.save()

def refresh_backup_code(self):
Expand Down
6 changes: 6 additions & 0 deletions deux/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@
"MFA_CODE_NUM_DIGITS": 6,
"MFA_MODEL": "deux.models.MultiFactorAuth",
"SEND_MFA_TEXT_FUNC": "deux.notifications.send_mfa_code_text_message",
"SEND_MFA_EMAIL_FUNC": "deux.notifications.send_mfa_code_email",
"STEP_SIZE": 30,
"TWILIO_ACCOUNT_SID": "",
"TWILIO_AUTH_TOKEN": "",
"TWILIO_SMS_POOL_SID": "",
"SENDGRID_API_KEY": "",
"SENDGRID_TEMPLATE_ID": "",
"SENDGRID_SENDER_EMAIL": "",
"SENDGRID_MAIL_SUBJECT": "MFA login code",
}

# List of settings that cannot be empty.
Expand All @@ -26,6 +31,7 @@
IMPORT_STRINGS = (
'MFA_MODEL',
'SEND_MFA_TEXT_FUNC',
'SEND_MFA_EMAIL_FUNC',
)


Expand Down
3 changes: 2 additions & 1 deletion deux/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#: Represents the state of using ``SMS`` for MFA.
SMS = "sms"
EMAIL = "email"

#: A tuple of all support challenge types.
CHALLENGE_TYPES = (SMS,)
CHALLENGE_TYPES = (SMS, EMAIL)
19 changes: 19 additions & 0 deletions deux/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,22 @@ class TwilioMessageError(FailedChallengeError):

def __init__(self, message=strings.SMS_SEND_ERROR):
super(TwilioMessageError, self).__init__(message)


class InvalidEmailAddressError(FailedChallengeError):
"""
Exception for Email that fails because email address is not a valid
number for receiving emails.
"""

def __init__(self, message=strings.INVALID_EMAIL_ADDRESS_ERROR):
super(InvalidEmailAddressError, self).__init__(message)


class EmailError(FailedChallengeError):
"""
Exception that Sendgrid failed to send the email message.
"""
def __init__(self, message=strings.EMAIL_SEND_ERROR):
super(EmailError, self).__init__(message)

31 changes: 31 additions & 0 deletions deux/migrations/0002_auto_20180305_1120.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.8 on 2018-03-05 11:20
from __future__ import unicode_literals

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('deux', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='multifactorauth',
name='email',
field=models.EmailField(blank=True, max_length=254, validators=[django.core.validators.EmailValidator()]),
),
migrations.AlterField(
model_name='multifactorauth',
name='challenge_type',
field=models.CharField(blank=True, choices=[('sms', 'SMS'), ('', 'Off'), ('email', 'EMAIL')], default='', max_length=16),
),
migrations.AlterField(
model_name='multifactorauth',
name='phone_number',
field=models.CharField(blank=True, default='', max_length=15, validators=[django.core.validators.RegexValidator(message='Please enter a valid phone number.', regex='^(\\+?\\d{7,15})$')]),
),
]
51 changes: 49 additions & 2 deletions deux/notifications.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
from __future__ import absolute_import, unicode_literals

from twilio.rest import TwilioRestClient
from twilio.rest.exceptions import TwilioRestException
from twilio.base.exceptions import TwilioRestException
import sendgrid
from sendgrid.helpers.mail import Email, Content, Mail, Substitution

from deux import strings
from deux.app_settings import mfa_settings
from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError
from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError, EmailError, InvalidEmailAddressError

try:
# Python 3
import urllib.request as urllib
except ImportError:
# Python 2
import urllib2 as urllib

#: Error code from Twilio to indicate at ``InvalidPhoneNumberError``
NOT_SMS_DEVICE_CODE = 21401
Expand Down Expand Up @@ -43,3 +52,41 @@ def send_mfa_code_text_message(mfa_instance, mfa_code):
if e.code == NOT_SMS_DEVICE_CODE:
raise InvalidPhoneNumberError()
raise TwilioMessageError()


def send_mfa_code_email(mfa_instance, mfa_code):
"""
Sends the MFA Code text message to the user.

:param mfa_instance: :class:`MultiFactorAuth` instance to use.
:param mfa_code: MFA code in the form of a string.

:raises deux.exceptions.EmailError: To tell system that the email failed to send.
"""
sid = mfa_settings.SENDGRID_API_KEY
template_id = mfa_settings.SENDGRID_TEMPLATE_ID
sender_email = mfa_settings.SENDGRID_SENDER_EMAIL
subject = mfa_settings.SENDGRID_MAIL_SUBJECT
mfa_text = strings.MFA_CODE_TEXT_MESSAGE.format(code=mfa_code)

sg = sendgrid.SendGridAPIClient(api_key=sid)
from_email = Email(sender_email)
to_email = Email(mfa_instance.email)
content = Content("text/html", mfa_text)

mail = Mail(
from_email=from_email,
subject=subject,
to_email=to_email,
content=content
)

if template_id:
mail.personalizations[0].add_substitution(Substitution('%mfa_text%', mfa_text))
mail.template_id = template_id

# Todo: add code to deal with incorrect email recipient
try:
sg.client.mail.send.post(request_body=mail.get())
except urllib.HTTPError:
raise EmailError()
50 changes: 49 additions & 1 deletion deux/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from deux.app_settings import mfa_settings
from deux import strings
from deux.constants import SMS
from deux.constants import SMS, EMAIL
from deux.exceptions import FailedChallengeError
from deux.services import MultiFactorChallenge, verify_mfa_code

Expand Down Expand Up @@ -35,6 +35,8 @@ def to_representation(self, mfa_instance):
data["challenge_type"] = mfa_instance.challenge_type
if mfa_instance.phone_number:
data["phone_number"] = mfa_instance.phone_number
if mfa_instance.email:
data["email"] = mfa_instance.email
return data

class Meta:
Expand Down Expand Up @@ -195,6 +197,40 @@ class Meta(_BaseChallengeRequestSerializer.Meta):
}


class EmailChallengeRequestSerializer(_BaseChallengeRequestSerializer):
"""
class::EmailChallengeRequestSerializer()

Serializer that facilitates a request to enable MFA over Email.
"""

#: This serializer represents the ``SMS`` challenge type.
challenge_type = EMAIL

def update(self, mfa_instance, validated_data):
"""
If the request data is valid, the serializer executes the challenge
by calling the super method and also saves the phone number the user
requested the SMS to.

:param mfa_instance: :class:`MultiFactorAuth` instance to use.
:param validated_data: Data returned by ``validate``.
"""
mfa_instance.email = validated_data["email"]
super(EmailChallengeRequestSerializer, self).update(
mfa_instance, validated_data)
mfa_instance.save()
return mfa_instance

class Meta(_BaseChallengeRequestSerializer.Meta):
fields = ("email",)
extra_kwargs = {
"email": {
"required": True,
},
}


class SMSChallengeVerifySerializer(_BaseChallengeVerifySerializer):
"""
class::SMSChallengeVerifySerializer()
Expand All @@ -207,6 +243,18 @@ class SMSChallengeVerifySerializer(_BaseChallengeVerifySerializer):
challenge_type = SMS


class EmailChallengeVerifySerializer(_BaseChallengeVerifySerializer):
"""
class::EmailChallengeVerifySerializer()

Extension of ``_BaseChallengeVerifySerializer`` that implements
challenge verification for the Email challenge.
"""

#: This serializer represents the ``SMS`` challenge type.
challenge_type = EMAIL


class BackupCodeSerializer(serializers.ModelSerializer):
"""
class::BackupCodeSerializer()
Expand Down
12 changes: 10 additions & 2 deletions deux/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django_otp.oath import totp

from deux.app_settings import mfa_settings
from deux.constants import CHALLENGE_TYPES, SMS
from deux.constants import CHALLENGE_TYPES, SMS, EMAIL


def generate_mfa_code(bin_key, drift=0):
Expand Down Expand Up @@ -79,7 +79,8 @@ def generate_challenge(self):
type of this object.
"""
dispatch = {
SMS: self._sms_challenge
SMS: self._sms_challenge,
EMAIL: self._email_challenge,
}
for challenge in CHALLENGE_TYPES:
assert challenge in dispatch, (
Expand All @@ -93,3 +94,10 @@ def _sms_challenge(self):
code = generate_mfa_code(bin_key=self.instance.sms_bin_key)
mfa_settings.SEND_MFA_TEXT_FUNC(
mfa_instance=self.instance, mfa_code=code)

def _email_challenge(self):
"""Executes the Email challenge."""
code = generate_mfa_code(bin_key=self.instance.sms_bin_key)
mfa_settings.SEND_MFA_EMAIL_FUNC(
mfa_instance=self.instance, mfa_code=code)

9 changes: 9 additions & 0 deletions deux/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,17 @@
PHONE_NUMBER_NOT_SET_ERROR = _(
"MFA phone number must be set for this challenge.")

#: Error if an invalid email address is entered.
INVALID_EMAIL_ADDRESS_ERROR = _("Please enter a valid email address.")

#: Error if SMS fails to send.
SMS_SEND_ERROR = _("SMS failed to send.")

#: Error if email fails to send.
EMAIL_SEND_ERROR = _("Email failed to send.")

#: Message body for a MFA code.
MFA_CODE_TEXT_MESSAGE = _("Two Factor Authentication Code: {code}")

#: Email body
MFA_CODE_EMAIL_TEXT = _("Two Factor Authentication Code: {code}")
4 changes: 4 additions & 0 deletions deux/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
name="sms_request-detail"),
url(r"^sms/verify/$", views.SMSChallengeVerifyDetail.as_view(),
name="sms_verify-detail"),
url(r"^email/request/$", views.EmailChallengeRequestDetail.as_view(),
name="email_request-detail"),
url(r"^email/verify/$", views.EmailChallengeVerifyDetail.as_view(),
name="email_verify-detail"),
url(r"^recovery/$", views.BackupCodeDetail.as_view(),
name="backup_code-detail"),
]
Expand Down
6 changes: 4 additions & 2 deletions deux/validators.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import absolute_import, unicode_literals

from django.core.validators import RegexValidator
from django.core.validators import RegexValidator, EmailValidator

from deux import strings

#: Regex validator for phone numbers.
phone_number_validator = RegexValidator(
regex=r"^(\d{7,15})$",
regex=r"^(\+?\d{7,15})$",
message=strings.INVALID_PHONE_NUMBER_ERROR)

email_address_validator = EmailValidator()
Loading