├── 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 |
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 | 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 |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 |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 | 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 |Thanks for your interest in signing up! Can you tell us a bit about yourself?
5 | 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 |