├── .coveragerc ├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── flask.yml ├── .gitignore ├── .mergify.yml ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── black.toml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── f2733ec8543e_create_user_table.py ├── requirements-to-freeze.txt ├── requirements.txt ├── setup.cfg ├── test.py ├── tests ├── __init__.py ├── auth │ ├── __init__.py │ └── test_views.py ├── base.py ├── main │ ├── __init__.py │ └── test_views.py └── test_models.py └── twofa ├── __init__.py ├── auth ├── __init__.py ├── forms.py └── views.py ├── config.py ├── database.py ├── decorators.py ├── main ├── __init__.py └── views.py ├── models.py ├── static ├── css │ └── main.css └── js │ ├── main.js │ └── sessions.js ├── templates ├── _authy_modal.html ├── _formhelpers.html ├── _login_error.html ├── account.html ├── base.html ├── index.html ├── login.html ├── signup.html └── verify.html └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # Coverage configuration file 2 | 3 | [run] 4 | source = . 5 | 6 | omit = 7 | migrations/* 8 | tests/* 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables for authy2fa-flask 2 | 3 | # Secret key (used for sessions) 4 | SECRET_KEY=not-so-secret 5 | 6 | # Authy API Key 7 | # Found at https://dashboard.authy.com under your application 8 | AUTHY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 9 | -------------------------------------------------------------------------------- /.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: sqlalchemy 10 | versions: 11 | - 1.3.23 12 | - 1.4.0 13 | - 1.4.1 14 | - 1.4.2 15 | - 1.4.3 16 | - 1.4.4 17 | - 1.4.5 18 | - 1.4.6 19 | - 1.4.7 20 | - 1.4.8 21 | - 1.4.9 22 | - dependency-name: flake8 23 | versions: 24 | - 3.9.0 25 | - dependency-name: alembic 26 | versions: 27 | - 1.5.6 28 | - 1.5.7 29 | - dependency-name: urllib3 30 | versions: 31 | - 1.26.3 32 | - dependency-name: coverage 33 | versions: 34 | - "5.4" 35 | - dependency-name: flask-migrate 36 | versions: 37 | - 2.6.0 38 | - dependency-name: flask-sqlalchemy 39 | versions: 40 | - 2.4.4 41 | - dependency-name: flask 42 | versions: 43 | - 1.1.2 44 | - dependency-name: authy 45 | versions: 46 | - 2.2.6 47 | - dependency-name: gunicorn 48 | versions: 49 | - 20.0.4 50 | - dependency-name: flask-wtf 51 | versions: 52 | - 0.14.3 53 | - dependency-name: flask-dotenv 54 | versions: 55 | - 0.1.2 56 | -------------------------------------------------------------------------------- /.github/workflows/flask.yml: -------------------------------------------------------------------------------- 1 | name: Flask 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 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: Run Linter 30 | run: | 31 | flake8 32 | - name: Run Tests 33 | run: | 34 | cp .env.example .env 35 | python test.py 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables through autoenv 2 | .env 3 | 4 | # Database files 5 | *.sqlite 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | venv/ 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | -------------------------------------------------------------------------------- /.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 6 | actions: 7 | merge: 8 | method: squash 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.6' 5 | install: 6 | - pip install -r requirements.txt 7 | - pip install coveralls 8 | script: coverage run manage.py test 9 | env: 10 | - SECRET_KEY=not-so-secret export AUTHY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 11 | after_success: 12 | - coveralls 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 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. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn manage:app --log-file - 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Two-Factor Authentication with Authy OneTouch 2 | This application example demonstrates how to implement Two-Factor Authentication on a Python Flask application using [Authy OneTouch](https://www.twilio.com/authy). 3 | 4 | ![Flask](https://github.com/TwilioDevEd/authy2fa-flask/workflows/Flask/badge.svg) 5 | 6 | 7 | [Learn more about this code in our interactive code walkthrough](https://www.twilio.com/docs/howto/walkthrough/two-factor-authentication/python/flask). 8 | 9 | ## Quickstart 10 | 11 | ### Create an Authy app 12 | Create a free [Twilio account](https://www.twilio.com/console/authy) if you haven't already done so. 13 | 14 | Create a new Authy application. Be sure to set the OneTouch callback endpoint to `http://your-server-here.com/authy/callback` once you've finished configuring the app. 15 | 16 | ### Local development 17 | This project is built using the [Flask](http://flask.pocoo.org/) web framework and the SQlite3 database. 18 | 19 | 1. To run the app locally, first clone this repository and `cd` into it. 20 | 21 | 1. Create and activate a new python3 virtual environment. 22 | 23 | ```bash 24 | python3 -m venv venv 25 | source venv/bin/activate 26 | ``` 27 | 28 | 1. Install the requirements using [pip](https://pip.pypa.io/en/stable/installing/). 29 | 30 | ```bash 31 | pip install -r requirements.txt 32 | ``` 33 | 34 | 1. Copy the `.env.example` file to `.env`, and edit it to include your **Authy Application's Production API key**. This key can be found right below the Application's name in its **Settings** menu. 35 | 36 | ```bash 37 | cp .env.example .env 38 | ``` 39 | 40 | 1. Create the Flask app specific environment variables 41 | 42 | ```bash 43 | export FLASK_APP=twofa 44 | export FLASK_ENV=development 45 | ``` 46 | 47 | 1. Initialize the development database 48 | 49 | ```bash 50 | flask db upgrade 51 | ``` 52 | 53 | 1. Start the development server. 54 | 55 | ```bash 56 | flask run 57 | ``` 58 | 59 | ## Expose your app in the internet 60 | To actually process OneTouch authentication requests, your development server will need to be publicly accessible. [We recommend using ngrok to solve this problem](https://www.twilio.com/blog/2015/09/6-awesome-reasons-to-use-ngrok-when-testing-webhooks.html). **Note that in this tutorial only the HTTP address from ngrok will work**, so you should start it using this command: 61 | 62 | ```bash 63 | ngrok http -bind-tls=false 5000 64 | ``` 65 | 66 | Once you have started ngrok, set your Authy app's OneTouch callback URL to use your ngrok hostname, like this: 67 | 68 | ``` 69 | http://[your ngrok subdomain].ngrok.io/authy/callback 70 | ``` 71 | 72 | ## Run the tests 73 | You can run the tests locally through [coverage](http://coverage.readthedocs.org/): 74 | 75 | 1. Run the tests. 76 | 77 | ```bash 78 | python test.py 79 | ``` 80 | 81 | You can then view the results with `coverage report` or build an HTML report with `coverage html`. 82 | 83 | That's it! 84 | 85 | ## Meta 86 | 87 | * No warranty expressed or implied. Software is as is. Diggity. 88 | * [MIT License](LICENSE) 89 | * Lovingly crafted by Twilio Developer Education. 90 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Authy 2FA with Flask", 3 | "description": "A Flask app for 2FA with Authy", 4 | "keywords": [ 5 | "python", 6 | "flask", 7 | "2 factor authentication", 8 | "2fa" 9 | ], 10 | "website": "https://www.authy.com", 11 | "repository": "https://github.com/TwilioDevEd/authy2fa-flask", 12 | "env": { 13 | "SECRET_KEY": { 14 | "description": "A secret key for the Flask app", 15 | "generator": "secret" 16 | }, 17 | "AUTHY_API_KEY": { 18 | "description": "Your Authy API key", 19 | "value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 20 | } 21 | }, 22 | "addons" : [ 23 | "heroku-postgresql" 24 | ], 25 | "scripts": { 26 | "postdeploy": "python manage.py db upgrade" 27 | }, 28 | "success_url": "/" 29 | } 30 | -------------------------------------------------------------------------------- /black.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 90 3 | target-version = ['py36'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | 7 | ( 8 | /( 9 | \.eggs # exclude a few common directories in the 10 | | \.git # root of the project 11 | | \.tox 12 | | \.venv 13 | | _build 14 | | build 15 | | dist 16 | )/ 17 | ) 18 | ''' 19 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | # Logging configuration 42 | [loggers] 43 | keys = root,sqlalchemy,alembic 44 | 45 | [handlers] 46 | keys = console 47 | 48 | [formatters] 49 | keys = generic 50 | 51 | [logger_root] 52 | level = WARN 53 | handlers = console 54 | qualname = 55 | 56 | [logger_sqlalchemy] 57 | level = WARN 58 | handlers = 59 | qualname = sqlalchemy.engine 60 | 61 | [logger_alembic] 62 | level = INFO 63 | handlers = 64 | qualname = alembic 65 | 66 | [handler_console] 67 | class = StreamHandler 68 | args = (sys.stderr,) 69 | level = NOTSET 70 | formatter = generic 71 | 72 | [formatter_generic] 73 | format = %(levelname)-5.5s [%(name)s] %(message)s 74 | datefmt = %H:%M:%S 75 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | 22 | config.set_main_option( 23 | 'sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI') 24 | ) 25 | target_metadata = current_app.extensions['migrate'].db.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | connectable = engine_from_config( 60 | config.get_section(config.config_ini_section), 61 | prefix='sqlalchemy.', 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | with connectable.connect() as connection: 66 | context.configure(connection=connection, target_metadata=target_metadata) 67 | 68 | with context.begin_transaction(): 69 | context.run_migrations() 70 | 71 | 72 | if context.is_offline_mode(): 73 | run_migrations_offline() 74 | else: 75 | run_migrations_online() 76 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/f2733ec8543e_create_user_table.py: -------------------------------------------------------------------------------- 1 | """create user table 2 | 3 | Revision ID: f2733ec8543e 4 | Revises: 5 | Create Date: 2018-02-16 16:05:46.735172 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f2733ec8543e' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | 'users', 23 | sa.Column('id', sa.Integer(), nullable=False), 24 | sa.Column('email', sa.String(length=64), nullable=True), 25 | sa.Column('password_hash', sa.String(length=128), nullable=True), 26 | sa.Column('full_name', sa.String(length=256), nullable=True), 27 | sa.Column('country_code', sa.Integer(), nullable=True), 28 | sa.Column('phone', sa.String(length=30), nullable=True), 29 | sa.Column('authy_id', sa.Integer(), nullable=True), 30 | sa.Column( 31 | 'authy_status', 32 | sa.Enum( 33 | 'unverified', 34 | 'onetouch', 35 | 'sms', 36 | 'token', 37 | 'approved', 38 | 'denied', 39 | name='authy_statuses', 40 | ), 41 | nullable=True, 42 | ), 43 | sa.PrimaryKeyConstraint('id'), 44 | ) 45 | op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_index(op.f('ix_users_email'), table_name='users') 52 | op.drop_table('users') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /requirements-to-freeze.txt: -------------------------------------------------------------------------------- 1 | # Requirements for authy2fa-flask 2 | 3 | # Flask + assorted extensions 4 | Flask 5 | Flask-Bootstrap 6 | Flask-Migrate 7 | Flask-SQLAlchemy 8 | Flask-WTF 9 | 10 | # Other libraries 11 | authy 12 | requests 13 | python-dotenv 14 | 15 | # Production server 16 | gunicorn 17 | 18 | # Testing && development 19 | flake8 20 | black 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.4.3 2 | appdirs==1.4.4 3 | authy==2.2.6 4 | black==20.8b1 5 | certifi==2020.12.5 6 | chardet==4.0.0 7 | click==7.1.2 8 | coverage==5.3.1 9 | dominate==2.6.0 10 | flake8==3.8.4 11 | Flask==1.1.2 12 | Flask-Bootstrap==3.3.7.1 13 | Flask-Migrate==2.5.3 14 | Flask-SQLAlchemy==2.4.4 15 | Flask-WTF==0.14.3 16 | gunicorn==20.0.4 17 | idna==2.10 18 | itsdangerous==1.1.0 19 | Jinja2==2.11.2 20 | Mako==1.1.3 21 | MarkupSafe==1.1.1 22 | mccabe==0.6.1 23 | mypy-extensions==0.4.3 24 | pathspec==0.8.1 25 | pycodestyle==2.6.0 26 | pyflakes==2.2.0 27 | python-dateutil==2.8.1 28 | python-dotenv==0.15.0 29 | python-editor==1.0.4 30 | regex==2020.11.13 31 | requests==2.25.1 32 | six==1.15.0 33 | SQLAlchemy==1.3.22 34 | toml==0.10.2 35 | typed-ast==1.4.2 36 | typing-extensions==3.7.4.3 37 | urllib3==1.26.2 38 | visitor==0.1.3 39 | Werkzeug==1.0.1 40 | WTForms==2.3.3 41 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | exclude = *migrations*,*venv* 4 | extend-ignore = W503,E203 5 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | 6 | def test(): 7 | """Run the unit tests.""" 8 | os.environ['FLASK_ENV'] = 'testing' 9 | tests = unittest.TestLoader().discover('.', pattern="test_*.py") 10 | result = unittest.TextTestRunner(verbosity=2).run(tests) 11 | 12 | if not result.wasSuccessful(): 13 | sys.exit(1) 14 | 15 | 16 | if __name__ == "__main__": 17 | test() 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/authy2fa-flask/7009249a9b3eb85b297aab2d480e5da8755095c7/tests/__init__.py -------------------------------------------------------------------------------- /tests/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/authy2fa-flask/7009249a9b3eb85b297aab2d480e5da8755095c7/tests/auth/__init__.py -------------------------------------------------------------------------------- /tests/auth/test_views.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock, PropertyMock 2 | 3 | from ..base import BaseTestCase 4 | from twofa.models import User 5 | 6 | 7 | class ViewsTestCase(BaseTestCase): 8 | def setUp(self): 9 | super().setUp() 10 | self.user = User( 11 | 'test@example.com', 'fakepassword', 'test', 33, '611223344', 1234 12 | ) 13 | 14 | def test_sign_up(self): 15 | # Arrange 16 | fake_authy_user = MagicMock() 17 | fake_authy_user.ok.return_value = True 18 | type(fake_authy_user).id = PropertyMock(return_value=1234) 19 | fake_client = MagicMock() 20 | fake_client.users.create.return_value = fake_authy_user 21 | 22 | # Act 23 | with patch('twofa.utils.get_authy_client', return_value=fake_client): 24 | resp = self.client.post( 25 | '/sign-up', 26 | data={ 27 | 'name': 'test', 28 | 'email': 'test@example.com', 29 | 'password': 'fakepassword', 30 | 'country_code': 33, 31 | 'phone_number': '611223344', 32 | }, 33 | ) 34 | 35 | # Assert 36 | fake_client.users.create.assert_called() 37 | self.assertEqual(resp.status_code, 302) 38 | self.assertEqual(resp.location, 'http://localhost/account') 39 | 40 | self.assertEqual(self.user.full_name, 'test') 41 | self.assertEqual(self.user.country_code, 33) 42 | self.assertEqual(self.user.phone, '611223344') 43 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from twofa import app, db 4 | 5 | 6 | class BaseTestCase(TestCase): 7 | render_templates = False 8 | 9 | def setUp(self): 10 | self.client = app.test_client() 11 | app.app_context().push() 12 | db.create_all() 13 | 14 | def tearDown(self): 15 | db.session.remove() 16 | db.drop_all() 17 | -------------------------------------------------------------------------------- /tests/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/authy2fa-flask/7009249a9b3eb85b297aab2d480e5da8755095c7/tests/main/__init__.py -------------------------------------------------------------------------------- /tests/main/test_views.py: -------------------------------------------------------------------------------- 1 | from twofa import db 2 | from twofa.models import User 3 | 4 | from ..base import BaseTestCase 5 | 6 | 7 | class ViewsTestCase(BaseTestCase): 8 | def test_home(self): 9 | # Act 10 | resp = self.client.get('/') 11 | 12 | # Assert 13 | self.assertEqual(resp.status_code, 200) 14 | 15 | def test_account_as_anonymous(self): 16 | # Act 17 | resp = self.client.get('/account') 18 | 19 | # Assert 20 | self.assertEqual(resp.status_code, 302) 21 | self.assertEqual(resp.location, 'http://localhost/login') 22 | 23 | def test_account_as_logged_in(self): 24 | # Arrange 25 | user = User( 26 | 'example@example.com', 27 | 'fakepassword', 28 | 'Alice', 29 | 33, 30 | 600112233, 31 | 123, 32 | authy_status='unverified', 33 | ) 34 | db.session.add(user) 35 | db.session.commit() 36 | db.session.refresh(user) 37 | with self.client.session_transaction() as sess: 38 | sess['user_id'] = user.id 39 | 40 | # Act 41 | resp = self.client.get('/account') 42 | 43 | # Assert 44 | self.assertEqual(resp.status_code, 302) 45 | self.assertEqual(resp.location, 'http://localhost/login') 46 | 47 | def test_account_as_verified(self): 48 | # Arrange 49 | user = User( 50 | 'example@example.com', 51 | 'fakepassword', 52 | 'Alice', 53 | 33, 54 | 600112233, 55 | 123, 56 | authy_status='approved', 57 | ) 58 | db.session.add(user) 59 | db.session.commit() 60 | db.session.refresh(user) 61 | with self.client.session_transaction() as sess: 62 | sess['user_id'] = user.id 63 | 64 | # Act 65 | resp = self.client.get('/account') 66 | 67 | # Assert 68 | self.assertEqual(resp.status_code, 200) 69 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from twofa.models import User 4 | 5 | from .base import BaseTestCase 6 | 7 | 8 | class UserTestCase(BaseTestCase): 9 | def setUp(self): 10 | self.user = User( 11 | 'example@example.com', 'fakepassword', 'Alice', 33, 600112233, 123 12 | ) 13 | 14 | def test_has_authy_app(self): 15 | # Arrange / Act 16 | with patch('twofa.models.authy_user_has_app', return_value=True): 17 | has_authy_app = self.user.has_authy_app 18 | 19 | # Assert 20 | self.assertTrue(has_authy_app) 21 | 22 | def test_has_not_authy_app(self): 23 | # Arrange / Act 24 | with patch('twofa.models.authy_user_has_app', return_value=False): 25 | has_authy_app = self.user.has_authy_app 26 | 27 | # Assert 28 | self.assertFalse(has_authy_app) 29 | 30 | def test_password_is_unreadable(self): 31 | # Act / Assert 32 | with self.assertRaises(AttributeError): 33 | self.user.password 34 | 35 | def test_password_setter(self): 36 | # Arrange 37 | old_password_hash = self.user.password_hash 38 | password = 'superpassword' 39 | 40 | # Act 41 | self.user.password = password 42 | 43 | # Assert 44 | self.assertNotEqual(password, self.user.password_hash) 45 | self.assertNotEqual(old_password_hash, self.user.password_hash) 46 | 47 | def test_verify_password(self): 48 | # Arrange 49 | password = 'anothercoolpassword' 50 | unused_password = 'unusedpassword' 51 | self.user.password = password 52 | 53 | # Act 54 | ret_good_password = self.user.verify_password(password) 55 | ret_bad_password = self.user.verify_password(unused_password) 56 | 57 | # Assert 58 | self.assertTrue(ret_good_password) 59 | self.assertFalse(ret_bad_password) 60 | 61 | def test_send_one_touch_request(self): 62 | # Arrange 63 | 64 | # Act 65 | with patch('twofa.models.send_authy_one_touch_request') as fake_send: 66 | self.user.send_one_touch_request() 67 | 68 | # Assert 69 | fake_send.assert_called_with(self.user.authy_id, self.user.email) 70 | -------------------------------------------------------------------------------- /twofa/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_bootstrap import Bootstrap 3 | 4 | from .config import config_classes 5 | from .database import db, migrate 6 | 7 | app = Flask(__name__, instance_relative_config=True) 8 | # load the instance config, if it exists, when not testing 9 | env = app.config.get("ENV") 10 | app.config.from_object(config_classes[env]) 11 | 12 | Bootstrap(app) 13 | 14 | db.init_app(app) 15 | migrate.init_app(app, db) 16 | 17 | import twofa.models # noqa E402 18 | 19 | from .auth import auth as auth_blueprint # noqa E402 20 | from .main import main as main_blueprint # noqa E402 21 | 22 | app.register_blueprint(auth_blueprint) 23 | app.register_blueprint(main_blueprint) 24 | -------------------------------------------------------------------------------- /twofa/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth = Blueprint('auth', __name__) 4 | 5 | from . import views # noqa: E402, F401 6 | -------------------------------------------------------------------------------- /twofa/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import IntegerField, PasswordField, StringField, validators 3 | 4 | from .. import db 5 | from ..models import User 6 | 7 | 8 | def validate_unique_email(form, field): 9 | """Validates that an email address hasn't been registered already""" 10 | if User.query.filter_by(email=field.data).count() > 0: 11 | raise validators.ValidationError( 12 | 'This email address has already been registered.' 13 | ) 14 | 15 | 16 | class SignUpForm(FlaskForm): 17 | """Form used for registering new users""" 18 | 19 | name = StringField('Full name', validators=[validators.InputRequired()]) 20 | email = StringField( 21 | 'Email', validators=[validators.InputRequired(), validate_unique_email] 22 | ) # noqa: E501 23 | password = PasswordField( 24 | 'Password', validators=[validators.InputRequired()] 25 | ) # noqa: E501 26 | country_code = IntegerField( 27 | 'Country code', validators=[validators.InputRequired()] 28 | ) # noqa: E501 29 | phone_number = StringField( 30 | 'Mobile phone', validators=[validators.InputRequired()] 31 | ) # noqa: E501 32 | 33 | def create_user(self, authy_user_id): 34 | user = User( 35 | self.email.data, 36 | self.password.data, 37 | self.name.data, 38 | self.country_code.data, 39 | self.phone_number.data, 40 | authy_user_id, 41 | ) 42 | 43 | # Save the user 44 | db.session.add(user) 45 | db.session.commit() 46 | db.session.refresh(user) 47 | return user 48 | 49 | 50 | class LoginForm(FlaskForm): 51 | """Form used for logging in existing users""" 52 | 53 | email = StringField('Email', validators=[validators.InputRequired()]) 54 | password = PasswordField( 55 | 'Password', validators=[validators.InputRequired()] 56 | ) # noqa: E501 57 | 58 | 59 | class VerifyForm(FlaskForm): 60 | """Form used to verify SMS two factor authentication codes""" 61 | 62 | verification_code = StringField( 63 | 'Verification code', 64 | validators=[validators.InputRequired(), validators.Length(min=6, max=10)], 65 | ) 66 | -------------------------------------------------------------------------------- /twofa/auth/views.py: -------------------------------------------------------------------------------- 1 | from authy import AuthyApiException 2 | from flask import flash, jsonify, redirect, render_template, request, session, url_for 3 | 4 | from . import auth 5 | from .forms import LoginForm, SignUpForm, VerifyForm 6 | from ..database import db 7 | from ..decorators import login_required, verify_authy_request 8 | from ..models import User 9 | from ..utils import create_user, send_authy_token_request, verify_authy_token 10 | 11 | 12 | @auth.route('/sign-up', methods=['GET', 'POST']) 13 | def sign_up(): 14 | """Powers the new user form""" 15 | form = SignUpForm(request.form) 16 | 17 | if form.validate_on_submit(): 18 | try: 19 | user = create_user(form) 20 | session['user_id'] = user.id 21 | 22 | return redirect(url_for('main.account')) 23 | 24 | except AuthyApiException as e: 25 | form.errors['Authy API'] = [ 26 | 'There was an error creating the Authy user', 27 | e.msg, 28 | ] 29 | 30 | return render_template('signup.html', form=form) 31 | 32 | 33 | @auth.route('/login', methods=['GET', 'POST']) 34 | def log_in(): 35 | """ 36 | Powers the main login form. 37 | 38 | - GET requests render the username / password form 39 | - POST requests process the form data via an AJAX request triggered in the 40 | user's browser 41 | """ 42 | form = LoginForm(request.form) 43 | 44 | if form.validate_on_submit(): 45 | user = User.query.filter_by(email=form.email.data).first() 46 | if user is not None and user.verify_password(form.password.data): 47 | session['user_id'] = user.id 48 | 49 | if user.has_authy_app: 50 | # Send a request to verify this user's login with OneTouch 51 | one_touch_response = user.send_one_touch_request() 52 | return jsonify(one_touch_response) 53 | else: 54 | return jsonify({'success': False}) 55 | else: 56 | # The username and password weren't valid 57 | form.email.errors.append( 58 | 'The username and password combination you entered are invalid' 59 | ) 60 | 61 | if request.method == 'POST': 62 | # This was an AJAX request, and we should return any errors as JSON 63 | return jsonify( 64 | {'error': render_template('_login_error.html', form=form)} 65 | ) # noqa: E501 66 | else: 67 | return render_template('login.html', form=form) 68 | 69 | 70 | @auth.route('/authy/callback', methods=['POST']) 71 | @verify_authy_request 72 | def authy_callback(): 73 | """Authy uses this endpoint to tell us the result of a OneTouch request""" 74 | authy_id = request.json.get('authy_id') 75 | # When you're configuring your Endpoint/URL under OneTouch settings '1234' 76 | # is the preset 'authy_id' 77 | if authy_id != 1234: 78 | user = User.query.filter_by(authy_id=authy_id).one() 79 | 80 | if not user: 81 | return ('', 404) 82 | 83 | user.authy_status = request.json.get('status') 84 | db.session.add(user) 85 | db.session.commit() 86 | 87 | return ('', 200) 88 | 89 | 90 | @auth.route('/login/status') 91 | def login_status(): 92 | """ 93 | Used by AJAX requests to check the OneTouch verification status of a user 94 | """ 95 | user = User.query.get(session['user_id']) 96 | return user.authy_status 97 | 98 | 99 | @auth.route('/verify', methods=['GET', 'POST']) 100 | @login_required 101 | def verify(): 102 | """Powers token validation (not using OneTouch)""" 103 | form = VerifyForm(request.form) 104 | user = User.query.get(session['user_id']) 105 | 106 | # Send a token to our user when they GET this page 107 | if request.method == 'GET': 108 | send_authy_token_request(user.authy_id) 109 | 110 | if form.validate_on_submit(): 111 | user_entered_code = form.verification_code.data 112 | 113 | verified = verify_authy_token(user.authy_id, str(user_entered_code)) 114 | if verified.ok(): 115 | user.authy_status = 'approved' 116 | db.session.add(user) 117 | db.session.commit() 118 | 119 | flash( 120 | "You're logged in! Thanks for using two factor verification.", 'success' 121 | ) # noqa: E501 122 | return redirect(url_for('main.account')) 123 | else: 124 | form.errors['verification_code'] = ['Code invalid - please try again.'] 125 | 126 | return render_template('verify.html', form=form) 127 | 128 | 129 | @auth.route('/resend', methods=['POST']) 130 | @login_required 131 | def resend(): 132 | """Resends a verification token to a user""" 133 | user = User.query.get(session.get('user_id')) 134 | send_authy_token_request(user.authy_id) 135 | flash('I just re-sent your verification code - enter it below.', 'info') 136 | return redirect(url_for('auth.verify')) 137 | 138 | 139 | @auth.route('/logout') 140 | def log_out(): 141 | """Log out a user, clearing their session variables""" 142 | user_id = session.pop('user_id', None) 143 | user = User.query.get(user_id) 144 | user.authy_status = 'unverified' 145 | db.session.add(user) 146 | db.session.commit() 147 | 148 | flash("You're now logged out! Thanks for visiting.", 'info') 149 | return redirect(url_for('main.home')) 150 | -------------------------------------------------------------------------------- /twofa/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | class DefaultConfig: 9 | SECRET_KEY = os.environ.get('SECRET_KEY', 'not-so-secret') 10 | AUTHY_API_KEY = os.environ.get('AUTHY_API_KEY') 11 | db_path = os.path.join(os.path.dirname(__file__), 'authy2fa.sqlite') 12 | SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format(db_path) 13 | SQLALCHEMY_TRACK_MODIFICATIONS = True 14 | DEBUG = False 15 | 16 | 17 | class DevelopmentConfig(DefaultConfig): 18 | DEBUG = True 19 | 20 | 21 | class TestingConfig(DefaultConfig): 22 | TESTING = True 23 | WTF_CSRF_ENABLED = False 24 | SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' 25 | 26 | 27 | config_classes = { 28 | 'development': DevelopmentConfig, 29 | 'testing': TestingConfig, 30 | 'production': DefaultConfig, 31 | } 32 | -------------------------------------------------------------------------------- /twofa/database.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_migrate import Migrate 3 | 4 | db = SQLAlchemy() 5 | migrate = Migrate() 6 | -------------------------------------------------------------------------------- /twofa/decorators.py: -------------------------------------------------------------------------------- 1 | from flask import abort, flash, redirect, request, session, url_for 2 | from functools import wraps 3 | 4 | from .models import User 5 | 6 | 7 | def login_required(f): 8 | """Redirects requests to /login if the user isn't authenticated""" 9 | 10 | @wraps(f) 11 | def decorated_function(*args, **kwargs): 12 | user_id = session.get('user_id', None) 13 | if user_id: 14 | user = User.query.filter_by(id=user_id).one_or_none() 15 | if user is not None: 16 | return f(*args, **kwargs) 17 | 18 | flash('Please log in before accessing that page.', 'info') 19 | return redirect(url_for('auth.log_in')) 20 | 21 | return decorated_function 22 | 23 | 24 | def login_verified(f): 25 | """ 26 | Redirects requests if the current user has not verified their login with 27 | Authy 28 | """ 29 | 30 | @wraps(f) 31 | def decorated_function(*args, **kwargs): 32 | user_id = session.get('user_id', False) 33 | if user_id: 34 | user = User.query.filter_by(id=user_id).one_or_none() 35 | if user is not None and user.authy_status == 'approved': 36 | return f(*args, **kwargs) 37 | 38 | flash( 39 | 'You must complete your login before accessing that page.', 'info' 40 | ) # noqa: E501 41 | return redirect(url_for('auth.log_in')) 42 | 43 | return decorated_function 44 | 45 | 46 | def verify_authy_request(f): 47 | """ 48 | Verifies that a OneTouch callback request came from Authy 49 | """ 50 | 51 | @wraps(f) 52 | def decorated_function(*args, **kwargs): 53 | # Get the request URL without the parameters 54 | from .utils import get_authy_client 55 | 56 | client = get_authy_client() 57 | 58 | response = client.one_touch.validate_one_touch_signature( 59 | request.headers['X-Authy-Signature'], 60 | request.headers['X-Authy-Signature-Nonce'], 61 | request.method, 62 | request.url, 63 | request.json, 64 | ) 65 | if response: 66 | # The two signatures match - this request is authentic 67 | return f(*args, **kwargs) 68 | 69 | # The signatures didn't match - abort this request 70 | return abort(400) 71 | 72 | return decorated_function 73 | -------------------------------------------------------------------------------- /twofa/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | main = Blueprint('main', __name__) 4 | 5 | from . import views # noqa: E402, F401 6 | -------------------------------------------------------------------------------- /twofa/main/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, session 2 | 3 | from . import main 4 | from ..decorators import login_verified 5 | from ..models import User 6 | 7 | 8 | @main.route('/') 9 | def home(): 10 | return render_template("index.html") 11 | 12 | 13 | @main.route('/account') 14 | @login_verified 15 | def account(): 16 | """A sample user account page. Only accessible to logged in users""" 17 | user = User.query.get(session['user_id']) 18 | return render_template('account.html', user=user) 19 | -------------------------------------------------------------------------------- /twofa/models.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import generate_password_hash, check_password_hash 2 | 3 | from . import db 4 | from .utils import authy_user_has_app, send_authy_one_touch_request 5 | 6 | 7 | class User(db.Model): 8 | """ 9 | Represents a single user in the system. 10 | """ 11 | 12 | __tablename__ = 'users' 13 | 14 | AUTHY_STATUSES = ('unverified', 'onetouch', 'sms', 'token', 'approved', 'denied') 15 | 16 | id = db.Column(db.Integer, primary_key=True) 17 | email = db.Column(db.String(64), unique=True, index=True) 18 | password_hash = db.Column(db.String(128)) 19 | full_name = db.Column(db.String(256)) 20 | country_code = db.Column(db.Integer) 21 | phone = db.Column(db.String(30)) 22 | authy_id = db.Column(db.Integer) 23 | authy_status = db.Column(db.Enum(*AUTHY_STATUSES, name='authy_statuses')) 24 | 25 | def __init__( 26 | self, 27 | email, 28 | password, 29 | full_name, 30 | country_code, 31 | phone, 32 | authy_id, 33 | authy_status='approved', 34 | ): 35 | self.email = email 36 | self.password = password 37 | self.full_name = full_name 38 | self.country_code = country_code 39 | self.phone = phone 40 | self.authy_id = authy_id 41 | self.authy_status = authy_status 42 | 43 | def __repr__(self): 44 | return '' % self.email 45 | 46 | @property 47 | def password(self): 48 | raise AttributeError('password is not readable') 49 | 50 | @property 51 | def has_authy_app(self): 52 | return authy_user_has_app(self.authy_id) 53 | 54 | @password.setter 55 | def password(self, password): 56 | self.password_hash = generate_password_hash(password) 57 | 58 | def verify_password(self, password): 59 | return check_password_hash(self.password_hash, password) 60 | 61 | def send_one_touch_request(self): 62 | return send_authy_one_touch_request(self.authy_id, self.email) 63 | -------------------------------------------------------------------------------- /twofa/static/css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing:border-box; 3 | } 4 | 5 | *, *:before, *:after { 6 | box-sizing:inherit; 7 | } 8 | 9 | footer { 10 | font-size:12px; 11 | color:#787878; 12 | margin-top:20px; 13 | padding-top:20px; 14 | text-align:center; 15 | } 16 | 17 | footer i { 18 | color:#ff0000; 19 | } 20 | 21 | nav a:focus { 22 | text-decoration:none; 23 | color:#337ab7; 24 | } 25 | 26 | td { 27 | padding:10px; 28 | } 29 | 30 | #main { 31 | margin-top:10px; 32 | } 33 | 34 | #messages p { 35 | padding:10px; 36 | border:1px solid #eee; 37 | } 38 | 39 | #messages i { 40 | float:right; 41 | margin-left:5px; 42 | cursor:pointer; 43 | } 44 | 45 | #countries-input-0{ 46 | display: block; 47 | width: 100%; 48 | height: 34px; 49 | padding: 6px 12px; 50 | font-size: 14px; 51 | line-height: 1.42857; 52 | color: #555; 53 | background-color: #FFF; 54 | background-image: none; 55 | border: 1px solid #CCC; 56 | border-radius: 4px; 57 | box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.075) inset; 58 | transition: border-color 0.15s ease-in-out 0s, box-shadow 0.15s ease-in-out 0s; 59 | } 60 | 61 | .auth-ot, .auth-token { 62 | display:none; 63 | } 64 | -------------------------------------------------------------------------------- /twofa/static/js/main.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/authy2fa-flask/7009249a9b3eb85b297aab2d480e5da8755095c7/twofa/static/js/main.js -------------------------------------------------------------------------------- /twofa/static/js/sessions.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | 3 | $('#login-form').submit(function(e) { 4 | e.preventDefault(); 5 | const formData = $(e.currentTarget).serialize(); 6 | attemptOneTouchVerification(formData); 7 | }); 8 | 9 | const attemptOneTouchVerification = function(form) { 10 | $.post( "/login", form, function(data) { 11 | $('.form-errors').remove(); 12 | // Check first if we successfully authenticated the username and password 13 | if (data.hasOwnProperty('error')) { 14 | $('#login-form').prepend(data.error); 15 | return; 16 | } 17 | 18 | if (data.success) { 19 | $('#authy-modal').modal({backdrop:'static'},'show'); 20 | $('.auth-ot').fadeIn(); 21 | checkForOneTouch(); 22 | } else { 23 | redirectToTokenForm(); 24 | } 25 | }); 26 | }; 27 | 28 | const checkForOneTouch = function() { 29 | $.get( "/login/status", function(data) { 30 | 31 | if (data === 'approved') { 32 | window.location.href = "/account"; 33 | } else if (data === 'denied') { 34 | redirectToTokenForm(); 35 | } else { 36 | setTimeout(checkForOneTouch, 2000); 37 | } 38 | }); 39 | }; 40 | 41 | const redirectToTokenForm = function() { 42 | window.location.href = "/verify"; 43 | }; 44 | }); 45 | -------------------------------------------------------------------------------- /twofa/templates/_authy_modal.html: -------------------------------------------------------------------------------- 1 |