From d14ee2816afe0a32440f0fe36cfbb71d06fa22b2 Mon Sep 17 00:00:00 2001 From: Jon Nairn Date: Thu, 25 Jan 2018 15:59:18 +0000 Subject: [PATCH 01/10] Modified phone number validation regexp to account for '+' character (required for international numbers) --- deux/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deux/validators.py b/deux/validators.py index 60ddd1b..6f785af 100644 --- a/deux/validators.py +++ b/deux/validators.py @@ -6,5 +6,5 @@ #: 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) From 5a551ea726ff85015f60b9b025e315fae251f9eb Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Tue, 27 Feb 2018 11:29:20 +0000 Subject: [PATCH 02/10] add constants --- deux/constants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deux/constants.py b/deux/constants.py index 775fb9a..1e82c5b 100644 --- a/deux/constants.py +++ b/deux/constants.py @@ -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) From 820c33da7930ed5ca27cb8bbd3ab91e35b207d47 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Tue, 27 Feb 2018 11:58:56 +0000 Subject: [PATCH 03/10] update model choices to add email --- deux/abstract_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deux/abstract_models.py b/deux/abstract_models.py index 943ae20..d6ce243 100644 --- a/deux/abstract_models.py +++ b/deux/abstract_models.py @@ -7,7 +7,7 @@ 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 @@ -24,6 +24,7 @@ class AbstractMultiFactorAuth(models.Model): CHALLENGE_CHOICES = ( (SMS, "SMS"), (DISABLED, "Off"), + (EMAIL, "EMAIL"), ) #: User this MFA object represents. From 999c369aae8c2ee97112219ad411d2650d601775 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Thu, 1 Mar 2018 11:29:17 +0000 Subject: [PATCH 04/10] added email error codes and serializers --- deux/abstract_models.py | 8 ++++++- deux/app_settings.py | 2 ++ deux/exceptions.py | 19 ++++++++++++++++ deux/notifications.py | 15 ++++++++++++- deux/serializers.py | 50 ++++++++++++++++++++++++++++++++++++++++- deux/services.py | 12 ++++++++-- deux/strings.py | 6 +++++ 7 files changed, 107 insertions(+), 5 deletions(-) diff --git a/deux/abstract_models.py b/deux/abstract_models.py index d6ce243..1ab0d4c 100644 --- a/deux/abstract_models.py +++ b/deux/abstract_models.py @@ -38,6 +38,11 @@ class AbstractMultiFactorAuth(models.Model): max_length=15, default="", blank=True, validators=[phone_number_validator]) + #: User's email. + email = models.EmailField( + blank=True, + ) + #: Challenge type used for MFA. challenge_type = models.CharField( max_length=16, default=DISABLED, @@ -86,7 +91,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): diff --git a/deux/app_settings.py b/deux/app_settings.py index d13260b..11d7f30 100644 --- a/deux/app_settings.py +++ b/deux/app_settings.py @@ -13,6 +13,7 @@ "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": "", @@ -26,6 +27,7 @@ IMPORT_STRINGS = ( 'MFA_MODEL', 'SEND_MFA_TEXT_FUNC', + 'SEND_MFA_EMAIL_FUNC', ) diff --git a/deux/exceptions.py b/deux/exceptions.py index a471d62..8d76583 100644 --- a/deux/exceptions.py +++ b/deux/exceptions.py @@ -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) + diff --git a/deux/notifications.py b/deux/notifications.py index 50a9c1d..7259cee 100644 --- a/deux/notifications.py +++ b/deux/notifications.py @@ -5,7 +5,7 @@ from deux import strings from deux.app_settings import mfa_settings -from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError +from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError, EmailError, InvalidEmailError #: Error code from Twilio to indicate at ``InvalidPhoneNumberError`` NOT_SMS_DEVICE_CODE = 21401 @@ -43,3 +43,16 @@ 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.InvalidEmailError: To tell system that this + MFA object's email is not a valid email. + :raises deux.exceptions.EmailError: To tell system that the email failed to send. + """ diff --git a/deux/serializers.py b/deux/serializers.py index d6905d4..20ec0cc 100644 --- a/deux/serializers.py +++ b/deux/serializers.py @@ -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 @@ -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: @@ -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.phone_number = 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() @@ -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() diff --git a/deux/services.py b/deux/services.py index bceecc1..e30e7c9 100644 --- a/deux/services.py +++ b/deux/services.py @@ -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): @@ -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, ( @@ -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) + diff --git a/deux/strings.py b/deux/strings.py index c1ed5c0..85b6724 100644 --- a/deux/strings.py +++ b/deux/strings.py @@ -28,8 +28,14 @@ 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}") From 7dac7f3d93a37023043b42d810859ea8c5ea13da Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Thu, 1 Mar 2018 15:51:44 +0000 Subject: [PATCH 05/10] added a notifications sendgrid backend --- .gitignore | 1 + deux/abstract_models.py | 4 +++- deux/app_settings.py | 4 ++++ deux/notifications.py | 39 +++++++++++++++++++++++++++++++++++++-- deux/strings.py | 3 +++ deux/validators.py | 4 +++- requirements/default.txt | 2 ++ 7 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b705b4e..9d58823 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ cover/ coverage.* htmlcov/ .vagrant/ +venv/ diff --git a/deux/abstract_models.py b/deux/abstract_models.py index 1ab0d4c..567b2cc 100644 --- a/deux/abstract_models.py +++ b/deux/abstract_models.py @@ -9,7 +9,7 @@ from deux.app_settings import mfa_settings 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): @@ -41,6 +41,7 @@ class AbstractMultiFactorAuth(models.Model): #: User's email. email = models.EmailField( blank=True, + validators=[email_address_validator] ) #: Challenge type used for MFA. @@ -125,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): diff --git a/deux/app_settings.py b/deux/app_settings.py index 11d7f30..98c0697 100644 --- a/deux/app_settings.py +++ b/deux/app_settings.py @@ -18,6 +18,10 @@ "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. diff --git a/deux/notifications.py b/deux/notifications.py index 7259cee..dbd7a9f 100644 --- a/deux/notifications.py +++ b/deux/notifications.py @@ -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, EmailError, InvalidEmailError +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 @@ -56,3 +65,29 @@ def send_mfa_code_email(mfa_instance, mfa_code): MFA object's email is not a valid email. :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 + + try: + sg.client.mail.send.post(request_body=mail.get()) + except urllib.HTTPError: + raise EmailError() diff --git a/deux/strings.py b/deux/strings.py index 85b6724..44dffad 100644 --- a/deux/strings.py +++ b/deux/strings.py @@ -39,3 +39,6 @@ #: 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}") diff --git a/deux/validators.py b/deux/validators.py index 6f785af..b7208f7 100644 --- a/deux/validators.py +++ b/deux/validators.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -from django.core.validators import RegexValidator +from django.core.validators import RegexValidator, EmailValidator from deux import strings @@ -8,3 +8,5 @@ phone_number_validator = RegexValidator( regex=r"^(\+?\d{7,15})$", message=strings.INVALID_PHONE_NUMBER_ERROR) + +email_address_validator = EmailValidator() diff --git a/requirements/default.txt b/requirements/default.txt index f5e0258..5be8acb 100644 --- a/requirements/default.txt +++ b/requirements/default.txt @@ -3,3 +3,5 @@ django-oauth-toolkit>=0.10.0 django-otp>=0.3.5 six>=1.10.0 twilio>=5.4.0 +sendgrid>=5.3.0 + From 586921091bf842b4f1508b7e1c6e90152ca85be0 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Thu, 1 Mar 2018 16:04:08 +0000 Subject: [PATCH 06/10] added email request detail and verify detail --- deux/notifications.py | 3 +-- deux/urls.py | 4 ++++ deux/views.py | 24 +++++++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/deux/notifications.py b/deux/notifications.py index dbd7a9f..56c78e2 100644 --- a/deux/notifications.py +++ b/deux/notifications.py @@ -61,8 +61,6 @@ def send_mfa_code_email(mfa_instance, mfa_code): :param mfa_instance: :class:`MultiFactorAuth` instance to use. :param mfa_code: MFA code in the form of a string. - :raises deux.exceptions.InvalidEmailError: To tell system that this - MFA object's email is not a valid email. :raises deux.exceptions.EmailError: To tell system that the email failed to send. """ sid = mfa_settings.SENDGRID_API_KEY @@ -87,6 +85,7 @@ def send_mfa_code_email(mfa_instance, mfa_code): 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: diff --git a/deux/urls.py b/deux/urls.py index d9492fd..50b4666 100644 --- a/deux/urls.py +++ b/deux/urls.py @@ -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.SMSChallengeVerifyDetail.as_view(), + name="email_verify-detail"), url(r"^recovery/$", views.BackupCodeDetail.as_view(), name="backup_code-detail"), ] diff --git a/deux/views.py b/deux/views.py index 4303682..1aba6f2 100644 --- a/deux/views.py +++ b/deux/views.py @@ -6,12 +6,14 @@ from deux import strings from deux.app_settings import mfa_settings -from deux.constants import SMS +from deux.constants import SMS, EMAIL from deux.serializers import ( BackupCodeSerializer, MultiFactorAuthSerializer, SMSChallengeRequestSerializer, SMSChallengeVerifySerializer, + EmailChallengeRequestSerializer, + EmailChallengeVerifySerializer, ) @@ -92,6 +94,26 @@ class SMSChallengeVerifyDetail(_BaseChallengeView): serializer_class = SMSChallengeVerifySerializer +class EmailChallengeRequestDetail(_BaseChallengeView): + """ + class::SMSChallengeRequestDetail() + + View for requesting SMS challenges to enable MFA through SMS. + """ + challenge_type = EMAIL + serializer_class = EmailChallengeRequestSerializer + + +class EmailChallengeVerifyDetail(_BaseChallengeView): + """ + class::SMSChallengeVerifyDetail() + + View for verify SMS challenges to enable MFA through SMS. + """ + challenge_type = EMAIL + serializer_class = EmailChallengeVerifySerializer + + class BackupCodeDetail(MultiFactorAuthMixin, generics.RetrieveAPIView): """ class::BackupCodeDetail() From e6dadc1cb738c5ffce6b88a0e6b7d3752023fd73 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Thu, 1 Mar 2018 16:29:05 +0000 Subject: [PATCH 07/10] add email urls --- deux/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deux/urls.py b/deux/urls.py index 50b4666..4bdfae5 100644 --- a/deux/urls.py +++ b/deux/urls.py @@ -14,7 +14,7 @@ name="sms_verify-detail"), url(r"^email/request/$", views.EmailChallengeRequestDetail.as_view(), name="email_request-detail"), - url(r"^email/verify/$", views.SMSChallengeVerifyDetail.as_view(), + url(r"^email/verify/$", views.EmailChallengeVerifyDetail.as_view(), name="email_verify-detail"), url(r"^recovery/$", views.BackupCodeDetail.as_view(), name="backup_code-detail"), From ac105eae296402628eca7d3278bbade21070a723 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Thu, 1 Mar 2018 16:30:15 +0000 Subject: [PATCH 08/10] fix serializer to update email --- deux/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deux/serializers.py b/deux/serializers.py index 20ec0cc..536e5fc 100644 --- a/deux/serializers.py +++ b/deux/serializers.py @@ -216,7 +216,7 @@ def update(self, mfa_instance, validated_data): :param mfa_instance: :class:`MultiFactorAuth` instance to use. :param validated_data: Data returned by ``validate``. """ - mfa_instance.phone_number = validated_data["email"] + mfa_instance.email = validated_data["email"] super(EmailChallengeRequestSerializer, self).update( mfa_instance, validated_data) mfa_instance.save() From 8e344f0774a134a4a31f8e335dfbee2a8e9049a9 Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Mon, 5 Mar 2018 11:21:01 +0000 Subject: [PATCH 09/10] added migration changes for email MFA --- deux/migrations/0002_auto_20180305_1120.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 deux/migrations/0002_auto_20180305_1120.py diff --git a/deux/migrations/0002_auto_20180305_1120.py b/deux/migrations/0002_auto_20180305_1120.py new file mode 100644 index 0000000..7cd8bda --- /dev/null +++ b/deux/migrations/0002_auto_20180305_1120.py @@ -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})$')]), + ), + ] From ff3cf52c39c4650a68f90a032e4961b827b381af Mon Sep 17 00:00:00 2001 From: "will.leong" Date: Mon, 5 Mar 2018 11:23:05 +0000 Subject: [PATCH 10/10] bump version number --- deux/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deux/__init__.py b/deux/__init__.py index f9c5201..90dfc55 100644 --- a/deux/__init__.py +++ b/deux/__init__.py @@ -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'