108537814: Implementation of 3 factor authentication.

The post register endpoint will send a random 5 digit code via sms and another via email.
If either code fails to send, the user will not be created and the person can register again.
The codes are saved to the session cookie, and expire in 1 hour.

Another iteration of this story will save the codes to a database.
This commit is contained in:
Rebecca Law
2015-12-04 14:40:16 +00:00
parent abd344fc57
commit a741c128da
10 changed files with 103 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ from flask_wtf import CsrfProtect
from webassets.filter import get_filter
from werkzeug.exceptions import abort
from app.notify_client.api_client import AdminAPIClient
from app.its_dangerous_session import ItsdangerousSessionInterface
import app.proxy_fix
from config import configs
@@ -18,6 +19,8 @@ db = SQLAlchemy()
login_manager = LoginManager()
csrf = CsrfProtect()
admin_api_client = AdminAPIClient()
def create_app(config_name):
application = Flask(__name__)
@@ -37,6 +40,7 @@ def create_app(config_name):
proxy_fix.init_app(application)
application.session_interface = ItsdangerousSessionInterface()
admin_api_client.init_app(application)
return application

5
app/main/exceptions.py Normal file
View File

@@ -0,0 +1,5 @@
class AdminApiClientException(Exception):
def __init__(self, message):
self.value = message

View File

@@ -15,19 +15,16 @@ def govuk():
@main.route("/register-from-invite")
@login_required
def registerfrominvite():
return render_template('register-from-invite.html')
@main.route("/verify")
@login_required
def verify():
return render_template('verify.html')
@main.route("/verify-mobile")
@login_required
def verifymobile():
return render_template('verify-mobile.html')
@@ -50,7 +47,6 @@ def addservice():
@main.route("/two-factor")
@login_required
def twofactor():
return render_template('two-factor.html')

View File

@@ -1,10 +1,14 @@
from datetime import datetime
from datetime import datetime, timedelta
from random import randint
from flask import render_template, redirect, jsonify
from flask_login import login_user
from flask import render_template, redirect, jsonify, session
from sqlalchemy.exc import SQLAlchemyError
from app import admin_api_client
from app.main import main
from app.main.dao import users_dao
from app.main.encryption import hashpw
from app.main.exceptions import AdminApiClientException
from app.main.forms import RegisterUserForm
from app.models import User
@@ -26,10 +30,43 @@ def process_register():
created_at=datetime.now(),
role_id=1)
try:
sms_code = send_sms_code(form.mobile_number.data)
email_code = send_email_code(form.email_address.data)
session['sms_code'] = hashpw(sms_code)
session['email_code'] = hashpw(email_code)
session['expiry_date'] = str(datetime.now() + timedelta(hours=2))
users_dao.insert_user(user)
login_user(user)
return redirect('/two-factor')
except Exception as e:
return jsonify(database_error=e.message), 400
except AdminApiClientException as e:
return jsonify(admin_api_client_error=e.value)
except SQLAlchemyError:
return jsonify(database_error='encountered database error'), 400
else:
return jsonify(form.errors), 400
return redirect('/verify')
def send_sms_code(mobile_number):
sms_code = _create_code()
try:
admin_api_client.send_sms(mobile_number, message=sms_code, token=admin_api_client.auth_token)
except:
raise AdminApiClientException('Exception when sending sms.')
return sms_code
def send_email_code(email):
email_code = _create_code()
try:
admin_api_client.send_email(email_address=email,
from_str='notify@digital.cabinet-office.gov.uk',
message=email_code,
subject='Verification code',
token=admin_api_client.auth_token)
except:
raise AdminApiClientException('Exception when sending email.')
return email_code
def _create_code():
return ''.join(["%s" % randint(0, 9) for _ in range(0, 5)])

View File

View File

@@ -0,0 +1,8 @@
from __future__ import unicode_literals
from notify_client import NotifyAPIClient
class AdminAPIClient(NotifyAPIClient):
def init_app(self, app):
self.base_url = app.config['NOTIFY_DATA_API_URL']
self.auth_token = app.config['NOTIFY_DATA_API_AUTH_TOKEN']

