├── tests ├── __init__.py ├── views_tests.py ├── base.py ├── confirmation_sender_tests.py ├── sign_up_view_tests.py ├── sign_in_view_tests.py └── confirmation_view_tests.py ├── migrations ├── README ├── script.py.mako ├── alembic.ini ├── versions │ └── 6d3d1c849abb_.py └── env.py ├── sms2fa_flask ├── static │ ├── images │ │ └── top-secret.jpg │ └── css │ │ └── main.css ├── templates │ ├── secrets.html │ ├── index.html │ ├── sign_in.html │ ├── confirmation.html │ ├── signup.html │ └── layout.html ├── confirmation_sender.py ├── __init__.py ├── config.py ├── models.py ├── forms.py └── views.py ├── .env.example ├── .github └── dependabot.yml ├── .travis.yml ├── requirements.txt ├── manage.py ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /sms2fa_flask/static/images/top-secret.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/sms2fa-flask/HEAD/sms2fa_flask/static/images/top-secret.jpg -------------------------------------------------------------------------------- /sms2fa_flask/templates/secrets.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

Shhh... It's secret for your eyes only

4 | 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | export DEBUG=True 2 | export SECRET=SuperSecret 3 | export SQLALCHEMY_DATABASE_URI=sqlite://survey.sqlite 4 | export TWILIO_ACCOUNT_SID=AC2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 5 | export TWILIO_AUTH_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 6 | export TWILIO_NUMBER=+15551230987 7 | -------------------------------------------------------------------------------- /sms2fa_flask/static/css/main.css: -------------------------------------------------------------------------------- 1 | .field_with_errors { 2 | color: red; 3 | } 4 | 5 | body { 6 | padding-top:62px; 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 | -------------------------------------------------------------------------------- /tests/views_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | 3 | 4 | class RootTest(BaseTest): 5 | 6 | def test_root(self): 7 | response = self.client.get('/') 8 | self.assertEquals(200, response.status_code) 9 | self.assertIn('can be tricky', response.data.decode('utf8')) 10 | 11 | def test_secret_page_without_auth(self): 12 | response = self.client.get('/secret-page') 13 | self.assertEquals(401, response.status_code) 14 | -------------------------------------------------------------------------------- /sms2fa_flask/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

Two Factor Authentication with Twilio

4 |

Doing two-factor authentication (2FA) at a level of excellence can be tricky. Let's demonstrate how to use the Twilio API to make implementing 2FA a snap.

5 |

Sign Up or Sign In to get started.

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /.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: twilio 10 | versions: 11 | - 6.51.1 12 | - 6.52.0 13 | - 6.53.0 14 | - 6.54.0 15 | - 6.55.0 16 | - 6.56.0 17 | - dependency-name: phonenumbers 18 | versions: 19 | - 8.12.17 20 | - 8.12.18 21 | - 8.12.19 22 | - 8.12.20 23 | - dependency-name: lxml 24 | versions: 25 | - 4.6.2 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.4' 5 | install: 6 | - pip install -r requirements.txt 7 | - pip install coveralls 8 | env: 9 | global: 10 | - DEBUG=False 11 | - SECRET=SuperSecret 12 | - SQLALCHEMY_DATABASE_URI=sqlite://survey.sqlite 13 | - TWILIO_ACCOUNT_SID=AC2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 14 | - TWILIO_AUTH_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 15 | - TWILIO_NUMBER=+15551230987 16 | script: 17 | - coverage run --source=sms2fa_flask manage.py test 18 | after_script: 19 | - coverage report 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Flask 2 | Flask==0.10.1 3 | 4 | # Flask components for forms/database/bootstrap 5 | Flask-Migrate==1.7.0 6 | Flask-Script==2.0.5 7 | Flask-SQLAlchemy==2.1 8 | Flask-Login==0.3.2 9 | Flask-WTF==0.9.2 10 | Flask-Bootstrap==3.3.5.7 11 | Flask-Session==0.3.1 12 | 13 | 14 | # External libraries 15 | SQLAlchemy==1.0.11 16 | twilio==6.9.0 17 | passlib==1.6.5 18 | bcrypt==2.0.0 19 | phonenumbers==8.3.0 20 | pysocks==1.6.6 21 | 22 | 23 | # Testing 24 | unittest2==1.1.0 25 | coverage==4.0.3 26 | xmlunittest==0.3.2 27 | lxml==3.4 28 | mock==2.0.0 29 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from xmlunittest import XmlTestCase 2 | from sms2fa_flask.models import User 3 | from passlib.hash import bcrypt 4 | 5 | 6 | class BaseTest(XmlTestCase): 7 | 8 | def setUp(self): 9 | from sms2fa_flask import app, db 10 | self.app = app 11 | self.app.config['WTF_CSRF_ENABLED'] = False 12 | self.db = db 13 | self.client = self.app.test_client() 14 | User.query.delete() 15 | self.email = 'example@example.com' 16 | self.password = '1234' 17 | pwd_hash = bcrypt.encrypt(self.password) 18 | self.default_user = User(email=self.email, 19 | phone_number='+555155555555', 20 | password=pwd_hash) 21 | db.save(self.default_user) 22 | -------------------------------------------------------------------------------- /sms2fa_flask/templates/sign_in.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

