├── deux ├── oauth2 │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── test_authentication.py │ ├── urls.py │ ├── views.py │ ├── exceptions.py │ ├── backends.py │ └── validators.py ├── tests │ ├── __init__.py │ ├── test_validators.py │ ├── test_models.py │ ├── test_services.py │ ├── test_notifications.py │ ├── test_base.py │ └── test_views.py ├── authtoken │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_views.py │ │ └── test_serializers.py │ ├── urls.py │ ├── views.py │ └── serializers.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── locale │ └── en-us │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── constants.py ├── validators.py ├── models.py ├── urls.py ├── __init__.py ├── exceptions.py ├── strings.py ├── notifications.py ├── app_settings.py ├── views.py ├── services.py ├── abstract_models.py └── serializers.py ├── test_proj ├── __init__.py ├── wsgi.py ├── urls.py └── settings.py ├── requirements ├── django.txt ├── test-django19.txt ├── test-ci.txt ├── test-django110.txt ├── docs.txt ├── pkgutils.txt ├── default.txt └── test.txt ├── .python-version ├── docs ├── changelog.rst ├── _dummy_key_images.rst ├── images │ ├── logo.png │ ├── favicon.ico │ ├── deux_banner.png │ ├── state_diagram.png │ └── deux_banner_text.png ├── theme │ └── deux │ │ ├── theme.conf │ │ └── static │ │ └── deux.css_t ├── includes │ ├── start.txt │ ├── resources.txt │ ├── introduction.txt │ └── installation.txt ├── reference │ ├── deux.oauth2.views.rst │ ├── deux.models.rst │ ├── deux.oauth2.exceptions.rst │ ├── deux.oauth2.validators.rst │ ├── deux.strings.rst │ ├── deux.services.rst │ ├── deux.constants.rst │ ├── deux.exceptions.rst │ ├── deux.validators.rst │ ├── deux.views.rst │ ├── deux.notifications.rst │ ├── deux.oauth2.backends.rst │ ├── deux.abstract_models.rst │ ├── deux.authtoken.views.rst │ ├── deux.serializers.rst │ ├── deux.authtoken.serializers.rst │ ├── index.rst │ ├── deux.authtoken.rst │ ├── deux.oauth2.rst │ └── deux.rst ├── userguide │ ├── index.rst │ ├── usage.rst │ ├── extending.rst │ └── django.rst ├── introduction.rst ├── index.rst ├── copyright.rst ├── conf.py ├── templates │ └── readme.txt ├── make.bat └── Makefile ├── AUTHORS ├── .editorconfig ├── manage.py ├── .coveragerc ├── setup.cfg ├── .gitignore ├── MANIFEST.in ├── Changelog ├── .travis.yml ├── tox.ini ├── appveyor.yml ├── extra └── appveyor │ ├── run_with_compiler.cmd │ └── install.ps1 ├── LICENSE ├── README.rst ├── Makefile ├── setup.py └── CONTRIBUTING.rst /deux/oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deux/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_proj/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deux/authtoken/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deux/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deux/authtoken/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deux/oauth2/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/django.txt: -------------------------------------------------------------------------------- 1 | django 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 2.7.11 2 | 3.4.4 3 | 3.5.1 4 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../Changelog 2 | -------------------------------------------------------------------------------- /requirements/test-django19.txt: -------------------------------------------------------------------------------- 1 | django>=1.9,<1.10 2 | -------------------------------------------------------------------------------- /requirements/test-ci.txt: -------------------------------------------------------------------------------- 1 | coverage>=3.0 2 | codecov 3 | -------------------------------------------------------------------------------- /requirements/test-django110.txt: -------------------------------------------------------------------------------- 1 | django>=1.10,<1.11 2 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx_celery>=1.1 2 | -r django.txt 3 | -------------------------------------------------------------------------------- /docs/_dummy_key_images.rst: -------------------------------------------------------------------------------- 1 | .. image:: images/deux_banner.png 2 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/docs/images/favicon.ico -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Abhishek Fatehpuria 2 | Max Burstein 3 | -------------------------------------------------------------------------------- /docs/theme/deux/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = deux.css 4 | 5 | [options] 6 | -------------------------------------------------------------------------------- /docs/images/deux_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/docs/images/deux_banner.png -------------------------------------------------------------------------------- /docs/images/state_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/docs/images/state_diagram.png -------------------------------------------------------------------------------- /docs/images/deux_banner_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/docs/images/deux_banner_text.png -------------------------------------------------------------------------------- /deux/locale/en-us/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinhood/deux/HEAD/deux/locale/en-us/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /requirements/pkgutils.txt: -------------------------------------------------------------------------------- 1 | setuptools>=20.6.7 2 | wheel>=0.29.0 3 | flake8>=2.5.4 4 | flakeplus>=1.1 5 | tox>=2.3.1 6 | sphinx2rst>=1.0 7 | -------------------------------------------------------------------------------- /requirements/default.txt: -------------------------------------------------------------------------------- 1 | djangorestframework>=2.4.3 2 | django-oauth-toolkit>=0.10.0 3 | django-otp>=0.3.5 4 | six>=1.10.0 5 | twilio>=5.4.0 6 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | mock==2.0.0 2 | coverage>=3.0 3 | pytest-cov>=2.3.1,<3.0.0 4 | pytest-django>=3.0.0,<4.0.0 5 | pytest-runner>=2.9,<3.0 6 | -------------------------------------------------------------------------------- /docs/includes/start.txt: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | Go immediately to the :ref:`django-guide` guide to get started using 5 | deux in your Django Rest Framework projects. 6 | -------------------------------------------------------------------------------- /docs/reference/deux.oauth2.views.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.oauth2.views 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.oauth2.views 6 | 7 | .. automodule:: deux.oauth2.views 8 | :members: 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [Makefile] 14 | indent_style = tab 15 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /docs/reference/deux.models.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.models 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.models 8 | 9 | .. automodule:: deux.models 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.oauth2.exceptions.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.oauth2.exceptions 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.oauth2.exceptions 6 | 7 | .. automodule:: deux.oauth2.exceptions 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/deux.oauth2.validators.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.oauth2.validators 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.oauth2.validators 6 | 7 | .. automodule:: deux.oauth2.validators 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/deux.strings.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.strings 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.strings 8 | 9 | .. automodule:: deux.strings 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.services.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.services 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.services 8 | 9 | .. automodule:: deux.services 10 | :members: 11 | -------------------------------------------------------------------------------- /deux/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | #: Represents the ``DISABLED`` state of MFA. 4 | DISABLED = "" 5 | 6 | #: Represents the state of using ``SMS`` for MFA. 7 | SMS = "sms" 8 | 9 | #: A tuple of all support challenge types. 10 | CHALLENGE_TYPES = (SMS,) 11 | -------------------------------------------------------------------------------- /docs/reference/deux.constants.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.constants 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.constants 8 | 9 | .. automodule:: deux.constants 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.exceptions.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.exceptions 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.exceptions 8 | 9 | .. automodule:: deux.exceptions 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.validators.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.validators 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.validators 8 | 9 | .. automodule:: deux.validators 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.views.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.views 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.views 8 | 9 | .. automodule:: deux.views 10 | :members: 11 | :private-members: 12 | -------------------------------------------------------------------------------- /docs/reference/deux.notifications.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.notifications 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.notifications 8 | 9 | .. automodule:: deux.notifications 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.oauth2.backends.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.oauth2.backends 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.oauth2.backends 6 | 7 | .. automodule:: deux.oauth2.backends 8 | :members: 9 | :private-members: 10 | -------------------------------------------------------------------------------- /docs/userguide/index.rst: -------------------------------------------------------------------------------- 1 | .. _guide: 2 | 3 | ============ 4 | User Guide 5 | ============ 6 | 7 | :Release: |version| 8 | 9 | Guide for installing, configuring, and extending ``deux`` inside your 10 | application. 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | 15 | django 16 | usage 17 | extending 18 | -------------------------------------------------------------------------------- /docs/reference/deux.abstract_models.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.abstract_models 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.abstract_models 8 | 9 | .. automodule:: deux.abstract_models 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.authtoken.views.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.authtoken.views 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.authtoken.views 8 | 9 | .. automodule:: deux.authtoken.views 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/deux.serializers.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.serializers 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.serializers 8 | 9 | .. automodule:: deux.serializers 10 | :members: 11 | :private-members: 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = 1 3 | cover_pylib = 0 4 | include = *deux/* 5 | omit = deux.tests.* 6 | 7 | [report] 8 | omit = 9 | */python?.?/* 10 | */site-packages/* 11 | */pypy/* 12 | deux/app_settings.py 13 | deux/exceptions.py 14 | deux/tests/* 15 | deux/migrations/* 16 | manage.py 17 | test_proj/* 18 | -------------------------------------------------------------------------------- /deux/validators.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.core.validators import RegexValidator 4 | 5 | from deux import strings 6 | 7 | #: Regex validator for phone numbers. 8 | phone_number_validator = RegexValidator( 9 | regex=r"^(\d{7,15})$", 10 | message=strings.INVALID_PHONE_NUMBER_ERROR) 11 | -------------------------------------------------------------------------------- /docs/reference/deux.authtoken.serializers.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.authtoken.serializers 3 | ===================================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: deux.authtoken.serializers 8 | 9 | .. automodule:: deux.authtoken.serializers 10 | :members: 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | DJANGO_SETTINGS_MODULE = test_proj.settings 6 | testpaths = deux 7 | addopts = --cov=deux --cov-report term --cov-report html:cover --color yes --no-cov-on-fail 8 | 9 | [wheel] 10 | universal = 1 11 | 12 | [flake8] 13 | ignore = E731 14 | max-line-length = 79 15 | exclude = deux/migrations/* 16 | -------------------------------------------------------------------------------- /deux/oauth2/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import url 4 | from rest_framework.urlpatterns import format_suffix_patterns 5 | 6 | from deux.oauth2 import views 7 | 8 | urlpatterns = [ 9 | url(r'^token/$', views.MFATokenView.as_view(), name="token"), 10 | ] 11 | 12 | urlpatterns = format_suffix_patterns(urlpatterns) 13 | -------------------------------------------------------------------------------- /deux/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from deux.abstract_models import AbstractMultiFactorAuth 4 | 5 | 6 | class MultiFactorAuth(AbstractMultiFactorAuth): 7 | """ 8 | class::MultiFactorAuth() 9 | 10 | Blank extension of ``AbstractMultiFactorAuth`` that is used as the 11 | default model in this package. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | .. image:: images/deux_banner_text.png 2 | :width: 524 3 | :height: 207 4 | 5 | **Multifactor Authentication for Django Rest Framework** 6 | 7 | ============== 8 | Introduction 9 | ============== 10 | 11 | .. include:: includes/introduction.txt 12 | .. include:: includes/start.txt 13 | .. include:: includes/installation.txt 14 | .. include:: includes/resources.txt 15 | -------------------------------------------------------------------------------- /deux/authtoken/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import url 4 | from rest_framework.urlpatterns import format_suffix_patterns 5 | 6 | from deux.authtoken import views 7 | 8 | urlpatterns = [ 9 | url(r"^login/$", views.ObtainMFAAuthToken.as_view(), 10 | name="login"), 11 | ] 12 | 13 | urlpatterns = format_suffix_patterns(urlpatterns) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *$py.class 4 | *~ 5 | .*.sw[pon] 6 | dist/ 7 | *.egg-info 8 | *.egg 9 | *.eggs/ 10 | *.egg/ 11 | *.cache/ 12 | build/ 13 | .build/ 14 | _build/ 15 | pip-log.txt 16 | .directory 17 | erl_crash.dump 18 | *.db 19 | db.sqlite3 20 | Documentation/ 21 | .tox/ 22 | .ropeproject/ 23 | .project 24 | .pydevproject 25 | .idea/ 26 | .coverage 27 | celery/tests/cover/ 28 | .ve* 29 | cover/ 30 | coverage.* 31 | htmlcov/ 32 | .vagrant/ 33 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include Changelog 3 | include LICENSE 4 | include README.rst 5 | include MANIFEST.in 6 | include setup.cfg 7 | include setup.py 8 | include tox.ini 9 | recursive-include docs * 10 | recursive-include extra/* 11 | recursive-include examples * 12 | recursive-include requirements *.txt *.rst 13 | recursive-include test_proj * 14 | 15 | recursive-exclude * __pycache__ 16 | recursive-exclude * *.py[co] 17 | recursive-exclude * .*.sw[a-z] 18 | -------------------------------------------------------------------------------- /test_proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproj project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: images/deux_banner_text.png 2 | :width: 524 3 | :height: 207 4 | 5 | **Multifactor Authentication for Django Rest Framework** 6 | 7 | Contents 8 | ======== 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | copyright 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | introduction 19 | userguide/index 20 | 21 | .. toctree:: 22 | :maxdepth: 1 23 | 24 | reference/index 25 | contributing 26 | changelog 27 | 28 | Indices and tables 29 | ================== 30 | 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | -------------------------------------------------------------------------------- /deux/oauth2/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from oauth2_provider.views import TokenView 4 | 5 | from deux.oauth2.backends import MFARequestBackend 6 | from deux.oauth2.validators import MFAOAuth2Validator 7 | 8 | 9 | class MFATokenView(TokenView): 10 | """ 11 | class::MFATokenView() 12 | 13 | Extends OAuth's base TokenView to support MFA. 14 | """ 15 | 16 | #: Use Deux's custom backend for the MFA OAuth api. 17 | oauthlib_backend_class = MFARequestBackend 18 | 19 | #: Use Deux's custom validator for the MFA OAuth api. 20 | validator_class = MFAOAuth2Validator 21 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | .. _apiref: 2 | 3 | =============== 4 | API Reference 5 | =============== 6 | 7 | :Release: |version| 8 | :Date: |today| 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | deux 14 | deux.abstract_models 15 | deux.authtoken 16 | deux.authtoken.serializers 17 | deux.authtoken.views 18 | deux.constants 19 | deux.exceptions 20 | deux.models 21 | deux.notifications 22 | deux.oauth2 23 | deux.oauth2.backends 24 | deux.oauth2.exceptions 25 | deux.oauth2.validators 26 | deux.oauth2.views 27 | deux.serializers 28 | deux.services 29 | deux.strings 30 | deux.validators 31 | deux.views 32 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | ========== 2 | Changelog 3 | ========== 4 | 5 | 1.2.0 6 | ===== 7 | :release-date: 2016-10-28 06:00 P.M PDT 8 | :release-by: Abhishek Fatehpuria 9 | 10 | - [oauth2] Support url-encoded requests for oauth 11 | 12 | 1.1.1 13 | ===== 14 | :release-date: 2016-09-13 06:00 P.M PDT 15 | :release-by: Abhishek Fatehpuria 16 | 17 | - [bugfix] Remove duplicate strings in Deux strings.py 18 | 19 | 1.1.0 20 | ===== 21 | :release-date: 2016-09-13 06:00 P.M PDT 22 | :release-by: Abhishek Fatehpuria 23 | 24 | - Added pytest 25 | - Added codecov 26 | - Changed settings name from "Deux" to "DEUX" 27 | 28 | 1.0.0 29 | ===== 30 | :release-date: 2016-09-07 03:00 P.M PDT 31 | :release-by: Abhishek Fatehpuria 32 | 33 | - Initial release 34 | -------------------------------------------------------------------------------- /deux/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import url 4 | from rest_framework.urlpatterns import format_suffix_patterns 5 | 6 | from deux import views 7 | 8 | urlpatterns = [ 9 | url(r"^$", views.MultiFactorAuthDetail.as_view(), 10 | name="multi_factor_auth-detail"), 11 | url(r"^sms/request/$", views.SMSChallengeRequestDetail.as_view(), 12 | name="sms_request-detail"), 13 | url(r"^sms/verify/$", views.SMSChallengeVerifyDetail.as_view(), 14 | name="sms_verify-detail"), 15 | url(r"^recovery/$", views.BackupCodeDetail.as_view(), 16 | name="backup_code-detail"), 17 | ] 18 | 19 | urlpatterns = format_suffix_patterns(urlpatterns) 20 | -------------------------------------------------------------------------------- /deux/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Multifactor Authentication for Django Rest Framework""" 3 | # :copyright: (c) 2016, Robinhood Markets. 4 | # All rights reserved. 5 | # :license: BSD (3 Clause), see LICENSE for more details. 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | 9 | from collections import namedtuple 10 | 11 | version_info_t = namedtuple( 12 | 'version_info_t', ('major', 'minor', 'micro', 'releaselevel', 'serial'), 13 | ) 14 | 15 | VERSION = version_info = version_info_t(1, 2, 0, '', '') 16 | 17 | __version__ = '{0.major}.{0.minor}.{0.micro}{0.releaselevel}'.format(VERSION) 18 | __author__ = 'Robinhood Markets' 19 | __contact__ = 'opensource@robinhood.com' 20 | __homepage__ = 'https://github.com/robinhood/deux' 21 | __docformat__ = 'restructuredtext' 22 | 23 | # -eof meta- 24 | 25 | __all__ = [] 26 | -------------------------------------------------------------------------------- /deux/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from deux import strings 4 | 5 | 6 | class FailedChallengeError(Exception): 7 | """Generic exception for a failed challenge execution.""" 8 | pass 9 | 10 | 11 | class InvalidPhoneNumberError(FailedChallengeError): 12 | """ 13 | Exception for SMS that fails because phone number is not a valid 14 | number for receiving SMS's. 15 | """ 16 | 17 | def __init__(self, message=strings.INVALID_PHONE_NUMBER_ERROR): 18 | super(InvalidPhoneNumberError, self).__init__(message) 19 | 20 | 21 | class TwilioMessageError(FailedChallengeError): 22 | """ 23 | Exception that Twilio failed to send the text message for reasons 24 | other than ``NotSMSNumberError``. 25 | """ 26 | 27 | def __init__(self, message=strings.SMS_SEND_ERROR): 28 | super(TwilioMessageError, self).__init__(message) 29 | -------------------------------------------------------------------------------- /docs/includes/resources.txt: -------------------------------------------------------------------------------- 1 | .. _bug-tracker: 2 | 3 | Bug tracker 4 | =========== 5 | 6 | If you have any suggestions, bug reports or annoyances please report them 7 | to our issue tracker at https://github.com/robinhood/deux/issues/ 8 | 9 | .. _contributing-short: 10 | 11 | Contributing 12 | ============ 13 | 14 | Development of `Deux` happens at GitHub: https://github.com/robinhood/deux 15 | 16 | You are highly encouraged to participate in the development 17 | of `deux`. If you don't like GitHub (for some reason) you're welcome 18 | to send regular patches. 19 | 20 | Be sure to also read the `Contributing to Deux`_ section in the 21 | documentation. 22 | 23 | .. _`Contributing to Deux`: 24 | http://deux.readthedocs.io/en/latest/contributing.html 25 | 26 | .. _license: 27 | 28 | License 29 | ======= 30 | 31 | This software is licensed under the `New BSD License`. See the :file:`LICENSE` 32 | file in the top distribution directory for the full license text. 33 | -------------------------------------------------------------------------------- /docs/includes/introduction.txt: -------------------------------------------------------------------------------- 1 | :Version: 1.2.0 2 | :Web: https://deux.readthedocs.org/ 3 | :Download: https://pypi.python.org/pypi/deux 4 | :Source: https://github.com/robinhood/deux 5 | :Keywords: authentication, two-factor, multifactor 6 | 7 | About 8 | ===== 9 | 10 | Multifactor Authentication provides multifactor authentication integration for 11 | the Django Rest Framework. It integrates with Token Authentication built into 12 | DRF and OAuth2 provided by django-oauth-toolkit_. 13 | 14 | What is Multifactor Authentication? 15 | ==================================== 16 | 17 | Multifactor Authentication (MFA) is a security system that requires more than 18 | one method of authentication from independent categories of credentials to 19 | verify the user's identity for a login or other transaction. 20 | (Source: SearchSecurity_) 21 | 22 | .. _django-oauth-toolkit: https://django-oauth-toolkit.readthedocs.io/ 23 | .. _SearchSecurity: http://searchsecurity.techtarget.com/definition/multifactor-authentication-MFA 24 | -------------------------------------------------------------------------------- /deux/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.test import TestCase 5 | 6 | from deux.validators import phone_number_validator 7 | 8 | 9 | class PhoneNumberValidatorTest(TestCase): 10 | 11 | def test_success(self): 12 | number = "123456789012345" 13 | success_tests = (number[:i] for i in range(7, 16)) 14 | for phone_number in success_tests: 15 | phone_number_validator(phone_number) 16 | 17 | def test_fail(self): 18 | fail_tests = ( 19 | None, 20 | "1", 21 | "123456", 22 | "123-321-1234", 23 | "123-123-1234x4321", 24 | "asdfghjkl;", 25 | "", 26 | "123456", 27 | "12345678901234567" 28 | ) 29 | for phone_number in fail_tests: 30 | with self.assertRaises(ValidationError): 31 | phone_number_validator(phone_number) 32 | -------------------------------------------------------------------------------- /docs/reference/deux.authtoken.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.authtoken 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.authtoken 6 | 7 | .. automodule:: deux.authtoken 8 | :members: 9 | :undoc-members: 10 | 11 | 12 | POST /mfa/authtoken/login/ 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | **Expected Request** 16 | 17 | .. code-block:: none 18 | 19 | { 20 | "username": "testuser", 21 | "password": "mypassword", 22 | "mfa_code": "123456", (Optional) 23 | "backup_code": "123456789012" (Optional) 24 | } 25 | 26 | **Expected Response if Authenticated** 27 | 28 | .. code-block:: none 29 | 30 | 200 OK 31 | { 32 | "token": "", 33 | } 34 | 35 | **Expected Response if MFA Required** 36 | 37 | .. code-block:: none 38 | 39 | 200 OK 40 | { 41 | "mfa_required": True, 42 | "mfa_type": "sms" 43 | } 44 | -------------------------------------------------------------------------------- /test_proj/urls.py: -------------------------------------------------------------------------------- 1 | """testproj URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.9/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import include, url 17 | 18 | urlpatterns = [ 19 | url(r"^api-auth/", 20 | include("rest_framework.urls", namespace="rest_framework")), 21 | url(r"^mfa/", include("deux.urls")), 22 | url(r"^mfa/authtoken/", 23 | include("deux.authtoken.urls", namespace="authtoken"), 24 | ), 25 | url(r"^mfa/oauth2/", 26 | include("deux.oauth2.urls", namespace="oauth2"), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /docs/copyright.rst: -------------------------------------------------------------------------------- 1 | .. _copyright: 2 | 3 | Copyright 4 | ========= 5 | 6 | *Deux User Manual* 7 | 8 | by Robinhood Markets, Inc. and individual contributors. 9 | 10 | .. |copy| unicode:: U+000A9 .. COPYRIGHT SIGN 11 | 12 | Copyright |copy| 2015-2016, Robinhood Markets, Inc. and individual 13 | contributors. 14 | 15 | All rights reserved. This material may be copied or distributed only 16 | subject to the terms and conditions set forth in the `Creative Commons 17 | Attribution-ShareAlike 4.0 International 18 | `_ license. 19 | 20 | You may share and adapt the material, even for commercial purposes, but 21 | you must give the original author credit. 22 | If you alter, transform, or build upon this 23 | work, you may distribute the resulting work only under the same license or 24 | a license compatible to this one. 25 | 26 | .. note:: 27 | 28 | While the *Deux* documentation is offered under the 29 | Creative Commons *Attribution-ShareAlike 4.0 International* license 30 | the Deux *software* is offered under the 31 | `BSD License (3 Clause) `_ 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import os 5 | 6 | from sphinx_celery import conf 7 | 8 | globals().update(conf.build_config( 9 | 'deux', __file__, 10 | project='deux', 11 | canonical_url='http://deux.readthedocs.io', 12 | webdomain='robinhood.com', 13 | github_project='robinhood/deux', 14 | copyright='2016', 15 | html_logo='images/logo.png', 16 | html_favicon='images/favicon.ico', 17 | html_static_path=[], 18 | include_intersphinx={'python', 'sphinx'}, 19 | django_settings='test_proj.settings', 20 | apicheck_package='deux', 21 | apicheck_ignore_modules=[ 22 | 'deux.authtoken.tests.*', 23 | 'deux.authtoken.urls', 24 | 'deux.migrations.*', 25 | 'deux.locale.*', 26 | 'deux.oauth2.tests.*', 27 | 'deux.oauth2.urls', 28 | 'deux.tests.*', 29 | 'deux.urls', 30 | 'deux.app_settings', 31 | ], 32 | spelling_word_list_filename='spelling/spelling_wordlist.txt', 33 | )) 34 | 35 | html_theme_path = ['theme'] 36 | html_theme = 'deux' 37 | 38 | def configcheck_project_settings(): 39 | return set() 40 | -------------------------------------------------------------------------------- /docs/reference/deux.oauth2.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux.oauth2 3 | ===================================================== 4 | 5 | .. currentmodule:: deux.oauth2 6 | 7 | .. automodule:: deux.oauth2 8 | :members: 9 | :undoc-members: 10 | 11 | 12 | POST /mfa/oauth2/token/ 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | **Expected Request** 16 | 17 | .. code-block:: none 18 | 19 | { 20 | "grant_type": "password" 21 | "username": "testuser", 22 | "password": "mypassword", 23 | "mfa_code": "123456", (Optional) 24 | "backup_code": "123456789012" (Optional) 25 | } 26 | 27 | **Expected Response if Authenticated** 28 | 29 | .. code-block:: none 30 | 31 | 200 OK 32 | { 33 | "access_token": "", 34 | "expires_in": "", 35 | "token_type": "Bearer", 36 | "scope": "", 37 | "refresh_token": "" 38 | } 39 | 40 | **Expected Response if MFA Required** 41 | 42 | .. code-block:: none 43 | 44 | 200 OK 45 | { 46 | "mfa_required": True, 47 | "mfa_type": "sms" 48 | } 49 | -------------------------------------------------------------------------------- /docs/includes/installation.txt: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | You can install deux either via the Python Package Index (PyPI) 7 | or from source. 8 | 9 | Requirements 10 | ------------ 11 | 12 | ``deux`` version 1.2.0 runs on Python (2.7, 3.4, 3.5). 13 | 14 | Installing with pip 15 | ------------------- 16 | 17 | To install using `pip`: 18 | 19 | .. code-block:: console 20 | 21 | $ pip install -U deux 22 | 23 | .. _installing-from-source: 24 | 25 | Downloading and installing from source 26 | -------------------------------------- 27 | 28 | Download the latest version of deux from 29 | http://pypi.python.org/pypi/deux 30 | 31 | You can install it by doing the following: 32 | 33 | .. code-block:: console 34 | 35 | $ tar xvfz deux-0.0.0.tar.gz 36 | $ cd deux-0.0.0 37 | $ python setup.py build 38 | # python setup.py install 39 | 40 | The last command must be executed as a privileged user if 41 | you are not currently using a virtualenv. 42 | 43 | .. _installing-from-git: 44 | 45 | Using the development version 46 | ----------------------------- 47 | 48 | With pip 49 | ~~~~~~~~ 50 | 51 | You can install it by doing the following: 52 | 53 | .. code-block:: console 54 | 55 | $ pip install https://github.com/robinhood/deux/zipball/master#egg=deux 56 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: false 4 | python: 5 | - '3.5' 6 | env: 7 | global: 8 | PYTHONUNBUFFERED=yes 9 | matrix: 10 | - TOXENV=2.7-django1.10 11 | - TOXENV=2.7-django1.9 12 | - TOXENV=pypy-django1.10 13 | - TOXENV=pypy-django1.9 14 | - TOXENV=3.4-django1.10 15 | - TOXENV=3.4-django1.9 16 | - TOXENV=3.5-django1.10 17 | - TOXENV=3.5-django1.9 18 | - TOXENV=flake8 19 | - TOXENV=flakeplus 20 | - TOXENV=apicheck 21 | - TOXENV=cov 22 | before_install: 23 | - | 24 | if [ "$TOXENV" = "pypy" ]; then 25 | export PYENV_ROOT="$HOME/.pyenv" 26 | if [ -f "$PYENV_ROOT/bin/pyenv" ]; then 27 | cd "$PYENV_ROOT" && git pull 28 | else 29 | rm -rf "$PYENV_ROOT" && git clone --depth 1 https://github.com/yyuu/pyenv.git "$PYENV_ROOT" 30 | fi 31 | "$PYENV_ROOT/bin/pyenv" install "pypy-$PYPY_VERSION" 32 | virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION" 33 | source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate" 34 | fi 35 | install: travis_retry pip install -U tox 36 | script: tox -v -- -v 37 | after_success: 38 | - .tox/$TOXENV/bin/coverage xml 39 | - .tox/$TOXENV/bin/codecov -e TOXENV 40 | -------------------------------------------------------------------------------- /docs/templates/readme.txt: -------------------------------------------------------------------------------- 1 | .. image:: https://deux.readthedocs.io/en/latest/_images/deux_banner.png 2 | :align: center 3 | :width: 721 4 | :height: 250 5 | 6 | |build-status| |codecov| |license| |wheel| |pyversion| |pyimp| 7 | 8 | .. include:: ../includes/introduction.txt 9 | 10 | .. include:: ../includes/installation.txt 11 | 12 | .. |build-status| image:: https://travis-ci.org/robinhood/deux.svg?branch=master 13 | :alt: Build status 14 | :target: https://travis-ci.org/robinhood/deux 15 | 16 | .. |license| image:: https://img.shields.io/pypi/l/deux.svg 17 | :alt: BSD License 18 | :target: https://opensource.org/licenses/BSD-3-Clause 19 | 20 | .. |wheel| image:: https://img.shields.io/pypi/wheel/deux.svg 21 | :alt: Deux can be installed via wheel 22 | :target: https://pypi.python.org/pypi/deux/ 23 | 24 | .. |pyversion| image:: https://img.shields.io/pypi/pyversions/deux.svg 25 | :alt: Supported Python versions. 26 | :target: https://pypi.python.org/pypi/deux/ 27 | 28 | .. |pyimp| image:: https://img.shields.io/pypi/implementation/deux.svg 29 | :alt: Support Python implementations. 30 | :target: https://pypi.python.org/pypi/deux/ 31 | 32 | .. |codecov| image:: https://codecov.io/gh/robinhood/deux/branch/master/graph/badge.svg 33 | :alt: Code Coverage 34 | :target: https://codecov.io/gh/robinhood/deux 35 | -------------------------------------------------------------------------------- /deux/strings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | #: Error if user submits both MFA and backup code for authentication. 6 | BOTH_CODES_ERROR = _( 7 | "Login does not take both a verification and backup code.") 8 | 9 | #: Error if MFA is unexpectedly in a disabled state. 10 | DISABLED_ERROR = _("Two factor authentication is not enabled.") 11 | 12 | #: Error if MFA is unexpectedly in an enabled state. 13 | ENABLED_ERROR = _("Two factor authentication is already enabled.") 14 | 15 | #: Error if an invalid backup code is entered. 16 | INVALID_BACKUP_CODE_ERROR = _("Please enter a valid backup code.") 17 | 18 | #: Error if a user provides an invalid username/password combination. 19 | INVALID_CREDENTIALS_ERROR = _("Unable to log in with provided credentials.") 20 | 21 | #: Error if an invalid MFA code is entered. 22 | INVALID_MFA_CODE_ERROR = _("Please enter a valid code.") 23 | 24 | #: Error if an invalid phone number is entered. 25 | INVALID_PHONE_NUMBER_ERROR = _("Please enter a valid phone number.") 26 | 27 | #: Error if phone number is not set for a challenge that requires it. 28 | PHONE_NUMBER_NOT_SET_ERROR = _( 29 | "MFA phone number must be set for this challenge.") 30 | 31 | #: Error if SMS fails to send. 32 | SMS_SEND_ERROR = _("SMS failed to send.") 33 | 34 | #: Message body for a MFA code. 35 | MFA_CODE_TEXT_MESSAGE = _("Two Factor Authentication Code: {code}") 36 | -------------------------------------------------------------------------------- /deux/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-06-22 21:31 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | from django.conf import settings 6 | import django.core.validators 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | import deux.services 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ('auth', '0007_alter_validators_add_error_messages'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='MultiFactorAuth', 23 | fields=[ 24 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='multi_factor_auth', serialize=False, to=settings.AUTH_USER_MODEL)), 25 | ('phone_number', models.CharField(blank=True, default='', max_length=15, validators=[django.core.validators.RegexValidator(message='Please enter a valid phone number.', regex='^(\\d{7,15})$')])), 26 | ('challenge_type', models.CharField(blank=True, choices=[('sms', 'SMS'), ('', 'Off')], default='', max_length=16)), 27 | ('backup_key', models.CharField(blank=True, default='', help_text='Hex-Encoded Secret Key', max_length=32)), 28 | ('sms_secret_key', models.CharField(default=deux.services.generate_key, help_text='Hex-Encoded Secret Key', max_length=32)), 29 | ], 30 | options={ 31 | 'abstract': False, 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | 2.7-django1.10 4 | 2.7-django1.9 5 | pypy-django1.10 6 | pypy-django1.9 7 | 3.4-django1.10 8 | 3.4-django1.9 9 | 3.5-django1.10 10 | 3.5-django1.9 11 | 12 | flake8 13 | flakeplus 14 | apicheck 15 | configcheck 16 | cov 17 | 18 | [testenv] 19 | deps= 20 | -r{toxinidir}/requirements/default.txt 21 | -r{toxinidir}/requirements/test.txt 22 | -r{toxinidir}/requirements/test-ci.txt 23 | 24 | django1.10: -r{toxinidir}/requirements/test-django110.txt 25 | django1.9: -r{toxinidir}/requirements/test-django19.txt 26 | 27 | linkcheck,apicheck: -r{toxinidir}/requirements/docs.txt 28 | flake8,flakeplus: -r{toxinidir}/requirements/pkgutils.txt 29 | sitepackages = False 30 | recreate = False 31 | commands = pytest --cov-report=xml 32 | 33 | basepython = 34 | 2.7,flake8,flakeplus,apicheck,linkcheck,configcheck,cov: python2.7 35 | 3.4: python3.4 36 | 3.5: python3.5 37 | pypy: pypy 38 | 39 | [testenv:apicheck] 40 | commands = 41 | sphinx-build -b apicheck -d {envtmpdir}/doctrees docs docs/_build/apicheck 42 | 43 | [testenv:configcheck] 44 | commands = 45 | sphinx-build -b configcheck -d {envtmpdir}/doctrees docs docs/_build/configcheck 46 | 47 | [testenv:linkcheck] 48 | commands = 49 | sphinx-build -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/linkcheck 50 | 51 | [testenv:flake8] 52 | commands = 53 | flake8 {toxinidir}/deux 54 | 55 | [testenv:flakeplus] 56 | commands = 57 | flakeplus --2.7 {toxinidir}/deux 58 | 59 | [testenv:cov] 60 | commands = 61 | pytest -xv --cov-report=xml 62 | -------------------------------------------------------------------------------- /deux/authtoken/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from rest_framework.authtoken.views import ObtainAuthToken 4 | from rest_framework.authtoken.models import Token 5 | from rest_framework.response import Response 6 | 7 | from deux.authtoken.serializers import MFAAuthTokenSerializer 8 | 9 | 10 | class ObtainMFAAuthToken(ObtainAuthToken): 11 | """ 12 | class::ObtainMFAAuthToken() 13 | 14 | View for authenticating which extends the ``ObtainAuthToken`` from 15 | Django Rest Framework's Token Authentication. 16 | """ 17 | serializer_class = MFAAuthTokenSerializer 18 | 19 | def post(self, request, *args, **kwargs): 20 | """ 21 | function::post(self, request) 22 | 23 | Override ObtainAuthToken's post method for multifactor 24 | authentication. 25 | 26 | (1) When MFA is required, send the user a response 27 | indicating which challenge is required. 28 | (2) When authentication is successful return the auth token. 29 | 30 | :param request: Request object from the client. 31 | """ 32 | serializer = self.serializer_class(data=request.data) 33 | serializer.is_valid(raise_exception=True) 34 | data = serializer.validated_data 35 | if "mfa_required" in data and data["mfa_required"]: 36 | return Response({ 37 | "mfa_required": True, 38 | "mfa_type": serializer.validated_data["mfa_type"] 39 | }) 40 | else: 41 | user = serializer.validated_data['user'] 42 | token, created = Token.objects.get_or_create(user=user) 43 | return Response({"token": token.key}) 44 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | 3 | global: 4 | # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the 5 | # /E:ON and /V:ON options are not enabled in the batch script intepreter 6 | # See: http://stackoverflow.com/a/13751649/163740 7 | WITH_COMPILER: "cmd /E:ON /V:ON /C .\\extra\\appveyor\\run_with_compiler.cmd" 8 | 9 | matrix: 10 | 11 | # Pre-installed Python versions, which Appveyor may upgrade to 12 | # a later point release. 13 | # See: http://www.appveyor.com/docs/installed-software#python 14 | 15 | - PYTHON: "C:\\Python27" 16 | PYTHON_VERSION: "2.7.x" 17 | PYTHON_ARCH: "32" 18 | 19 | - PYTHON: "C:\\Python34" 20 | PYTHON_VERSION: "3.4.x" 21 | PYTHON_ARCH: "32" 22 | 23 | - PYTHON: "C:\\Python27-x64" 24 | PYTHON_VERSION: "2.7.x" 25 | PYTHON_ARCH: "64" 26 | WINDOWS_SDK_VERSION: "v7.0" 27 | 28 | - PYTHON: "C:\\Python34-x64" 29 | PYTHON_VERSION: "3.4.x" 30 | PYTHON_ARCH: "64" 31 | WINDOWS_SDK_VERSION: "v7.1" 32 | 33 | 34 | init: 35 | - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" 36 | 37 | install: 38 | - "powershell extra\\appveyor\\install.ps1" 39 | - "%PYTHON%/Scripts/pip.exe install -U setuptools" 40 | - "%PYTHON%/Scripts/pip.exe install -r requirements/default.txt" 41 | - "%PYTHON%/Scripts/pip.exe install -r requirements/django.txt" 42 | - "%PYTHON%/Scripts/pip.exe install -r requirements/test.txt" 43 | 44 | build: off 45 | 46 | test_script: 47 | - "%WITH_COMPILER% %PYTHON%/python setup.py test" 48 | 49 | after_test: 50 | - "%WITH_COMPILER% %PYTHON%/python setup.py bdist_wheel" 51 | 52 | artifacts: 53 | - path: dist\* 54 | 55 | #on_success: 56 | # - TODO: upload the content of dist/*.whl to a public wheelhouse 57 | -------------------------------------------------------------------------------- /docs/reference/deux.rst: -------------------------------------------------------------------------------- 1 | ===================================================== 2 | deux 3 | ===================================================== 4 | 5 | .. currentmodule:: deux 6 | 7 | .. automodule:: deux 8 | :members: 9 | :undoc-members: 10 | 11 | GET /mfa/ 12 | ~~~~~~~~~ 13 | 14 | **Sample Response** 15 | 16 | .. code-block:: none 17 | 18 | 200 OK 19 | { 20 | "enabled": True or False, 21 | "challenge_type": "sms" 22 | "phone_number": "14085862744" 23 | } 24 | 25 | DELETE /mfa/ 26 | ~~~~~~~~~~~~ 27 | 28 | **Expected Response** 29 | 30 | .. code-block:: none 31 | 32 | 204 NO CONTENT 33 | 34 | PUT /mfa/sms/request/ 35 | ~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | **Expected Request** 38 | 39 | .. code-block:: none 40 | 41 | { 42 | "phone_number": "14085862744" 43 | } 44 | 45 | **Expected Response** 46 | 47 | .. code-block:: none 48 | 49 | 200 OK 50 | { 51 | "enabled": False, 52 | "challenge_type": "" 53 | "phone_number": "14085862744" 54 | } 55 | 56 | PUT /mfa/sms/verify/ 57 | ~~~~~~~~~~~~~~~~~~~~ 58 | 59 | **Expected Request** 60 | 61 | .. code-block:: none 62 | 63 | { 64 | "mfa_code": "123456" 65 | } 66 | 67 | **Expected Response** 68 | 69 | .. code-block:: none 70 | 71 | 200 OK 72 | { 73 | "enabled": True, 74 | "challenge_type": "sms" 75 | "phone_number": "14085862744" 76 | } 77 | 78 | GET /mfa/recovery/ 79 | ~~~~~~~~~~~~~~~~~~ 80 | 81 | **Expected Response** 82 | 83 | .. code-block:: none 84 | 85 | 200 OK 86 | { 87 | "backup_code: "123456789012" 88 | } 89 | -------------------------------------------------------------------------------- /deux/notifications.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from twilio.rest import TwilioRestClient 4 | from twilio.rest.exceptions import TwilioRestException 5 | 6 | from deux import strings 7 | from deux.app_settings import mfa_settings 8 | from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError 9 | 10 | #: Error code from Twilio to indicate at ``InvalidPhoneNumberError`` 11 | NOT_SMS_DEVICE_CODE = 21401 12 | 13 | 14 | def send_mfa_code_text_message(mfa_instance, mfa_code): 15 | """ 16 | Sends the MFA Code text message to the user. 17 | 18 | :param mfa_instance: :class:`MultiFactorAuth` instance to use. 19 | :param mfa_code: MFA code in the form of a string. 20 | 21 | :raises deux.exceptions.InvalidPhoneNumberError: To tell system that this 22 | MFA object's phone number if not a valid number to receive SMS's. 23 | :raises deux.exceptions.TwilioMessageError: To tell system that Twilio 24 | failed to send message. 25 | """ 26 | 27 | sid = mfa_settings.TWILIO_ACCOUNT_SID 28 | token = mfa_settings.TWILIO_AUTH_TOKEN 29 | twilio_num = mfa_settings.TWILIO_SMS_POOL_SID 30 | if not sid or not token or not twilio_num: 31 | print("Please provide Twilio credentials to send text messages. For " 32 | "testing purposes, the MFA code is {code}".format(code=mfa_code)) 33 | return 34 | 35 | twilio_client = TwilioRestClient(sid, token) 36 | try: 37 | twilio_client.messages.create( 38 | body=strings.MFA_CODE_TEXT_MESSAGE.format(code=mfa_code), 39 | to=mfa_instance.phone_number, 40 | from_=twilio_num 41 | ) 42 | except TwilioRestException as e: 43 | if e.code == NOT_SMS_DEVICE_CODE: 44 | raise InvalidPhoneNumberError() 45 | raise TwilioMessageError() 46 | -------------------------------------------------------------------------------- /deux/locale/en-us/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-09-14 23:59+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: build/lib/deux/strings.py:7 deux/strings.py:7 21 | msgid "Login does not take both a verification and backup code." 22 | msgstr "" 23 | 24 | #: build/lib/deux/strings.py:10 deux/strings.py:10 25 | msgid "Two factor authentication is not enabled." 26 | msgstr "" 27 | 28 | #: build/lib/deux/strings.py:13 deux/strings.py:13 29 | msgid "Two factor authentication is already enabled." 30 | msgstr "" 31 | 32 | #: build/lib/deux/strings.py:16 deux/strings.py:16 33 | msgid "Please enter a valid backup code." 34 | msgstr "" 35 | 36 | #: build/lib/deux/strings.py:19 deux/strings.py:19 37 | msgid "Unable to log in with provided credentials." 38 | msgstr "" 39 | 40 | #: build/lib/deux/strings.py:22 deux/strings.py:22 41 | msgid "Please enter a valid code." 42 | msgstr "" 43 | 44 | #: build/lib/deux/strings.py:25 build/lib/deux/strings.py:32 deux/strings.py:25 45 | msgid "Please enter a valid phone number." 46 | msgstr "" 47 | 48 | #: build/lib/deux/strings.py:29 deux/strings.py:29 49 | msgid "MFA phone number must be set for this challenge." 50 | msgstr "" 51 | 52 | #: build/lib/deux/strings.py:35 deux/strings.py:32 53 | msgid "SMS failed to send." 54 | msgstr "" 55 | 56 | #: build/lib/deux/strings.py:38 deux/strings.py:35 57 | #, python-brace-format 58 | msgid "Two Factor Authentication Code: {code}" 59 | msgstr "" 60 | -------------------------------------------------------------------------------- /deux/oauth2/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | from oauthlib.oauth2 import OAuth2Error 5 | 6 | from rest_framework import status 7 | 8 | 9 | class InvalidLoginError(OAuth2Error): 10 | """ 11 | Generic exception for a failed login attempt through OAuth2. This exception 12 | will result in a 400 Bad Request error in the OAuth API. 13 | """ 14 | 15 | def __init__(self, message): 16 | """ 17 | Initializes this error with the given error message. 18 | 19 | :param message: The error message describing this exception. 20 | """ 21 | super(Exception, self).__init__(message) 22 | 23 | @property 24 | def twotuples(self): 25 | """ 26 | Returns a list of tuples that will be converted to the error response. 27 | This method override the ``two_tuples`` method from ``OAuth2Error``. 28 | """ 29 | return [("detail", six.text_type(self))] 30 | 31 | 32 | class ChallengeRequiredMessage(OAuth2Error): 33 | """ 34 | This exception is used to prompt the user for an MFA code. 35 | 36 | This exception is used when a user passes in their username and password, 37 | and they have MFA enabled. 38 | """ 39 | 40 | #: This exception returns a 200 response. 41 | status_code = status.HTTP_200_OK 42 | 43 | def __init__(self, challenge_type): 44 | """ 45 | Initalizes this exception class with a challenge type. 46 | 47 | :param challenge_type: The challenge type the user should expect. 48 | """ 49 | super(Exception, self).__init__(challenge_type) 50 | 51 | @property 52 | def twotuples(self): 53 | """ 54 | Returns a list of tuples that will be converted to the error response. 55 | This method override the ``two_tuples`` method from ``OAuth2Error``. 56 | """ 57 | return [ 58 | ("mfa_required", True), 59 | ("mfa_type", six.text_type(self)), 60 | ] 61 | -------------------------------------------------------------------------------- /extra/appveyor/run_with_compiler.cmd: -------------------------------------------------------------------------------- 1 | :: To build extensions for 64 bit Python 3, we need to configure environment 2 | :: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: 3 | :: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) 4 | :: 5 | :: To build extensions for 64 bit Python 2, we need to configure environment 6 | :: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: 7 | :: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) 8 | :: 9 | :: 32 bit builds do not require specific environment configurations. 10 | :: 11 | :: Note: this script needs to be run with the /E:ON and /V:ON flags for the 12 | :: cmd interpreter, at least for (SDK v7.0) 13 | :: 14 | :: More details at: 15 | :: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows 16 | :: http://stackoverflow.com/a/13751649/163740 17 | :: 18 | :: Author: Olivier Grisel 19 | :: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ 20 | @ECHO OFF 21 | 22 | SET COMMAND_TO_RUN=%* 23 | SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows 24 | 25 | SET MAJOR_PYTHON_VERSION="%PYTHON_VERSION:~0,1%" 26 | IF %MAJOR_PYTHON_VERSION% == "2" ( 27 | SET WINDOWS_SDK_VERSION="v7.0" 28 | ) ELSE IF %MAJOR_PYTHON_VERSION% == "3" ( 29 | SET WINDOWS_SDK_VERSION="v7.1" 30 | ) ELSE ( 31 | ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" 32 | EXIT 1 33 | ) 34 | 35 | IF "%PYTHON_ARCH%"=="64" ( 36 | ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture 37 | SET DISTUTILS_USE_SDK=1 38 | SET MSSdk=1 39 | "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% 40 | "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release 41 | ECHO Executing: %COMMAND_TO_RUN% 42 | call %COMMAND_TO_RUN% || EXIT 1 43 | ) ELSE ( 44 | ECHO Using default MSVC build environment for 32 bit architecture 45 | ECHO Executing: %COMMAND_TO_RUN% 46 | call %COMMAND_TO_RUN% || EXIT 1 47 | ) 48 | -------------------------------------------------------------------------------- /deux/authtoken/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | from mock import patch 5 | 6 | from django.core.urlresolvers import reverse 7 | from rest_framework import status 8 | 9 | from deux.app_settings import mfa_settings 10 | from deux.constants import SMS 11 | from deux.tests.test_base import BaseUserTestCase 12 | 13 | 14 | class _BaseMFAViewTest(BaseUserTestCase): 15 | 16 | def setUp(self): 17 | self.simpleUserSetup() 18 | self.mfa_1 = mfa_settings.MFA_MODEL.objects.create(user=self.user1) 19 | self.mfa_2 = mfa_settings.MFA_MODEL.objects.create(user=self.user2) 20 | self.mfa_2.enable(SMS) 21 | self.phone_number = "1234567890" 22 | self.mfa_2.phone_number = self.phone_number 23 | self.mfa_2.save() 24 | 25 | 26 | class ObtainMFAAuthTokenTest(_BaseMFAViewTest): 27 | url = reverse("authtoken:login") 28 | 29 | @patch("deux.authtoken.serializers.MultiFactorChallenge") 30 | def test_login_mfa_required(self, multifactorchallenge): 31 | # Correct credentials without MFA code. 32 | data = { 33 | "username": self.user2.username, 34 | "password": self.password2 35 | } 36 | resp = self.check_post_response( 37 | self.url, status.HTTP_200_OK, data=data) 38 | self.assertEqual(resp.data, { 39 | "mfa_type": SMS, 40 | "mfa_required": True 41 | }) 42 | with self.assertRaises(KeyError): 43 | resp.data["token"] 44 | 45 | def test_login_mfa_not_required(self): 46 | # Incorrect password. 47 | data = {"username": self.user1.username, 48 | "password": "incorrect_password"} 49 | resp = self.check_post_response( 50 | self.url, status.HTTP_400_BAD_REQUEST, data=data) 51 | self.assertEqual(resp.data, { 52 | "non_field_errors": [ 53 | "Unable to log in with provided credentials." 54 | ] 55 | }) 56 | 57 | # Correct password. 58 | data["password"] = self.password1 59 | resp = self.check_post_response( 60 | self.url, status.HTTP_200_OK, data=data) 61 | self.assertEqual(resp.data, { 62 | "token": six.text_type(self.user1.auth_token), 63 | }) 64 | -------------------------------------------------------------------------------- /deux/oauth2/backends.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import sys 4 | from oauth2_provider.oauth2_backends import OAuthLibCore 5 | 6 | from rest_framework.request import Request as DRFRequest 7 | from rest_framework.views import APIView 8 | 9 | if sys.version_info < (3,): 10 | from urlparse import parse_qs 11 | else: 12 | from urllib.parse import parse_qs 13 | 14 | 15 | class MFARequestBackend(OAuthLibCore): 16 | """ 17 | class::MFARequestBackend() 18 | 19 | OAuth2 backend class for MFA extending ``JSONOAuthLibCore``. It extracts 20 | extra credentials (``mfa_code`` and ``backup_code``) from the request body. 21 | """ 22 | 23 | def extract_body(self, request): 24 | """ 25 | Extract request body by coercing the request to a Django Rest 26 | Framework Request. 27 | 28 | :params request: The request to extract the body from. 29 | :returns: Returns the items in the requests body. 30 | """ 31 | if not isinstance(request, DRFRequest): 32 | request = APIView().initialize_request(request) 33 | # our custom authorization view is already using DRF 34 | return request.data.items() if request.data else [] 35 | 36 | def _get_extra_credentials(self, body): 37 | """ 38 | Gets dictionary of ``mfa_code`` and ``backup_code`` from the body. 39 | 40 | :param body: The request body in url encoded form. 41 | :returns: Dictionary with ``mfa_code`` and ``backup_code``. 42 | """ 43 | params = {key: value[0] for key, value in parse_qs(body).items()} 44 | return { 45 | "mfa_code": params.get("mfa_code"), 46 | "backup_code": params.get("backup_code"), 47 | } 48 | 49 | def create_token_response(self, request): 50 | """ 51 | Overrides the base method to pass in the request body instead of the 52 | request because Django only allows the request data stream to be read 53 | once. 54 | 55 | :param request: The request to create a token response from. 56 | :returns: The redirect uri, headers, body, and status of the response. 57 | """ 58 | uri, http_method, body, headers = self._extract_params(request) 59 | extra_credentials = self._get_extra_credentials(body) 60 | 61 | headers, body, status = self.server.create_token_response( 62 | uri, http_method, body, headers, extra_credentials) 63 | uri = headers.get("Location", None) 64 | return uri, headers, body, status 65 | -------------------------------------------------------------------------------- /docs/userguide/usage.rst: -------------------------------------------------------------------------------- 1 | .. _configuration-guide: 2 | 3 | ============================================================================= 4 | Usage 5 | ============================================================================= 6 | 7 | Introduction 8 | ============ 9 | 10 | This library provides support for enabling Multifactor Authentication and then 11 | authenticating through MFA. 12 | 13 | Currently, the library only supports multifactor authentication over SMS, but 14 | it can be easily extended to support new challenge types. The high level 15 | API is as followed. 16 | 17 | View detailed URL documentation here_. 18 | 19 | .. _here: ../reference/deux.html 20 | 21 | Getting MFA Status 22 | ================== 23 | 24 | Users can submit a request to ``GET mfa/`` to get information about whether MFA 25 | is enabled and which phone number it is enabled through. 26 | 27 | Authentication 28 | ============== 29 | 30 | Deux supports authentication through both ``authtoken`` and ``oauth2``. For both of these protocols, users must submit their username, password, and an MFA code or backup code. If the request is submitted without the token, they will be prompted for a token. 31 | 32 | #. For ``authtoken``, place the request to ``PUT /mfa/authtoken/login``. 33 | 34 | #. For ``oauth2``, place the request to ``PUT /mfa/oauth2/token`` with a 35 | ``password`` grant type. 36 | 37 | 38 | If MFA is not enabled, these endpoints will behave like the base authentication protocols. 39 | 40 | Enabling MFA 41 | ============ 42 | 43 | The enabling process involves submitting a request to an MFA method and receiving back a code. The user must then submit the code to verify the request. If the code is correct, the MFA will then be enabled. 44 | 45 | .. image:: ../images/state_diagram.png 46 | 47 | MFA can be enabled through the following methods: 48 | 49 | SMS 50 | --- 51 | 52 | To enable MFA through SMS, the user must first submit a request to 53 | ``PUT mfa/sms/request/`` with a phone number, which will send an SMS to the 54 | phone number with the MFA code. 55 | 56 | The user should then submit a ``PUT mfa/sms/verify/`` request with the MFA code 57 | to enable MFA. 58 | 59 | Disabling MFA 60 | ============= 61 | 62 | Users can submit a ``DELETE mfa/`` request to disable MFA. 63 | 64 | Backup Code 65 | =========== 66 | 67 | Users only have one backup code which can be used to authenticate. If you use 68 | a backup code to authenticate, MFA will be disabled. To get the backup code, 69 | the user can submit a request to ``GET mfa/recovery``. 70 | 71 | The backup code will be reset every request. 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /deux/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from deux.app_settings import mfa_settings 4 | from deux.constants import DISABLED, SMS 5 | 6 | from .test_base import BaseUserTestCase 7 | 8 | 9 | class MultiFactorAuthTests(BaseUserTestCase): 10 | 11 | def setUp(self): 12 | self.simpleUserSetup() 13 | self.mfa = mfa_settings.MFA_MODEL.objects.create( 14 | user=self.user1, phone_number="12345678900") 15 | 16 | def test_default_disabled(self): 17 | self.assertEqual(self.mfa.challenge_type, DISABLED) 18 | self.assertFalse(self.mfa.enabled) 19 | self.assertEqual(self.mfa.backup_code, "") 20 | 21 | def test_set_sms_enabled(self): 22 | self.mfa.enable(SMS) 23 | self.assertEqual(self.mfa.challenge_type, SMS) 24 | self.assertTrue(self.mfa.enabled) 25 | self.assertEqual( 26 | len(self.mfa.backup_code), mfa_settings.BACKUP_CODE_DIGITS) 27 | 28 | def test_disable(self): 29 | self.mfa.enable(SMS) 30 | self.assertEqual(self.mfa.challenge_type, SMS) 31 | 32 | self.mfa.disable() 33 | self.assertEqual(self.mfa.challenge_type, DISABLED) 34 | self.assertFalse(self.mfa.enabled) 35 | 36 | def test_phone_number(self): 37 | self.assertEqual(self.mfa.phone_number, "12345678900") 38 | 39 | def test_backup_code_generation(self): 40 | # AssertionError if disabled. 41 | with self.assertRaises(AssertionError): 42 | self.mfa.refresh_backup_code() 43 | 44 | # Valid backup code if enabled. 45 | self.mfa.enable(SMS) 46 | self.assertEqual( 47 | len(self.mfa.refresh_backup_code()), 48 | mfa_settings.BACKUP_CODE_DIGITS 49 | ) 50 | 51 | # Codes should not be same after regeneration. 52 | current_code = self.mfa.backup_code 53 | new_code = self.mfa.refresh_backup_code() 54 | self.assertIsNotNone(new_code) 55 | self.assertNotEquals(current_code, new_code) 56 | 57 | def test_check_and_use_backup_code(self): 58 | self.mfa.enable(SMS) 59 | self.mfa.refresh_backup_code() 60 | 61 | # Test for using incorrect code. 62 | bad_code = "123456abcdef" 63 | self.assertFalse(self.mfa.check_and_use_backup_code(bad_code)) 64 | 65 | # Test for using correct code. 66 | code = self.mfa.backup_code 67 | self.assertTrue(self.mfa.check_and_use_backup_code(code)) 68 | instance = mfa_settings.MFA_MODEL.objects.get(user=self.user1) 69 | self.assertFalse(instance.enabled) 70 | self.assertEqual(instance.challenge_type, DISABLED) 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Robinhood Markets and individual contributors. 2 | All rights reserved. 3 | 4 | Deux is licensed under The BSD License (3 Clause, also known as 5 | the new BSD license). The license is an OSI approved Open Source 6 | license and is GPL-compatible(1). 7 | 8 | The license text can also be found here: 9 | http://www.opensource.org/licenses/BSD-3-Clause 10 | 11 | License 12 | ======= 13 | 14 | Redistribution and use in source and binary forms, with or without 15 | modification, are permitted provided that the following conditions are met: 16 | * Redistributions of source code must retain the above copyright 17 | notice, this list of conditions and the following disclaimer. 18 | * Redistributions in binary form must reproduce the above copyright 19 | notice, this list of conditions and the following disclaimer in the 20 | documentation and/or other materials provided with the distribution. 21 | * Neither the name of Robinhood Markets nor the 22 | names of its contributors may be used to endorse or promote products 23 | derived from this software without specific prior written permission. 24 | 25 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 26 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 27 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 28 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Robinhood Markets OR CONTRIBUTORS 29 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 30 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 31 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 32 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 33 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 34 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 35 | POSSIBILITY OF SUCH DAMAGE. 36 | 37 | Documentation License 38 | ===================== 39 | 40 | The documentation portion of Deux (the rendered contents of the 41 | "docs" directory of a software distribution or checkout) is supplied 42 | under the "Creative Commons Attribution-ShareAlike 4.0 43 | International" (CC BY-SA 4.0) License as described by 44 | http://creativecommons.org/licenses/by-sa/4.0/ 45 | 46 | Footnotes 47 | ========= 48 | (1) A GPL-compatible license makes it possible to 49 | combine Deux with other software that is released 50 | under the GPL, it does not mean that we're distributing 51 | Deux under the GPL license. The BSD license, unlike the GPL, 52 | let you distribute a modified version without making your 53 | changes open source. 54 | -------------------------------------------------------------------------------- /deux/app_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import importlib 4 | import six 5 | 6 | from django.conf import settings 7 | 8 | 9 | USER_SETTINGS = getattr(settings, 'DEUX', None) 10 | 11 | DEFAULTS = { 12 | "BACKUP_CODE_DIGITS": 12, 13 | "MFA_CODE_NUM_DIGITS": 6, 14 | "MFA_MODEL": "deux.models.MultiFactorAuth", 15 | "SEND_MFA_TEXT_FUNC": "deux.notifications.send_mfa_code_text_message", 16 | "STEP_SIZE": 30, 17 | "TWILIO_ACCOUNT_SID": "", 18 | "TWILIO_AUTH_TOKEN": "", 19 | "TWILIO_SMS_POOL_SID": "", 20 | } 21 | 22 | # List of settings that cannot be empty. 23 | MANDATORY = () 24 | 25 | # List of settings that may be in string import notation. 26 | IMPORT_STRINGS = ( 27 | 'MFA_MODEL', 28 | 'SEND_MFA_TEXT_FUNC', 29 | ) 30 | 31 | 32 | def perform_import(val, setting_name): 33 | 34 | if isinstance(val, six.string_types): 35 | return import_from_string(val, setting_name) 36 | elif isinstance(val, (list, tuple)): 37 | return [import_from_string(item, setting_name) for item in val] 38 | return val 39 | 40 | 41 | def import_from_string(val, setting_name): 42 | 43 | try: 44 | parts = val.split('.') 45 | module_path, class_name = '.'.join(parts[:-1]), parts[-1] 46 | module = importlib.import_module(module_path) 47 | return getattr(module, class_name) 48 | except ImportError: 49 | msg = "Coud not import {val} for setting {setting_name}".format( 50 | val=val, setting_name=setting_name) 51 | raise ImportError(msg) 52 | 53 | 54 | class MFASettings(object): 55 | 56 | def __init__(self, user_settings=None, defaults=None, import_strings=None, 57 | mandatory=None): 58 | self.user_settings = user_settings or {} 59 | self.defaults = defaults or {} 60 | self.import_strings = import_strings or () 61 | self.mandatory = mandatory or () 62 | 63 | def __getattr__(self, attr): 64 | if attr not in self.defaults.keys(): 65 | raise AttributeError("Invalid deux setting: '%s'" % attr) 66 | 67 | try: 68 | # Check if present in user settings 69 | val = self.user_settings[attr] 70 | except KeyError: 71 | # Fall back to defaults 72 | val = self.defaults[attr] 73 | 74 | # Coerce import strings into classes 75 | if val and attr in self.import_strings: 76 | val = perform_import(val, attr) 77 | 78 | self.validate_setting(attr, val) 79 | 80 | # Cache the result 81 | setattr(self, attr, val) 82 | return val 83 | 84 | def validate_setting(self, attr, val): 85 | if not val and attr in self.mandatory: 86 | raise AttributeError("deux setting: '%s' is mandatory" % attr) 87 | 88 | 89 | mfa_settings = MFASettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) 90 | -------------------------------------------------------------------------------- /deux/tests/test_services.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | from binascii import unhexlify 5 | from mock import patch 6 | 7 | from django.test import TestCase 8 | from django_otp.util import random_hex 9 | 10 | from deux.app_settings import mfa_settings 11 | from deux.constants import SMS 12 | from deux.services import ( 13 | MultiFactorChallenge, 14 | generate_mfa_code, 15 | verify_mfa_code, 16 | ) 17 | 18 | from .test_base import BaseUserTestCase 19 | 20 | 21 | class GenerateMFACodeTests(TestCase): 22 | 23 | def setUp(self): 24 | self.bin_key = unhexlify(random_hex()) 25 | 26 | def test_generate_mfa_code(self): 27 | mfa_code = generate_mfa_code(self.bin_key) 28 | self.assertEqual(len(mfa_code), mfa_settings.MFA_CODE_NUM_DIGITS) 29 | 30 | def test_time_based_mfa_code(self): 31 | mfa_code_0 = generate_mfa_code(self.bin_key, drift=0) 32 | mfa_code_1 = generate_mfa_code(self.bin_key, drift=1) 33 | self.assertNotEquals(mfa_code_0, mfa_code_1) 34 | 35 | 36 | class VerifyMFACodeTests(TestCase): 37 | 38 | def setUp(self): 39 | self.bin_key = unhexlify(random_hex()) 40 | 41 | def test_verify_mfa_code_success(self): 42 | mfa_code_tests = ( 43 | generate_mfa_code(self.bin_key, -1), 44 | generate_mfa_code(self.bin_key, 0), 45 | generate_mfa_code(self.bin_key, 1) 46 | ) 47 | for mfa_code in mfa_code_tests: 48 | self.assertTrue(verify_mfa_code(self.bin_key, mfa_code)) 49 | 50 | def test_verify_mfa_code_fail(self): 51 | int_mfa_code = int(generate_mfa_code(self.bin_key, 0)) 52 | mfa_code_tests = ( 53 | None, 54 | "", 55 | generate_mfa_code(self.bin_key, -3), 56 | generate_mfa_code(self.bin_key, -2), 57 | generate_mfa_code(self.bin_key, 2), 58 | generate_mfa_code(self.bin_key, 3), 59 | six.text_type(int_mfa_code + 1).zfill( 60 | mfa_settings.MFA_CODE_NUM_DIGITS), 61 | "abcdef" 62 | ) 63 | for mfa_code in mfa_code_tests: 64 | self.assertFalse(verify_mfa_code(self.bin_key, mfa_code)) 65 | 66 | 67 | class MultiFactorChallengeTests(BaseUserTestCase): 68 | 69 | def setUp(self): 70 | self.simpleUserSetup() 71 | self.mfa = mfa_settings.MFA_MODEL.objects.create(user=self.user1) 72 | 73 | @patch("deux.services.mfa_settings.SEND_MFA_TEXT_FUNC") 74 | @patch("deux.services.generate_mfa_code") 75 | def test_sms_challenge(self, generate_mfa_code, text_function): 76 | generate_mfa_code.return_value = "123456" 77 | MultiFactorChallenge(self.mfa, SMS).generate_challenge() 78 | text_function.assert_called_once_with( 79 | mfa_instance=self.mfa, 80 | mfa_code="123456") 81 | 82 | def test_invalid_challenge(self): 83 | fail_tests = ("SMS", "abc", 123) 84 | for test in fail_tests: 85 | with self.assertRaises(AssertionError): 86 | MultiFactorChallenge(self.mfa, test) 87 | -------------------------------------------------------------------------------- /extra/appveyor/install.ps1: -------------------------------------------------------------------------------- 1 | # Sample script to install Python and pip under Windows 2 | # Authors: Olivier Grisel and Kyle Kastner 3 | # License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ 4 | 5 | $BASE_URL = "https://www.python.org/ftp/python/" 6 | $GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" 7 | $GET_PIP_PATH = "C:\get-pip.py" 8 | 9 | 10 | function DownloadPython ($python_version, $platform_suffix) { 11 | $webclient = New-Object System.Net.WebClient 12 | $filename = "python-" + $python_version + $platform_suffix + ".msi" 13 | $url = $BASE_URL + $python_version + "/" + $filename 14 | 15 | $basedir = $pwd.Path + "\" 16 | $filepath = $basedir + $filename 17 | if (Test-Path $filename) { 18 | Write-Host "Reusing" $filepath 19 | return $filepath 20 | } 21 | 22 | # Download and retry up to 5 times in case of network transient errors. 23 | Write-Host "Downloading" $filename "from" $url 24 | $retry_attempts = 3 25 | for($i=0; $i -lt $retry_attempts; $i++){ 26 | try { 27 | $webclient.DownloadFile($url, $filepath) 28 | break 29 | } 30 | Catch [Exception]{ 31 | Start-Sleep 1 32 | } 33 | } 34 | Write-Host "File saved at" $filepath 35 | return $filepath 36 | } 37 | 38 | 39 | function InstallPython ($python_version, $architecture, $python_home) { 40 | Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 41 | if (Test-Path $python_home) { 42 | Write-Host $python_home "already exists, skipping." 43 | return $false 44 | } 45 | if ($architecture -eq "32") { 46 | $platform_suffix = "" 47 | } else { 48 | $platform_suffix = ".amd64" 49 | } 50 | $filepath = DownloadPython $python_version $platform_suffix 51 | Write-Host "Installing" $filepath "to" $python_home 52 | $args = "/qn /i $filepath TARGETDIR=$python_home" 53 | Write-Host "msiexec.exe" $args 54 | Start-Process -FilePath "msiexec.exe" -ArgumentList $args -Wait -Passthru 55 | Write-Host "Python $python_version ($architecture) installation complete" 56 | return $true 57 | } 58 | 59 | 60 | function InstallPip ($python_home) { 61 | $pip_path = $python_home + "/Scripts/pip.exe" 62 | $python_path = $python_home + "/python.exe" 63 | if (-not(Test-Path $pip_path)) { 64 | Write-Host "Installing pip..." 65 | $webclient = New-Object System.Net.WebClient 66 | $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) 67 | Write-Host "Executing:" $python_path $GET_PIP_PATH 68 | Start-Process -FilePath "$python_path" -ArgumentList "$GET_PIP_PATH" -Wait -Passthru 69 | } else { 70 | Write-Host "pip already installed." 71 | } 72 | } 73 | 74 | function InstallPackage ($python_home, $pkg) { 75 | $pip_path = $python_home + "/Scripts/pip.exe" 76 | & $pip_path install $pkg 77 | } 78 | 79 | function main () { 80 | InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON 81 | InstallPip $env:PYTHON 82 | InstallPackage $env:PYTHON wheel 83 | } 84 | 85 | main 86 | -------------------------------------------------------------------------------- /deux/authtoken/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.utils.encoding import force_text 4 | from rest_framework import serializers 5 | from rest_framework.authtoken.serializers import AuthTokenSerializer 6 | 7 | from deux import strings 8 | from deux.services import MultiFactorChallenge, verify_mfa_code 9 | 10 | 11 | class MFAAuthTokenSerializer(AuthTokenSerializer): 12 | """ 13 | class::MFAAuthTokenSerializer() 14 | 15 | This extends the ``AuthTokenSerializer`` to support multifactor 16 | authentication. 17 | """ 18 | 19 | #: Serializer field for MFA code field. 20 | mfa_code = serializers.CharField(required=False) 21 | 22 | #: Serializer field for Backup code. 23 | backup_code = serializers.CharField(required=False) 24 | 25 | def validate(self, attrs): 26 | """ 27 | Extends the AuthTokenSerializer validate method to implement multi 28 | factor authentication. 29 | 30 | If MFA is disabled, authentication requires just a username and 31 | password. 32 | 33 | If MFA is enabled, authentication requires a username, password, 34 | and either a MFA code or a backup code. If the request only provides 35 | the username and password, the server will generate an appropriate 36 | challenge and respond with `mfa_required = True`. 37 | 38 | Upon using a backup code to authenticate, MFA will be disabled. 39 | 40 | :param attrs: Dictionary of data inputted by the user. 41 | :raises serializers.ValidationError: If invalid MFA code or backup code 42 | are submitted. Also if both types of code are submitted 43 | simultaneously. 44 | """ 45 | attrs = super(MFAAuthTokenSerializer, self).validate(attrs) 46 | # User must exist if super method didn't throw error. 47 | user = attrs["user"] 48 | assert user is not None, "User should exist after super call." 49 | 50 | mfa = getattr(user, "multi_factor_auth", None) 51 | 52 | if mfa and mfa.enabled: 53 | mfa_code = attrs.get("mfa_code") 54 | backup_code = attrs.get("backup_code") 55 | 56 | if mfa_code and backup_code: 57 | raise serializers.ValidationError( 58 | force_text(strings.BOTH_CODES_ERROR)) 59 | elif mfa_code: 60 | bin_key = mfa.get_bin_key(mfa.challenge_type) 61 | if not verify_mfa_code(bin_key, mfa_code): 62 | raise serializers.ValidationError( 63 | force_text(strings.INVALID_MFA_CODE_ERROR)) 64 | elif backup_code: 65 | if not mfa.check_and_use_backup_code(backup_code): 66 | raise serializers.ValidationError( 67 | force_text(strings.INVALID_BACKUP_CODE_ERROR)) 68 | else: 69 | challenge = MultiFactorChallenge(mfa, mfa.challenge_type) 70 | challenge.generate_challenge() 71 | attrs["mfa_required"] = True 72 | attrs["mfa_type"] = mfa.challenge_type 73 | return attrs 74 | -------------------------------------------------------------------------------- /deux/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from rest_framework import generics 4 | from rest_framework.exceptions import ValidationError 5 | from rest_framework.permissions import IsAuthenticated 6 | 7 | from deux import strings 8 | from deux.app_settings import mfa_settings 9 | from deux.constants import SMS 10 | from deux.serializers import ( 11 | BackupCodeSerializer, 12 | MultiFactorAuthSerializer, 13 | SMSChallengeRequestSerializer, 14 | SMSChallengeVerifySerializer, 15 | ) 16 | 17 | 18 | class MultiFactorAuthMixin(object): 19 | """ 20 | class::MultiFactorAuthMixin() 21 | 22 | Mixin that defines queries for MFA objects. 23 | """ 24 | 25 | def get_object(self): 26 | """Gets the current user's MFA instance""" 27 | instance, created = mfa_settings.MFA_MODEL.objects.get_or_create( 28 | user=self.request.user) 29 | return instance 30 | 31 | 32 | class MultiFactorAuthDetail( 33 | MultiFactorAuthMixin, generics.RetrieveDestroyAPIView): 34 | """ 35 | class::MultiFactorAuthDetail() 36 | 37 | View for requesting data about MultiFactorAuth and disabling MFA. 38 | """ 39 | permission_classes = (IsAuthenticated,) 40 | serializer_class = MultiFactorAuthSerializer 41 | 42 | def perform_destroy(self, instance): 43 | """ 44 | The delete method should disable MFA for this user. 45 | 46 | :raises rest_framework.exceptions.ValidationError: If MFA is not 47 | enabled. 48 | """ 49 | if not instance.enabled: 50 | raise ValidationError({ 51 | "detail": strings.DISABLED_ERROR 52 | }) 53 | instance.disable() 54 | 55 | 56 | class _BaseChallengeView(MultiFactorAuthMixin, generics.UpdateAPIView): 57 | """ 58 | class::_BaseChallengeView() 59 | 60 | Base view for different challenges. 61 | """ 62 | permission_classes = (IsAuthenticated,) 63 | 64 | @property 65 | def challenge_type(self): 66 | """ 67 | Represents the challenge type this serializer represents. 68 | 69 | :raises NotImplemented: If the extending class does not define 70 | ``challenge_type``. 71 | """ 72 | raise NotImplemented # pragma: no cover 73 | 74 | 75 | class SMSChallengeRequestDetail(_BaseChallengeView): 76 | """ 77 | class::SMSChallengeRequestDetail() 78 | 79 | View for requesting SMS challenges to enable MFA through SMS. 80 | """ 81 | challenge_type = SMS 82 | serializer_class = SMSChallengeRequestSerializer 83 | 84 | 85 | class SMSChallengeVerifyDetail(_BaseChallengeView): 86 | """ 87 | class::SMSChallengeVerifyDetail() 88 | 89 | View for verify SMS challenges to enable MFA through SMS. 90 | """ 91 | challenge_type = SMS 92 | serializer_class = SMSChallengeVerifySerializer 93 | 94 | 95 | class BackupCodeDetail(MultiFactorAuthMixin, generics.RetrieveAPIView): 96 | """ 97 | class::BackupCodeDetail() 98 | 99 | View for retrieving the user's backup code. 100 | """ 101 | permission_classes = (IsAuthenticated,) 102 | serializer_class = BackupCodeSerializer 103 | -------------------------------------------------------------------------------- /deux/tests/test_notifications.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from mock import Mock, patch 4 | from twilio.rest.exceptions import TwilioRestException 5 | 6 | from deux.app_settings import mfa_settings 7 | from deux.exceptions import InvalidPhoneNumberError, TwilioMessageError 8 | from deux.notifications import send_mfa_code_text_message 9 | 10 | from .test_base import BaseUserTestCase 11 | 12 | 13 | class SendMFACodeTextMessageTests(BaseUserTestCase): 14 | 15 | def setUp(self): 16 | self.simpleUserSetup() 17 | self.mfa = mfa_settings.MFA_MODEL.objects.create(user=self.user1) 18 | self.mfa.phone_number = "1234567890" 19 | self.mfa.save() 20 | self.code = "123456" 21 | 22 | @patch("deux.notifications.TwilioRestClient") 23 | @patch("deux.notifications.mfa_settings") 24 | def test_success(self, mfa_settings, twilio_client): 25 | mfa_settings.TWILIO_ACCOUNT_SID = "sid" 26 | mfa_settings.TWILIO_AUTH_TOKEN = "authtoken" 27 | mfa_settings.TWILIO_SMS_POOL_SID = "0987654321" 28 | 29 | twilio_client_instance = Mock() 30 | twilio_client.return_value = twilio_client_instance 31 | send_mfa_code_text_message(mfa_instance=self.mfa, mfa_code=self.code) 32 | twilio_client_instance.messages.create.assert_called_once_with( 33 | body="Two Factor Authentication Code: 123456", 34 | to="1234567890", 35 | from_="0987654321" 36 | ) 37 | 38 | @patch("deux.notifications.TwilioRestClient") 39 | @patch("deux.notifications.mfa_settings") 40 | def test_invalid_number(self, mfa_settings, twilio_client): 41 | mfa_settings.TWILIO_ACCOUNT_SID = "sid" 42 | mfa_settings.TWILIO_AUTH_TOKEN = "authtoken" 43 | mfa_settings.TWILIO_SMS_POOL_SID = "0987654321" 44 | 45 | twilio_client_instance = Mock() 46 | twilio_client_instance.messages.create.side_effect = ( 47 | TwilioRestException(400, "abc", code=21401)) 48 | twilio_client.return_value = twilio_client_instance 49 | 50 | with self.assertRaises(InvalidPhoneNumberError): 51 | send_mfa_code_text_message( 52 | mfa_instance=self.mfa, mfa_code=self.code) 53 | 54 | @patch("deux.notifications.TwilioRestClient") 55 | @patch("deux.notifications.mfa_settings") 56 | def test_failed_sms_error(self, mfa_settings, twilio_client): 57 | mfa_settings.TWILIO_ACCOUNT_SID = "sid" 58 | mfa_settings.TWILIO_AUTH_TOKEN = "authtoken" 59 | mfa_settings.TWILIO_SMS_POOL_SID = "0987654321" 60 | 61 | twilio_client_instance = Mock() 62 | twilio_client_instance.messages.create.side_effect = ( 63 | TwilioRestException(400, "abc")) 64 | twilio_client.return_value = twilio_client_instance 65 | 66 | with self.assertRaises(TwilioMessageError): 67 | send_mfa_code_text_message( 68 | mfa_instance=self.mfa, mfa_code=self.code) 69 | 70 | @patch("deux.notifications.TwilioRestClient") 71 | def test_no_twilio_credentials(self, twilio_client): 72 | twilio_client_instance = Mock() 73 | twilio_client.return_value = twilio_client_instance 74 | send_mfa_code_text_message(mfa_instance=self.mfa, mfa_code=self.code) 75 | twilio_client_instance.messages.create.assert_not_called() 76 | -------------------------------------------------------------------------------- /deux/services.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | from uuid import uuid4 5 | 6 | from django.utils.crypto import constant_time_compare 7 | from django_otp.oath import totp 8 | 9 | from deux.app_settings import mfa_settings 10 | from deux.constants import CHALLENGE_TYPES, SMS 11 | 12 | 13 | def generate_mfa_code(bin_key, drift=0): 14 | """ 15 | Generates an MFA code based on the ``bin_key`` for the current timestamp 16 | offset by the ``drift``. 17 | 18 | :param bin_key: The secret key to be converted into an MFA code 19 | :param drift: Number of time steps to shift the conversion. 20 | """ 21 | return six.text_type(totp( 22 | bin_key, 23 | step=mfa_settings.STEP_SIZE, 24 | digits=mfa_settings.MFA_CODE_NUM_DIGITS, 25 | drift=drift 26 | )).zfill(mfa_settings.MFA_CODE_NUM_DIGITS) 27 | 28 | 29 | def generate_key(): 30 | """Generates a key used for secret keys.""" 31 | return uuid4().hex 32 | 33 | 34 | def verify_mfa_code(bin_key, mfa_code): 35 | """ 36 | Verifies that the inputted ``mfa_code`` is a valid code for the given 37 | secret key. We check the ``mfa_code`` against the current time stamp as 38 | well as one time step before and after. 39 | 40 | :param bin_key: The secret key to verify the MFA code again. 41 | :param mfa_code: The code whose validity this function tests. 42 | """ 43 | if not mfa_code: 44 | return False 45 | try: 46 | mfa_code = int(mfa_code) 47 | except ValueError: 48 | return False 49 | else: 50 | totp_check = lambda drift: int( 51 | generate_mfa_code(bin_key=bin_key, drift=drift)) 52 | return any( 53 | constant_time_compare(totp_check(drift), mfa_code) 54 | for drift in [-1, 0, 1] 55 | ) 56 | 57 | 58 | class MultiFactorChallenge(object): 59 | """ 60 | A class that represents a supported challenge and has the ability to 61 | execute the challenge. 62 | 63 | :param instance: :class:`MultiFactorAuth` instance to use. 64 | :param challenge_type: Challenge type being used for this object. 65 | :raises AssertionError: If ``challenge_type`` is not a supported 66 | challenge type. 67 | """ 68 | 69 | def __init__(self, instance, challenge_type): 70 | assert challenge_type in CHALLENGE_TYPES, ( 71 | "Inputted challenge type is not supported." 72 | ) 73 | self.instance = instance 74 | self.challenge_type = challenge_type 75 | 76 | def generate_challenge(self): 77 | """ 78 | Generates and executes the challenge object based on the challenge 79 | type of this object. 80 | """ 81 | dispatch = { 82 | SMS: self._sms_challenge 83 | } 84 | for challenge in CHALLENGE_TYPES: 85 | assert challenge in dispatch, ( 86 | "'{challenge}' does not have a challenge dispatch " 87 | "method.".format(challenge=challenge) 88 | ) 89 | return dispatch[self.challenge_type]() 90 | 91 | def _sms_challenge(self): 92 | """Executes the SMS challenge.""" 93 | code = generate_mfa_code(bin_key=self.instance.sms_bin_key) 94 | mfa_settings.SEND_MFA_TEXT_FUNC( 95 | mfa_instance=self.instance, mfa_code=code) 96 | -------------------------------------------------------------------------------- /deux/tests/test_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.contrib.auth.models import User 4 | from rest_framework.test import APITestCase 5 | 6 | 7 | class BaseUserTestCase(APITestCase): 8 | 9 | def simpleUserSetup(self): 10 | self.password1 = "password_1" 11 | self.user1 = User.objects.create_user( 12 | username="test1", 13 | email="test1@example.com", 14 | password=self.password1, 15 | first_name="Test", 16 | last_name="One") 17 | 18 | self.password2 = "password_2" 19 | self.user2 = User.objects.create_user( 20 | username="test2", 21 | email="test2@example.com", 22 | password=self.password2, 23 | first_name="Test", 24 | last_name="Two") 25 | 26 | def check_get_response( 27 | self, url, expected_status_code, data=None, user=None): 28 | self.client.force_authenticate(user) 29 | response = self.client.get(url, data=data) 30 | self.assertEqual(response.status_code, expected_status_code) 31 | return response 32 | 33 | def check_post_response( 34 | self, url, expected_status_code, data=None, user=None, 35 | format="json", headers=None): 36 | headers = headers or {} 37 | self.client.force_authenticate(user) 38 | response = self.client.post(url, data=data, format=format, **headers) 39 | self.assertEqual(response.status_code, expected_status_code) 40 | return response 41 | 42 | def check_post_response_with_url_encoded( 43 | self, url, expected_status_code, data=None, user=None, 44 | headers=None): 45 | headers = headers or {} 46 | self.client.force_authenticate(user) 47 | response = self.client.post( 48 | url, data=data, content_type='application/x-www-form-urlencoded', 49 | **headers) 50 | self.assertEqual(response.status_code, expected_status_code) 51 | return response 52 | 53 | def check_put_response( 54 | self, url, expected_status_code, data=None, user=None, 55 | format="json"): 56 | self.client.force_authenticate(user) 57 | response = self.client.put(url, data=data, format=format) 58 | self.assertEqual(response.status_code, expected_status_code) 59 | return response 60 | 61 | def check_patch_response( 62 | self, url, expected_status_code, data=None, user=None, 63 | format="json"): 64 | self.client.force_authenticate(user) 65 | response = self.client.patch(url, data=data, format=format) 66 | self.assertEqual(response.status_code, expected_status_code) 67 | return response 68 | 69 | def check_delete_response( 70 | self, url, expected_status_code, user=None): 71 | self.client.force_authenticate(user) 72 | response = self.client.delete(url) 73 | self.assertEqual(response.status_code, expected_status_code) 74 | return response 75 | 76 | def check_options_response( 77 | self, url, expected_status_code, data=None, user=None, 78 | format="json"): 79 | self.client.force_authenticate(user) 80 | response = self.client.options(url, data=data, format=format) 81 | self.assertEqual(response.status_code, expected_status_code) 82 | return response 83 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://deux.readthedocs.io/en/latest/_images/deux_banner.png 2 | :align: center 3 | :width: 721 4 | :height: 250 5 | 6 | |build-status| |codecov| |license| |wheel| |pyversion| |pyimp| 7 | 8 | :Version: 1.2.0 9 | :Web: https://deux.readthedocs.org/ 10 | :Download: https://pypi.python.org/pypi/deux 11 | :Source: https://github.com/robinhood/deux 12 | :Keywords: authentication, two-factor, multifactor 13 | 14 | About 15 | ===== 16 | 17 | Multifactor Authentication provides multifactor authentication integration for 18 | the Django Rest Framework. It integrates with Token Authentication built into 19 | DRF and OAuth2 provided by django-oauth-toolkit_. 20 | 21 | What is Multifactor Authentication? 22 | ==================================== 23 | 24 | Multifactor Authentication (MFA) is a security system that requires more than 25 | one method of authentication from independent categories of credentials to 26 | verify the user's identity for a login or other transaction. 27 | (Source: SearchSecurity_) 28 | 29 | .. _django-oauth-toolkit: https://django-oauth-toolkit.readthedocs.io/ 30 | .. _SearchSecurity: http://searchsecurity.techtarget.com/definition/multifactor-authentication-MFA 31 | 32 | .. _installation: 33 | 34 | Installation 35 | ============ 36 | 37 | You can install deux either via the Python Package Index (PyPI) 38 | or from source. 39 | 40 | Requirements 41 | ------------ 42 | 43 | ``deux`` version 1.2.0 runs on Python (2.7, 3.4, 3.5). 44 | 45 | Installing with pip 46 | ------------------- 47 | 48 | To install using `pip`: 49 | :: 50 | 51 | $ pip install -U deux 52 | 53 | .. _installing-from-source: 54 | 55 | Downloading and installing from source 56 | -------------------------------------- 57 | 58 | Download the latest version of deux from 59 | http://pypi.python.org/pypi/deux 60 | 61 | You can install it by doing the following: 62 | :: 63 | 64 | $ tar xvfz deux-0.0.0.tar.gz 65 | $ cd deux-0.0.0 66 | $ python setup.py build 67 | # python setup.py install 68 | 69 | The last command must be executed as a privileged user if 70 | you are not currently using a virtualenv. 71 | 72 | .. _installing-from-git: 73 | 74 | Using the development version 75 | ----------------------------- 76 | 77 | With pip 78 | ~~~~~~~~ 79 | 80 | You can install it by doing the following: 81 | :: 82 | 83 | $ pip install https://github.com/robinhood/deux/zipball/master#egg=deux 84 | 85 | .. |build-status| image:: https://travis-ci.org/robinhood/deux.svg?branch=master 86 | :alt: Build status 87 | :target: https://travis-ci.org/robinhood/deux 88 | 89 | .. |license| image:: https://img.shields.io/pypi/l/deux.svg 90 | :alt: BSD License 91 | :target: https://opensource.org/licenses/BSD-3-Clause 92 | 93 | .. |wheel| image:: https://img.shields.io/pypi/wheel/deux.svg 94 | :alt: Deux can be installed via wheel 95 | :target: https://pypi.python.org/pypi/deux/ 96 | 97 | .. |pyversion| image:: https://img.shields.io/pypi/pyversions/deux.svg 98 | :alt: Supported Python versions. 99 | :target: https://pypi.python.org/pypi/deux/ 100 | 101 | .. |pyimp| image:: https://img.shields.io/pypi/implementation/deux.svg 102 | :alt: Support Python implementations. 103 | :target: https://pypi.python.org/pypi/deux/ 104 | 105 | .. |codecov| image:: https://codecov.io/gh/robinhood/deux/branch/master/graph/badge.svg 106 | :alt: Code Coverage 107 | :target: https://codecov.io/gh/robinhood/deux 108 | 109 | -------------------------------------------------------------------------------- /deux/oauth2/validators.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from oauth2_provider.oauth2_validators import OAuth2Validator 4 | 5 | from django.contrib.auth import authenticate 6 | from django.utils.encoding import force_text 7 | 8 | from deux import strings 9 | from deux.oauth2.exceptions import ( 10 | ChallengeRequiredMessage, 11 | InvalidLoginError, 12 | ) 13 | from deux.services import MultiFactorChallenge, verify_mfa_code 14 | 15 | 16 | class MFAOAuth2Validator(OAuth2Validator): 17 | """ 18 | class::MFAOAuth2Validator() 19 | 20 | OAuth2 validator class for MFA that validates requests to authenticate 21 | with username and password by also verifying that they supply the correct 22 | MFA code or backup code if multifactor authentication is enabled. 23 | """ 24 | 25 | def validate_user( 26 | self, username, password, client, request, *args, **kwargs): 27 | """ 28 | Overrides the OAuth2Validator validate method to implement multi factor 29 | authentication. 30 | 31 | If MFA is disabled, authentication requires just a username and 32 | password. 33 | 34 | If MFA is enabled, authentication requires a username, password, 35 | and either a MFA code or a backup code. If the request only provides 36 | the username and password, the server will generate an appropriate 37 | challenge and respond with `mfa_required = True`. 38 | 39 | Upon using a backup code to authenticate, MFA will be disabled. 40 | 41 | :param attrs: Dictionary of data inputted by the user. 42 | :raises deux.oauth2.exceptions.InvalidLoginError: If invalid MFA 43 | code or backup code are submitted. Also if both types of code are 44 | submitted simultaneously. 45 | :raises deux.oauth2.exceptions.ChallengeRequiredMessage: If the user 46 | has MFA enabled but only supplies the correct username and 47 | password. This exception will prompt the OAuth2 system to send a 48 | response asking the user to supply an MFA code. 49 | """ 50 | 51 | user = authenticate(username=username, password=password) 52 | if not (user and user.is_active): 53 | raise InvalidLoginError(force_text( 54 | strings.INVALID_CREDENTIALS_ERROR)) 55 | 56 | mfa = None 57 | if hasattr(user, "multi_factor_auth"): 58 | mfa = user.multi_factor_auth 59 | 60 | if mfa and mfa.enabled: 61 | mfa_code = request.extra_credentials.get("mfa_code") 62 | backup_code = request.extra_credentials.get("backup_code") 63 | 64 | if mfa_code and backup_code: 65 | raise InvalidLoginError(force_text(strings.BOTH_CODES_ERROR)) 66 | elif mfa_code: 67 | bin_key = mfa.get_bin_key(mfa.challenge_type) 68 | if not verify_mfa_code(bin_key, mfa_code): 69 | raise InvalidLoginError(force_text( 70 | strings.INVALID_MFA_CODE_ERROR)) 71 | elif backup_code: 72 | if not mfa.check_and_use_backup_code(backup_code): 73 | raise InvalidLoginError(force_text( 74 | strings.INVALID_BACKUP_CODE_ERROR)) 75 | else: 76 | challenge = MultiFactorChallenge(mfa, mfa.challenge_type) 77 | challenge.generate_challenge() 78 | raise ChallengeRequiredMessage(mfa.challenge_type) 79 | request.user = user 80 | return True 81 | -------------------------------------------------------------------------------- /test_proj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_proj project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | from __future__ import absolute_import, unicode_literals 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = '%wy$&^3mi6oussc2xd!^i5-#@^%dvdv6z2@3l$=110@4_xo0r7' 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | SITE_ID = 1 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'rest_framework', 41 | 'rest_framework.authtoken', 42 | 'oauth2_provider', 43 | 'deux', 44 | ] 45 | 46 | MIDDLEWARE_CLASSES = [ 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'test_proj.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | LOCALE_PATHS = [os.path.join(BASE_DIR, "deux", "locale")] 73 | 74 | WSGI_APPLICATION = 'test_proj.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.sqlite3', 83 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 84 | } 85 | } 86 | 87 | 88 | # Internationalization 89 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 90 | 91 | LANGUAGE_CODE = 'en-us' 92 | 93 | TIME_ZONE = 'UTC' 94 | 95 | USE_I18N = True 96 | 97 | USE_L10N = True 98 | 99 | USE_TZ = True 100 | 101 | 102 | # Static files (CSS, JavaScript, Images) 103 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 104 | 105 | STATIC_URL = '/static/' 106 | 107 | 108 | PASSWORD_HASHERS = ( 109 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 110 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 111 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 112 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 113 | 'django.contrib.auth.hashers.MD5PasswordHasher', 114 | 'django.contrib.auth.hashers.CryptPasswordHasher', 115 | ) 116 | 117 | 118 | DEUX = { 119 | "TWILIO_ACCOUNT_SID": "", 120 | "TWILIO_AUTH_TOKEN": "", 121 | "TWILIO_SMS_POOL_SID": "", 122 | } 123 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJ=deux 2 | PYTHON=python 3 | GIT=git 4 | TOX=tox 5 | NOSETESTS=nosetests 6 | ICONV=iconv 7 | FLAKE8=flake8 8 | FLAKEPLUS=flakeplus 9 | SPHINX2RST=sphinx2rst 10 | 11 | SPHINX_DIR=docs/ 12 | SPHINX_BUILDDIR="${SPHINX_DIR}/_build" 13 | README=README.rst 14 | README_SRC="docs/templates/readme.txt" 15 | CONTRIBUTING=CONTRIBUTING.rst 16 | CONTRIBUTING_SRC="docs/contributing.rst" 17 | SPHINX_HTMLDIR="${SPHINX_BUILDDIR}/html" 18 | DOCUMENTATION=Documentation 19 | FLAKEPLUSTARGET=2.7 20 | 21 | all: help 22 | 23 | help: 24 | @echo "docs - Build documentation." 25 | @echo "test-all - Run tests for all supported python versions." 26 | @echo "distcheck ---------- - Check distribution for problems." 27 | @echo " test - Run unittests using current python." 28 | @echo " lint ------------ - Check codebase for problems." 29 | @echo " apicheck - Check API reference coverage." 30 | @echo " configcheck - Check configuration reference coverage." 31 | @echo " readmecheck - Check README.rst encoding." 32 | @echo " contribcheck - Check CONTRIBUTING.rst encoding" 33 | @echo " flakes -------- - Check code for syntax and style errors." 34 | @echo " flakecheck - Run flake8 on the source code." 35 | @echo " flakepluscheck - Run flakeplus on the source code." 36 | @echo "readme - Regenerate README.rst file." 37 | @echo "contrib - Regenerate CONTRIBUTING.rst file" 38 | @echo "clean-dist --------- - Clean all distribution build artifacts." 39 | @echo " clean-git-force - Remove all uncomitted files." 40 | @echo " clean ------------ - Non-destructive clean" 41 | @echo " clean-pyc - Remove .pyc/__pycache__ files" 42 | @echo " clean-docs - Remove documentation build artifacts." 43 | @echo " clean-build - Remove setup artifacts." 44 | 45 | clean: clean-docs clean-pyc clean-build 46 | 47 | clean-dist: clean clean-git-force 48 | 49 | Documentation: 50 | (cd "$(SPHINX_DIR)"; $(MAKE) html) 51 | mv "$(SPHINX_HTMLDIR)" $(DOCUMENTATION) 52 | 53 | docs: Documentation 54 | 55 | clean-docs: 56 | -rm -rf "$(SPHINX_BUILDDIR)" 57 | 58 | lint: flakecheck apicheck configcheck readmecheck 59 | 60 | apicheck: 61 | (cd "$(SPHINX_DIR)"; $(MAKE) apicheck) 62 | 63 | configcheck: 64 | (cd "$(SPHINX_DIR)"; $(MAKE) configcheck) 65 | 66 | flakecheck: 67 | $(FLAKE8) "$(PROJ)" 68 | 69 | flakediag: 70 | -$(MAKE) flakecheck 71 | 72 | flakepluscheck: 73 | $(FLAKEPLUS) --$(FLAKEPLUSTARGET) "$(PROJ)" 74 | 75 | flakeplusdiag: 76 | -$(MAKE) flakepluscheck 77 | 78 | flakes: flakediag flakeplusdiag 79 | 80 | clean-readme: 81 | -rm -f $(README) 82 | 83 | readmecheck: 84 | $(ICONV) -f ascii -t ascii $(README) >/dev/null 85 | 86 | $(README): 87 | $(SPHINX2RST) "$(README_SRC)" --ascii > $@ 88 | 89 | readme: clean-readme $(README) readmecheck 90 | 91 | clean-contrib: 92 | -rm -f "$(CONTRIBUTING)" 93 | 94 | $(CONTRIBUTING): 95 | $(SPHINX2RST) "$(CONTRIBUTING_SRC)" > $@ 96 | 97 | contrib: clean-contrib $(CONTRIBUTING) 98 | 99 | clean-pyc: 100 | -find . -type f -a \( -name "*.pyc" -o -name "*$$py.class" \) | xargs rm 101 | -find . -type d -name "__pycache__" | xargs rm -r 102 | 103 | removepyc: clean-pyc 104 | 105 | clean-build: 106 | rm -rf build/ dist/ .eggs/ *.egg-info/ .tox/ .coverage cover/ 107 | 108 | clean-git: 109 | $(GIT) clean -xdn 110 | 111 | clean-git-force: 112 | $(GIT) clean -xdf 113 | 114 | test-all: clean-pyc 115 | $(TOX) 116 | 117 | test: 118 | $(PYTHON) setup.py test 119 | 120 | cov: 121 | mv test_proj/manage.py ./ 122 | coverage run ./manage.py test -x 123 | coverage report 124 | mv manage.py test_proj/ 125 | 126 | build: 127 | $(PYTHON) setup.py sdist bdist_wheel 128 | 129 | distcheck: lint test clean 130 | 131 | dist: readme contrib clean-dist build 132 | -------------------------------------------------------------------------------- /docs/userguide/extending.rst: -------------------------------------------------------------------------------- 1 | .. _custom-guide: 2 | 3 | ============================================================================= 4 | Extending 5 | ============================================================================= 6 | 7 | Notifications 8 | ============= 9 | 10 | The send SMS function can be directly overridden by a custom function. You can 11 | configure the function in your ``SEND_MFA_TEXT_FUNC`` setting. 12 | 13 | Your SMS function should throw :class:`deux.exceptions.FailedChallengeError` for any errors to be caught by this library's functions. 14 | 15 | Your function can look something like this: 16 | 17 | .. code-block:: python 18 | 19 | def custom_send_function(mfa_instance, mfa_code): 20 | ... 21 | 22 | To use the function, in your ``settings.py``: 23 | 24 | .. code-block:: python 25 | 26 | DEUX = { 27 | ... 28 | "SEND_MFA_TEXT_FUNC": ".custom_send_function", 29 | } 30 | 31 | 32 | Models 33 | ====== 34 | 35 | You can write your own custom model that extends :class:`deux.abstract_models.AbstractMultiFactorAuth` and configure the model in your ``MFA_MODEL`` setting. 36 | 37 | Your model can look something like this: 38 | 39 | .. code-block:: python 40 | 41 | class CustomMultiFactorAuth(AbstractMultiFactorAuth): 42 | ... 43 | 44 | To use the function, in your ``settings.py``: 45 | 46 | .. code-block:: python 47 | 48 | DEUX = { 49 | ... 50 | "MFA_MODEL": ".CustomMultiFactorAuth", 51 | } 52 | 53 | 54 | Authentication Protocols 55 | ======================== 56 | 57 | Currently, the package supports ``authtoken`` and ``oauth2``. You can easily 58 | extend the package to support your authentication protocol of choice. 59 | 60 | Create a new sub-directory under the main application for your authentication and 61 | create a new login endpoint that follows the same two factor login protocol as 62 | the rest of the package. 63 | 64 | Register your new endpoint in the ``test_proj/urls.py`` file like this: 65 | 66 | .. code-block:: python 67 | 68 | url(r"^mfa//", 69 | include("deux..urls", namespace=""), 70 | ), 71 | 72 | Look at :class:`deux.authtoken` or :class:`deux.oauth2` for examples. 73 | 74 | 75 | Challenge Methods 76 | ================= 77 | 78 | Currently, the package supports two factor over text message. However, it is easy to add your own challenge method for two factor (i.e. Google Authenticator or email). 79 | 80 | Create a new challenge type in :class:`deux.constants`. 81 | 82 | .. code-block:: python 83 | 84 | YOUR_CHALLENGE_METHOD = "" 85 | 86 | CHALLENGE_TYPES = (SMS, YOUR_CHALLENGE_METHOD) 87 | 88 | 89 | Then, add a new challenge method to the :class:`deux.services.MultiFactorChallenge` class. 90 | 91 | .. code-block:: python 92 | 93 | class MultiFactorChallenge(object): 94 | ... 95 | 96 | def generate_challenge(self): 97 | """ 98 | Generates and executes the challenge object based on the challenge 99 | type of this object. 100 | """ 101 | dispatch = { 102 | SMS: self._sms_challenge, 103 | YOUR_CHALLENGE_METHOD: self._your_challenge_method, 104 | } 105 | 106 | ... 107 | 108 | def _your_challenge_method(self): 109 | """Executes your challenge method.""" 110 | ... 111 | 112 | 113 | Then, add the necessary endpoints around requesting and verifying Two Factor with this challenge method. 114 | 115 | .. code-block:: python 116 | 117 | url(r"^your_challenge_method/request/$", 118 | views.YourChallengeMethodRequestDetail.as_view(), 119 | name="your_challenge_method_request-detail" 120 | ), 121 | url(r"^your_challenge_method/verify/$", 122 | views.YourChallengeMethodVerifyDetail.as_view(), 123 | name="your_challenge_method_verify-detail" 124 | ), 125 | url(r"^sms/verify/$", views.SMSChallengeVerifyDetail.as_view(), 126 | name="sms_verify-detail"), 127 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import Command, setup, find_packages 4 | from setuptools.command.test import test 5 | 6 | import os 7 | import re 8 | import sys 9 | import codecs 10 | 11 | try: 12 | import platform 13 | _pyimp = platform.python_implementation 14 | except (AttributeError, ImportError): 15 | def _pyimp(): 16 | return 'Python' 17 | 18 | NAME = 'deux' 19 | 20 | E_UNSUPPORTED_PYTHON = '%s 1.2.0 requires %%s %%s or later!' % (NAME,) 21 | 22 | PYIMP = _pyimp() 23 | PY26_OR_LESS = sys.version_info < (2, 7) 24 | PY3 = sys.version_info[0] == 3 25 | PY33_OR_LESS = PY3 and sys.version_info < (3, 4) 26 | PYPY_VERSION = getattr(sys, 'pypy_version_info', None) 27 | PYPY = PYPY_VERSION is not None 28 | PYPY24_ATLEAST = PYPY_VERSION and PYPY_VERSION >= (2, 4) 29 | 30 | if PY26_OR_LESS: 31 | raise Exception(E_UNSUPPORTED_PYTHON % (PYIMP, '2.7')) 32 | elif PY33_OR_LESS and not PYPY24_ATLEAST: 33 | raise Exception(E_UNSUPPORTED_PYTHON % (PYIMP, '3.4')) 34 | 35 | # -*- Classifiers -*- 36 | 37 | classes = """ 38 | Development Status :: 4 - Beta 39 | License :: OSI Approved :: BSD License 40 | Programming Language :: Python 41 | Programming Language :: Python :: 2 42 | Programming Language :: Python :: 2.7 43 | Programming Language :: Python :: 3 44 | Programming Language :: Python :: 3.4 45 | Programming Language :: Python :: 3.5 46 | Programming Language :: Python :: Implementation :: CPython 47 | Programming Language :: Python :: Implementation :: PyPy 48 | Framework :: Django 49 | Framework :: Django :: 1.9 50 | Framework :: Django :: 1.10 51 | Operating System :: OS Independent 52 | """ 53 | classifiers = [s.strip() for s in classes.split('\n') if s] 54 | 55 | # -*- Distribution Meta -*- 56 | 57 | re_meta = re.compile(r'__(\w+?)__\s*=\s*(.*)') 58 | re_vers = re.compile(r'VERSION\s*=.*?\((.*?)\)') 59 | re_doc = re.compile(r'^"""(.+?)"""') 60 | 61 | 62 | def rq(s): 63 | return s.strip("\"'") 64 | 65 | 66 | def add_default(m): 67 | attr_name, attr_value = m.groups() 68 | return ((attr_name, rq(attr_value)),) 69 | 70 | 71 | def add_version(m): 72 | v = list(map(rq, m.groups()[0].split(', '))) 73 | return (('VERSION', '.'.join(v[0:3]) + ''.join(v[3:])),) 74 | 75 | 76 | def add_doc(m): 77 | return (('doc', m.groups()[0]),) 78 | 79 | pats = {re_meta: add_default, 80 | re_vers: add_version, 81 | re_doc: add_doc} 82 | here = os.path.abspath(os.path.dirname(__file__)) 83 | with open(os.path.join(here, NAME, '__init__.py')) as meta_fh: 84 | meta = {} 85 | for line in meta_fh: 86 | if line.strip() == '# -eof meta-': 87 | break 88 | for pattern, handler in pats.items(): 89 | m = pattern.match(line.strip()) 90 | if m: 91 | meta.update(handler(m)) 92 | 93 | # -*- Installation Requires -*- 94 | 95 | def strip_comments(l): 96 | return l.split('#', 1)[0].strip() 97 | 98 | 99 | def _pip_requirement(req): 100 | if req.startswith('-r '): 101 | _, path = req.split() 102 | return reqs(*path.split('/')) 103 | return [req] 104 | 105 | 106 | def _reqs(*f): 107 | return [ 108 | _pip_requirement(r) for r in ( 109 | strip_comments(l) for l in open( 110 | os.path.join(os.getcwd(), 'requirements', *f)).readlines() 111 | ) if r] 112 | 113 | 114 | def reqs(*f): 115 | return [req for subreq in _reqs(*f) for req in subreq] 116 | 117 | # -*- Long Description -*- 118 | 119 | if os.path.exists('README.rst'): 120 | long_description = codecs.open('README.rst', 'r', 'utf-8').read() 121 | else: 122 | long_description = 'See http://pypi.python.org/pypi/%s' % (NAME,) 123 | 124 | 125 | setup( 126 | name=NAME, 127 | version=meta['VERSION'], 128 | description=meta['doc'], 129 | author=meta['author'], 130 | author_email=meta['contact'], 131 | url=meta['homepage'], 132 | platforms=['any'], 133 | license='BSD', 134 | packages=find_packages(exclude=['ez_setup', 'tests', 'tests.*']), 135 | include_package_data=False, 136 | zip_safe=False, 137 | install_requires=reqs('default.txt'), 138 | tests_require=reqs('test.txt'), 139 | classifiers=classifiers, 140 | long_description=long_description, 141 | ) 142 | -------------------------------------------------------------------------------- /docs/userguide/django.rst: -------------------------------------------------------------------------------- 1 | .. _django-guide: 2 | 3 | ============================================================================= 4 | DRF Integration 5 | ============================================================================= 6 | 7 | .. _django-installation: 8 | 9 | Setup 10 | ===== 11 | 12 | .. _guide: http://www.django-rest-framework.org/ 13 | .. _INSTALLED_APPS: https://docs.djangoproject.com/en/1.9/ref/settings/#std:setting-INSTALLED_APPS 14 | 15 | To set up ``deux`` for your Django Rest Framework application, follow these steps. For help setting up a DRF project, see guide_ here. 16 | 17 | #. Install deux. 18 | 19 | .. code-block:: console 20 | 21 | $ pip install deux 22 | 23 | #. Add ``deux`` to INSTALLED_APPS_ after ``rest_framework.authtoken`` 24 | and ``oauth2_provider``, depending on which authentication protocol you use. 25 | 26 | .. code-block:: python 27 | 28 | INSTALLED_APPS = ( 29 | # ..., 30 | 'rest_framework.authtoken', 31 | 'oauth2_provider', 32 | # ..., 33 | 'deux', 34 | ) 35 | 36 | #. Migrate your database to add the ``MultiFactorAuth`` model. 37 | 38 | .. code-block:: console 39 | 40 | $ python manage.py migrate 41 | 42 | #. Configure your ``settings.py`` file, as described in :ref:`settings`. 43 | 44 | .. _api: 45 | 46 | Views 47 | ===== 48 | 49 | The library comes with a standard set of views you can add to your 50 | Django Rest Framework API, that allows your users to enable/disable 51 | multifactor authentication. 52 | 53 | To enable them, add the following configuration to your file :file:`urls.py`: 54 | 55 | .. code-block:: python 56 | 57 | url(r"^mfa/", include("deux.urls", namespace="mfa")), 58 | 59 | The library also provides views for authenticating through multifactor 60 | authentication depending on your authentication protocol. 61 | 62 | #. For ``authtoken``, add the following to :file:`urls.py`: 63 | 64 | .. code-block:: python 65 | 66 | url(r"^mfa/authtoken/", include( 67 | "deux.authtoken.urls", namespace="mfa-authtoken:login")), 68 | 69 | #. For ``oauth2``, add the following to :file:`urls.py`: 70 | 71 | .. code-block:: python 72 | 73 | url(r"^mfa/oauth2/", include( 74 | "deux.oauth2.urls", namespace="mfa-oauth2:login")), 75 | 76 | .. _settings: 77 | 78 | Settings 79 | ======== 80 | 81 | The library takes the following settings object. The default values are as 82 | followed: 83 | 84 | .. code-block:: python 85 | 86 | DEUX = { 87 | "BACKUP_CODE_DIGITS": 12, 88 | "MFA_CODE_NUM_DIGITS": 6, 89 | "STEP_SIZE": 30, 90 | "MFA_MODEL": "deux.models.MultiFactorAuth", 91 | "SEND_MFA_TEXT_FUNC": "deux.notifications.send_mfa_code_text_message", 92 | "TWILIO_ACCOUNT_SID": "", 93 | "TWILIO_AUTH_TOKEN": "", 94 | "TWILIO_PHONE_NUMBER": "", 95 | } 96 | 97 | MFA Optional Settings 98 | --------------------- 99 | 100 | #. ``BACKUP_CODE_DIGITS``: The length of multifactor backup code. 101 | 102 | - **Default**: ``12`` 103 | 104 | #. ``MFA_CODE_NUM_DIGITS``: The length of a multifactor authentication code. 105 | 106 | - **Default**: ``6`` 107 | 108 | #. ``STEP_SIZE``: The length of an authentication window in seconds. 109 | 110 | - **Usage**: An authentication code is valid for 3 windows: the window in which the code is generated, the window before, and the window after. 111 | - **Default**: ``6`` 112 | 113 | #. ``MFA_MODEL``: The model used for multifactor authentication 114 | 115 | - **Default**: ``models.MultiFactorAuth`` 116 | - **Descrtiption**: The default model is a blank extension of 117 | ``abstract_models.AbstractMultiFactorAuth`` 118 | 119 | Twilio Driver Settings 120 | ---------------------- 121 | 122 | #. ``SEND_MFA_TEXT_FUNC``: The function used for sending text messages to users. 123 | 124 | - **Default**: ``deux.notifications.send_mfa_code_text_message`` 125 | 126 | If you use our default Twilio driver, you must also include your Twilio 127 | credentials in the settings object. 128 | 129 | #. ``TWILIO_ACCOUNT_SID``: Your Twilio account's SID. 130 | 131 | #. ``TWILIO_AUTH_TOKEN``: Your Twilio account's authentication token. 132 | 133 | #. ``TWILIO_PHONE_NUMBER``: Your Twilio account's phone number. 134 | -------------------------------------------------------------------------------- /deux/abstract_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from binascii import unhexlify 4 | 5 | from django.conf import settings 6 | from django.db import models 7 | from django.utils.crypto import constant_time_compare 8 | 9 | from deux.app_settings import mfa_settings 10 | from deux.constants import CHALLENGE_TYPES, DISABLED, SMS 11 | from deux.services import generate_key 12 | from deux.validators import phone_number_validator 13 | 14 | 15 | class AbstractMultiFactorAuth(models.Model): 16 | """ 17 | class::AbstractMultiFactorAuth() 18 | 19 | This abstract class holds user information, MFA status, and secret 20 | keys for the user. 21 | """ 22 | 23 | #: Different status options for this MFA object. 24 | CHALLENGE_CHOICES = ( 25 | (SMS, "SMS"), 26 | (DISABLED, "Off"), 27 | ) 28 | 29 | #: User this MFA object represents. 30 | user = models.OneToOneField( 31 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, 32 | related_name="multi_factor_auth", primary_key=True 33 | ) 34 | 35 | #: User's phone number. 36 | phone_number = models.CharField( 37 | max_length=15, default="", blank=True, 38 | validators=[phone_number_validator]) 39 | 40 | #: Challenge type used for MFA. 41 | challenge_type = models.CharField( 42 | max_length=16, default=DISABLED, 43 | blank=True, choices=CHALLENGE_CHOICES 44 | ) 45 | 46 | #: Secret key used for backup code. 47 | backup_key = models.CharField( 48 | max_length=32, default="", blank=True, 49 | help_text="Hex-Encoded Secret Key" 50 | ) 51 | 52 | #: Secret key used for SMS codes. 53 | sms_secret_key = models.CharField( 54 | max_length=32, default=generate_key, 55 | help_text="Hex-Encoded Secret Key" 56 | ) 57 | 58 | @property 59 | def sms_bin_key(self): 60 | """Returns binary data of the SMS secret key.""" 61 | return unhexlify(self.sms_secret_key) 62 | 63 | @property 64 | def enabled(self): 65 | """Returns if MFA is enabled.""" 66 | return self.challenge_type in CHALLENGE_TYPES 67 | 68 | @property 69 | def backup_code(self): 70 | """Returns the users backup code.""" 71 | return self.backup_key.upper()[:mfa_settings.BACKUP_CODE_DIGITS] 72 | 73 | def get_bin_key(self, challenge_type): 74 | """ 75 | Returns the key associated with the inputted challenge type. 76 | 77 | :param challenge_type: The challenge type the key is requested for. 78 | The type must be in the supported 79 | `CHALLENGE_TYPES`. 80 | :raises AssertionError: If ``challenge_type`` is not a supported 81 | challenge type. 82 | """ 83 | assert challenge_type in CHALLENGE_TYPES, ( 84 | "'{challenge}' is not a valid challenge type.".format( 85 | challenge=challenge_type) 86 | ) 87 | return { 88 | SMS: self.sms_bin_key 89 | }.get(challenge_type, None) 90 | 91 | def enable(self, challenge_type): 92 | """ 93 | Enables MFA for this user with the inputted challenge type. 94 | 95 | The enabling process includes setting this objects challenge type and 96 | generating a new backup key. 97 | 98 | :param challenge_type: Enable MFA for this type of challenge. The type 99 | must be in the supported `CHALLENGE_TYPES`. 100 | :raises AssertionError: If ``challenge_type`` is not a supported 101 | challenge type. 102 | """ 103 | assert challenge_type in CHALLENGE_TYPES, ( 104 | "'{challenge}' is not a valid challenge type.".format( 105 | challenge=challenge_type) 106 | ) 107 | self.challenge_type = challenge_type 108 | self.backup_key = generate_key() 109 | self.save() 110 | 111 | def disable(self): 112 | """ 113 | Disables MFA for this user. 114 | 115 | The disabling process includes setting the objects challenge type to 116 | `DISABLED`, and removing the `backup_key` and `phone_number`. 117 | """ 118 | self.challenge_type = DISABLED 119 | self.backup_key = "" 120 | self.phone_number = "" 121 | self.save() 122 | 123 | def refresh_backup_code(self): 124 | """ 125 | Refreshes the users backup key and returns a new backup code. 126 | 127 | This method should be used to request new backup codes for the user. 128 | """ 129 | assert self.enabled, ( 130 | "MFA must be on to run refresh_backup_codes." 131 | ) 132 | self.backup_key = generate_key() 133 | self.save() 134 | return self.backup_code 135 | 136 | def check_and_use_backup_code(self, code): 137 | """ 138 | Checks if the inputted backup code is correct and disables MFA if 139 | the code is correct. 140 | 141 | This method should be used for authenticating with a backup code. Using 142 | a backup code to authenticate disables MFA as a side effect. 143 | """ 144 | backup = self.backup_code 145 | if code and constant_time_compare(code, backup): 146 | self.disable() 147 | return True 148 | return False 149 | 150 | class Meta: 151 | abstract = True 152 | -------------------------------------------------------------------------------- /deux/authtoken/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | from mock import patch 5 | 6 | from deux.app_settings import mfa_settings 7 | from deux.constants import DISABLED, SMS 8 | from deux.authtoken.serializers import MFAAuthTokenSerializer 9 | from deux.services import generate_mfa_code 10 | from deux.tests.test_base import BaseUserTestCase 11 | 12 | 13 | class MFAAuthTokenSerializerTest(BaseUserTestCase): 14 | 15 | def setUp(self): 16 | self.simpleUserSetup() 17 | self.mfa = mfa_settings.MFA_MODEL.objects.create(user=self.user2) 18 | 19 | def test_login_with_no_mfa_object(self): 20 | serializer = MFAAuthTokenSerializer(data={ 21 | "username": self.user1.username, 22 | "password": "incorrect_password", 23 | }) 24 | self.assertFalse(serializer.is_valid()) 25 | 26 | serializer = MFAAuthTokenSerializer(data={ 27 | "username": self.user1.username, 28 | "password": self.password1 29 | }) 30 | self.assertTrue(serializer.is_valid()) 31 | 32 | def test_login_with_disabled_mfa_object(self): 33 | serializer = MFAAuthTokenSerializer(data={ 34 | "username": self.user2.username, 35 | "password": "incorrect_password", 36 | }) 37 | self.assertFalse(serializer.is_valid()) 38 | 39 | serializer = MFAAuthTokenSerializer(data={ 40 | "username": self.user2.username, 41 | "password": self.password2 42 | }) 43 | self.assertTrue(serializer.is_valid()) 44 | 45 | def test_login_fail_with_both_codes(self): 46 | self.mfa.enable(SMS) 47 | self.mfa.refresh_backup_code() 48 | 49 | mfa_code = generate_mfa_code(self.mfa.sms_bin_key) 50 | backup_code = self.mfa.backup_code 51 | 52 | serializer = MFAAuthTokenSerializer(data={ 53 | "username": self.user2.username, 54 | "password": self.password2, 55 | "mfa_code": mfa_code, 56 | "backup_code": backup_code 57 | }) 58 | self.assertFalse(serializer.is_valid()) 59 | 60 | def test_login_with_mfa_code(self): 61 | self.mfa.enable(SMS) 62 | mfa_code = generate_mfa_code(self.mfa.sms_bin_key) 63 | serializer = MFAAuthTokenSerializer(data={ 64 | "username": self.user2.username, 65 | "password": self.password2, 66 | "mfa_code": mfa_code 67 | }) 68 | self.assertTrue(serializer.is_valid()) 69 | 70 | bad_code = six.text_type(int(mfa_code) + 1) 71 | serializer = MFAAuthTokenSerializer(data={ 72 | "username": self.user2.username, 73 | "password": self.password2, 74 | "mfa_code": bad_code 75 | }) 76 | self.assertFalse(serializer.is_valid()) 77 | 78 | def test_login_with_backup_code(self): 79 | self.mfa.enable(SMS) 80 | bad_code = "abcdef123456" 81 | serializer = MFAAuthTokenSerializer(data={ 82 | "username": self.user2.username, 83 | "password": self.password2, 84 | "backup_code": bad_code 85 | }) 86 | self.assertFalse(serializer.is_valid()) 87 | 88 | backup_code = self.mfa.backup_code 89 | serializer = MFAAuthTokenSerializer(data={ 90 | "username": self.user2.username, 91 | "password": self.password2, 92 | "backup_code": backup_code 93 | }) 94 | self.assertTrue(serializer.is_valid(raise_exception=True)) 95 | instance = mfa_settings.MFA_MODEL.objects.get(user=self.user2) 96 | self.assertFalse(instance.enabled) 97 | self.assertEqual(instance.challenge_type, DISABLED) 98 | self.assertEqual(instance.backup_code, "") 99 | 100 | @patch("deux.authtoken.serializers.MultiFactorChallenge") 101 | def test_login_and_continue_with_challenge(self, challenge): 102 | self.mfa.enable(SMS) 103 | self.mfa.phone_number = "1234567890" 104 | self.mfa.save() 105 | serializer = MFAAuthTokenSerializer(data={ 106 | "username": self.user2.username, 107 | "password": "incorrect_password", 108 | }) 109 | self.assertFalse(serializer.is_valid()) 110 | 111 | # With correct username and password, response should require MFA. 112 | serializer = MFAAuthTokenSerializer(data={ 113 | "username": self.user2.username, 114 | "password": self.password2 115 | }) 116 | self.assertTrue(serializer.is_valid()) 117 | data = serializer.validated_data 118 | self.assertTrue(data["mfa_required"]) 119 | self.assertEqual(data["mfa_type"], SMS) 120 | challenge.assert_called_once_with(self.mfa, SMS) 121 | challenge.return_value.generate_challenge.assert_called_once_with() 122 | 123 | def test_login_with_other_users_code(self): 124 | mfa_1 = mfa_settings.MFA_MODEL.objects.create(user=self.user1) 125 | mfa_2 = self.mfa 126 | 127 | mfa_1.enable(SMS) 128 | mfa_2.enable(SMS) 129 | 130 | # User 1 using User 2's MFA Code should fail. 131 | mfa_2_code = generate_mfa_code(mfa_2.sms_bin_key) 132 | serializer = MFAAuthTokenSerializer(data={ 133 | "username": self.user1.username, 134 | "password": self.password1, 135 | "mfa_code": mfa_2_code 136 | }) 137 | self.assertFalse(serializer.is_valid()) 138 | 139 | # User 1 using User 2's Backup Code should fail. 140 | mfa_2_backup = mfa_2.backup_code 141 | serializer = MFAAuthTokenSerializer(data={ 142 | "username": self.user1.username, 143 | "password": self.password1, 144 | "backup_code": mfa_2_backup 145 | }) 146 | self.assertFalse(serializer.is_valid()) 147 | -------------------------------------------------------------------------------- /deux/oauth2/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import json 4 | import six 5 | import sys 6 | from base64 import b64encode 7 | from mock import patch 8 | from oauth2_provider.models import get_application_model 9 | 10 | from django.core.urlresolvers import reverse 11 | from rest_framework import status 12 | 13 | from deux.app_settings import mfa_settings 14 | from deux.constants import SMS 15 | from deux.services import generate_mfa_code 16 | from deux.tests.test_base import BaseUserTestCase 17 | 18 | if sys.version_info < (3,): 19 | from urllib import urlencode 20 | else: 21 | from urllib.parse import urlencode 22 | 23 | Application = get_application_model() 24 | 25 | 26 | class MFAOAuth2TokenTests(BaseUserTestCase): 27 | url = reverse("oauth2:token") 28 | 29 | def setUp(self): 30 | self.simpleUserSetup() 31 | self.application = Application.objects.create( 32 | name="Test Password Application", 33 | user=self.user1, 34 | authorization_grant_type=Application.GRANT_PASSWORD, 35 | ) 36 | self.headers = self._get_basic_auth_header( 37 | self.application.client_id, self.application.client_secret) 38 | 39 | self.mfa = mfa_settings.MFA_MODEL.objects.create(user=self.user2) 40 | self.mfa.enable(SMS) 41 | self.phone_number = "1234567890" 42 | self.mfa.phone_number = self.phone_number 43 | self.mfa.save() 44 | self.backup_code = self.mfa.refresh_backup_code() 45 | self.mfa_code = generate_mfa_code(self.mfa.sms_bin_key) 46 | 47 | def test_incorrect_credentials(self): 48 | data = self._get_data( 49 | username=self.user1.username, password="wrong password") 50 | response = self.check_post_response( 51 | self.url, status.HTTP_400_BAD_REQUEST, data=data, 52 | headers=self.headers) 53 | self._assert_error_msg( 54 | response, "Unable to log in with provided credentials.") 55 | 56 | def test_inactive_user(self): 57 | self.user1.is_active = False 58 | self.user1.save() 59 | data = self._get_data( 60 | username=self.user1.username, password=self.user1.password) 61 | response = self.check_post_response( 62 | self.url, status.HTTP_400_BAD_REQUEST, data=data, 63 | headers=self.headers) 64 | self._assert_error_msg( 65 | response, "Unable to log in with provided credentials.") 66 | 67 | def test_get_token_mfa_object_does_not_exist(self): 68 | data = self._get_data( 69 | username=self.user1.username, password=self.password1) 70 | response = self.check_post_response( 71 | self.url, status.HTTP_200_OK, data=data, headers=self.headers) 72 | self._assert_authenticated(response) 73 | 74 | def test_get_token_mfa_not_required(self): 75 | self.mfa.disable() 76 | data = self._get_data() 77 | response = self.check_post_response( 78 | self.url, status.HTTP_200_OK, data=data, headers=self.headers) 79 | self._assert_authenticated(response) 80 | 81 | @patch("deux.oauth2.validators.MultiFactorChallenge") 82 | def test_get_token_mfa_required(self, challenge): 83 | data = self._get_data() 84 | response = self.check_post_response( 85 | self.url, status.HTTP_200_OK, data=data, headers=self.headers) 86 | content = json.loads(response.content.decode("utf-8")) 87 | self.assertTrue(content.get("mfa_required")) 88 | self.assertEqual(content.get("mfa_type"), SMS) 89 | challenge.assert_called_once_with(self.mfa, SMS) 90 | 91 | def test_login_success_with_mfa_code(self): 92 | data = self._get_data(mfa_code=self.mfa_code) 93 | response = self.check_post_response( 94 | self.url, status.HTTP_200_OK, data=data, headers=self.headers) 95 | self._assert_authenticated(response) 96 | 97 | def test_login_fail_with_invalid_mfa_code(self): 98 | bad_code = six.text_type(int(self.mfa_code) + 1) 99 | data = self._get_data(mfa_code=bad_code) 100 | response = self.check_post_response( 101 | self.url, status.HTTP_400_BAD_REQUEST, data=data, 102 | headers=self.headers) 103 | self._assert_error_msg(response, "Please enter a valid code.") 104 | 105 | def test_login_success_with_backup_code(self): 106 | data = self._get_data(backup_code=self.backup_code) 107 | response = self.check_post_response( 108 | self.url, status.HTTP_200_OK, data=data, headers=self.headers) 109 | self._assert_authenticated(response) 110 | 111 | def test_login_fail_with_invalid_backup_code(self): 112 | bad_backup_code = "abcdef123456" 113 | data = self._get_data(backup_code=bad_backup_code) 114 | response = self.check_post_response( 115 | self.url, status.HTTP_400_BAD_REQUEST, data=data, 116 | headers=self.headers) 117 | self._assert_error_msg(response, "Please enter a valid backup code.") 118 | 119 | def test_login_fail_with_both_codes(self): 120 | data = self._get_data( 121 | mfa_code=self.mfa_code, backup_code=self.backup_code) 122 | response = self.check_post_response( 123 | self.url, status.HTTP_400_BAD_REQUEST, data=data, 124 | headers=self.headers) 125 | self._assert_error_msg( 126 | response, 127 | "Login does not take both a verification and backup code." 128 | ) 129 | 130 | def test_login_success_with_multipart(self): 131 | data = self._get_data(backup_code=self.backup_code) 132 | response = self.check_post_response( 133 | self.url, status.HTTP_200_OK, data=data, headers=self.headers, 134 | format="multipart") 135 | self._assert_authenticated(response) 136 | 137 | def test_login_success_with_urlencoded(self): 138 | data = self._get_data(backup_code=self.backup_code) 139 | response = self.check_post_response_with_url_encoded( 140 | self.url, status.HTTP_200_OK, data=urlencode(data), 141 | headers=self.headers) 142 | self._assert_authenticated(response) 143 | 144 | def _assert_authenticated(self, response): 145 | content = json.loads(response.content.decode("utf-8")) 146 | self.assertIsNotNone(content.get("access_token")) 147 | self.assertIsNotNone(content.get("refresh_token")) 148 | 149 | def _assert_error_msg(self, response, msg): 150 | content = json.loads(response.content.decode("utf-8")) 151 | self.assertEqual(content["detail"], msg) 152 | 153 | def _get_data( 154 | self, username=None, password=None, mfa_code=None, 155 | backup_code=None): 156 | data = { 157 | "grant_type": "password", 158 | "username": username or self.user2.username, 159 | "password": password or self.password2, 160 | } 161 | if mfa_code: 162 | data["mfa_code"] = mfa_code 163 | if backup_code: 164 | data["backup_code"] = backup_code 165 | return data 166 | 167 | def _get_basic_auth_header(self, client_id, client_secret): 168 | """ 169 | Return a dict containg the correct headers to set to make HTTP 170 | an Auth request to Oauth2. 171 | """ 172 | id_secret = '{id}:{secret}'.format(id=client_id, secret=client_secret) 173 | auth_string = b64encode(id_secret.encode('utf-8')) 174 | return {'HTTP_AUTHORIZATION': 'Basic ' + auth_string.decode("utf-8")} 175 | -------------------------------------------------------------------------------- /deux/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from mock import patch 4 | 5 | from django.core.urlresolvers import reverse 6 | from rest_framework import status 7 | 8 | from deux.app_settings import mfa_settings 9 | from deux.constants import DISABLED, SMS 10 | from deux.exceptions import FailedChallengeError 11 | from deux.services import generate_mfa_code 12 | from deux import strings 13 | 14 | from .test_base import BaseUserTestCase 15 | 16 | 17 | class _BaseMFAViewTest(BaseUserTestCase): 18 | 19 | def setUp(self): 20 | self.simpleUserSetup() 21 | self.mfa_1 = mfa_settings.MFA_MODEL.objects.create(user=self.user1) 22 | self.mfa_2 = mfa_settings.MFA_MODEL.objects.create(user=self.user2) 23 | self.mfa_2.enable(SMS) 24 | self.phone_number = "1234567890" 25 | self.mfa_2.phone_number = self.phone_number 26 | self.mfa_2.save() 27 | 28 | 29 | class MultiFactorAuthViewTest(_BaseMFAViewTest): 30 | url = reverse("multi_factor_auth-detail") 31 | 32 | def test_get(self): 33 | # Check for HTTP401. 34 | self.check_get_response(self.url, status.HTTP_403_FORBIDDEN) 35 | 36 | # Check for HTTP200 - MFA for a disabled user. 37 | resp = self.check_get_response( 38 | self.url, status.HTTP_200_OK, user=self.user1 39 | ) 40 | resp_json = resp.data 41 | self.assertEqual(resp_json["enabled"], False) 42 | with self.assertRaises(KeyError): 43 | resp_json["challenge_type"] 44 | 45 | # Check for HTTP200 - MFA Enabled User. 46 | resp = self.check_get_response( 47 | self.url, status.HTTP_200_OK, user=self.user2 48 | ) 49 | resp_json = resp.data 50 | self.assertEqual(resp_json["enabled"], True) 51 | self.assertEqual(resp_json["challenge_type"], SMS) 52 | 53 | def test_delete(self): 54 | # Check for HTTP401. 55 | self.check_delete_response(self.url, status.HTTP_403_FORBIDDEN) 56 | 57 | # Check for HTTP400 - MFA for a disabled user. 58 | resp = self.check_delete_response( 59 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1) 60 | self.assertEqual(resp.data, { 61 | "detail": strings.DISABLED_ERROR 62 | }) 63 | 64 | # Check for HTTP200. 65 | self.mfa_2.refresh_backup_code() 66 | self.check_delete_response( 67 | self.url, status.HTTP_204_NO_CONTENT, user=self.user2) 68 | instance = mfa_settings.MFA_MODEL.objects.get(user=self.user2) 69 | self.assertFalse(instance.enabled) 70 | self.assertEqual(instance.challenge_type, DISABLED) 71 | self.assertEqual(instance.backup_code, "") 72 | self.assertEqual(instance.phone_number, "") 73 | 74 | 75 | class SMSChallengeRequestViewTest(_BaseMFAViewTest): 76 | url = reverse("sms_request-detail") 77 | 78 | def test_unauthorized(self): 79 | self.check_put_response(self.url, status.HTTP_403_FORBIDDEN) 80 | 81 | def test_already_enabled(self): 82 | resp = self.check_put_response( 83 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user2, 84 | data={"phone_number": self.phone_number}) 85 | self.assertEqual(resp.data, {"detail": [strings.ENABLED_ERROR]}) 86 | 87 | def test_bad_phone_numbers(self): 88 | # No phone number inputted. 89 | resp = self.check_put_response( 90 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1) 91 | self.assertEqual(resp.data, { 92 | "phone_number": ["This field is required."] 93 | }) 94 | 95 | # Invalid phone number. 96 | resp = self.check_put_response( 97 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1, 98 | data={"phone_number": "bad_number"}) 99 | self.assertEqual(resp.data, { 100 | "phone_number": [strings.INVALID_PHONE_NUMBER_ERROR] 101 | }) 102 | 103 | @patch("deux.serializers.MultiFactorChallenge") 104 | def test_failed_sms_error(self, challenge): 105 | challenge.return_value.generate_challenge.side_effect = ( 106 | FailedChallengeError("Error Message.")) 107 | resp = self.check_put_response( 108 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1, 109 | data={"phone_number": self.phone_number}) 110 | self.assertEqual(resp.data, { 111 | "detail": "Error Message." 112 | }) 113 | 114 | @patch("deux.serializers.MultiFactorChallenge") 115 | def test_success(self, challenge): 116 | resp = self.check_put_response( 117 | self.url, status.HTTP_200_OK, user=self.user1, 118 | data={"phone_number": self.phone_number}) 119 | self.assertEqual(resp.data, { 120 | "enabled": False, "phone_number": self.phone_number 121 | }) 122 | challenge.assert_called_once_with(self.mfa_1, SMS) 123 | challenge.return_value.generate_challenge.assert_called_once_with() 124 | 125 | 126 | class SMSChallengeVerifyViewTest(_BaseMFAViewTest): 127 | url = reverse("sms_verify-detail") 128 | 129 | def test_unauthorized(self): 130 | self.check_put_response(self.url, status.HTTP_403_FORBIDDEN) 131 | 132 | def test_already_enabled(self): 133 | resp = self.check_put_response( 134 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user2, 135 | data={"mfa_code": "code"}) 136 | self.assertEqual(resp.data, {"detail": [strings.ENABLED_ERROR]}) 137 | 138 | def test_incorrect_mfa_codes(self): 139 | # Check for failure with incorrect mfa_code. 140 | resp = self.check_put_response( 141 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1, 142 | data={"mfa_code": "bad_code"} 143 | ) 144 | self.assertEqual(resp.data, { 145 | "mfa_code": [strings.INVALID_MFA_CODE_ERROR] 146 | }) 147 | 148 | # Check for failure with None mfa_code. 149 | self.check_put_response( 150 | self.url, status.HTTP_400_BAD_REQUEST, 151 | user=self.user1, data=None 152 | ) 153 | self.assertEqual(resp.data, { 154 | "mfa_code": [strings.INVALID_MFA_CODE_ERROR] 155 | }) 156 | 157 | def test_success(self): 158 | mfa_code = generate_mfa_code(self.mfa_1.sms_bin_key) 159 | resp = self.check_put_response( 160 | self.url, status.HTTP_200_OK, user=self.user1, 161 | data={"mfa_code": mfa_code} 162 | ) 163 | resp_json = resp.data 164 | self.assertEqual(resp_json["enabled"], True) 165 | self.assertEqual(resp_json["challenge_type"], SMS) 166 | instance = mfa_settings.MFA_MODEL.objects.get(user=self.user1) 167 | self.assertTrue(instance.enabled) 168 | self.assertEqual(instance.challenge_type, SMS) 169 | 170 | 171 | class BackupCodesViewTest(_BaseMFAViewTest): 172 | url = reverse("backup_code-detail") 173 | 174 | def test_get(self): 175 | # Check for HTTP401. 176 | self.check_get_response(self.url, status.HTTP_403_FORBIDDEN) 177 | 178 | # Check for HTTP400 - MFA for a disabled user. 179 | resp = self.check_get_response( 180 | self.url, status.HTTP_400_BAD_REQUEST, user=self.user1) 181 | self.assertEqual(resp.data, { 182 | "backup_code": strings.DISABLED_ERROR 183 | }) 184 | 185 | # Check for HTTP200. 186 | resp = self.check_get_response( 187 | self.url, status.HTTP_200_OK, user=self.user2) 188 | self.assertEqual( 189 | len(resp.data["backup_code"]), mfa_settings.BACKUP_CODE_DIGITS) 190 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | goto end 43 | ) 44 | 45 | if "%1" == "clean" ( 46 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 47 | del /q /s %BUILDDIR%\* 48 | goto end 49 | ) 50 | 51 | 52 | REM Check if sphinx-build is available and fallback to Python version if any 53 | %SPHINXBUILD% 1>NUL 2>NUL 54 | if errorlevel 9009 goto sphinx_python 55 | goto sphinx_ok 56 | 57 | :sphinx_python 58 | 59 | set SPHINXBUILD=python -m sphinx.__init__ 60 | %SPHINXBUILD% 2> nul 61 | if errorlevel 9009 ( 62 | echo. 63 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 64 | echo.installed, then set the SPHINXBUILD environment variable to point 65 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 66 | echo.may add the Sphinx directory to PATH. 67 | echo. 68 | echo.If you don't have Sphinx installed, grab it from 69 | echo.http://sphinx-doc.org/ 70 | exit /b 1 71 | ) 72 | 73 | :sphinx_ok 74 | 75 | 76 | if "%1" == "html" ( 77 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 78 | if errorlevel 1 exit /b 1 79 | echo. 80 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 81 | goto end 82 | ) 83 | 84 | if "%1" == "dirhtml" ( 85 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 86 | if errorlevel 1 exit /b 1 87 | echo. 88 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 89 | goto end 90 | ) 91 | 92 | if "%1" == "singlehtml" ( 93 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 97 | goto end 98 | ) 99 | 100 | if "%1" == "pickle" ( 101 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 102 | if errorlevel 1 exit /b 1 103 | echo. 104 | echo.Build finished; now you can process the pickle files. 105 | goto end 106 | ) 107 | 108 | if "%1" == "json" ( 109 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished; now you can process the JSON files. 113 | goto end 114 | ) 115 | 116 | if "%1" == "htmlhelp" ( 117 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished; now you can run HTML Help Workshop with the ^ 121 | .hhp project file in %BUILDDIR%/htmlhelp. 122 | goto end 123 | ) 124 | 125 | if "%1" == "qthelp" ( 126 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 127 | if errorlevel 1 exit /b 1 128 | echo. 129 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 130 | .qhcp project file in %BUILDDIR%/qthelp, like this: 131 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PROJ.qhcp 132 | echo.To view the help file: 133 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PROJ.ghc 134 | goto end 135 | ) 136 | 137 | if "%1" == "devhelp" ( 138 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 139 | if errorlevel 1 exit /b 1 140 | echo. 141 | echo.Build finished. 142 | goto end 143 | ) 144 | 145 | if "%1" == "epub" ( 146 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 147 | if errorlevel 1 exit /b 1 148 | echo. 149 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 150 | goto end 151 | ) 152 | 153 | if "%1" == "epub3" ( 154 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 155 | if errorlevel 1 exit /b 1 156 | echo. 157 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 158 | goto end 159 | ) 160 | 161 | if "%1" == "latex" ( 162 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 166 | goto end 167 | ) 168 | 169 | if "%1" == "latexpdf" ( 170 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 171 | cd %BUILDDIR%/latex 172 | make all-pdf 173 | cd %~dp0 174 | echo. 175 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 176 | goto end 177 | ) 178 | 179 | if "%1" == "latexpdfja" ( 180 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 181 | cd %BUILDDIR%/latex 182 | make all-pdf-ja 183 | cd %~dp0 184 | echo. 185 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 186 | goto end 187 | ) 188 | 189 | if "%1" == "text" ( 190 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 191 | if errorlevel 1 exit /b 1 192 | echo. 193 | echo.Build finished. The text files are in %BUILDDIR%/text. 194 | goto end 195 | ) 196 | 197 | if "%1" == "man" ( 198 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 199 | if errorlevel 1 exit /b 1 200 | echo. 201 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 202 | goto end 203 | ) 204 | 205 | if "%1" == "texinfo" ( 206 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 207 | if errorlevel 1 exit /b 1 208 | echo. 209 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 210 | goto end 211 | ) 212 | 213 | if "%1" == "gettext" ( 214 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 215 | if errorlevel 1 exit /b 1 216 | echo. 217 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 218 | goto end 219 | ) 220 | 221 | if "%1" == "changes" ( 222 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 223 | if errorlevel 1 exit /b 1 224 | echo. 225 | echo.The overview file is in %BUILDDIR%/changes. 226 | goto end 227 | ) 228 | 229 | if "%1" == "linkcheck" ( 230 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Link check complete; look for any errors in the above output ^ 234 | or in %BUILDDIR%/linkcheck/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "doctest" ( 239 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of doctests in the sources finished, look at the ^ 243 | results in %BUILDDIR%/doctest/output.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "coverage" ( 248 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Testing of coverage in the sources finished, look at the ^ 252 | results in %BUILDDIR%/coverage/python.txt. 253 | goto end 254 | ) 255 | 256 | if "%1" == "xml" ( 257 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 258 | if errorlevel 1 exit /b 1 259 | echo. 260 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 261 | goto end 262 | ) 263 | 264 | if "%1" == "pseudoxml" ( 265 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 266 | if errorlevel 1 exit /b 1 267 | echo. 268 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 269 | goto end 270 | ) 271 | 272 | :end 273 | -------------------------------------------------------------------------------- /docs/theme/deux/static/deux.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * celery.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: BSD, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = 940 %} 10 | {% set sidebar_width = 220 %} 11 | {% set body_font_stack = 'Optima, Segoe, "Segoe UI", Candara, Calibri, Arial, sans-serif' %} 12 | {% set headline_font_stack = 'Futura, "Trebuchet MS", Arial, sans-serif' %} 13 | {% set code_font_stack = "'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace" %} 14 | 15 | @import url("basic.css"); 16 | 17 | /* -- page layout ----------------------------------------------------------- */ 18 | 19 | body { 20 | font-family: {{ body_font_stack }}; 21 | font-size: 17px; 22 | background-color: white; 23 | color: #000; 24 | margin: 30px 0 0 0; 25 | padding: 0; 26 | } 27 | 28 | div.document { 29 | width: {{ page_width }}px; 30 | margin: 0 auto; 31 | } 32 | 33 | div.deck { 34 | font-size: 18px; 35 | } 36 | 37 | p.developmentversion { 38 | color: red; 39 | } 40 | 41 | div.related { 42 | width: {{ page_width - 20 }}px; 43 | padding: 5px 10px; 44 | background: #F2FCEE; 45 | margin: 15px auto 15px auto; 46 | } 47 | 48 | div.documentwrapper { 49 | float: left; 50 | width: 100%; 51 | } 52 | 53 | div.bodywrapper { 54 | margin: 0 0 0 {{ sidebar_width }}px; 55 | } 56 | 57 | div.sphinxsidebar { 58 | width: {{ sidebar_width }}px; 59 | } 60 | 61 | hr { 62 | border: 1px solid #B1B4B6; 63 | } 64 | 65 | div.body { 66 | background-color: #ffffff; 67 | color: #3E4349; 68 | padding: 0 30px 0 30px; 69 | } 70 | 71 | img.celerylogo { 72 | padding: 0 0 10px 10px; 73 | float: right; 74 | } 75 | 76 | div.footer { 77 | width: {{ page_width - 15 }}px; 78 | margin: 10px auto 30px auto; 79 | padding-right: 15px; 80 | font-size: 14px; 81 | color: #888; 82 | text-align: right; 83 | } 84 | 85 | div.footer a { 86 | color: #888; 87 | } 88 | 89 | div.sphinxsidebar a { 90 | color: #444; 91 | text-decoration: none; 92 | border-bottom: 1px dashed #DCF0D5; 93 | } 94 | 95 | div.sphinxsidebar a:hover { 96 | border-bottom: 1px solid #999; 97 | } 98 | 99 | div.sphinxsidebar { 100 | font-size: 14px; 101 | line-height: 1.5; 102 | } 103 | 104 | div.sphinxsidebarwrapper { 105 | padding: 7px 10px; 106 | } 107 | 108 | div.sphinxsidebarwrapper p.logo { 109 | padding: 0 0 20px 0; 110 | margin: 0; 111 | } 112 | 113 | div.sphinxsidebar h3, 114 | div.sphinxsidebar h4 { 115 | font-family: {{ headline_font_stack }}; 116 | color: #444; 117 | font-size: 24px; 118 | font-weight: normal; 119 | margin: 0 0 5px 0; 120 | padding: 0; 121 | } 122 | 123 | div.sphinxsidebar h4 { 124 | font-size: 20px; 125 | } 126 | 127 | div.sphinxsidebar h3 a { 128 | color: #444; 129 | } 130 | 131 | div.sphinxsidebar p.logo a, 132 | div.sphinxsidebar h3 a, 133 | div.sphinxsidebar p.logo a:hover, 134 | div.sphinxsidebar h3 a:hover { 135 | border: none; 136 | } 137 | 138 | div.sphinxsidebar p { 139 | color: #555; 140 | margin: 10px 0; 141 | } 142 | 143 | div.sphinxsidebar ul { 144 | margin: 10px 0; 145 | padding: 0; 146 | color: #000; 147 | } 148 | 149 | div.sphinxsidebar input { 150 | border: 1px solid #ccc; 151 | font-family: {{ body_font_stack }}; 152 | font-size: 1em; 153 | } 154 | 155 | /* -- body styles ----------------------------------------------------------- */ 156 | 157 | a { 158 | color: #21CE99; 159 | text-decoration: underline; 160 | } 161 | 162 | a:hover { 163 | color: #17AD7B; 164 | text-decoration: underline; 165 | } 166 | 167 | div.body h1, 168 | div.body h2, 169 | div.body h3, 170 | div.body h4, 171 | div.body h5, 172 | div.body h6 { 173 | font-family: {{ headline_font_stack }}; 174 | font-weight: normal; 175 | margin: 30px 0px 10px 0px; 176 | padding: 0; 177 | } 178 | 179 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 200%; } 180 | div.body h2 { font-size: 180%; } 181 | div.body h3 { font-size: 150%; } 182 | div.body h4 { font-size: 130%; } 183 | div.body h5 { font-size: 100%; } 184 | div.body h6 { font-size: 100%; } 185 | 186 | div.body h1 a.toc-backref, 187 | div.body h2 a.toc-backref, 188 | div.body h3 a.toc-backref, 189 | div.body h4 a.toc-backref, 190 | div.body h5 a.toc-backref, 191 | div.body h6 a.toc-backref { 192 | color: inherit!important; 193 | text-decoration: none; 194 | } 195 | 196 | a.headerlink { 197 | color: #ddd; 198 | padding: 0 4px; 199 | text-decoration: none; 200 | } 201 | 202 | a.headerlink:hover { 203 | color: #444; 204 | background: #eaeaea; 205 | } 206 | 207 | div.body p, div.body dd, div.body li { 208 | line-height: 1.4em; 209 | } 210 | 211 | div.admonition { 212 | background: #fafafa; 213 | margin: 20px -30px; 214 | padding: 10px 30px; 215 | border-top: 1px solid #ccc; 216 | border-bottom: 1px solid #ccc; 217 | } 218 | 219 | div.admonition p.admonition-title { 220 | font-family: {{ headline_font_stack }}; 221 | font-weight: normal; 222 | font-size: 24px; 223 | margin: 0 0 10px 0; 224 | padding: 0; 225 | line-height: 1; 226 | } 227 | 228 | div.admonition p.last { 229 | margin-bottom: 0; 230 | } 231 | 232 | div.highlight{ 233 | background-color: white; 234 | } 235 | 236 | dt:target, .highlight { 237 | background: #FAF3E8; 238 | } 239 | 240 | div.note { 241 | background-color: #eee; 242 | border: 1px solid #ccc; 243 | } 244 | 245 | div.seealso { 246 | background-color: #ffc; 247 | border: 1px solid #ff6; 248 | } 249 | 250 | div.topic { 251 | background-color: #eee; 252 | } 253 | 254 | div.warning { 255 | background-color: #ffe4e4; 256 | border: 1px solid #f66; 257 | } 258 | 259 | p.admonition-title { 260 | display: inline; 261 | } 262 | 263 | p.admonition-title:after { 264 | content: ":"; 265 | } 266 | 267 | pre, tt { 268 | font-family: {{ code_font_stack }}; 269 | font-size: 0.9em; 270 | } 271 | 272 | img.screenshot { 273 | } 274 | 275 | tt.descname, tt.descclassname { 276 | font-size: 0.95em; 277 | } 278 | 279 | tt.descname { 280 | padding-right: 0.08em; 281 | } 282 | 283 | img.screenshot { 284 | -moz-box-shadow: 2px 2px 4px #eee; 285 | -webkit-box-shadow: 2px 2px 4px #eee; 286 | box-shadow: 2px 2px 4px #eee; 287 | } 288 | 289 | table.docutils { 290 | border: 1px solid #888; 291 | -moz-box-shadow: 2px 2px 4px #eee; 292 | -webkit-box-shadow: 2px 2px 4px #eee; 293 | box-shadow: 2px 2px 4px #eee; 294 | } 295 | 296 | table.docutils td, table.docutils th { 297 | border: 1px solid #888; 298 | padding: 0.25em 0.7em; 299 | } 300 | 301 | table.field-list, table.footnote { 302 | border: none; 303 | -moz-box-shadow: none; 304 | -webkit-box-shadow: none; 305 | box-shadow: none; 306 | } 307 | 308 | table.footnote { 309 | margin: 15px 0; 310 | width: 100%; 311 | border: 1px solid #eee; 312 | background: #fdfdfd; 313 | font-size: 0.9em; 314 | } 315 | 316 | table.footnote + table.footnote { 317 | margin-top: -15px; 318 | border-top: none; 319 | } 320 | 321 | table.field-list th { 322 | padding: 0 0.8em 0 0; 323 | } 324 | 325 | table.field-list td { 326 | padding: 0; 327 | } 328 | 329 | table.footnote td.label { 330 | width: 0px; 331 | padding: 0.3em 0 0.3em 0.5em; 332 | } 333 | 334 | table.footnote td { 335 | padding: 0.3em 0.5em; 336 | } 337 | 338 | dl { 339 | margin: 0; 340 | padding: 0; 341 | } 342 | 343 | dl dd { 344 | margin-left: 30px; 345 | } 346 | 347 | blockquote { 348 | margin: 0 0 0 30px; 349 | padding: 0; 350 | } 351 | 352 | ul { 353 | margin: 10px 0 10px 30px; 354 | padding: 0; 355 | } 356 | 357 | pre { 358 | background: #E8EBEF; 359 | padding: 7px 10px; 360 | margin: 15px 0; 361 | border: 1px solid #C7ECB8; 362 | border-radius: 2px; 363 | -moz-border-radius: 2px; 364 | -webkit-border-radius: 2px; 365 | line-height: 1.3em; 366 | } 367 | 368 | tt { 369 | background: #F0FFEB; 370 | color: #222; 371 | /* padding: 1px 2px; */ 372 | } 373 | 374 | tt.xref, a tt { 375 | background: #F0FFEB; 376 | border-bottom: 1px solid white; 377 | } 378 | 379 | a.reference { 380 | text-decoration: none; 381 | border-bottom: 1px dashed #DCF0D5; 382 | } 383 | 384 | a.reference:hover { 385 | border-bottom: 1px solid #6D4100; 386 | } 387 | 388 | a.footnote-reference { 389 | text-decoration: none; 390 | font-size: 0.7em; 391 | vertical-align: top; 392 | border-bottom: 1px dashed #DCF0D5; 393 | } 394 | 395 | a.footnote-reference:hover { 396 | border-bottom: 1px solid #6D4100; 397 | } 398 | 399 | a:hover tt { 400 | background: #EEE; 401 | } 402 | -------------------------------------------------------------------------------- /deux/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import six 4 | 5 | from rest_framework import serializers 6 | 7 | from deux.app_settings import mfa_settings 8 | from deux import strings 9 | from deux.constants import SMS 10 | from deux.exceptions import FailedChallengeError 11 | from deux.services import MultiFactorChallenge, verify_mfa_code 12 | 13 | 14 | class MultiFactorAuthSerializer(serializers.ModelSerializer): 15 | """ 16 | class::MultiFactorAuthSerializer() 17 | 18 | Basic MultiFactorAuthSerializer that encodes MFA objects into a standard 19 | response. 20 | 21 | The standard response returns whether MFA is enabled, the challenge 22 | type, and the user's phone number. 23 | """ 24 | 25 | def to_representation(self, mfa_instance): 26 | """ 27 | Encodes an MFA instance as the standard response. 28 | 29 | :param mfa_instance: :class:`MultiFactorAuth` instance to use. 30 | :returns: Dictionary with ``enabled``, ``challengetype``, and 31 | ``phone_number`` from the MFA instance. 32 | """ 33 | data = {"enabled": mfa_instance.enabled} 34 | if mfa_instance.enabled: 35 | data["challenge_type"] = mfa_instance.challenge_type 36 | if mfa_instance.phone_number: 37 | data["phone_number"] = mfa_instance.phone_number 38 | return data 39 | 40 | class Meta: 41 | model = mfa_settings.MFA_MODEL 42 | 43 | 44 | class _BaseChallengeRequestSerializer(MultiFactorAuthSerializer): 45 | """ 46 | class::_BaseChallengeRequestSerializer() 47 | 48 | Base Serializer class for all challenge request. 49 | """ 50 | 51 | @property 52 | def challenge_type(self): 53 | """ 54 | Represents the challenge type this serializer represents. 55 | 56 | :raises NotImplemented: If the extending class does not define 57 | ``challenge_type``. 58 | """ 59 | raise NotImplemented # pragma: no cover 60 | 61 | def execute_challenge(self, instance): 62 | """ 63 | Execute challenge for this instance based on the ``challenge_type``. 64 | 65 | :param instance: :class:`MultiFactorAuth` instance to use. 66 | :raises serializers.ValidationError: If the challenge fails to execute. 67 | """ 68 | try: 69 | MultiFactorChallenge( 70 | instance, self.challenge_type).generate_challenge() 71 | except FailedChallengeError as e: 72 | raise serializers.ValidationError({ 73 | "detail": six.text_type(e) 74 | }) 75 | 76 | def validate(self, internal_data): 77 | """ 78 | Validate the request to enable MFA through this challenge. 79 | 80 | Extending classes should extend for additional functionality. The 81 | base functionality ensures that MFA is not already enabled. 82 | 83 | :param internal_data: Dictionary of the request data. 84 | :raises serializers.ValidationError: If MFA is already enabled. 85 | """ 86 | if self.instance.enabled: 87 | raise serializers.ValidationError({ 88 | "detail": strings.ENABLED_ERROR 89 | }) 90 | return internal_data 91 | 92 | def update(self, mfa_instance, validated_data): 93 | """ 94 | If the request is valid, the serializer calls update which executes 95 | the ``challenge_type``. 96 | 97 | :param mfa_instance: :class:`MultiFactorAuth` instance to use. 98 | :param validated_data: Data returned by ``validate``. 99 | """ 100 | self.execute_challenge(mfa_instance) 101 | 102 | 103 | class _BaseChallengeVerifySerializer(MultiFactorAuthSerializer): 104 | """ 105 | class::_BaseChallengeVerifySerializer() 106 | 107 | This serializer first extracts MFA code from request body 108 | (`to_internal_value`). It then verifies the code against the 109 | corresponding `MultiFactorAuth` instance (`validate`). If the code 110 | is valid, it enables MFA based on the challenge type (`update`). 111 | """ 112 | 113 | #: Requests to verify an MFA code must include an ``mfa_code``. 114 | mfa_code = serializers.CharField() 115 | 116 | @property 117 | def challenge_type(self): 118 | """ 119 | Represents the challenge type this serializer represents. 120 | 121 | :raises NotImplemented: If the extending class does not define 122 | ``challenge_type``. 123 | """ 124 | raise NotImplemented # pragma: no cover 125 | 126 | def validate(self, internal_data): 127 | """ 128 | Validates the request to verify the MFA code. It first ensures that 129 | MFA is not already enabled and then verifies that the MFA code is the 130 | correct code. 131 | 132 | :param internal_data: Dictionary of the request data. 133 | :raises serializers.ValidationError: If MFA is already enabled or if 134 | the inputted MFA code is not valid. 135 | """ 136 | if self.instance.enabled: 137 | raise serializers.ValidationError({ 138 | "detail": strings.ENABLED_ERROR 139 | }) 140 | 141 | mfa_code = internal_data.get("mfa_code") 142 | bin_key = self.instance.get_bin_key(self.challenge_type) 143 | if not verify_mfa_code(bin_key, mfa_code): 144 | raise serializers.ValidationError({ 145 | "mfa_code": strings.INVALID_MFA_CODE_ERROR 146 | }) 147 | return {"mfa_code": mfa_code} 148 | 149 | def update(self, mfa_instance, validated_data): 150 | """ 151 | If the request is valid, the serializer enables MFA on this instance 152 | for this serializer's ``challenge_type``. 153 | 154 | :param mfa_instance: :class:`MultiFactorAuth` instance to use. 155 | :param validated_data: Data returned by ``validate``. 156 | """ 157 | mfa_instance.enable(self.challenge_type) 158 | return mfa_instance 159 | 160 | class Meta(MultiFactorAuthSerializer.Meta): 161 | fields = ("mfa_code",) 162 | 163 | 164 | class SMSChallengeRequestSerializer(_BaseChallengeRequestSerializer): 165 | """ 166 | class::SMSChallengeRequestSerializer() 167 | 168 | Serializer that facilitates a request to enable MFA over SMS. 169 | """ 170 | 171 | #: This serializer represents the ``SMS`` challenge type. 172 | challenge_type = SMS 173 | 174 | def update(self, mfa_instance, validated_data): 175 | """ 176 | If the request data is valid, the serializer executes the challenge 177 | by calling the super method and also saves the phone number the user 178 | requested the SMS to. 179 | 180 | :param mfa_instance: :class:`MultiFactorAuth` instance to use. 181 | :param validated_data: Data returned by ``validate``. 182 | """ 183 | mfa_instance.phone_number = validated_data["phone_number"] 184 | super(SMSChallengeRequestSerializer, self).update( 185 | mfa_instance, validated_data) 186 | mfa_instance.save() 187 | return mfa_instance 188 | 189 | class Meta(_BaseChallengeRequestSerializer.Meta): 190 | fields = ("phone_number",) 191 | extra_kwargs = { 192 | "phone_number": { 193 | "required": True, 194 | }, 195 | } 196 | 197 | 198 | class SMSChallengeVerifySerializer(_BaseChallengeVerifySerializer): 199 | """ 200 | class::SMSChallengeVerifySerializer() 201 | 202 | Extension of ``_BaseChallengeVerifySerializer`` that implements 203 | challenge verification for the SMS challenge. 204 | """ 205 | 206 | #: This serializer represents the ``SMS`` challenge type. 207 | challenge_type = SMS 208 | 209 | 210 | class BackupCodeSerializer(serializers.ModelSerializer): 211 | """ 212 | class::BackupCodeSerializer() 213 | 214 | Serializer for the user requesting backup codes. 215 | """ 216 | 217 | #: Serializer field for the backup code. 218 | backup_code = serializers.SerializerMethodField() 219 | 220 | def get_backup_code(self, instance): 221 | """ 222 | Method for retrieving the backup code. On every request, the backup 223 | code is refreshed. 224 | 225 | :param instance: :class:`MultiFactorAuth` instance to use. 226 | :raises serializers.ValidationError: If MFA is disabled. 227 | """ 228 | if self.instance.enabled: 229 | return self.instance.refresh_backup_code() 230 | else: 231 | raise serializers.ValidationError({ 232 | "backup_code": strings.DISABLED_ERROR 233 | }) 234 | 235 | class Meta: 236 | model = mfa_settings.MFA_MODEL 237 | fields = ("backup_code",) 238 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " epub3 to make an epub3" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | @echo " apicheck to verify that all modules are present in autodoc" 51 | @echo " configcheck to verify that all modules are present in autodoc" 52 | 53 | .PHONY: clean 54 | clean: 55 | rm -rf $(BUILDDIR)/* 56 | 57 | .PHONY: html 58 | html: 59 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 62 | 63 | .PHONY: dirhtml 64 | dirhtml: 65 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 66 | @echo 67 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 68 | 69 | .PHONY: singlehtml 70 | singlehtml: 71 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 72 | @echo 73 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 74 | 75 | .PHONY: pickle 76 | pickle: 77 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 78 | @echo 79 | @echo "Build finished; now you can process the pickle files." 80 | 81 | .PHONY: json 82 | json: 83 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 84 | @echo 85 | @echo "Build finished; now you can process the JSON files." 86 | 87 | .PHONY: htmlhelp 88 | htmlhelp: 89 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 90 | @echo 91 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 92 | ".hhp project file in $(BUILDDIR)/htmlhelp." 93 | 94 | .PHONY: qthelp 95 | qthelp: 96 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 97 | @echo 98 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 99 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 100 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PROJ.qhcp" 101 | @echo "To view the help file:" 102 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PROJ.qhc" 103 | 104 | .PHONY: applehelp 105 | applehelp: 106 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 107 | @echo 108 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 109 | @echo "N.B. You won't be able to view it unless you put it in" \ 110 | "~/Library/Documentation/Help or install it in your application" \ 111 | "bundle." 112 | 113 | .PHONY: devhelp 114 | devhelp: 115 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 116 | @echo 117 | @echo "Build finished." 118 | @echo "To view the help file:" 119 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PROJ" 120 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PROJ" 121 | @echo "# devhelp" 122 | 123 | .PHONY: epub 124 | epub: 125 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 126 | @echo 127 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 128 | 129 | .PHONY: epub3 130 | epub3: 131 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 132 | @echo 133 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 134 | 135 | .PHONY: latex 136 | latex: 137 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 138 | @echo 139 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 140 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 141 | "(use \`make latexpdf' here to do that automatically)." 142 | 143 | .PHONY: latexpdf 144 | latexpdf: 145 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 146 | @echo "Running LaTeX files through pdflatex..." 147 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 148 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 149 | 150 | .PHONY: latexpdfja 151 | latexpdfja: 152 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 153 | @echo "Running LaTeX files through platex and dvipdfmx..." 154 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 155 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 156 | 157 | .PHONY: text 158 | text: 159 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 160 | @echo 161 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 162 | 163 | .PHONY: man 164 | man: 165 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 166 | @echo 167 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 168 | 169 | .PHONY: texinfo 170 | texinfo: 171 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 172 | @echo 173 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 174 | @echo "Run \`make' in that directory to run these through makeinfo" \ 175 | "(use \`make info' here to do that automatically)." 176 | 177 | .PHONY: info 178 | info: 179 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 180 | @echo "Running Texinfo files through makeinfo..." 181 | make -C $(BUILDDIR)/texinfo info 182 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 183 | 184 | .PHONY: gettext 185 | gettext: 186 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 187 | @echo 188 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 189 | 190 | .PHONY: changes 191 | changes: 192 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 193 | @echo 194 | @echo "The overview file is in $(BUILDDIR)/changes." 195 | 196 | .PHONY: linkcheck 197 | linkcheck: 198 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 199 | @echo 200 | @echo "Link check complete; look for any errors in the above output " \ 201 | "or in $(BUILDDIR)/linkcheck/output.txt." 202 | 203 | .PHONY: doctest 204 | doctest: 205 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 206 | @echo "Testing of doctests in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/doctest/output.txt." 208 | 209 | .PHONY: coverage 210 | coverage: 211 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 212 | @echo "Testing of coverage in the sources finished, look at the " \ 213 | "results in $(BUILDDIR)/coverage/python.txt." 214 | 215 | .PHONY: apicheck 216 | apicheck: 217 | $(SPHINXBUILD) -b apicheck $(ALLSPHINXOPTS) $(BUILDDIR)/apicheck 218 | 219 | .PHONY: configcheck 220 | configcheck: 221 | $(SPHINXBUILD) -b configcheck $(ALLSPHINXOPTS) $(BUILDDIR)/configcheck 222 | 223 | .PHONY: xml 224 | xml: 225 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 226 | @echo 227 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 228 | 229 | .PHONY: pseudoxml 230 | pseudoxml: 231 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 232 | @echo 233 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 234 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | ============== 4 | Contributing 5 | ============== 6 | 7 | Welcome! 8 | 9 | This document is fairly extensive and you are not really expected 10 | to study this in detail for small contributions; 11 | 12 | The most important rule is that contributing must be easy 13 | and that the community is friendly and not nitpicking on details 14 | such as coding style. 15 | 16 | If you're reporting a bug you should read the Reporting bugs section 17 | below to ensure that your bug report contains enough information 18 | to successfully diagnose the issue, and if you're contributing code 19 | you should try to mimic the conventions you see surrounding the code 20 | you are working on, but in the end all patches will be cleaned up by 21 | the person merging the changes so don't worry too much. 22 | 23 | .. contents:: 24 | :local: 25 | 26 | .. _community-code-of-conduct: 27 | 28 | Community Code of Conduct 29 | ========================= 30 | 31 | The goal is to maintain a diverse community that is pleasant for everyone. 32 | That is why we would greatly appreciate it if everyone contributing to and 33 | interacting with the community also followed this Code of Conduct. 34 | 35 | The Code of Conduct covers our behavior as members of the community, 36 | in any forum, mailing list, wiki, website, Internet relay chat (IRC), public 37 | meeting or private correspondence. 38 | 39 | The Code of Conduct is heavily based on the `Ubuntu Code of Conduct`_, and 40 | the `Pylons Code of Conduct`_. 41 | 42 | .. _`Ubuntu Code of Conduct`: http://www.ubuntu.com/community/conduct 43 | .. _`Pylons Code of Conduct`: http://docs.pylonshq.com/community/conduct.html 44 | 45 | Be considerate. 46 | --------------- 47 | 48 | Your work will be used by other people, and you in turn will depend on the 49 | work of others. Any decision you take will affect users and colleagues, and 50 | we expect you to take those consequences into account when making decisions. 51 | Even if it's not obvious at the time, our contributions to Deux will impact 52 | the work of others. For example, changes to code, infrastructure, policy, 53 | documentation and translations during a release may negatively impact 54 | others work. 55 | 56 | Be respectful. 57 | -------------- 58 | 59 | The Deux community and its members treat one another with respect. Everyone 60 | can make a valuable contribution to Deux. We may not always agree, but 61 | disagreement is no excuse for poor behavior and poor manners. We might all 62 | experience some frustration now and then, but we cannot allow that frustration 63 | to turn into a personal attack. It's important to remember that a community 64 | where people feel uncomfortable or threatened is not a productive one. We 65 | expect members of the Deux community to be respectful when dealing with 66 | other contributors as well as with people outside the Deux project and with 67 | users of Deux. 68 | 69 | Be collaborative. 70 | ----------------- 71 | 72 | Collaboration is central to Deux and to the larger free software community. 73 | We should always be open to collaboration. Your work should be done 74 | transparently and patches from Deux should be given back to the community 75 | when they are made, not just when the distribution releases. If you wish 76 | to work on new code for existing upstream projects, at least keep those 77 | projects informed of your ideas and progress. It many not be possible to 78 | get consensus from upstream, or even from your colleagues about the correct 79 | implementation for an idea, so don't feel obliged to have that agreement 80 | before you begin, but at least keep the outside world informed of your work, 81 | and publish your work in a way that allows outsiders to test, discuss and 82 | contribute to your efforts. 83 | 84 | When you disagree, consult others. 85 | ---------------------------------- 86 | 87 | Disagreements, both political and technical, happen all the time and 88 | the Deux community is no exception. It is important that we resolve 89 | disagreements and differing views constructively and with the help of the 90 | community and community process. If you really want to go a different 91 | way, then we encourage you to make a derivative distribution or alternate 92 | set of packages that still build on the work we've done to utilize as common 93 | of a core as possible. 94 | 95 | When you are unsure, ask for help. 96 | ---------------------------------- 97 | 98 | Nobody knows everything, and nobody is expected to be perfect. Asking 99 | questions avoids many problems down the road, and so questions are 100 | encouraged. Those who are asked questions should be responsive and helpful. 101 | However, when asking a question, care must be taken to do so in an appropriate 102 | forum. 103 | 104 | Step down considerately. 105 | ------------------------ 106 | 107 | Developers on every project come and go and Deux is no different. When you 108 | leave or disengage from the project, in whole or in part, we ask that you do 109 | so in a way that minimizes disruption to the project. This means you should 110 | tell people you are leaving and take the proper steps to ensure that others 111 | can pick up where you leave off. 112 | 113 | .. _reporting-bugs: 114 | 115 | 116 | Reporting Bugs 117 | ============== 118 | 119 | .. _vulnsec: 120 | 121 | Security 122 | -------- 123 | 124 | You must never report security related issues, vulnerabilities or bugs 125 | including sensitive information to the bug tracker, or elsewhere in public. 126 | Instead sensitive bugs must be sent by email to ``security@robinhood.com``. 127 | 128 | If you'd like to submit the information encrypted our PGP key is:: 129 | 130 | 131 | -----BEGIN PGP PUBLIC KEY BLOCK----- 132 | Version: SKS 1.1.5 133 | 134 | mQINBFfPKmcBEADYx/ZGUwc6/x3CtViIRXz1ZyOHxERAcE2Lenmkr6oop3bt36smIgFSsU7K 135 | VMl32j+OlKaoLlVGRevxj6kKsFdNyqYGTUM2CTWx1gmd39QBPOqQeWDmTUa6ze332bJ1yJG1 136 | dtd/m2PuUZLAYLvUOLJSMmZgSeB22DKvNjnCZnNIw7nuGW/OIZHNYYZztNAxjIVCpXYvzPUh 137 | 2yRBN+8ZxHaQUzrwXvU8h924mS06F0q2FRz++ClMKUh42UIXUFlIkXv5iIvTM6G4TVM5wt5p 138 | G+gCnRzbPUmStoU/RYbLj8GkFMs52rb3gAFHy+Yx/K3awVTV985eo7PuJM+TzMqdD4zPeE7Z 139 | V626fO+cVVCSmF+3ikO65RZJ8eWYeTWlQ3dQr+kLxQcK9ZUBFCjNqab4m9OchjamvtyvKt// 140 | V4H6datfIN/4Ss5qcpegQ3SwOokz/vWPU4qZSKAp2cQY2WU4fSkKQK2Q9m5zKhyH6E/GH9nR 141 | x4MsBIFgRAAts8FeP3d/Xf49qd8oLje8UkNChHrLUbzaSdRNZQu3KM7K/OVI13OzSRov+mP8 142 | Twhk2xXFRy6iibR1n4YsSWmtHv7iiin3rWk9uJXO9P7V9P8xghudfja3SstxnK1ueASTbC2f 143 | iJgN4H0mPXNn0BC1I2na2xczP/83sOv0nHCk9PjeuSYsjhk+8wARAQABtDVSb2Jpbmhvb2Qg 144 | TWFya2V0cyAoU2VjdXJpdHkpIDxzZWN1cml0eUByb2Jpbmhvb2QuY29tPokCOAQTAQIAIgUC 145 | V88qZwIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQFKwy0jX7jrxJFA//TKzjxO84 146 | yodjwAO4IIO/nUeqvwWKiSr2dcPtAFQGUno5NjxM0iM170ff8qg5WoLQsic786PM71Q0I0aF 147 | OGFiiNRxRdS/sm0e1XYyIqu/24hwyHybpmxM+LYAoZNpUi6hAy5a+iTrorCJnGpFOUlYPDpM 148 | rMjOhRNeo5YOLW1WXQ0mAH93lwIHCm8XkkZWiFtrg/3zLyHLz0KV7nwpY4/fm0qjp2C/B/kw 149 | lF/Ol3opHrX8WNDYnr9IillRurqjh0Hvm8U7aNlDx9nFwb4uMYcXano37EMyVOnnCBVYT9kM 150 | BiGBxnucTPsgs/KZLCRqihSt2qkSK3EB344oFZ5bqum8jKn/cGCLYv2GzG217FNTdNTIlAMN 151 | zkgPlUCK885YpJDNaqScuOXphgpJr+4a71ml6GhM2G+Grkfo+YVR/d8X3Z7MJSRXxWHf5P5U 152 | PK1QS7pbQdTG5TrEd4NNI6a4ixBWk0OJIsBcer2dFDTBQXIMfcUZ+Nb1C1vxdrvBPVkUtCIf 153 | XbXeW4cYjxO7/AoarvPANqFol6mhZeBSHw/AiADaXs8oCIYVHPoaa5sJALhZD65vUvYZxYom 154 | QJE+8EuV5X5EhDSWoqvnC+ugVum9wSBjI2OF4PlEfifhfo+z5Xhpus6GdniEQ9jNBr4+Lvoc 155 | rssIUSxQQ4fsNqAgrmTauIOOWaa5Ag0EV88qZwEQALUX5gUbAmK6CRxM/15+eRuKq0IAP6+5 156 | sJsH0IrRr7mHUi8QxYzHouWK9klVdjRvd1crr9Q48wsty13togbiDTFPRCa/Z6K0vKdAneeS 157 | RQL89FGpQBq7nMM9GytUoBQ6BWAxItxdRiRKQ4NeyzCTcJjq1zN3KRd1d+RwnFLr3HTWbevv 158 | yOktdbklV6ld7IT8mMsuiZw3AA74tIWD0res6FtIqUVS2I2CEIODOlIXEjRDdcTES0bXxH/2 159 | /T3wPIfMEb1aSyhBYsGHRB7pAAqGrpb7LguVTt2hpfRShtew5O9hwLquA+kaGU/MIjKIKrxH 160 | PVkng8TwqhS3Et/hhAdLXtWj1ZXbRV5RPa1T90+JVX2PU4IapvHjZG4iZ4Oe7wtwtRSU1mQK 161 | Q30BpArsv7+1ezZMALsenYxbAh1ckp8bDEiNTboDzn7rBGXY2sUvLrl05oUbA7ntX0w6PIP4 162 | SHtWshCtu5+4g2/QX4zv4OEfFY6CeLHuuaw2zSUCXEAkVJCdjAXjmLpH5LftHDGn91kqmfgl 163 | VSQWeIfTCEue7Ehvfke1k5ASKi/L3+HPinRtT8JhCFGFM2gViNXtFMk5Dqb7TFo3g6s/Kd0/ 164 | gCpnE0844ts5Wh6S1DtbZ2YawS8lxEh0yQ1VJ4FraVEiMQ3SHFtKsAsGuR1Sz2/QL+tcRyXj 165 | xv17ABEBAAGJAh8EGAECAAkFAlfPKmcCGwwACgkQFKwy0jX7jrwOPw//XeJ64XoWVY9NAxLP 166 | PwXhKdZGfB8WxIs0pyF2KOAqbXisbp9Cu9OYgm42/idzobyHq1ebkQrW/hKs0248oX+dz83J 167 | TbkllHf+5SBPJCYm5jBnWRz+knaLZwFGkjtdy7NIkArfK9u5ytzKAhWqsi06B90e3MWSWo+X 168 | aLIGIiKZBDGbj2OCDDQyY1Sxh2r6i7Wx4ViI64GoZ0Te8gGM7r2swXYn95vSKISRDaffrczD 169 | 83qwdenp84pPFarSMtCTaNzmwwc7MzUXAEnehlfcxs6aPp3I+H4G9JrWB6jUs8pGqqe2qyvo 170 | 85K2ffTLUsmoA1+Z7tPqK8nmFe9TPUuAQiZRJuV8X0Ur4l01QwBdmFKqp9yvARoIqIEVbIxM 171 | xofwaRkDLeewWcVa5/tVTdeovI0zAyIfiFgNes1Zi3JK1Z13cGhjHZun2EWY3dufEdzkmGxY 172 | 1D09/QyHyLi2NcDavMEblJjg95NWVwQMkTzkAngd/1bJXXzwC82wtrmTYPnDHOaLqO9WbV6L 173 | OuCHg+ZKaLuG3fRYO+n6dYXqdoAnnYrgxhxLPFWW8knso+mz5HEc+N1ND27xzBCimQQEEjlA 174 | YgQslkRvzsczaG7feItsnz1vWAUQvwtr22iJtaYxG1+QhKDINkJkJ9LluK7nMC3SYvZBkh4n 175 | HBu2dHUJXU7b845lvTo= 176 | =JVgV 177 | -----END PGP PUBLIC KEY BLOCK----- 178 | 179 | Other bugs 180 | ---------- 181 | 182 | The best way to report an issue and to ensure a timely response is to use the 183 | issue tracker. 184 | 185 | 1) **Create a GitHub account.** 186 | 187 | You need to `create a GitHub account`_ to be able to create new issues 188 | and participate in the discussion. 189 | 190 | .. _`create a GitHub account`: https://github.com/signup/free 191 | 192 | 2) **Determine if your bug is really a bug.** 193 | 194 | You should not file a bug if you are requesting support. 195 | 196 | 3) **Make sure your bug hasn't already been reported.** 197 | 198 | Search through the appropriate Issue tracker. If a bug like yours was found, 199 | check if you have new information that could be reported to help 200 | the developers fix the bug. 201 | 202 | 4) **Check if you're using the latest version.** 203 | 204 | A bug could be fixed by some other improvements and fixes - it might not have 205 | an existing report in the bug tracker. Make sure you're using the latest 206 | release of Deux, and try the development version to see if the issue is 207 | already fixed and pending release. 208 | 209 | 5) **Collect information about the bug.** 210 | 211 | To have the best chance of having a bug fixed, we need to be able to easily 212 | reproduce the conditions that caused it. Most of the time this information 213 | will be from a Python traceback message, though some bugs might be in design, 214 | spelling or other errors on the website/docs/code. 215 | 216 | A) If the error is from a Python traceback, include it in the bug report. 217 | 218 | B) We also need to know what platform you're running (Windows, macOS, 219 | Linux, etc.), the version of your Python interpreter, and the version of 220 | Deux, and related packages that you were running when the bug occurred. 221 | 222 | 6) **Submit the bug.** 223 | 224 | By default `GitHub`_ will email you to let you know when new comments have 225 | been made on your bug. In the event you've turned this feature off, you 226 | should check back on occasion to ensure you don't miss any questions a 227 | developer trying to fix the bug might ask. 228 | 229 | .. _`GitHub`: https://github.com 230 | 231 | .. _issue-tracker: 232 | 233 | Issue Tracker 234 | ------------- 235 | 236 | The Deux issue tracker can be found at GitHub: 237 | https://github.com/robinhood/deux 238 | 239 | .. _versions: 240 | 241 | Versions 242 | ======== 243 | 244 | Version numbers consists of a major version, minor version and a release 245 | number, and conforms to the SemVer versioning spec: http://semver.org. 246 | 247 | Stable releases are published at PyPI 248 | while development releases are only available in the GitHub git repository as 249 | tags. All version tags starts with “v”, so version 0.8.0 is the tag v0.8.0. 250 | 251 | .. _git-branches: 252 | 253 | Branches 254 | ======== 255 | 256 | Current active version branches: 257 | 258 | * master (https://github.com/robinhood/deux/tree/master) 259 | 260 | You can see the state of any branch by looking at the Changelog: 261 | 262 | https://github.com/robinhood/deux/blob/master/Changelog 263 | 264 | If the branch is in active development the topmost version info should 265 | contain meta-data like: 266 | :: 267 | 268 | 2.4.0 269 | ====== 270 | :release-date: TBA 271 | :status: DEVELOPMENT 272 | :branch: master 273 | 274 | The ``status`` field can be one of: 275 | 276 | * ``PLANNING`` 277 | 278 | The branch is currently experimental and in the planning stage. 279 | 280 | * ``DEVELOPMENT`` 281 | 282 | The branch is in active development, but the test suite should 283 | be passing and the product should be working and possible for users to test. 284 | 285 | * ``FROZEN`` 286 | 287 | The branch is frozen, and no more features will be accepted. 288 | When a branch is frozen the focus is on testing the version as much 289 | as possible before it is released. 290 | 291 | ``master`` branch 292 | ----------------- 293 | 294 | The master branch is where development of the next version happens. 295 | 296 | Maintenance branches 297 | -------------------- 298 | 299 | Maintenance branches are named after the version, e.g. the maintenance branch 300 | for the 2.2.x series is named ``2.2``. Previously these were named 301 | ``releaseXX-maint``. 302 | 303 | The versions we currently maintain is: 304 | 305 | * 1.0 306 | 307 | This is the current series. 308 | 309 | Archived branches 310 | ----------------- 311 | 312 | Archived branches are kept for preserving history only, 313 | and theoretically someone could provide patches for these if they depend 314 | on a series that is no longer officially supported. 315 | 316 | An archived version is named ``X.Y-archived``. 317 | 318 | Deux does not currently have any archived branches. 319 | 320 | 321 | Feature branches 322 | ---------------- 323 | 324 | Major new features are worked on in dedicated branches. 325 | There is no strict naming requirement for these branches. 326 | 327 | Feature branches are removed once they have been merged into a release branch. 328 | 329 | Tags 330 | ==== 331 | 332 | Tags are used exclusively for tagging releases. A release tag is 333 | named with the format ``vX.Y.Z``, e.g. ``v2.3.1``. 334 | Experimental releases contain an additional identifier ``vX.Y.Z-id``, e.g. 335 | ``v3.0.0-rc1``. Experimental tags may be removed after the official release. 336 | 337 | .. _contributing-changes: 338 | 339 | Working on Features & Patches 340 | ============================= 341 | 342 | .. note:: 343 | 344 | Contributing to Deux should be as simple as possible, 345 | so none of these steps should be considered mandatory. 346 | 347 | You can even send in patches by email if that is your preferred 348 | work method. We won't like you any less, any contribution you make 349 | is always appreciated! 350 | 351 | However following these steps may make maintainers life easier, 352 | and may mean that your changes will be accepted sooner. 353 | 354 | Forking and setting up the repository 355 | ------------------------------------- 356 | 357 | First you need to fork the Deux repository, a good introduction to this 358 | is in the GitHub Guide: `Fork a Repo`_. 359 | 360 | After you have cloned the repository you should checkout your copy 361 | to a directory on your machine: 362 | :: 363 | 364 | $ git clone git@github.com:username/deux.git 365 | 366 | When the repository is cloned enter the directory to set up easy access 367 | to upstream changes: 368 | :: 369 | 370 | $ cd deux 371 | $ git remote add upstream git://github.com/robinhood/deux.git 372 | $ git fetch upstream 373 | 374 | If you need to pull in new changes from upstream you should 375 | always use the ``--rebase`` option to ``git pull``: 376 | :: 377 | 378 | git pull --rebase upstream master 379 | 380 | With this option you don't clutter the history with merging 381 | commit notes. See `Rebasing merge commits in git`_. 382 | If you want to learn more about rebasing see the `Rebase`_ 383 | section in the GitHub guides. 384 | 385 | If you need to work on a different branch than ``master`` you can 386 | fetch and checkout a remote branch like this:: 387 | 388 | git checkout --track -b 3.0-devel origin/3.0-devel 389 | 390 | .. _`Fork a Repo`: http://help.github.com/fork-a-repo/ 391 | .. _`Rebasing merge commits in git`: 392 | http://notes.envato.com/developers/rebasing-merge-commits-in-git/ 393 | .. _`Rebase`: http://help.github.com/rebase/ 394 | 395 | .. _contributing-testing: 396 | 397 | Running the unit test suite 398 | --------------------------- 399 | 400 | To run the Deux test suite you need to install a few dependencies. 401 | A complete list of the dependencies needed are located in 402 | ``requirements/test.txt``. 403 | 404 | If you're working on the development version, then you need to 405 | install the development requirements first: 406 | :: 407 | 408 | $ pip install -U -r requirements/dev.txt 409 | 410 | Both the stable and the development version have testing related 411 | dependencies, so install these next: 412 | :: 413 | 414 | $ pip install -U -r requirements/test.txt 415 | $ pip install -U -r requirements/default.txt 416 | 417 | After installing the dependencies required, you can now execute 418 | the test suite by calling: 419 | :: 420 | 421 | $ python setup.py test 422 | 423 | This will run all of the test. 424 | 425 | .. _contributing-pull-requests: 426 | 427 | Creating pull requests 428 | ---------------------- 429 | 430 | When your feature/bugfix is complete you may want to submit 431 | a pull requests so that it can be reviewed by the maintainers. 432 | 433 | Creating pull requests is easy, and also let you track the progress 434 | of your contribution. Read the `Pull Requests`_ section in the GitHub 435 | Guide to learn how this is done. 436 | 437 | You can also attach pull requests to existing issues by following 438 | the steps outlined here: http://bit.ly/koJoso 439 | 440 | .. _`Pull Requests`: http://help.github.com/send-pull-requests/ 441 | 442 | .. _contributing-coverage: 443 | 444 | Calculating test coverage 445 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 446 | 447 | To calculate test coverage you must first install the ``coverage`` module. 448 | 449 | Installing the ``coverage`` module: 450 | :: 451 | 452 | $ pip install -U coverage 453 | 454 | Code coverage in HTML: 455 | :: 456 | 457 | $ make cov 458 | 459 | The coverage output will then be located at 460 | ``cover/index.html``. 461 | 462 | .. _contributing-tox: 463 | 464 | Running the tests on all supported Python versions 465 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 466 | 467 | There is a ``tox`` configuration file in the top directory of the 468 | distribution. 469 | 470 | To run the tests for all supported Python versions simply execute: 471 | :: 472 | 473 | $ tox 474 | 475 | Use the ``tox -e`` option if you only want to test specific Python versions: 476 | :: 477 | 478 | $ tox -e 2.7 479 | 480 | Building the documentation 481 | -------------------------- 482 | 483 | To build the documentation you need to install the dependencies 484 | listed in ``requirements/docs.txt``: 485 | :: 486 | 487 | $ pip install -U -r requirements/docs.txt 488 | 489 | After these dependencies are installed you should be able to 490 | build the docs by running: 491 | :: 492 | 493 | $ cd docs 494 | $ rm -rf _build 495 | $ make html 496 | 497 | Make sure there are no errors or warnings in the build output. 498 | After building succeeds the documentation is available at ``_build/html``. 499 | 500 | .. _contributing-verify: 501 | 502 | Verifying your contribution 503 | --------------------------- 504 | 505 | To use these tools you need to install a few dependencies. These dependencies 506 | can be found in ``requirements/pkgutils.txt``. 507 | 508 | Installing the dependencies: 509 | :: 510 | 511 | $ pip install -U -r requirements/pkgutils.txt 512 | 513 | pyflakes & PEP8 514 | ~~~~~~~~~~~~~~~ 515 | 516 | To ensure that your changes conform to PEP8 and to run pyflakes 517 | execute: 518 | :: 519 | 520 | $ make flakecheck 521 | 522 | To not return a negative exit code when this command fails use 523 | the ``flakes`` target instead: 524 | :: 525 | 526 | $ make flakes 527 | 528 | API reference 529 | ~~~~~~~~~~~~~ 530 | 531 | To make sure that all modules have a corresponding section in the API 532 | reference please execute: 533 | :: 534 | 535 | $ make apicheck 536 | $ make configcheck 537 | 538 | If files are missing you can add them by copying an existing reference file. 539 | 540 | If the module is internal it should be part of the internal reference 541 | located in ``docs/internals/reference/``. If the module is public 542 | it should be located in ``docs/reference/``. 543 | 544 | For example if reference is missing for the module ``deux.awesome`` 545 | and this module is considered part of the public API, use the following steps: 546 | 547 | 548 | Use an existing file as a template: 549 | :: 550 | 551 | $ cd docs/reference/ 552 | $ cp deux.request.rst deux.awesome.rst 553 | 554 | Edit the file using your favorite editor: 555 | :: 556 | 557 | $ vim deux.awesome.rst 558 | 559 | # change every occurrence of ``deux.request`` to 560 | # ``deux.awesome`` 561 | 562 | 563 | Edit the index using your favorite editor: 564 | :: 565 | 566 | $ vim index.rst 567 | 568 | # Add ``deux.awesome`` to the index. 569 | 570 | 571 | Commit your changes: 572 | :: 573 | 574 | # Add the file to git 575 | $ git add deux.awesome.rst 576 | $ git add index.rst 577 | $ git commit deux.awesome.rst index.rst \ 578 | -m "Adds reference for deux.awesome" 579 | 580 | .. _coding-style: 581 | 582 | Coding Style 583 | ============ 584 | 585 | You should probably be able to pick up the coding style 586 | from surrounding code, but it is a good idea to be aware of the 587 | following conventions. 588 | 589 | * All Python code must follow the `PEP-8`_ guidelines. 590 | 591 | `pep8.py`_ is an utility you can use to verify that your code 592 | is following the conventions. 593 | 594 | .. _`PEP-8`: http://www.python.org/dev/peps/pep-0008/ 595 | .. _`pep8.py`: http://pypi.python.org/pypi/pep8 596 | 597 | * Docstrings must follow the `PEP-257`_ conventions, and use the following 598 | style. 599 | 600 | Do this: 601 | :: 602 | 603 | def method(self, arg): 604 | """Short description. 605 | 606 | More details. 607 | 608 | """ 609 | 610 | or: 611 | :: 612 | 613 | def method(self, arg): 614 | """Short description.""" 615 | 616 | 617 | but not this: 618 | :: 619 | 620 | def method(self, arg): 621 | """ 622 | Short description. 623 | """ 624 | 625 | .. _`PEP-257`: http://www.python.org/dev/peps/pep-0257/ 626 | 627 | * Lines should not exceed 78 columns. 628 | 629 | You can enforce this in ``vim`` by setting the ``textwidth`` option: 630 | :: 631 | 632 | set textwidth=78 633 | 634 | If adhering to this limit makes the code less readable, you have one more 635 | character to go on, which means 78 is a soft limit, and 79 is the hard 636 | limit :) 637 | 638 | * Import order 639 | 640 | * Python standard library (`import xxx`) 641 | * Python standard library ('from xxx import`) 642 | * Third-party packages. 643 | * Other modules from the current package. 644 | 645 | or in case of code using Django: 646 | 647 | * Python standard library (`import xxx`) 648 | * Python standard library ('from xxx import`) 649 | * Third-party packages. 650 | * Django packages. 651 | * Other modules from the current package. 652 | 653 | Within these sections the imports should be sorted by module name. 654 | 655 | Example: 656 | :: 657 | 658 | import threading 659 | import time 660 | 661 | from collections import deque 662 | from Queue import Queue, Empty 663 | 664 | from .datastructures import TokenBucket 665 | from .five import zip_longest, items, range 666 | from .utils import timeutils 667 | 668 | * Wild-card imports must not be used (`from xxx import *`). 669 | 670 | * For distributions where Python 2.5 is the oldest support version 671 | additional rules apply: 672 | 673 | * Absolute imports must be enabled at the top of every module:: 674 | 675 | from __future__ import absolute_import 676 | 677 | * If the module uses the ``with`` statement and must be compatible 678 | with Python 2.5 (deux is not) then it must also enable that:: 679 | 680 | from __future__ import with_statement 681 | 682 | * Every future import must be on its own line, as older Python 2.5 683 | releases did not support importing multiple features on the 684 | same future import line:: 685 | 686 | # Good 687 | from __future__ import absolute_import 688 | from __future__ import with_statement 689 | 690 | # Bad 691 | from __future__ import absolute_import, with_statement 692 | 693 | (Note that this rule does not apply if the package does not include 694 | support for Python 2.5) 695 | 696 | 697 | * Note that we use "new-style` relative imports when the distribution 698 | does not support Python versions below 2.5 699 | 700 | This requires Python 2.5 or later: 701 | :: 702 | 703 | from . import submodule 704 | 705 | 706 | .. _feature-with-extras: 707 | 708 | Contributing features requiring additional libraries 709 | ==================================================== 710 | 711 | Some features like a new result backend may require additional libraries 712 | that the user must install. 713 | 714 | We use setuptools `extra_requires` for this, and all new optional features 715 | that require third-party libraries must be added. 716 | 717 | 1) Add a new requirements file in `requirements/extras` 718 | 719 | E.g. for a Cassandra backend this would be 720 | ``requirements/extras/cassandra.txt``, and the file looks like this: 721 | :: 722 | 723 | pycassa 724 | 725 | These are pip requirement files so you can have version specifiers and 726 | multiple packages are separated by newline. A more complex example could 727 | be: 728 | :: 729 | 730 | # pycassa 2.0 breaks Foo 731 | pycassa>=1.0,<2.0 732 | thrift 733 | 734 | 2) Modify ``setup.py`` 735 | 736 | After the requirements file is added you need to add it as an option 737 | to ``setup.py`` in the ``extras_require`` section:: 738 | 739 | extra['extras_require'] = { 740 | # ... 741 | 'cassandra': extras('cassandra.txt'), 742 | } 743 | 744 | 3) Document the new feature in ``docs/includes/installation.txt`` 745 | 746 | You must add your feature to the list in the Bundles section 747 | of ``docs/includes/installation.txt``. 748 | 749 | After you've made changes to this file you need to render 750 | the distro ``README`` file: 751 | :: 752 | 753 | $ pip install -U requirements/pkgutils.txt 754 | $ make readme 755 | 756 | 757 | That's all that needs to be done, but remember that if your feature 758 | adds additional configuration options then these needs to be documented 759 | in ``docs/configuration.rst``. 760 | 761 | .. _contact_information: 762 | 763 | Contacts 764 | ======== 765 | 766 | This is a list of people that can be contacted for questions 767 | regarding the official git repositories, PyPI packages 768 | Read the Docs pages. 769 | 770 | If the issue is not an emergency then it is better 771 | to `report an issue`_. 772 | 773 | 774 | Committers 775 | ---------- 776 | 777 | Abhishek Fatehpuria 778 | ~~~~~~~~~~~~~~~~~~~ 779 | 780 | :github: https://github.com/abhishek776 781 | 782 | 783 | Jamshed Vesuna 784 | ~~~~~~~~~~~~~~ 785 | 786 | :github: https://github.com/JamshedVesuna 787 | 788 | .. _packages: 789 | 790 | Packages 791 | ======== 792 | 793 | ``Deux`` 794 | --------- 795 | 796 | :git: https://github.com/robinhood/deux 797 | :CI: https://travis-ci.org/#!/robinhood/deux 798 | :Windows-CI: https://ci.appveyor.com/project/robinhood/deux 799 | :PyPI: https://pypi.python.org/pypi/deux 800 | :docs: https://deux.readthedocs.io 801 | 802 | .. _release-procedure: 803 | 804 | 805 | Release Procedure 806 | ================= 807 | 808 | Updating the version number 809 | --------------------------- 810 | 811 | The version number must be updated two places: 812 | 813 | * ``deux/__init__.py`` 814 | * ``docs/include/introduction.txt`` 815 | 816 | After you have changed these files you must render 817 | the ``README`` files. There is a script to convert Sphinx syntax 818 | to generic reStructured Text syntax, and the make target `readme` 819 | does this for you: 820 | :: 821 | 822 | $ make readme 823 | 824 | Now commit the changes: 825 | :: 826 | 827 | $ git commit -a -m "Bumps version to X.Y.Z" 828 | 829 | and make a new version tag: 830 | :: 831 | 832 | $ git tag vX.Y.Z 833 | $ git push --tags 834 | 835 | Releasing 836 | --------- 837 | 838 | Commands to make a new public stable release: 839 | :: 840 | 841 | $ make distcheck # checks pep8, autodoc index, runs tests and more 842 | $ make dist # NOTE: Runs git clean -xdf and removes files not in the repo. 843 | $ python setup.py sdist upload --sign --identity='Ask Solem' 844 | $ python setup.py bdist_wheel upload --sign --identity='Ask Solem' 845 | 846 | If this is a new release series then you also need to do the 847 | following: 848 | 849 | * Go to the Read The Docs management interface at: 850 | http://readthedocs.org/projects/deux 851 | 852 | * Enter "Edit project" 853 | 854 | Change default branch to the branch of this series, e.g. ``2.4`` 855 | for series 2.4. 856 | 857 | * Also add the previous version under the "versions" tab. 858 | 859 | .. _`report an issue`: https://deux.readthedocs.io/en/latest//contributing.html#reporting-bugs 860 | 861 | --------------------------------------------------------------------------------