├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── django.yml ├── .gitignore ├── .mergify.yml ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── account_security ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── api_key.png ├── clients └── twilio_client.py ├── docker-compose.yml ├── manage.py ├── phone_verification ├── __init__.py ├── apps.py ├── forms.py ├── templates │ ├── phone_verification.html │ ├── token_validation.html │ └── verified.html ├── tests.py └── views.py ├── requirements-dev.txt ├── requirements.txt ├── static ├── favicon-16x16.png ├── favicon-32x32.png └── favicon.ico └── twofa ├── __init__.py ├── apps.py ├── decorators.py ├── forms.py ├── managers.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── templates ├── 2fa.html ├── base.html ├── home.html ├── protected.html ├── register.html └── registration │ └── login.html ├── tests.py └── views.py /.env.example: -------------------------------------------------------------------------------- 1 | # You can get/create one here : 2 | # https://www.twilio.com/console/authy/applications 3 | ACCOUNT_SECURITY_API_KEY='ENTER_SECRET_HERE' 4 | 5 | # Twilio API credentials 6 | # (find here https://www.twilio.com/console) 7 | TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 8 | TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 9 | 10 | # Verification Service SID 11 | # (create one here https://www.twilio.com/console/verify/services) 12 | TWILIO_VERIFICATION_SID=VAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: pre-commit 10 | versions: 11 | - 2.10.0 12 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django 2 | 3 | on: 4 | push: 5 | branches: [ master, next ] 6 | pull_request: 7 | branches: [ master, next ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.platform }} 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: [3.6, 3.7, 3.8] 17 | platform: [windows-latest, macos-latest, ubuntu-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v1 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install Dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | - name: Set up project 30 | run: | 31 | cp .env.example .env 32 | python manage.py migrate 33 | - name: Run Tests 34 | run: | 35 | python manage.py test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | 4 | # virtualenv 5 | venv 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Django stuff: 50 | *.log 51 | db.sqlite3 52 | 53 | .vscode 54 | .tool-versions 55 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge for Dependabot pull requests 3 | conditions: 4 | - author=dependabot-preview[bot] 5 | - status-success=build (3.6, macos-latest) 6 | - status-success=build (3.7, macos-latest) 7 | - status-success=build (3.8, macos-latest) 8 | - status-success=build (3.6, windows-latest) 9 | - status-success=build (3.7, windows-latest) 10 | - status-success=build (3.8, windows-latest) 11 | - status-success=build (3.6, ubuntu-latest) 12 | - status-success=build (3.7, ubuntu-latest) 13 | - status-success=build (3.8, ubuntu-latest) 14 | actions: 15 | merge: 16 | method: squash 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git@github.com:pre-commit/pre-commit-hooks 2 | sha: e718847ccbc65cd72a89fefb4db61c55ec4d430d 3 | hooks: 4 | - id: trailing-whitespace 5 | exclude: \.html$ 6 | - id: end-of-file-fixer 7 | exclude: \.html$ 8 | - id: autopep8-wrapper 9 | args: ['-i', '--ignore=E309,E501'] 10 | - id: check-json 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: requirements-txt-fixer 14 | - id: flake8 15 | exclude: > 16 | (?x)^( 17 | .*\/migrations\/.*| 18 | account_security/settings.py 19 | )$ 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at open-source@twilio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Twilio 2 | 3 | All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | WORKDIR /usr/src/app 6 | 7 | COPY requirements.txt ./ 8 | 9 | RUN pip3 install -r requirements.txt 10 | 11 | COPY . . 12 | 13 | RUN python3 manage.py migrate 14 | 15 | EXPOSE 8000 16 | 17 | CMD ["sh", "-c", "python3 manage.py runserver 0.0.0.0:8000"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Twilio Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: venv install serve-setup serve 2 | UNAME := $(shell uname) 3 | venv: 4 | ifeq ($(UNAME), Windows) 5 | py -3 -m venv venv; 6 | else 7 | python3 -m venv venv 8 | endif 9 | install: venv 10 | ifeq ($(UNAME), Windows) 11 | venv\Scripts\activate.bat; \ 12 | pip3 install -r requirements.txt; 13 | else 14 | . venv/bin/activate; \ 15 | pip3 install -r requirements.txt; 16 | endif 17 | 18 | serve-setup: 19 | . venv/bin/activate; \ 20 | python3 manage.py migrate; 21 | 22 | serve: 23 | . venv/bin/activate; \ 24 | python3 manage.py runserver 0.0.0.0:8000; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | # Twilio Account Security Quickstart - Two-Factor Authentication and Phone Verification 6 | 7 | ![](https://github.com/TwilioDevEd/account-security-quickstart-django/workflows/Django/badge.svg) 8 | 9 | > This template is part of Twilio CodeExchange. If you encounter any issues with this code, please open an issue at [github.com/twilio-labs/code-exchange/issues](https://github.com/twilio-labs/code-exchange/issues). 10 | 11 | ## About 12 | 13 | A simple Python and Django implementation of a website that uses Twilio Account Security services to protect all assets within a folder. Additionally, it shows a Phone Verification implementation. 14 | 15 | It uses four channels for delivery: SMS, Voice, Soft Tokens, and Push Notifications. You should have the [Authy App](https://authy.com/download/) installed to try Soft Token and Push Notification support. 16 | 17 | Learn more about Account Security and when to use the Authy API vs the Verify API in the [Account Security documentation](https://www.twilio.com/docs/verify/authy-vs-verify). 18 | 19 | Implementations in other languages: 20 | 21 | | .NET | Java | Node | PHP | Ruby | 22 | | :--- | :--- | :----- | :-- | :--- | 23 | | TBD | [Done](https://github.com/TwilioDevEd/account-security-quickstart-spring) | [Done](https://github.com/TwilioDevEd/account-security-quickstart-node) | [Done](https://github.com/TwilioDevEd/account-security-quickstart-php) | [Done](https://github.com/TwilioDevEd/account-security-quickstart-rails) | 24 | 25 | ## Features 26 | 27 | #### Two-Factor Authentication Demo 28 | - URL path "/protected" is protected with both user session and Twilio Two-Factor Authentication 29 | - One Time Passwords (SMS and Voice) 30 | - SoftTokens 31 | - Push Notifications (via polling) 32 | 33 | #### Phone Verification 34 | - Phone Verification 35 | - SMS or Voice Call 36 | 37 | ## Set up 38 | 39 | ### Requirements 40 | - This project only runs on Python 3.6+. In some environments when both version 2 41 | and 3 are installed, you may substitute the Python executables below with 42 | `python3` and `pip3` unless you use a version manager such as 43 | [pyenv](https://github.com/pyenv/pyenv). 44 | 45 | ### Twilio Account Settings 46 | 47 | This application should give you a ready-made starting point for writing your own application. 48 | Before we begin, we need to collect all the config values we need to run the application: 49 | 50 | | Config Value | Description | 51 | | :---------- | :---------- | 52 | | ACCOUNT_SECURITY_API_KEY | Create a new Authy application in the [console](https://www.twilio.com/console/authy/). After you give it a name you can view the generated Account Security production API key. This is the string you will later need to set up in your environmental variables.| 53 | 54 | ![Get Authy API Key](api_key.png) 55 | 56 | ### Local Development 57 | 58 | 1. Clone this repo and `cd` into it. 59 | 60 | ```bash 61 | git clone https://github.com/TwilioDevEd/account-security-quickstart-django.git 62 | cd account-security-quickstart-django 63 | ``` 64 | 65 | 2. Create the virtual environment, load it and install dependencies. 66 | 67 | ```bash 68 | make install 69 | ``` 70 | 71 | 3. Set your environment variables. Copy the `env.example` file and edit it. 72 | 73 | ```bash 74 | cp .env.example .env 75 | ``` 76 | 77 | See [Twilio Account Settings](#twilio-account-settings) to locate the necessary environment variables. 78 | 79 | 4. Run migrations. 80 | 81 | ```bash 82 | make serve-setup 83 | ``` 84 | 85 | 5. Start the development server. Before running the following command, make sure the virtual environment is activated. 86 | 87 | ```bash 88 | make serve 89 | ``` 90 | 91 | 6. The application should now be running on http://localhost:8000/, here you can 92 | register a new user account and proceed with a phone verification. 93 | 94 | That's it! 95 | 96 | ### Docker 97 | 98 | If you have [Docker](https://www.docker.com/) already installed on your machine, you can use our `docker-compose.yml` to setup your project. 99 | 100 | 1. Make sure you have the project cloned. 101 | 2. Setup the `.env` file as outlined in the [Local Development](#local-development) steps. 102 | 3. Run `docker-compose up`. 103 | 104 | ### Tests 105 | 106 | You can run the tests locally by typing the following command, make sure the virtual environment is activated. 107 | 108 | ```bash 109 | python3 manage.py test 110 | ``` 111 | 112 | 113 | ### Cloud deployment 114 | 115 | Additionally to trying out this application locally, you can deploy it to a variety of host services. Here is a small selection of them. 116 | 117 | Please be aware that some of these might charge you for the usage or might make the source code for this application visible to the public. When in doubt research the respective hosting service first. 118 | 119 | | Service | | 120 | | :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 121 | | [Heroku](https://www.heroku.com/) | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) | 122 | 123 | ## Resources 124 | 125 | - The CodeExchange repository can be found [here](https://github.com/twilio-labs/code-exchange/). 126 | 127 | ## Contributing 128 | 129 | This template is open source and welcomes contributions. All contributions are subject to our [Code of Conduct](https://github.com/twilio-labs/.github/blob/master/CODE_OF_CONDUCT.md). 130 | 131 | ## License 132 | 133 | [MIT](http://www.opensource.org/licenses/mit-license.html) 134 | 135 | ## Disclaimer 136 | 137 | No warranty expressed or implied. Software is as is. 138 | 139 | [twilio]: https://www.twilio.com 140 | -------------------------------------------------------------------------------- /account_security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/account_security/__init__.py -------------------------------------------------------------------------------- /account_security/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for account_security project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | from dotenv import load_dotenv 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | load_dotenv(os.path.join(BASE_DIR, '.env')) 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = '!@v)i0w+*@ld2tsr#l-e6-zp$a(k*%qt68+ago0i(yn6z9g35_' 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | 'django.contrib.admin', 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | 44 | 'twofa', 45 | 'phone_verification', 46 | 'clients' 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | 'django.middleware.security.SecurityMiddleware', 51 | 'django.contrib.sessions.middleware.SessionMiddleware', 52 | 'django.middleware.common.CommonMiddleware', 53 | 'django.middleware.csrf.CsrfViewMiddleware', 54 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 55 | 'django.contrib.messages.middleware.MessageMiddleware', 56 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 57 | ] 58 | 59 | ROOT_URLCONF = 'account_security.urls' 60 | 61 | TEMPLATES = [ 62 | { 63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 64 | 'DIRS': [], 65 | 'APP_DIRS': True, 66 | 'OPTIONS': { 67 | 'context_processors': [ 68 | 'django.template.context_processors.debug', 69 | 'django.template.context_processors.request', 70 | 'django.contrib.auth.context_processors.auth', 71 | 'django.contrib.messages.context_processors.messages', 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = 'account_security.wsgi.application' 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 82 | 83 | DATABASES = { 84 | 'default': { 85 | 'ENGINE': 'django.db.backends.sqlite3', 86 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 106 | }, 107 | ] 108 | 109 | 110 | # Internationalization 111 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 112 | 113 | LANGUAGE_CODE = 'en-us' 114 | 115 | TIME_ZONE = 'UTC' 116 | 117 | USE_I18N = True 118 | 119 | USE_L10N = True 120 | 121 | USE_TZ = True 122 | 123 | LOGIN_URL = '/login/' 124 | LOGOUT_URL = '/logout/' 125 | LOGIN_REDIRECT_URL = '/protected/' 126 | LOGOUT_REDIRECT_URL = '/login/' 127 | 128 | # Static files (CSS, JavaScript, Images) 129 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 130 | 131 | STATIC_URL = '/static/' 132 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 133 | 134 | 135 | # Use our custom User Model 136 | AUTH_USER_MODEL = 'twofa.TwoFAUser' 137 | 138 | # Authy Application Key 139 | ACCOUNT_SECURITY_API_KEY = os.environ.get('ACCOUNT_SECURITY_API_KEY') 140 | TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID') 141 | TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN') 142 | TWILIO_VERIFICATION_SID = os.environ.get('TWILIO_VERIFICATION_SID') 143 | -------------------------------------------------------------------------------- /account_security/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path 3 | from django.conf.urls.static import static 4 | from django.contrib.auth import views as auth_views 5 | 6 | from twofa import views as twofa_views 7 | from phone_verification import views as verify_views 8 | 9 | urlpatterns = [ 10 | path('login/', auth_views.LoginView.as_view(), name='login'), 11 | path('logout/', auth_views.LogoutView.as_view(), name='logout'), 12 | 13 | path('register/', twofa_views.register, name='register'), 14 | path('2fa/', twofa_views.twofa, name='2fa'), 15 | path('token/sms', twofa_views.token_sms, name='token-sms'), 16 | path('token/voice', twofa_views.token_voice, name='token-voice'), 17 | path('token/onetouch', twofa_views.token_onetouch, name='token-onetouch'), # noqa: E501 18 | path('protected/', twofa_views.protected, name='protected'), 19 | path('onetouch-status', twofa_views.onetouch_status, name='onetouch-status'), # noqa: E501 20 | 21 | path('verification/', verify_views.phone_verification, name='phone_verification'), # noqa: E501 22 | path('verification/token/', verify_views.token_validation, name='token_validation'), # noqa: E501 23 | path('verified/', verify_views.verified, name='verified'), 24 | 25 | path('', twofa_views.home, name='home'), 26 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 27 | -------------------------------------------------------------------------------- /account_security/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for account_security 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.11/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", "account_security.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /api_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/api_key.png -------------------------------------------------------------------------------- /clients/twilio_client.py: -------------------------------------------------------------------------------- 1 | from twilio.rest import Client 2 | from django.conf import settings 3 | 4 | client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) 5 | 6 | def verifications(phone_number, via): 7 | return client.verify \ 8 | .services(settings.TWILIO_VERIFICATION_SID) \ 9 | .verifications \ 10 | .create(to=phone_number, channel=via) 11 | 12 | def verification_checks(phone_number, token): 13 | return client.verify \ 14 | .services(settings.TWILIO_VERIFICATION_SID) \ 15 | .verification_checks \ 16 | .create(to=phone_number, code=token) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | app: 4 | build: . 5 | ports: 6 | - "8000:8000" -------------------------------------------------------------------------------- /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", 7 | "account_security.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django # noqa: F401 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) 22 | raise 23 | execute_from_command_line(sys.argv) 24 | -------------------------------------------------------------------------------- /phone_verification/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/phone_verification/__init__.py -------------------------------------------------------------------------------- /phone_verification/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PhoneVerificationConfig(AppConfig): 5 | name = 'phone_verification' 6 | -------------------------------------------------------------------------------- /phone_verification/forms.py: -------------------------------------------------------------------------------- 1 | import phonenumbers 2 | 3 | from django import forms 4 | from phonenumbers import NumberParseException 5 | from twofa.forms import BootstrapInput 6 | 7 | 8 | class BootstrapSelect(forms.Select): 9 | def __init__(self, size=12, *args, **kwargs): 10 | self.size = size 11 | super(BootstrapSelect, self).__init__(attrs={ 12 | 'class': 'form-control input-sm', 13 | }) 14 | 15 | def bootwrap_input(self, input_tag): 16 | classes = 'col-xs-{n} col-sm-{n} col-md-{n}'.format(n=self.size) 17 | 18 | return '''
19 |
{input_tag}
20 |
21 | '''.format(classes=classes, input_tag=input_tag) 22 | 23 | def render(self, *args, **kwargs): 24 | input_tag = super(BootstrapSelect, self).render(*args, **kwargs) 25 | return self.bootwrap_input(input_tag) 26 | 27 | 28 | class VerificationForm(forms.Form): 29 | phone_number = forms.CharField( 30 | widget=forms.HiddenInput()) 31 | via = forms.ChoiceField( 32 | choices=[('sms', 'SMS'), ('call', 'Call')], 33 | widget=BootstrapSelect(size=3)) 34 | 35 | def clean(self): 36 | data = self.cleaned_data 37 | phone_number = data['phone_number'] 38 | try: 39 | phone_number = phonenumbers.parse(phone_number, None) 40 | if not phonenumbers.is_valid_number(phone_number): 41 | self.add_error('phone_number', 'Invalid phone number') 42 | except NumberParseException as e: 43 | self.add_error('phone_number', e) 44 | 45 | 46 | class TokenForm(forms.Form): 47 | token = forms.CharField( 48 | widget=BootstrapInput('Verification Token', size=6)) 49 | -------------------------------------------------------------------------------- /phone_verification/templates/phone_verification.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Phone Verification

9 |
10 |
11 |
12 | {% csrf_token %} 13 | 14 | {{ form.non_field_errors }} 15 | {{ form.phone_number.errors }} 16 | {{ form.via.errors }} 17 | 18 |
19 | 20 | {{ form.via }} 21 |
22 | 23 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | 38 | {% endblock %} 39 | 40 | {% block header %} 41 | 42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /phone_verification/templates/token_validation.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Phone Verification

9 |
10 | 11 |
12 |
13 | {% csrf_token %} 14 | 15 | {{ form.non_field_errors }} 16 | {{ form.token.errors}} 17 | 18 |
19 | {{ form.token }} 20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 | {% endblock %} -------------------------------------------------------------------------------- /phone_verification/templates/verified.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Protected by Twilio Phone Verification

9 |
10 |
11 |
12 |
13 |

14 | Congratulations. You have verified your Phone! 15 |

16 | 23 |
24 |
25 |
26 |
27 | Logout 28 |
29 |
30 |
31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /phone_verification/tests.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase, Client 3 | try: 4 | from unittest.mock import patch, MagicMock, Mock 5 | except ImportError: 6 | from mock import patch, MagicMock 7 | 8 | from . import views 9 | from .forms import VerificationForm 10 | 11 | class MockVerificationForm: 12 | cleaned_data = {"phone_number": "+593999999999", "via": "SMS"} 13 | 14 | def is_valid(self): 15 | return True 16 | 17 | class MockTokenForm: 18 | cleaned_data = {"phone_number": "+593999999999", "via": "SMS", "token":"token"} 19 | 20 | def is_valid(self): 21 | return True 22 | 23 | def add_error(self, param1, param2): 24 | return "added error" 25 | 26 | class MockVerification: 27 | def __init__(self, status_value): 28 | self.status = status_value 29 | 30 | def errors(self): 31 | return MockErrorValues() 32 | 33 | class MockErrorValues: 34 | def values(self): 35 | return "error" 36 | 37 | class PhoneVerificationTestCase(TestCase): 38 | 39 | @patch('phone_verification.views.twilio_client.verifications', return_value='mock verifications!') 40 | @patch('phone_verification.views.VerificationForm', return_value=MockVerificationForm()) 41 | def test_phone_verification_redirects_to_token_validation(self, mock_verification_form, mock_twilio_client): 42 | client = Client() 43 | 44 | response = client.post('/verification/') 45 | 46 | mock_twilio_client.assert_called_with('+593999999999', "SMS") 47 | assert response.status_code == 302 48 | assert '/verification/token/' in response.url 49 | 50 | def test_phone_verification_render_phone_verification_for_different_method(self): 51 | client = Client() 52 | 53 | response = client.get('/verification/') 54 | self.assertTemplateUsed(response, 'phone_verification.html') 55 | 56 | @patch('phone_verification.views.twilio_client.verification_checks', return_value=MockVerification("approved")) 57 | @patch('phone_verification.views.TokenForm', return_value=MockTokenForm()) 58 | def test_token_validation_redirects_to_verified_when_status_not_approved(self, mock_token_form, mock_twilio_client): 59 | client = Client() 60 | session = client.session 61 | session['phone_number'] = "1234" 62 | session.save() 63 | 64 | response = client.post('/verification/token/') 65 | 66 | assert client.session['is_verified'] == True 67 | assert response.status_code == 302 68 | assert '/verified/' in response.url 69 | 70 | @patch('phone_verification.views.twilio_client.verification_checks', return_value=MockVerification("pending")) 71 | @patch('phone_verification.views.TokenForm', return_value=MockTokenForm()) 72 | def test_token_validation_error_when_status_not_approved(self, mock_token_form, mock_twilio_client): 73 | client = Client() 74 | session = client.session 75 | session['phone_number'] = "1234" 76 | session.save() 77 | 78 | response = client.post('/verification/token/') 79 | mock_twilio_client.assert_called() 80 | self.assertTemplateUsed(response, 'token_validation.html') 81 | 82 | def test_token_validation_render_phone_verification_for_different_method(self): 83 | client = Client() 84 | 85 | response = client.get('/verification/') 86 | self.assertTemplateUsed(response, 'phone_verification.html') 87 | -------------------------------------------------------------------------------- /phone_verification/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | 3 | from clients import twilio_client 4 | from .forms import VerificationForm, TokenForm 5 | 6 | 7 | def phone_verification(request): 8 | if request.method == 'POST': 9 | form = VerificationForm(request.POST) 10 | if form.is_valid(): 11 | request.session['phone_number'] = form.cleaned_data['phone_number'] 12 | verification = twilio_client.verifications(form.cleaned_data['phone_number'], form.cleaned_data['via']) 13 | return redirect('token_validation') 14 | else: 15 | form = VerificationForm() 16 | return render(request, 'phone_verification.html', {'form': form}) 17 | 18 | 19 | def token_validation(request): 20 | if request.method == 'POST': 21 | form = TokenForm(request.POST) 22 | if form.is_valid(): 23 | verification = twilio_client.verification_checks(request.session['phone_number'], form.cleaned_data['token']) 24 | 25 | if verification.status == 'approved': 26 | request.session['is_verified'] = True 27 | return redirect('verified') 28 | else: 29 | for error_msg in verification.errors().values(): 30 | form.add_error(None, error_msg) 31 | else: 32 | form = TokenForm() 33 | return render(request, 'token_validation.html', {'form': form}) 34 | 35 | 36 | def verified(request): 37 | if not request.session.get('is_verified'): 38 | return redirect('phone_verification') 39 | return render(request, 'verified.html') 40 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8==3.9.1 2 | pre-commit==2.12.1 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | authy==2.2.6 2 | Django==3.2 3 | phonenumbers==8.12.21 4 | python-dotenv==0.17.0 5 | twilio==6.57.0 6 | -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/static/favicon.ico -------------------------------------------------------------------------------- /twofa/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/twofa/__init__.py -------------------------------------------------------------------------------- /twofa/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TwofaConfig(AppConfig): 5 | name = 'twofa' 6 | -------------------------------------------------------------------------------- /twofa/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from django.conf import settings 3 | from django.shortcuts import redirect 4 | 5 | 6 | def twofa_required(view): 7 | @functools.wraps(view) 8 | def wrapper(request, *args, **kwargs): 9 | if not request.user.is_authenticated: 10 | return redirect(settings.LOGIN_URL) 11 | if request.session.get('authy', False): 12 | return view(request, *args, **kwargs) 13 | return redirect('2fa') 14 | return wrapper 15 | -------------------------------------------------------------------------------- /twofa/forms.py: -------------------------------------------------------------------------------- 1 | import phonenumbers 2 | 3 | from authy.api import AuthyApiClient 4 | from django import forms 5 | from django.conf import settings 6 | from phonenumbers.phonenumberutil import NumberParseException 7 | 8 | from .models import TwoFAUser 9 | 10 | 11 | authy_api = AuthyApiClient(settings.ACCOUNT_SECURITY_API_KEY) 12 | 13 | 14 | class BootstrapInput(forms.TextInput): 15 | def __init__(self, placeholder, size=12, *args, **kwargs): 16 | self.size = size 17 | super(BootstrapInput, self).__init__(attrs={ 18 | 'class': 'form-control input-sm', 19 | 'placeholder': placeholder 20 | }) 21 | 22 | def bootwrap_input(self, input_tag): 23 | classes = 'col-xs-{n} col-sm-{n} col-md-{n}'.format(n=self.size) 24 | 25 | return '''
26 |
{input_tag}
27 |
28 | '''.format(classes=classes, input_tag=input_tag) 29 | 30 | def render(self, *args, **kwargs): 31 | input_tag = super(BootstrapInput, self).render(*args, **kwargs) 32 | return self.bootwrap_input(input_tag) 33 | 34 | 35 | class BootstrapPasswordInput(BootstrapInput): 36 | input_type = 'password' 37 | template_name = 'django/forms/widgets/password.html' 38 | 39 | 40 | class RegistrationForm(forms.ModelForm): 41 | class Meta: 42 | model = TwoFAUser 43 | fields = ('username', 'email', 'password') 44 | widgets = { 45 | 'username': BootstrapInput('User Name'), 46 | 'email': BootstrapInput('Email Address'), 47 | 'password': BootstrapPasswordInput('Password', size=6), 48 | } 49 | 50 | country_code = forms.CharField( 51 | widget=BootstrapInput('Country Code', size=6)) 52 | phone_number = forms.CharField( 53 | widget=BootstrapInput('Phone Number', size=6)) 54 | confirm_password = forms.CharField( 55 | widget=BootstrapPasswordInput('Confirm Password', size=6)) 56 | 57 | def clean_username(self): 58 | username = self.cleaned_data['username'] 59 | if TwoFAUser.objects.filter(username=username).exists(): 60 | self.add_error('username', 'Username is already taken') 61 | return username 62 | 63 | def clean_country_code(self): 64 | country_code = self.cleaned_data['country_code'] 65 | if not country_code.startswith('+'): 66 | country_code = '+' + country_code 67 | return country_code 68 | 69 | def clean(self): 70 | data = self.cleaned_data 71 | if data['password'] != data['confirm_password']: 72 | self.add_error( 73 | 'password', 74 | 'Password and confirmation did not match' 75 | ) 76 | 77 | phone_number = data['country_code'] + data['phone_number'] 78 | try: 79 | phone_number = phonenumbers.parse(phone_number, None) 80 | if not phonenumbers.is_valid_number(phone_number): 81 | self.add_error('phone_number', 'Invalid phone number') 82 | except NumberParseException as e: 83 | self.add_error('phone_number', e) 84 | 85 | 86 | class TokenVerificationForm(forms.Form): 87 | token = forms.CharField( 88 | required=True, 89 | widget=BootstrapInput('Token via SMS, Voice or SoftToken') 90 | ) 91 | 92 | def is_valid(self, authy_id): 93 | self.authy_id = authy_id 94 | return super(TokenVerificationForm, self).is_valid() 95 | 96 | def clean(self): 97 | token = self.cleaned_data['token'] 98 | verification = authy_api.tokens.verify(self.authy_id, token) 99 | if not verification.ok(): 100 | self.add_error('token', 'Invalid token') 101 | -------------------------------------------------------------------------------- /twofa/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import BaseUserManager 2 | 3 | 4 | class TwoFAUserManager(BaseUserManager): 5 | use_in_migrations = True 6 | 7 | def _create_user(self, username, email, authy_id, password, 8 | **extra_fields): 9 | if not username: 10 | raise ValueError('The given username must be set') 11 | email = self.normalize_email(email) 12 | username = self.model.normalize_username(username) 13 | user = self.model(username=username, email=email, 14 | authy_id=authy_id, **extra_fields) 15 | user.set_password(password) 16 | user.save(using=self._db) 17 | return user 18 | 19 | def create_user(self, username, email=None, authy_id=None, password=None, 20 | **extra_fields): 21 | extra_fields.setdefault('is_superuser', False) 22 | return self._create_user( 23 | username, 24 | email, 25 | authy_id, 26 | password, 27 | **extra_fields 28 | ) 29 | 30 | def create_superuser(self, username, email, authy_id, password, 31 | **extra_fields): 32 | extra_fields.setdefault('is_superuser', True) 33 | 34 | if extra_fields.get('is_superuser') is not True: 35 | raise ValueError('Superuser must have is_superuser=True.') 36 | 37 | return self._create_user( 38 | username, 39 | email, 40 | authy_id, 41 | password, 42 | **extra_fields 43 | ) 44 | -------------------------------------------------------------------------------- /twofa/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.6 on 2017-10-27 13:54 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import twofa.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0008_alter_user_username_max_length'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='TwoFAUser', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(max_length=42, unique=True)), 26 | ('email', models.EmailField(max_length=254)), 27 | ('authy_id', models.CharField(blank=True, max_length=12, null=True)), 28 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 29 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | managers=[ 35 | ('objects', twofa.models.TwoFAUserManager()), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /twofa/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/account-security-quickstart-django/e0c2b15e35beef640b31e1691d8f0d50eba26866/twofa/migrations/__init__.py -------------------------------------------------------------------------------- /twofa/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 3 | 4 | from .managers import TwoFAUserManager 5 | 6 | 7 | class TwoFAUser(AbstractBaseUser, PermissionsMixin): 8 | username = models.CharField(max_length=42, unique=True) 9 | email = models.EmailField() 10 | authy_id = models.CharField(max_length=12, null=True, blank=True) 11 | 12 | objects = TwoFAUserManager() 13 | 14 | USERNAME_FIELD = 'username' 15 | EMAIL_FIELD = 'email' 16 | -------------------------------------------------------------------------------- /twofa/templates/2fa.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 | 8 |
9 |

Token Verification

10 |
11 | 12 |
13 |
14 | {% csrf_token %} 15 | {{ form.non_field_errors }} 16 | {{ form.token.errors }} 17 | 18 |
19 | {{ form.token }} 20 |
21 | 22 |
23 |
24 | SMS 25 |
26 |
27 | Voice 28 |
29 |
30 | Push Notification 31 |
32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 | 43 | 76 | {% endblock %} -------------------------------------------------------------------------------- /twofa/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | Register User - Account Security Quickstart 7 | 8 | 9 | 10 | 12 | 14 | 15 | 16 | 17 | 20 | 25 | 26 | {% block header %} 27 | {% endblock %} 28 | 29 | 30 | 31 |
32 | {% block container %} 33 | {% endblock %} 34 |
35 | 36 | 65 | 66 | -------------------------------------------------------------------------------- /twofa/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Account Security Demos

9 |
10 |
11 |
12 |
13 |

14 | Implementations of both Verify Phone Verification and Authy Two-Factor Authentication 15 |

16 | 23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /twofa/templates/protected.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Protected by Twilio Account Security

9 |
10 |
11 |
12 |
13 |

14 | Congrats! You have successfully implemented Twilio Two-Factor Authentication. Browse the links below for more information related to Twilio Account Security. 15 |

16 | 17 | 24 |
25 |
26 |
27 |
28 | Logout 29 |
30 |
31 |
32 |
33 |
34 |
35 | {% endblock %} -------------------------------------------------------------------------------- /twofa/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Quickstart Registration

9 |
10 |
11 |
12 | 13 | {% csrf_token %} 14 | {{ form.non_field_errors }} 15 | {{ form.username.errors }} 16 | {{ form.email.errors }} 17 | {{ form.country_code.errors }} 18 | {{ form.phone_number.errors }} 19 | {{ form.password.errors }} 20 | 21 |
22 | {{ form.username }} 23 |
24 |
25 | {{ form.email }} 26 |
27 |
28 | {{ form.country_code }} {{ form.phone_number }} 29 |
30 |
31 | {{ form.password }} {{ form.confirm_password }} 32 |
33 | 34 | 35 | Login 36 |
37 |
38 |
39 |
40 |
41 | {% endblock %} -------------------------------------------------------------------------------- /twofa/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block container %} 4 |
5 |
6 |
7 |
8 |

Account Security Demo Login

9 |
10 |
11 |
12 | {% csrf_token %} 13 | {{ form.non_field_errors }} 14 | {{ form.username.errors }} 15 | {{ form.password.errors }} 16 | 17 |
18 |
19 |
20 | 22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 | 31 |
32 |
33 |
34 | 35 | 36 | Not registered? 37 |
38 |
39 |
40 |
41 |
42 | {% endblock %} -------------------------------------------------------------------------------- /twofa/tests.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase, Client 3 | try: 4 | from unittest.mock import patch, MagicMock 5 | except ImportError: 6 | from mock import patch, MagicMock 7 | 8 | from . import views 9 | from .models import TwoFAUser 10 | 11 | 12 | class TwoFATestCase(TestCase): 13 | def test_protected_redirect_anonymous_to_login(self): 14 | # Arrange 15 | client = Client() 16 | 17 | # Act 18 | response = client.get('/protected/') 19 | 20 | # Assert 21 | self.assertRedirects( 22 | response, 23 | settings.LOGIN_URL, 24 | fetch_redirect_response=False 25 | ) 26 | 27 | def test_protected_logged_in_user_redirected_to_2fa(self): 28 | # Arrange 29 | TwoFAUser.objects.create_user( 30 | username='test', 31 | authy_id='fake', 32 | password='test' 33 | ) 34 | client = Client() 35 | client.login(username='test', password='test') 36 | 37 | # Act 38 | response = client.get('/protected/') 39 | 40 | # Assert 41 | self.assertRedirects(response, '/2fa/', fetch_redirect_response=False) 42 | 43 | def test_protected_displays_to_authy_user(self): 44 | # Arrange 45 | TwoFAUser.objects.create_user( 46 | username='test', 47 | authy_id='fake', 48 | password='test' 49 | ) 50 | client = Client() 51 | client.login(username='test', password='test') 52 | session = client.session 53 | session['authy'] = True 54 | session.save() 55 | 56 | # Act 57 | response = client.get('/protected/', follow=True) 58 | 59 | # Assert 60 | self.assertEqual(response.status_code, 200) 61 | self.assertEqual(response.resolver_match.func, views.protected) 62 | 63 | @patch('twofa.views.authy_api') 64 | def test_token_sms_success(self, authy_api): 65 | # Arrange 66 | TwoFAUser.objects.create_user( 67 | username='test', 68 | authy_id='fake', 69 | password='test' 70 | ) 71 | client = Client() 72 | client.login(username='test', password='test') 73 | 74 | request_sms_response = MagicMock() 75 | request_sms_response.ok.return_value = True 76 | authy_api.users.request_sms.return_value = request_sms_response 77 | 78 | # Act 79 | response = client.post('/token/sms') 80 | 81 | # Assert 82 | self.assertEqual(response.status_code, 200) 83 | self.assertEqual(response.resolver_match.func, views.token_sms) 84 | authy_api.users.request_sms.assert_called_once_with( 85 | 'fake', 86 | {'force': True} 87 | ) 88 | request_sms_response.ok.assert_called_once() 89 | 90 | @patch('twofa.views.authy_api') 91 | def test_token_sms_failure(self, authy_api): 92 | # Arrange 93 | TwoFAUser.objects.create_user( 94 | username='test', 95 | authy_id='fake', 96 | password='test' 97 | ) 98 | client = Client() 99 | client.login(username='test', password='test') 100 | 101 | request_sms_response = MagicMock() 102 | request_sms_response.ok.return_value = False 103 | authy_api.users.request_sms.return_value = request_sms_response 104 | 105 | # Act 106 | response = client.post('/token/sms') 107 | 108 | # Assert 109 | self.assertEqual(response.status_code, 503) 110 | self.assertEqual(response.resolver_match.func, views.token_sms) 111 | authy_api.users.request_sms.assert_called_once_with( 112 | 'fake', 113 | {'force': True} 114 | ) 115 | request_sms_response.ok.assert_called_once() 116 | 117 | @patch('twofa.views.authy_api') 118 | def test_token_voice_success(self, authy_api): 119 | # Arrange 120 | TwoFAUser.objects.create_user( 121 | username='test', 122 | authy_id='fake', 123 | password='test' 124 | ) 125 | client = Client() 126 | client.login(username='test', password='test') 127 | 128 | request_call_response = MagicMock() 129 | request_call_response.ok.return_value = True 130 | authy_api.users.request_call.return_value = request_call_response 131 | 132 | # Act 133 | response = client.post('/token/voice') 134 | 135 | # Assert 136 | self.assertEqual(response.status_code, 200) 137 | self.assertEqual(response.resolver_match.func, views.token_voice) 138 | authy_api.users.request_call.assert_called_once_with( 139 | 'fake', 140 | {'force': True} 141 | ) 142 | request_call_response.ok.assert_called_once() 143 | 144 | @patch('twofa.views.authy_api') 145 | def test_token_voice_failure(self, authy_api): 146 | # Arrange 147 | TwoFAUser.objects.create_user( 148 | username='test', 149 | authy_id='fake', 150 | password='test' 151 | ) 152 | client = Client() 153 | client.login(username='test', password='test') 154 | 155 | request_call_response = MagicMock() 156 | request_call_response.ok.return_value = False 157 | authy_api.users.request_call.return_value = request_call_response 158 | 159 | # Act 160 | response = client.post('/token/voice') 161 | 162 | # Assert 163 | self.assertEqual(response.status_code, 503) 164 | self.assertEqual(response.resolver_match.func, views.token_voice) 165 | authy_api.users.request_call.assert_called_once_with( 166 | 'fake', 167 | {'force': True} 168 | ) 169 | request_call_response.ok.assert_called_once() 170 | -------------------------------------------------------------------------------- /twofa/views.py: -------------------------------------------------------------------------------- 1 | from authy.api import AuthyApiClient 2 | from django.conf import settings 3 | from django.contrib.auth import login 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import HttpResponse 6 | from django.shortcuts import render, redirect 7 | 8 | 9 | from .decorators import twofa_required 10 | from .forms import RegistrationForm, TokenVerificationForm 11 | from .models import TwoFAUser 12 | 13 | 14 | authy_api = AuthyApiClient(settings.ACCOUNT_SECURITY_API_KEY) 15 | 16 | 17 | def home(request): 18 | return render(request, 'home.html') 19 | 20 | 21 | def register(request): 22 | if request.method == 'POST': 23 | form = RegistrationForm(request.POST) 24 | if form.is_valid(): 25 | authy_user = authy_api.users.create( 26 | form.cleaned_data['email'], 27 | form.cleaned_data['phone_number'], 28 | form.cleaned_data['country_code'], 29 | ) 30 | if authy_user.ok(): 31 | twofa_user = TwoFAUser.objects.create_user( 32 | form.cleaned_data['username'], 33 | form.cleaned_data['email'], 34 | authy_user.id, 35 | form.cleaned_data['password'] 36 | ) 37 | login(request, twofa_user) 38 | return redirect('2fa') 39 | else: 40 | for key, value in authy_user.errors().items(): 41 | form.add_error( 42 | None, 43 | '{key}: {value}'.format(key=key, value=value) 44 | ) 45 | else: 46 | form = RegistrationForm() 47 | return render(request, 'register.html', {'form': form}) 48 | 49 | 50 | @login_required 51 | def twofa(request): 52 | if request.method == 'POST': 53 | form = TokenVerificationForm(request.POST) 54 | if form.is_valid(request.user.authy_id): 55 | request.session['authy'] = True 56 | return redirect('protected') 57 | else: 58 | form = TokenVerificationForm() 59 | return render(request, '2fa.html', {'form': form}) 60 | 61 | 62 | @login_required 63 | def token_sms(request): 64 | sms = authy_api.users.request_sms(request.user.authy_id, {'force': True}) 65 | if sms.ok(): 66 | return HttpResponse('SMS request successful', status=200) 67 | else: 68 | return HttpResponse('SMS request failed', status=503) 69 | 70 | 71 | @login_required 72 | def token_voice(request): 73 | call = authy_api.users.request_call(request.user.authy_id, {'force': True}) 74 | if call.ok(): 75 | return HttpResponse('Call request successfull', status=200) 76 | else: 77 | return HttpResponse('Call request failed', status=503) 78 | 79 | 80 | @login_required 81 | def token_onetouch(request): 82 | details = { 83 | 'Authy ID': request.user.authy_id, 84 | 'Username': request.user.username, 85 | 'Reason': 'Demo by Account Security' 86 | } 87 | 88 | hidden_details = { 89 | 'test': 'This is a' 90 | } 91 | 92 | response = authy_api.one_touch.send_request( 93 | int(request.user.authy_id), 94 | message='Login requested for Account Security account.', 95 | seconds_to_expire=120, 96 | details=details, 97 | hidden_details=hidden_details 98 | ) 99 | if response.ok(): 100 | request.session['onetouch_uuid'] = response.get_uuid() 101 | return HttpResponse('OneTouch request successfull', status=200) 102 | else: 103 | return HttpResponse('OneTouch request failed', status=503) 104 | 105 | 106 | @login_required 107 | def onetouch_status(request): 108 | uuid = request.session['onetouch_uuid'] 109 | approval_status = authy_api.one_touch.get_approval_status(uuid) 110 | if approval_status.ok(): 111 | if approval_status['approval_request']['status'] == 'approved': 112 | request.session['authy'] = True 113 | return HttpResponse( 114 | approval_status['approval_request']['status'], 115 | status=200 116 | ) 117 | else: 118 | return HttpResponse(approval_status.errros(), status=503) 119 | 120 | 121 | @twofa_required 122 | def protected(request): 123 | return render(request, 'protected.html') 124 | --------------------------------------------------------------------------------