diff --git a/app/__init__.py b/app/__init__.py index 926fd5f13..6f4c79ff0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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__) @@ -39,6 +42,7 @@ def create_app(config_name): proxy_fix.init_app(application) application.session_interface = ItsdangerousSessionInterface() + admin_api_client.init_app(application) return application @@ -98,7 +102,8 @@ def init_asset_environment(app): assets.Bundle( 'govuk_template/govuk-template.scss', filters='scss', - output='stylesheets/govuk-template.css' + output='stylesheets/govuk-template.css', + depends='*.scss' ) ) diff --git a/app/assets/stylesheets/app.scss b/app/assets/stylesheets/app.scss index 9ceb5468d..50c9ec702 100644 --- a/app/assets/stylesheets/app.scss +++ b/app/assets/stylesheets/app.scss @@ -19,4 +19,29 @@ font-size: 19px; line-height: 1.31579; } +} + +.phase-tag { + @include phase-tag(beta); +} + +@media (min-width: 641px) { + .phase-tag { + font-size: 16px; + line-height: 1.25; + margin-top: 7px; + + } +} + +@media (max-width: 641px) { + .phase-tag { + margin-top: 11px; + } +} + + + +#global-header #logo { + white-space: nowrap; } \ No newline at end of file diff --git a/app/main/exceptions.py b/app/main/exceptions.py new file mode 100644 index 000000000..45f0515c0 --- /dev/null +++ b/app/main/exceptions.py @@ -0,0 +1,5 @@ + + +class AdminApiClientException(Exception): + def __init__(self, message): + self.value = message diff --git a/app/main/forms.py b/app/main/forms.py index bc013fccc..ea5978fa3 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -2,6 +2,8 @@ from flask_wtf import Form from wtforms import StringField, PasswordField from wtforms.validators import DataRequired, Email, Length, Regexp +from app.main.validators import Blacklist + class LoginForm(Form): email_address = StringField('Email address', validators=[ @@ -19,7 +21,7 @@ mobile_number = "^\\+44[\\d]{10}$" class RegisterUserForm(Form): - name = StringField('Name', + name = StringField('Full name', validators=[DataRequired(message='Name can not be empty')]) email_address = StringField('Email address', validators=[ Length(min=5, max=255), @@ -30,6 +32,7 @@ class RegisterUserForm(Form): mobile_number = StringField('Mobile phone number', validators=[DataRequired(message='Please enter your mobile number'), Regexp(regex=mobile_number, message='Please enter a +44 mobile number')]) - password = PasswordField('Password', + password = PasswordField('Create a password', validators=[DataRequired(message='Please enter your password'), - Length(10, 255, message='Password must be at least 10 characters')]) + Length(10, 255, message='Password must be at least 10 characters'), + Blacklist(message='That password is blacklisted, too common')]) diff --git a/app/main/validators.py b/app/main/validators.py new file mode 100644 index 000000000..2638bd5e2 --- /dev/null +++ b/app/main/validators.py @@ -0,0 +1,12 @@ +from wtforms import ValidationError + + +class Blacklist(object): + def __init__(self, message=None): + if not message: + message = 'Password is blacklisted.' + self.message = message + + def __call__(self, form, field): + if field.data in ['password1234', 'passw0rd1234']: + raise ValidationError(self.message) diff --git a/app/main/views/index.py b/app/main/views/index.py index 2ffe73da4..f664ee835 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -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') diff --git a/app/main/views/register.py b/app/main/views/register.py index 4892832ea..48c9d68fa 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -1,9 +1,14 @@ -from datetime import datetime +from datetime import datetime, timedelta +from random import randint -from flask import render_template, redirect, jsonify +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 @@ -25,9 +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=1)) users_dao.insert_user(user) - return redirect('/two-factor') - except Exception as e: + 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)]) diff --git a/app/notify_client/__init__.py b/app/notify_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/notify_client/api_client.py b/app/notify_client/api_client.py new file mode 100644 index 000000000..e32e8e37e --- /dev/null +++ b/app/notify_client/api_client.py @@ -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'] diff --git a/app/templates/add-service.html b/app/templates/add-service.html index dd2f3b1a0..0924f35aa 100644 --- a/app/templates/add-service.html +++ b/app/templates/add-service.html @@ -9,6 +9,12 @@ GOV.UK Notify | Set up service
Users will see your service name:
+@@ -17,11 +23,6 @@ GOV.UK Notify | Set up service For example, 'Vehicle tax' or 'Carer's allowance'
-In test mode you can only send notifications to people in your team.
-When you're ready to go live we'll remove this restriction.
- - diff --git a/app/templates/admin_template.html b/app/templates/admin_template.html index 5e24c7294..e31094bed 100644 --- a/app/templates/admin_template.html +++ b/app/templates/admin_template.html @@ -7,5 +7,13 @@ GOV.UK notifications admin {% block cookie_message %} {% endblock %} + {% block inside_header %} + + {% endblock %} + {% set global_header_text = "GOV.UK Notify" %} diff --git a/app/templates/email-not-received.html b/app/templates/email-not-received.html index 462fbb961..758589199 100644 --- a/app/templates/email-not-received.html +++ b/app/templates/email-not-received.html @@ -10,7 +10,7 @@ GOV.UK NotifyCheck your email address is correct and resend a confirmation code.
+Check your email address is correct and then resend the confirmation code.
@@ -25,4 +25,4 @@ GOV.UK Notify
You can now create a new password for your account.
- + + Your password must have at least 10 characters
@@ -21,4 +24,4 @@ GOV.UK Notify
If you've used GOV.UK Notify before, sign in to your account.
- +
@@ -21,8 +21,9 @@ GOV.UK Notify | Create a user account
- + + Your password must have at least 10 characters
diff --git a/app/templates/register.html b/app/templates/register.html index 7a8f41f8b..99c585714 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -12,9 +12,7 @@ GOV.UK Notify | Create an account
If you've used GOV.UK Notify before, sign in to your account.
-You need to have access to your email account and a mobile phone to register.
- -If you do not have an account, you can register.
+If you do not have an account, you can register for one now.
Check your mobile phone number is correct and resend a confirmation code.
+Check your mobile phone number is correct and then resend the confirmation code.
@@ -26,4 +26,4 @@ GOV.UK Notify
Check your mobile phone number is correct and resend a confirmation code.
+Check your mobile phone number is correct and then resend the confirmation code.
@@ -24,4 +24,4 @@ GOV.UK Notify
We've sent you a text message containing a verification code.
+We've sent you a text message with a verification code.
- +
diff --git a/app/templates/verification-not-received.html b/app/templates/verification-not-received.html index b21b16850..de1d9204f 100644 --- a/app/templates/verification-not-received.html +++ b/app/templates/verification-not-received.html @@ -10,15 +10,15 @@ GOV.UK NotifyText messages sometimes take a few minutes to be received.
If you have not received a text message, you can resend.
Text messages sometimes take a few minutes to arrive. If you do not receive the text message, you can resend it.
-If you no longer have access to your mobile phone and registered number, get in contact with your service manager to reset your number.
P> +If you no longer have access to the phone with the number you registered for this service, speak to your service manager to reset the number.
You need to prove the contact details you gave us are yours.
-We've sent you an email and a text message containing confirmation codes.
+We've sent you a confirmation code by text message.
- +
diff --git a/app/templates/verify.html b/app/templates/verify.html index 729f099c2..a6ab5b8f5 100644 --- a/app/templates/verify.html +++ b/app/templates/verify.html @@ -8,20 +8,17 @@ GOV.UK Notify | Confirm email address and mobile numberYou need to prove the contact details you gave us are yours.
-We've sent you an email and a text message containing confirmation codes.
+We've sent you confirmation codes by email and text message. You need to enter both codes here.
diff --git a/config.py b/config.py index 6c799b561..0bc3a4c4a 100644 --- a/config.py +++ b/config.py @@ -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', "dev-token") + WTF_CSRF_ENABLED = True SECRET_KEY = 'secret-key' HTTP_PROTOCOL = 'http' diff --git a/requirements.txt b/requirements.txt index 9ac4a3971..aaa53cebc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +Flask-Bcrypt==0.6.2 + +git+https://github.com/alphagov/notify-api-client.git@0.1.4#egg=notify-api-client==0.1.4 \ No newline at end of file diff --git a/requirements_for_test.txt b/requirements_for_test.txt index f8aeacc70..ebea6362b 100644 --- a/requirements_for_test.txt +++ b/requirements_for_test.txt @@ -1,3 +1,4 @@ -r requirements.txt pep8==1.5.7 -pytest==2.8.1 \ No newline at end of file +pytest==2.8.1 +pytest-mock==0.8.1 diff --git a/tests/app/main/test_validators.py b/tests/app/main/test_validators.py new file mode 100644 index 000000000..a1cc52192 --- /dev/null +++ b/tests/app/main/test_validators.py @@ -0,0 +1,17 @@ +from pytest import fail + +from app.main.forms import RegisterUserForm + + +def test_should_raise_validation_error_for_password(notifications_admin): + form = RegisterUserForm() + form.name.data = 'test' + form.email_address.data = 'teset@example.gov.uk' + form.mobile_number.data = '+441231231231' + form.password.data = 'password1234' + + try: + form.validate() + fail() + except: + assert 'That password is blacklisted, too common' in form.errors['password'] diff --git a/tests/app/main/views/test_register.py b/tests/app/main/views/test_register.py index 2c6e384a0..a40b93481 100644 --- a/tests/app/main/views/test_register.py +++ b/tests/app/main/views/test_register.py @@ -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', @@ -37,3 +43,31 @@ def test_should_return_400_when_email_is_not_gov_uk(notifications_admin, notific assert response.status_code == 400 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', + 'email_address': 'bad_mobile@example.not.right', + 'mobile_number': '+44123412345', + 'password': 'password1234'}) + + response.status_code == 400 + assert 'That password is blacklisted, too common' in response.get_data(as_text=True) diff --git a/tests/conftest.py b/tests/conftest.py index 338e4d63b..8712733e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from _pytest.monkeypatch import monkeypatch from sqlalchemy.schema import MetaData, DropConstraint from app import create_app, db