View File

@@ -1,3 +1,5 @@
import os
class Config(object):
DEBUG = False
@@ -11,6 +13,14 @@ class Config(object):
MAX_FAILED_LOGIN_COUNT = 10
PASS_SECRET_KEY = 'secret-key-unique-changeme'
SESSION_COOKIE_NAME = 'notify_admin_session'
SESSION_COOKIE_PATH = '/admin'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
NOTIFY_DATA_API_URL = os.getenv('NOTIFY_API_URL', "http://localhost:6001")
NOTIFY_DATA_API_AUTH_TOKEN = os.getenv('NOTIFY_API_TOKEN', "pLuj5kat5auC9Ve")
WTF_CSRF_ENABLED = True
SECRET_KEY = 'secret-key'
HTTP_PROTOCOL = 'http'

View File

@@ -8,4 +8,6 @@ SQLAlchemy==1.0.5
SQLAlchemy-Utils==0.30.5
Flask-WTF==0.11
Flask-Login==0.2.11
Flask-Bcrypt==0.6.2
Flask-Bcrypt==0.6.2
git+https://github.com/alphagov/notify-api-client.git@0.1.4#egg=notify-api-client==0.1.4

View File

@@ -1,3 +1,4 @@
-r requirements.txt
pep8==1.5.7
pytest==2.8.1
pytest==2.8.1
pytest-mock==0.8.1

View File

@@ -7,17 +7,22 @@ def test_render_register_returns_template_with_form(notifications_admin, notific
assert 'Create an account' in response.get_data(as_text=True)
def test_process_register_creates_new_user(notifications_admin, notifications_admin_db):
def test_process_register_creates_new_user(notifications_admin, notifications_admin_db, mocker):
_set_up_mocker(mocker)
response = notifications_admin.test_client().post('/register',
data={'name': 'Some One Valid',
'email_address': 'someone@example.gov.uk',
'mobile_number': '+441231231231',
'password': 'validPassword!'})
assert response.status_code == 302
assert response.location == 'http://localhost/two-factor'
assert response.location == 'http://localhost/verify'
def test_process_register_returns_400_when_mobile_number_is_invalid(notifications_admin, notifications_admin_db):
def test_process_register_returns_400_when_mobile_number_is_invalid(notifications_admin,
notifications_admin_db,
mocker):
_set_up_mocker(mocker)
response = notifications_admin.test_client().post('/register',
data={'name': 'Bad Mobile',
'email_address': 'bad_mobile@example.gov.uk',
@@ -28,7 +33,8 @@ def test_process_register_returns_400_when_mobile_number_is_invalid(notification
assert 'Please enter a +44 mobile number' in response.get_data(as_text=True)
def test_should_return_400_when_email_is_not_gov_uk(notifications_admin, notifications_admin_db):
def test_should_return_400_when_email_is_not_gov_uk(notifications_admin, notifications_admin_db, mocker):
_set_up_mocker(mocker)
response = notifications_admin.test_client().post('/register',
data={'name': 'Bad Mobile',
'email_address': 'bad_mobile@example.not.right',
@@ -39,6 +45,23 @@ def test_should_return_400_when_email_is_not_gov_uk(notifications_admin, notific
assert 'Please enter a gov.uk email address' in response.get_data(as_text=True)
def test_should_add_verify_codes_on_session(notifications_admin, notifications_admin_db, mocker):
_set_up_mocker(mocker)
with notifications_admin.test_client() as client:
response = client.post('/register',
data={'name': 'Test Codes',
'email_address': 'test_codes@example.gov.uk',
'mobile_number': '+441234567890',
'password': 'validPassword!'})
assert response.status_code == 302
assert 'notify_admin_session' in response.headers.get('Set-Cookie')
def _set_up_mocker(mocker):
mocker.patch("app.admin_api_client.send_sms")
mocker.patch("app.admin_api_client.send_email")
def test_should_return_400_if_password_is_blacklisted(notifications_admin, notifications_admin_db):
response = notifications_admin.test_client().post('/register',
data={'name': 'Bad Mobile',