diff --git a/app/config/config.yml b/app/config/config.yml index acdf50a..6c77c23 100644 --- a/app/config/config.yml +++ b/app/config/config.yml @@ -19,10 +19,16 @@ SECURITY_CHANGEABLE: True SECURITY_PASSWORD_HASH: bcrypt SECURITY_PASSWORD_SALT: 'bb83d6fa8d0a4adc' SECURITY_SEND_REGISTER_EMAIL: False +SECURITY_CONFIRMABLE: True +SECURITY_LOGIN_WITHOUT_CONFIRMATION: True + +# This is a workaround for +# https://github.com/mattupstate/flask-security/pull/489. +SECURITY_MSG_CONFIRM_REGISTRATION: ['DO_NOT_DISPLAY_ME', 'success'] SECURITY_EMAIL_SENDER: noreply@networkofinnovators.org SECURITY_POST_REGISTER_VIEW: 'views.register_step_2' -SECURITY_POST_CONFIRM_VIEW: 'views.register_step_2' +SECURITY_POST_CONFIRM_VIEW: 'views.confirmation_success' SECURITY_POST_LOGIN_VIEW: 'views.activity' SECURITY_EMAIL_SUBJECT_REGISTER: 'Welcome to the Network of Innovators!' SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE: 'Your Network of Innovators password has been reset' diff --git a/app/error_handlers.py b/app/error_handlers.py new file mode 100644 index 0000000..65b1515 --- /dev/null +++ b/app/error_handlers.py @@ -0,0 +1,8 @@ +import flask +from flask import Blueprint, render_template + +blueprint = flask.Blueprint('error_handlers', __name__) + +@blueprint.app_errorhandler(404) +def page_not_found(e): + return render_template('404.html'), 404 diff --git a/app/factory.py b/app/factory.py index cd4949a..893ed68 100644 --- a/app/factory.py +++ b/app/factory.py @@ -17,7 +17,7 @@ QUESTIONS_BY_ID, LEVELS_BY_SCORE, QUESTIONNAIRES_BY_ID) from app.forms import (NOIForgotPasswordForm, NOILoginForm, NOIResetPasswordForm, NOIChangePasswordForm, - NOIRegisterForm) + NOIConfirmRegisterForm, NOISendConfirmationForm) from app.models import db, User, Role from app.views import views from app.utils import get_nopic_avatar @@ -29,6 +29,7 @@ import yaml import json import os +import error_handlers # App config vars that are exposed to client-side JavaScript code. @@ -68,6 +69,11 @@ def create_app(config=None): #pylint: disable=too-many-statements ''' app = Flask(__name__) + ''' + add error page handling + ''' + app.register_blueprint(error_handlers.blueprint) + with open('/noi/app/config/config.yml', 'r') as config_file: app.config.update(yaml.load(config_file)) @@ -81,9 +87,6 @@ def create_app(config=None): #pylint: disable=too-many-statements else: app.config.update(config) - # Confirming email is currently unsupported. - app.config['SECURITY_CONFIRMABLE'] = False - with open('/noi/app/data/deployments.yaml') as deployments_yaml: deployments = yaml.load(deployments_yaml) @@ -118,10 +121,11 @@ def create_app(config=None): #pylint: disable=too-many-statements user_datastore = DeploySQLAlchemyUserDatastore(db, User, Role) security.init_app(app, datastore=user_datastore, login_form=NOILoginForm, - register_form=NOIRegisterForm, + confirm_register_form=NOIConfirmRegisterForm, forgot_password_form=NOIForgotPasswordForm, reset_password_form=NOIResetPasswordForm, - change_password_form=NOIChangePasswordForm) + change_password_form=NOIChangePasswordForm, + send_confirmation_form=NOISendConfirmationForm) db.init_app(app) alchemydumps.init_app(app, db) diff --git a/app/forms.py b/app/forms.py index 2c4c0cd..44681a9 100644 --- a/app/forms.py +++ b/app/forms.py @@ -9,9 +9,10 @@ from flask.ext.babel import get_locale from flask_wtf import Form from flask_wtf.file import FileField, FileAllowed -from flask_security.forms import (LoginForm, RegisterForm, +from flask_security.forms import (LoginForm, ConfirmRegisterForm, ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, + SendConfirmationForm, email_required, email_validator, unique_user_email, valid_user_email, @@ -213,6 +214,13 @@ class ChangeLocaleForm(Form): ) +class NOISendConfirmationForm(SendConfirmationForm): + ''' + Localizeable version of Flask-Security's SendConfirmationForm + ''' + submit = SubmitField(lazy_gettext('Resend Confirmation Instructions')) + + class NOIForgotPasswordForm(ForgotPasswordForm): ''' Localizeable version of Flask-Security's ForgotPasswordForm @@ -233,9 +241,9 @@ class NOILoginForm(LoginForm): submit = SubmitField(lazy_gettext('Log in')) -class NOIRegisterForm(RegisterForm): +class NOIConfirmRegisterForm(ConfirmRegisterForm): ''' - Localizeable version of Flask-Security's RegisterForm + Localizeable version of Flask-Security's ConfirmRegisterForm ''' # Note that extra fields in this registration form are passed @@ -254,7 +262,6 @@ class NOIRegisterForm(RegisterForm): password = PasswordField( lazy_gettext('Password'), validators=[password_required, password_length]) - password_confirm = None submit = SubmitField(lazy_gettext('Sign up')) diff --git a/app/l10n.py b/app/l10n.py index f8aef37..d709785 100644 --- a/app/l10n.py +++ b/app/l10n.py @@ -30,6 +30,12 @@ def gettext_no_interpolate(string): app.config['SECURITY_MSG_UNAUTHORIZED'] = ( lazy_gettext('You do not have permission to view this resource.'), 'error') + app.config['SECURITY_MSG_EMAIL_CONFIRMED'] = ( + lazy_gettext('Thank you. Your email has been confirmed.'), 'success') + app.config['SECURITY_MSG_ALREADY_CONFIRMED'] = ( + lazy_gettext('Your email has already been confirmed.'), 'info') + app.config['SECURITY_MSG_INVALID_CONFIRMATION_TOKEN'] = ( + lazy_gettext('Invalid confirmation token.'), 'error') app.config['SECURITY_MSG_EMAIL_ALREADY_ASSOCIATED'] = ( lazy_gettext('%(email)s is already associated with an account.'), 'error') app.config['SECURITY_MSG_PASSWORD_MISMATCH'] = ( @@ -45,6 +51,14 @@ def gettext_no_interpolate(string): 'instructions have been sent to %(email)s.'), 'error') app.config['SECURITY_MSG_INVALID_RESET_PASSWORD_TOKEN'] = ( lazy_gettext('Invalid reset password token.'), 'error') + app.config['SECURITY_MSG_CONFIRMATION_REQUIRED'] = ( + lazy_gettext('Email requires confirmation.'), 'error') + app.config['SECURITY_MSG_CONFIRMATION_REQUEST'] = ( + lazy_gettext('Confirmation instructions have been sent to %(email)s.'), 'info') + app.config['SECURITY_MSG_CONFIRMATION_EXPIRED'] = ( + lazy_gettext('You did not confirm your email within %(within)s. New ' + 'instructions to confirm your email have been sent to ' + '%(email)s.'), 'error') app.config['SECURITY_MSG_LOGIN_EXPIRED'] = ( lazy_gettext('You did not login within %(within)s. New instructions to ' 'login have been sent to %(email)s.'), 'error') diff --git a/app/static/sass/_error-page.scss b/app/static/sass/_error-page.scss new file mode 100644 index 0000000..d3f2179 --- /dev/null +++ b/app/static/sass/_error-page.scss @@ -0,0 +1,20 @@ +.e-error-display { + background-color: $blue; + text-align: center; + text-transform: uppercase; + padding-top: 4em; + h2,h3,h4 { + color: $white-80;; + padding: .5em; + } +} + +.error-page-icon { + color: $white-80;; + font-size: 4em; + } + +.e-error-display a { + color: $white-90; + font-weight: bold; +} \ No newline at end of file diff --git a/app/static/sass/styles.scss b/app/static/sass/styles.scss index a37f45d..bef6bbb 100644 --- a/app/static/sass/styles.scss +++ b/app/static/sass/styles.scss @@ -96,6 +96,8 @@ body { @import 'desktop-hack'; +@import 'error-page'; + /* Useful for making blocks of content that we don't have time to style * not look utterly unstyled and horrible. */ .b-temporary-styling { diff --git a/app/style_guide.py b/app/style_guide.py index 6946e8b..ac44d4d 100644 --- a/app/style_guide.py +++ b/app/style_guide.py @@ -48,3 +48,4 @@ def send_static_asset(path): return send_from_directory('/noi/app/templates/style-guide/static', path) + diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..4654c70 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,15 @@ +{% extends '__base_ui__.html' %} + +{% block title %}Page Not Found | Network of Innovators{% endblock %} +{% block body_class %}b-landing-page{% endblock %} + +{% block content %} +
+
+ error_outline +