Welcome back

4 |

You never write. You never call. But we're glad you came, regardless! Be a pal and confirm your username and password, would you?

5 |
6 |
7 | {{ form.csrf_token }} 8 | {{ wtf.form_field(form.email, form_type='horizontal') }} 9 | {{ wtf.form_field(form.password, form_type='horizontal') }} 10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /sms2fa_flask/confirmation_sender.py: -------------------------------------------------------------------------------- 1 | from twilio.rest import Client 2 | from . import app 3 | from flask import session 4 | import random 5 | 6 | 7 | def send_confirmation_code(to_number): 8 | verification_code = generate_code() 9 | send_sms(to_number, verification_code) 10 | session['verification_code'] = verification_code 11 | return verification_code 12 | 13 | 14 | def generate_code(): 15 | return str(random.randrange(100000, 999999)) 16 | 17 | 18 | def send_sms(to_number, body): 19 | account_sid = app.config['TWILIO_ACCOUNT_SID'] 20 | auth_token = app.config['TWILIO_AUTH_TOKEN'] 21 | twilio_number = app.config['TWILIO_NUMBER'] 22 | client = Client(account_sid, auth_token) 23 | client.api.messages.create(to_number, 24 | from_=twilio_number, 25 | body=body) 26 | -------------------------------------------------------------------------------- /tests/confirmation_sender_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from flask import session 3 | from sms2fa_flask import confirmation_sender 4 | from mock import patch 5 | from mock import MagicMock 6 | 7 | 8 | class ConfirmationSenderTest(BaseTest): 9 | 10 | def test_sender_creates_a_message(self): 11 | confirmation_sender.generate_code = MagicMock(return_value='random_code') 12 | with patch('twilio.rest.api.v2010.account.message.MessageList.create') as create_mock: 13 | confirmation_sender.send_confirmation_code('+15551234321') 14 | create_mock.assert_called_once_with( 15 | u'+15551234321', 16 | from_=self.app.config['TWILIO_NUMBER'], 17 | body='random_code' 18 | ) 19 | self.assertEquals('random_code', session.get('verification_code')) 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from flask.ext.script import Manager 2 | from flask.ext.migrate import Migrate, MigrateCommand 3 | from flask_migrate import upgrade as upgrade_database 4 | from sms2fa_flask import db, prepare_app 5 | 6 | app = prepare_app(environment='development') 7 | migrate = Migrate(app, db) 8 | 9 | manager = Manager(app) 10 | manager.add_command('db', MigrateCommand) 11 | 12 | 13 | @manager.command 14 | def test(): 15 | """Run the unit tests.""" 16 | import sys 17 | import unittest 18 | prepare_app(environment='test') 19 | upgrade_database() 20 | tests = unittest.TestLoader().discover('.', pattern="*_tests.py") 21 | test_result = unittest.TextTestRunner(verbosity=2).run(tests) 22 | 23 | if not test_result.wasSuccessful(): 24 | sys.exit(1) 25 | 26 | 27 | @manager.command 28 | def dbseed(): 29 | pass 30 | 31 | if __name__ == "__main__": 32 | manager.run() 33 | -------------------------------------------------------------------------------- /sms2fa_flask/templates/confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