Error 404

+

We couldn't find what you were looking for

+

Try using the navigation above or click here to go back to the homepage

+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/__base_new__.html b/app/templates/__base_new__.html index f4e0293..e49fb7e 100644 --- a/app/templates/__base_new__.html +++ b/app/templates/__base_new__.html @@ -27,7 +27,13 @@ {% from "_macros.html" import render_alert %} {% for category, message in get_flashed_messages(with_categories=true) %} + {# Some flashed messages come from third-party code and we don't want + them to be displayed. The only way we can disable them from being + displayed, unfortunately, is to set their message text to something + we detect for here. #} + {%- if message != 'DO_NOT_DISPLAY_ME' -%} {{ render_alert(message=message, category=category) }} + {%- endif -%} {% endfor %} {% block header %}{% endblock %} diff --git a/app/templates/security/_menu_new.html b/app/templates/security/_menu_new.html index 5490e5c..5f6e29a 100644 --- a/app/templates/security/_menu_new.html +++ b/app/templates/security/_menu_new.html @@ -5,8 +5,5 @@

{{ gettext('Menu') }}

{% if security.recoverable %}
  • {{ gettext('Forgot password') }}
  • {% endif %} - {% if security.confirmable %} -
  • {{ gettext('Confirm account') }}
  • - {% endif %} {% endif %} diff --git a/app/templates/security/send_confirmation.html b/app/templates/security/send_confirmation.html new file mode 100644 index 0000000..46aa55c --- /dev/null +++ b/app/templates/security/send_confirmation.html @@ -0,0 +1,24 @@ +{% extends '__base_new__.html' %} + +{% block title %}{{ gettext('Send confirmation instructions') }}{% endblock %} + +{% from "security/_macros_new.html" import render_field_with_errors, render_field %} + +{% block content %} +
    +
    + +
    + +
    +

    {{ gettext('Send confirmation instructions') }}

    +
    + {{ send_confirmation_form.hidden_tag() }} + {{ render_field_with_errors(send_confirmation_form.email) }} + {{ send_confirmation_form.submit(class="b-button") }} +
    +
    + {% include "security/_menu_new.html" %} +
    + +{% endblock %} diff --git a/app/tests/test_admin.py b/app/tests/test_admin.py index 0de97e3..ad47232 100644 --- a/app/tests/test_admin.py +++ b/app/tests/test_admin.py @@ -1,8 +1,8 @@ from base64 import b64encode -from flask import Flask from flask_testing import TestCase +from .util import create_empty_flask_app from .test_views import ViewTestCase from ..admin import _init_basic_auth as init_basic_auth @@ -47,7 +47,7 @@ def test_admin_users_are_not_redirected_to_login(self): class BasicAuthTests(TestCase): def create_app(self): - app = Flask('test') + app = create_empty_flask_app() app.config.update({'ADMIN_UI_BASIC_AUTH': 'foo:bar'}) @app.route('/foo/bar') diff --git a/app/tests/test_l10n.py b/app/tests/test_l10n.py index 35cff5d..ca0f4c7 100644 --- a/app/tests/test_l10n.py +++ b/app/tests/test_l10n.py @@ -2,14 +2,13 @@ from babel.messages.pofile import read_po from babel.messages.catalog import TranslationError, Message from babel.messages import checkers -from flask import Flask from .test_views import ViewTestCase -from .util import eq_ +from .util import eq_, create_empty_flask_app from app import l10n def test_flask_security_strings_do_not_interpolate(): - app = Flask('test') + app = create_empty_flask_app() l10n.configure_app(app) eq_(unicode(app.config['SECURITY_MSG_EMAIL_ALREADY_ASSOCIATED'][0]), '%(email)s is already associated with an account.') diff --git a/app/tests/test_models.py b/app/tests/test_models.py index b5f2088..31caf76 100644 --- a/app/tests/test_models.py +++ b/app/tests/test_models.py @@ -1,7 +1,6 @@ import time import StringIO import contextlib -from flask import Flask from flask_testing import TestCase from sqlalchemy import create_engine from sqlalchemy.exc import OperationalError, IntegrityError @@ -9,7 +8,7 @@ from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT import psycopg2 -from .util import eq_ +from .util import eq_, create_empty_flask_app from .factories import UserFactory, UserSkillFactory from .. import models, LEVELS, babel @@ -60,7 +59,7 @@ def create_postgres_database(): con.close() def db_test_request_context(): - app = Flask('minimal_db_app') + app = create_empty_flask_app() app.config['SQLALCHEMY_DATABASE_URI'] = TEST_DB_URL db.init_app(app) return app.test_request_context() @@ -96,7 +95,7 @@ class DbTestCase(TestCase): ) def create_app(self): - app = Flask('test') + app = create_empty_flask_app() app.config.update(self.BASE_APP_CONFIG) db.init_app(app) return app diff --git a/app/tests/test_style_guide.py b/app/tests/test_style_guide.py index df26d07..82b3e32 100644 --- a/app/tests/test_style_guide.py +++ b/app/tests/test_style_guide.py @@ -2,12 +2,13 @@ from app import assets, csrf from app.views import views -from flask import Flask from flask.ext.testing import TestCase +from .util import create_empty_flask_app + class StyleGuideTests(TestCase): def create_app(self): - app = Flask('app') + app = create_empty_flask_app() app.config['SECRET_KEY'] = 'test' app.register_blueprint(views) app.register_blueprint(style_guide.views) diff --git a/app/tests/test_views.py b/app/tests/test_views.py index caf7915..94d02a9 100644 --- a/app/tests/test_views.py +++ b/app/tests/test_views.py @@ -1,15 +1,17 @@ +import re from StringIO import StringIO from urllib import urlencode from moto import mock_s3 import boto +from flask import url_for from flask_login import current_user from app import (QUESTIONS_BY_ID, MIN_QUESTIONS_TO_JOIN, LEVELS, QUESTIONNAIRES_BY_ID, mail) -from app.factory import create_app from app.views import views, get_best_registration_step_url from app.models import User, SharedMessageEvent, ConnectionEvent, db +from .util import create_app from .test_models import DbTestCase from .factories import UserFactory, UserSkillFactory @@ -96,7 +98,8 @@ def assertRequiresLogin(self, path, method='get'): self.assertRedirects(method(path), '/login?%s' % urlencode({'next': path})) -class InviteTests(ViewTestCase): + +class EmailTestCase(ViewTestCase): BASE_APP_CONFIG = ViewTestCase.BASE_APP_CONFIG.copy() BASE_APP_CONFIG.update( @@ -105,6 +108,8 @@ class InviteTests(ViewTestCase): MAIL_SUPPRESS_SEND=True, ) + +class InviteTests(EmailTestCase): def setUp(self): super(ViewTestCase, self).setUp() self.login() @@ -630,7 +635,48 @@ def test_valid_form_sets_picture(self): self.assertEqual(key.content_type, 'image/png') +class EmailConfirmationTests(EmailTestCase): + def test_confirmation_works(self): + self.register_and_login('foo@example.org', 'test123') + + with mail.record_messages() as outbox: + res = self.client.post('/confirm', data=dict( + email=u'foo@example.org' + )) + self.assertEqual(len(outbox), 1) + msg = outbox[0] + self.assertEqual(msg.sender, 'noreply@networkofinnovators.org') + self.assertEqual(msg.recipients, ['foo@example.org']) + assert 'http://localhost/confirm/' in msg.body + assert ('Confirmation instructions have been ' + 'sent to foo@example.org') in res.data + + token = re.search( + r'http:\/\/localhost\/confirm\/(.+)', + msg.body, + flags=re.MULTILINE + ).group(1) + + res = self.client.get('/confirm/%s' % token) + self.assertRedirects(res, '/confirm/success') + + res = self.client.get('/confirm/success') + self.assertRedirects(res, '/activity') + + res = self.client.get('/activity') + self.assert200(res) + assert 'Your email has been confirmed' in res.data + class ViewTests(ViewTestCase): + def test_security_post_views_exist(self): + view_names = [ + self.app.config['SECURITY_POST_REGISTER_VIEW'], + self.app.config['SECURITY_POST_CONFIRM_VIEW'], + self.app.config['SECURITY_POST_LOGIN_VIEW'], + ] + for view_name in view_names: + url_for(view_name) + def test_main_page_is_ok(self): self.assert200(self.client.get('/')) diff --git a/app/tests/util.py b/app/tests/util.py index 7b7940d..7c95a8d 100644 --- a/app/tests/util.py +++ b/app/tests/util.py @@ -1,3 +1,22 @@ +import logging +from flask import Flask + +from app import factory + +def add_logging_to_app(app): + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + app.logger.addHandler(handler) + + return app + +def create_app(*args, **kwargs): + app = factory.create_app(*args, **kwargs) + return add_logging_to_app(app) + +def create_empty_flask_app(name='app'): + return add_logging_to_app(Flask(name)) + # Taken from nose: # # https://github.com/nose-devs/nose/blob/master/nose/tools/trivial.py @@ -7,3 +26,4 @@ def eq_(a, b, msg=None): """ if not a == b: raise AssertionError(msg or "%r != %r" % (a, b)) + diff --git a/app/views.py b/app/views.py index 1137641..07eb6e9 100644 --- a/app/views.py +++ b/app/views.py @@ -295,6 +295,14 @@ def register_step_2(): return render_template('register-step-2.html', form=form) +@views.route('/confirm/success') +@login_required +def confirmation_success(): + # In the future, we can make this view redirect the user to + # whatever they wanted to do that required confirmation. + return redirect(url_for('views.activity')) + + def render_user_profile(userid=None, **kwargs): if userid is None: user = current_user