{{user.international_phone_number}}

4 |

We have sent you a SMS with a code to the number above.

5 |

To complete your phone number verification, please enter the 6-digits activation code.

6 |
7 |
8 | 9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /sms2fa_flask/__init__.py: -------------------------------------------------------------------------------- 1 | from sms2fa_flask.config import config_env_files 2 | from sms2fa_flask.models import db, User 3 | from flask import Flask 4 | 5 | from flask.ext.login import LoginManager 6 | from flask.ext.session import Session 7 | from flask_bootstrap import Bootstrap 8 | 9 | app = Flask(__name__) 10 | Bootstrap(app) 11 | login_manager = LoginManager() 12 | sess = Session() 13 | 14 | 15 | def prepare_app(environment='development', p_db=db): 16 | app.config.from_object(config_env_files[environment]) 17 | login_manager.setup_app(app) 18 | p_db.init_app(app) 19 | sess.init_app(app) 20 | app.session_interface.db.create_all() 21 | from . import views 22 | return app 23 | 24 | 25 | def save_and_commit(item): 26 | db.session.add(item) 27 | db.session.commit() 28 | db.save = save_and_commit 29 | 30 | 31 | @login_manager.user_loader 32 | def user_loader(user_id): 33 | return User.query.get(user_id) 34 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /sms2fa_flask/templates/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

We're going to be best friends

4 |

Thanks for your interest in signing up! Can you tell us a bit about yourself?

5 |
6 | {{ form.csrf_token }} 7 | {{ wtf.form_field(form.first_name, form_type='horizontal') }} 8 | {{ wtf.form_field(form.last_name, form_type='horizontal') }} 9 | {{ wtf.form_field(form.email, form_type='horizontal') }} 10 | {{ wtf.form_field(form.phone_number, form_type='horizontal') }} 11 | {{ wtf.form_field(form.password, form_type='horizontal') }} 12 | {{ wtf.form_field(form.confirm, form_type='horizontal') }} 13 |
14 |
15 | 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /migrations/versions/6d3d1c849abb_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6d3d1c849abb 4 | Revises: None 5 | Create Date: 2016-05-12 18:35:58.687351 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '6d3d1c849abb' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('user', 20 | sa.Column('first_name', sa.String(), nullable=True), 21 | sa.Column('last_name', sa.String(), nullable=True), 22 | sa.Column('phone_number', sa.String(), nullable=True), 23 | sa.Column('email', sa.String(), nullable=False), 24 | sa.Column('password', sa.String(), nullable=True), 25 | sa.PrimaryKeyConstraint('email') 26 | ) 27 | ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('user') 33 | ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # VIM swaps 12 | *.swp 13 | 14 | # virtualenv 15 | venv/ 16 | 17 | # SQLite 18 | *.sqlite 19 | 20 | # Distribution / packaging 21 | .Python 22 | env/ 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *,cover 57 | .hypothesis/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | #Ipython Notebook 73 | .ipynb_checkpoints 74 | .env 75 | -------------------------------------------------------------------------------- /sms2fa_flask/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | class DefaultConfig(object): 7 | SECRET_KEY = 'secret-key' 8 | DEBUG = False 9 | SQLALCHEMY_DATABASE_URI = ('sqlite:///' + 10 | os.path.join(basedir, 'default.sqlite')) 11 | TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID', None) 12 | TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN', None) 13 | TWILIO_NUMBER = os.environ.get('TWILIO_NUMBER', None) 14 | SESSION_TYPE = 'sqlalchemy' 15 | 16 | 17 | class DevelopmentConfig(DefaultConfig): 18 | DEBUG = True 19 | SQLALCHEMY_DATABASE_URI = ('sqlite:///' + 20 | os.path.join(basedir, 'dev.sqlite')) 21 | 22 | 23 | class TestConfig(DefaultConfig): 24 | SQLALCHEMY_DATABASE_URI = ('sqlite:///' + 25 | os.path.join(basedir, 'test.sqlite')) 26 | PRESERVE_CONTEXT_ON_EXCEPTION = False 27 | DEBUG = True 28 | TWILIO_ACCOUNT_SID = 'AC2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 29 | TWILIO_AUTH_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 30 | TWILIO_NUMBER = '+15551230987' 31 | 32 | config_env_files = { 33 | 'test': 'sms2fa_flask.config.TestConfig', 34 | 'development': 'sms2fa_flask.config.DevelopmentConfig', 35 | } 36 | -------------------------------------------------------------------------------- /sms2fa_flask/models.py: -------------------------------------------------------------------------------- 1 | from flask.ext.sqlalchemy import SQLAlchemy 2 | from flask.ext.login import UserMixin 3 | import phonenumbers 4 | from passlib.hash import bcrypt 5 | from phonenumbers import PhoneNumberFormat 6 | db = SQLAlchemy() 7 | 8 | 9 | class User(db.Model, UserMixin): 10 | 11 | first_name = db.Column(db.String) 12 | last_name = db.Column(db.String) 13 | phone_number = db.Column(db.String) 14 | email = db.Column(db.String, primary_key=True) 15 | password = db.Column(db.String) 16 | 17 | @classmethod 18 | def save_from_dict(cls, data): 19 | user = User(**data) 20 | user.set_password(data['password']) 21 | user.active = False 22 | db.save(user) 23 | return user 24 | 25 | def is_password_valid(self, given_password): 26 | return bcrypt.verify(given_password, self.password) 27 | 28 | def set_password(self, new_password): 29 | self.password = bcrypt.encrypt(new_password) 30 | 31 | @property 32 | def international_phone_number(self): 33 | parsed_number = phonenumbers.parse(self.phone_number) 34 | return phonenumbers.format_number(parsed_number, 35 | PhoneNumberFormat.INTERNATIONAL) 36 | 37 | # The methods below are required by flask-login 38 | def is_active(self): 39 | return True 40 | 41 | def is_anonymous(self): 42 | return False 43 | 44 | def get_id(self): 45 | return self.email 46 | -------------------------------------------------------------------------------- /sms2fa_flask/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from wtforms import TextField, PasswordField, validators 3 | from flask_wtf.html5 import TelField, EmailField 4 | from wtforms.validators import DataRequired 5 | import phonenumbers 6 | 7 | 8 | class LoginForm(Form): 9 | email = TextField('email', validators=[DataRequired()]) 10 | password = PasswordField('password', validators=[DataRequired()]) 11 | 12 | 13 | class SignUpForm(Form): 14 | first_name = TextField('first name', validators=[DataRequired()]) 15 | last_name = TextField('last name', validators=[DataRequired()]) 16 | phone_number = TelField('phone number', validators=[DataRequired()]) 17 | email = EmailField('email', validators=[DataRequired()]) 18 | password = PasswordField('password', [validators.Required(), 19 | validators.EqualTo('confirm', 20 | message='Passwords must match')]) 21 | confirm = PasswordField('confirm password', [validators.Required()]) 22 | 23 | def validate_phone_number(self, field): 24 | error_message = "Invalid phone number. Example: +5599999999" 25 | try: 26 | data = phonenumbers.parse(field.data) 27 | except: 28 | raise validators.ValidationError(error_message) 29 | if not phonenumbers.is_possible_number(data): 30 | raise validators.ValidationError(error_message) 31 | 32 | @property 33 | def as_dict(self): 34 | data = self.data 35 | del data['confirm'] 36 | return data 37 | -------------------------------------------------------------------------------- /tests/sign_up_view_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from flask import url_for, session 3 | from flask.ext.login import current_user 4 | import six 5 | from sms2fa_flask.models import User 6 | if six.PY3: 7 | from urllib.parse import urlparse 8 | else: 9 | from urlparse import urlparse 10 | 11 | 12 | class SignUpTest(BaseTest): 13 | def setUp(self): 14 | super(SignUpTest, self).setUp() 15 | data = {'email': '00@7.com', 16 | 'password': self.password, 17 | 'confirm': self.password, 18 | 'first_name': 'James', 19 | 'last_name': "O'Seven", 20 | 'phone_number': "+5599999007"} 21 | self.data = data 22 | 23 | def test_sign_up_success_creates_user(self): 24 | self.client.post(url_for('sign_up'), data=self.data) 25 | 26 | user = User.query.get(self.data['email']) 27 | self.assertTrue(user) 28 | self.assertTrue(user.is_password_valid(self.data['password'])) 29 | self.assertEquals(user.phone_number, self.data['phone_number']) 30 | 31 | def test_sign_up_success_redirects_to_confirmation(self): 32 | response = self.client.post(url_for('sign_up'), data=self.data) 33 | 34 | expected_path = url_for('confirmation') 35 | self.assertEquals(302, response.status_code) 36 | self.assertIn(expected_path, urlparse(response.location).path) 37 | 38 | def test_sign_up_success_puts_user_email_in_session(self): 39 | with self.app.test_client() as client: 40 | client.post(url_for('sign_up'), data=self.data) 41 | self.assertEquals(self.data['email'], session.get('user_email')) 42 | 43 | def test_sign_up_success_doesnt_authenticate_user(self): 44 | with self.app.test_client() as client: 45 | client.post(url_for('sign_up'), data=self.data) 46 | self.assertFalse(current_user.is_authenticated) 47 | -------------------------------------------------------------------------------- /sms2fa_flask/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, request, url_for, flash, redirect, session 2 | from flask import abort 3 | from flask.ext.login import login_required 4 | from flask.ext.login import login_user 5 | from flask.ext.login import logout_user 6 | from . import app 7 | from .models import User 8 | from .forms import LoginForm, SignUpForm 9 | from .confirmation_sender import send_confirmation_code 10 | 11 | 12 | @app.route('/') 13 | def root(): 14 | return render_template('index.html') 15 | 16 | 17 | @app.route('/secret-page') 18 | @login_required 19 | def secret_page(): 20 | return render_template('secrets.html') 21 | 22 | 23 | @app.route('/sign-up', methods=['GET', 'POST']) 24 | def sign_up(): 25 | form = SignUpForm() 26 | if form.validate_on_submit(): 27 | user = User.save_from_dict(form.as_dict) 28 | session['user_email'] = user.email 29 | send_confirmation_code(user.international_phone_number) 30 | return redirect(url_for('confirmation')) 31 | return render_template('signup.html', form=form) 32 | 33 | 34 | @app.route('/sign_in', methods=['GET', 'POST']) 35 | def sign_in(): 36 | form = LoginForm() 37 | 38 | if form.validate_on_submit(): 39 | user = User.query.get(form.email.data) 40 | if user and user.is_password_valid(form.password.data): 41 | session['user_email'] = user.email 42 | return redirect(url_for('confirmation')) 43 | flash('Wrong user/password.', 'error') 44 | 45 | send_confirmation_code(user.international_phone_number) 46 | return render_template('sign_in.html', form=form) 47 | 48 | 49 | @app.route('/confirmation', methods=['GET', 'POST']) 50 | def confirmation(): 51 | user = User.query.get(session.get('user_email', '')) or abort(401) 52 | 53 | if request.method == 'POST': 54 | if request.form['verification_code'] == session['verification_code']: 55 | login_user(user) 56 | return redirect(url_for('secret_page')) 57 | flash('Wrong code. Please try again.', 'error') 58 | 59 | return render_template('confirmation.html', user=user) 60 | 61 | 62 | @app.route('/logout') 63 | def logout(): 64 | logout_user() 65 | session.clear() 66 | return redirect(url_for('root')) 67 | -------------------------------------------------------------------------------- /tests/sign_in_view_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from flask import url_for, session 3 | from flask.ext.login import current_user 4 | import six 5 | if six.PY3: 6 | from urllib.parse import urlparse 7 | else: 8 | from urlparse import urlparse 9 | 10 | 11 | class SignInTest(BaseTest): 12 | 13 | def setUp(self): 14 | super(SignInTest, self).setUp() 15 | self.valid_data = {'email': self.email, 'password': self.password} 16 | self.invalid_data = {'email': self.email, 'password': 'I am a hacker'} 17 | 18 | def test_sign_in_success_redirects(self): 19 | response = self.client.post(url_for('sign_in'), data=self.valid_data) 20 | 21 | self.assertEquals(302, response.status_code) 22 | expected_path = url_for('confirmation') 23 | self.assertIn(expected_path, urlparse(response.location).path) 24 | 25 | def test_sign_in_success_puts_user_email_in_session(self): 26 | with self.app.test_client() as client: 27 | client.post(url_for('sign_in'), data=self.valid_data) 28 | self.assertEquals(self.email, session.get('user_email')) 29 | self.assertFalse(current_user.is_authenticated) 30 | 31 | def test_sign_in_failure_doesnt_redirect(self): 32 | with self.app.test_client() as client: 33 | response = client.post(url_for('sign_in'), data=self.invalid_data) 34 | self.assertEquals(200, response.status_code) 35 | self.assertIn('Welcome back', response.data.decode('utf8')) 36 | self.assertIn('Wrong user/password.', response.data.decode('utf8')) 37 | 38 | def test_sign_in_failure_doesnt_authenticate(self): 39 | with self.app.test_client() as client: 40 | client.post(url_for('sign_in'), data=self.invalid_data) 41 | self.assertFalse(current_user.is_authenticated) 42 | 43 | def test_logout_redirects(self): 44 | response = self.client.get(url_for('logout')) 45 | 46 | expected_path = url_for('root') 47 | self.assertEquals(302, response.status_code) 48 | self.assertIn(expected_path, urlparse(response.location).path) 49 | 50 | def test_logout_kills_session(self): 51 | with self.app.test_client() as client: 52 | with client.session_transaction() as current_session: 53 | current_session['user_email'] = self.email 54 | current_session['confirmation_code'] = '1234' 55 | client.get(url_for('logout')) 56 | self.assertNotIn('confirmation_code', session) 57 | self.assertNotIn('user_email', session) 58 | self.assertFalse(current_user.is_authenticated) 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | # SMS Two-Factor Authentication 6 | 7 | [![Build Status](https://travis-ci.org/TwilioDevEd/sms2fa-flask.svg?branch=master)](https://travis-ci.org/TwilioDevEd/sms2fa-flask) 8 | 9 | 12 | SMS Two-Factor Authentication (SMS-2FA) helps keep your user accounts secure by validating two "factors" of identity. Most login systems only validate a password known by the user. You can make it harder for evildoers to compromise a user account by also validating something a user has, such as their mobile phone. 13 | 14 | ## Local Development 15 | 16 | This project is built using [Flask](http://flask.pocoo.org/) web framework. 17 | 18 | 1. First clone this repository and `cd` into it. 19 | 20 | ```bash 21 | $ git clone git@github.com:TwilioDevEd/sms2fa-flask.git 22 | $ cd sms2fa-flask 23 | ``` 24 | 25 | 1. Create a new virtual environment. 26 | 27 | - If using vanilla [virtualenv](https://virtualenv.pypa.io/en/latest/): 28 | 29 | ```bash 30 | virtualenv venv 31 | source venv/bin/activate 32 | ``` 33 | 34 | - If using [virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/): 35 | 36 | ```bash 37 | mkvirtualenv automated-survey 38 | ``` 39 | 40 | 1. Install the dependencies. 41 | 42 | ```bash 43 | pip install -r requirements.txt 44 | ``` 45 | 46 | 47 | 1. Copy the sample configuration file and edit it to match your configuration. 48 | 49 | ```bash 50 | $ cp .env.example .env 51 | ``` 52 | 53 | You can find your `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` in your 54 | [Twilio Account Settings](https://www.twilio.com/console). 55 | You will also need a `TWILIO_NUMBER`, which you may find [here](https://www.twilio.com/console/phone-numbers/incoming). 56 | 57 | Run `source .env` to export the environment variables. 58 | 59 | 1. Run the migrations. 60 | 61 | Our app uses SQLite, so you probably will not need to install additional software. 62 | 63 | ```bash 64 | python manage.py db upgrade 65 | ``` 66 | 67 | 1. Make sure the tests succeed. 68 | 69 | ```bash 70 | $ coverage run manage.py test 71 | ``` 72 | 73 | 1. Start the server. 74 | 75 | ```bash 76 | python manage.py runserver 77 | ``` 78 | 79 | 1. Check it out at: [http://localhost:5000/](http://localhost:5000/). 80 | 81 | ## Meta 82 | 83 | * No warranty expressed or implied. Software is as is. Diggity. 84 | * [MIT License](http://www.opensource.org/licenses/mit-license.html) 85 | * Lovingly crafted by Twilio Developer Education. 86 | -------------------------------------------------------------------------------- /sms2fa_flask/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% import "bootstrap/utils.html" as util %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | 5 | 6 | SMS 2 Factor Authentication 7 | 8 | 9 | 10 | 11 | 12 | 13 | 41 | {% with messages = get_flashed_messages(category_filter=["success"]) %} 42 | {% if messages %} 43 | {% for message in messages %} 44 | 50 | {% endfor %} 51 | {% endif %} 52 | {% endwith %} 53 | {{ util.flashed_messages(dismissible=True) }} 54 |
55 | {% block content %}{% endblock content %} 56 |
57 | 60 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /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 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /tests/confirmation_view_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from flask import url_for 3 | from flask.ext.login import current_user 4 | import six 5 | from mock import MagicMock 6 | from sms2fa_flask import views 7 | if six.PY3: 8 | from urllib.parse import urlparse 9 | else: 10 | from urlparse import urlparse 11 | 12 | 13 | class ConfirmationPageTest(BaseTest): 14 | 15 | def test_confirmation_page_fails_for_strangers(self): 16 | response = self.client.get(url_for('confirmation')) 17 | self.assertEquals(401, response.status_code) 18 | 19 | def test_confirmation_page_success_for_a_valid_session(self): 20 | views.send_confirmation_code = MagicMock(return_value='random_code') 21 | with self.app.test_client() as client: 22 | with client.session_transaction() as current_session: 23 | current_session['user_email'] = self.email 24 | response = client.get(url_for('confirmation')) 25 | self.assertEquals(200, response.status_code) 26 | 27 | def test_confirmation_page_shows_current_phone(self): 28 | views.send_confirmation_code = MagicMock(return_value='1234') 29 | with self.app.test_client() as client: 30 | with client.session_transaction() as current_session: 31 | current_session['user_email'] = self.email 32 | response = client.get(url_for('confirmation')) 33 | self.assertIn(self.default_user.international_phone_number, 34 | response.data.decode('utf8')) 35 | 36 | def test_confirmation_page_authenticates_on_success(self): 37 | with self.app.test_client() as client: 38 | with client.session_transaction() as current_session: 39 | current_session['user_email'] = self.email 40 | current_session['verification_code'] = '1234' 41 | client.post(url_for('confirmation'), 42 | data={'verification_code': '1234'}) 43 | self.assertTrue(current_user.is_authenticated) 44 | 45 | def test_confirmation_page_doesnt_authenticates_on_failure(self): 46 | views.send_confirmation_code = MagicMock(return_value='1234') 47 | with self.app.test_client() as client: 48 | with client.session_transaction() as current_session: 49 | current_session['user_email'] = self.email 50 | current_session['verification_code'] = '1234' 51 | client.post(url_for('confirmation'), 52 | data={'verification_code': 'wrong_one'}) 53 | self.assertFalse(current_user.is_authenticated) 54 | 55 | def test_confirmation_page_redirect_to_secrets_page_on_success(self): 56 | with self.app.test_client() as client: 57 | with client.session_transaction() as current_session: 58 | current_session['user_email'] = self.email 59 | current_session['verification_code'] = '1234' 60 | response = client.post(url_for('confirmation'), 61 | data={'verification_code': '1234'}) 62 | expected_path = url_for('secret_page') 63 | self.assertEquals(302, response.status_code) 64 | self.assertIn(expected_path, urlparse(response.location).path) 65 | --------------------------------------------------------------------------------