diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss index 9beb5dd10..7a0a95feb 100644 --- a/app/assets/stylesheets/main.scss +++ b/app/assets/stylesheets/main.scss @@ -20,6 +20,7 @@ @import '../govuk_elements/public/sass/elements/details'; @import '../govuk_elements/public/sass/elements/elements-typography'; @import '../govuk_elements/public/sass/elements/forms'; +@import '../govuk_elements/public/sass/elements/forms/form-validation'; @import '../govuk_elements/public/sass/elements/forms/form-block-labels'; @import '../govuk_elements/public/sass/elements/icons'; @import '../govuk_elements/public/sass/elements/layout'; diff --git a/app/main/__init__.py b/app/main/__init__.py index 9354e06db..3d3ab5cde 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -2,8 +2,7 @@ from flask import Blueprint main = Blueprint('main', __name__) - from app.main.views import ( index, sign_in, sign_out, register, two_factor, verify, sms, add_service, - code_not_received, jobs, dashboard, templates, service_settings + code_not_received, jobs, dashboard, templates, service_settings, forgot_password, new_password ) diff --git a/app/main/dao/users_dao.py b/app/main/dao/users_dao.py index ea065e9d6..f45d2a95d 100644 --- a/app/main/dao/users_dao.py +++ b/app/main/dao/users_dao.py @@ -1,3 +1,7 @@ +from datetime import datetime + +from sqlalchemy.orm import load_only + from app import db, login_manager from app.models import User from app.main.encryption import hashpw @@ -53,3 +57,18 @@ def update_mobile_number(id, mobile_number): user.mobile_number = mobile_number db.session.add(user) db.session.commit() + + +def update_password(user, password): + user.password = hashpw(password) + user.password_changed_at = datetime.now() + user.state = 'active' + db.session.add(user) + db.session.commit() + + +def request_password_reset(email): + user = get_user_by_email(email) + user.state = 'request_password_reset' + db.session.add(user) + db.session.commit() diff --git a/app/main/exceptions.py b/app/main/exceptions.py deleted file mode 100644 index 45f0515c0..000000000 --- a/app/main/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ - - -class AdminApiClientException(Exception): - def __init__(self, message): - self.value = message diff --git a/app/main/forms.py b/app/main/forms.py index 5b7230477..e06595ba4 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1,67 +1,79 @@ -from datetime import datetime - from flask_wtf import Form from wtforms import StringField, PasswordField, ValidationError from wtforms.validators import DataRequired, Email, Length, Regexp -from app.main.dao import verify_codes_dao -from app.main.encryption import check_hash from app.main.validators import Blacklist, ValidateUserCodes +def email_address(): + gov_uk_email \ + = "(^[^@^\\s]+@[^@^\\.^\\s]+(\\.[^@^\\.^\\s]*)*.gov.uk)" + return StringField('Email address', validators=[ + Length(min=5, max=255), + DataRequired(message='Email cannot be empty'), + Email(message='Enter a valid email address'), + Regexp(regex=gov_uk_email, message='Enter a gov.uk email address')]) + + +def mobile_number(): + mobile_number_regex = "^\\+44[\\d]{10}$" + return StringField('Mobile phone number', + validators=[DataRequired(message='Mobile number can not be empty'), + Regexp(regex=mobile_number_regex, message='Enter a +44 mobile number')]) + + +def password(): + return PasswordField('Create a password', + validators=[DataRequired(message='Password can not be empty'), + Length(10, 255, message='Password must be at least 10 characters'), + Blacklist(message='That password is blacklisted, too common')]) + + +def sms_code(): + verify_code = '^\d{5}$' + return StringField('Text message confirmation code', + validators=[DataRequired(message='Text message confirmation code can not be empty'), + Regexp(regex=verify_code, + message='Text message confirmation code must be 5 digits'), + ValidateUserCodes(code_type='sms')]) + + +def email_code(): + verify_code = '^\d{5}$' + return StringField("Email confirmation code", + validators=[DataRequired(message='Email confirmation code can not be empty'), + Regexp(regex=verify_code, message='Email confirmation code must be 5 digits'), + ValidateUserCodes(code_type='email')]) + + class LoginForm(Form): email_address = StringField('Email address', validators=[ Length(min=5, max=255), DataRequired(message='Email cannot be empty'), - Email(message='Please enter a valid email address') + Email(message='Enter a valid email address') ]) password = PasswordField('Password', validators=[ - DataRequired(message='Please enter your password') + DataRequired(message='Enter your password') ]) -gov_uk_email = "(^[^@^\\s]+@[^@^\\.^\\s]+(\\.[^@^\\.^\\s]*)*.gov.uk)" -mobile_number = "^\\+44[\\d]{10}$" -verify_code = '^\d{5}$' - - class RegisterUserForm(Form): - - def __init__(self, existing_email_addresses, existing_mobile_numbers, *args, **kwargs): + def __init__(self, existing_email_addresses, *args, **kwargs): self.existing_emails = existing_email_addresses - self.existing_mobiles = existing_mobile_numbers super(RegisterUserForm, self).__init__(*args, **kwargs) name = StringField('Full name', validators=[DataRequired(message='Name can not be empty')]) - email_address = StringField('Email address', validators=[ - Length(min=5, max=255), - DataRequired(message='Email cannot be empty'), - Email(message='Please enter a valid email address'), - Regexp(regex=gov_uk_email, message='Please enter a gov.uk email address') - ]) - 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('Create a password', - validators=[DataRequired(message='Please enter your password'), - Length(10, 255, message='Password must be at least 10 characters'), - Blacklist(message='That password is blacklisted, too common')]) + email_address = email_address() + mobile_number = mobile_number() + password = password() def validate_email_address(self, field): # Validate email address is unique. - if field.data in self.existing_emails: + if self.existing_emails(field.data): raise ValidationError('Email address already exists') - def validate_mobile_number(self, field): - # Validate mobile number is unique - # Code to re-added later - # if field.data in self.existing_mobiles: - # raise ValidationError('Mobile number already exists') - pass - class TwoFactorForm(Form): - def __init__(self, user_codes, *args, **kwargs): ''' Keyword arguments: @@ -71,13 +83,10 @@ class TwoFactorForm(Form): self.user_codes = user_codes super(TwoFactorForm, self).__init__(*args, **kwargs) - sms_code = StringField('sms code', validators=[DataRequired(message='Enter verification code'), - Regexp(regex=verify_code, message='Code must be 5 digits'), - ValidateUserCodes(code_type='sms')]) + sms_code = sms_code() class VerifyForm(Form): - def __init__(self, user_codes, *args, **kwargs): ''' Keyword arguments: @@ -87,29 +96,16 @@ class VerifyForm(Form): self.user_codes = user_codes super(VerifyForm, self).__init__(*args, **kwargs) - sms_code = StringField("Text message confirmation code", - validators=[DataRequired(message='SMS code can not be empty'), - Regexp(regex=verify_code, message='Code must be 5 digits'), - ValidateUserCodes(code_type='sms')]) - email_code = StringField("Email confirmation code", - validators=[DataRequired(message='Email code can not be empty'), - Regexp(regex=verify_code, message='Code must be 5 digits'), - ValidateUserCodes(code_type='email')]) + sms_code = sms_code() + email_code = email_code() class EmailNotReceivedForm(Form): - email_address = StringField('Email address', validators=[ - Length(min=5, max=255), - DataRequired(message='Email cannot be empty'), - Email(message='Please enter a valid email address'), - Regexp(regex=gov_uk_email, message='Please enter a gov.uk email address') - ]) + email_address = email_address() class TextNotReceivedForm(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')]) + mobile_number = mobile_number() class AddServiceForm(Form): @@ -118,8 +114,16 @@ class AddServiceForm(Form): super(AddServiceForm, self).__init__(*args, **kwargs) service_name = StringField(validators=[ - DataRequired(message='Please enter your service name')]) + DataRequired(message='Service name can not be empty')]) def validate_service_name(self, a): if self.service_name.data in self.service_names: raise ValidationError('Service name already exists') + + +class ForgotPasswordForm(Form): + email_address = email_address() + + +class NewPasswordForm(Form): + new_password = password() diff --git a/app/main/views/__init__.py b/app/main/views/__init__.py index e092ba59c..a76b4feb4 100644 --- a/app/main/views/__init__.py +++ b/app/main/views/__init__.py @@ -1,6 +1,8 @@ from random import randint + +from flask import url_for, current_app + from app import admin_api_client -from app.main.exceptions import AdminApiClientException from app.main.dao import verify_codes_dao @@ -10,24 +12,43 @@ def create_verify_code(): def send_sms_code(user_id, mobile_number): sms_code = create_verify_code() - try: - verify_codes_dao.add_code(user_id=user_id, code=sms_code, code_type='sms') - admin_api_client.send_sms(mobile_number=mobile_number, message=sms_code, token=admin_api_client.auth_token) - except: - raise AdminApiClientException('Exception when sending sms.') + verify_codes_dao.add_code(user_id=user_id, code=sms_code, code_type='sms') + admin_api_client.send_sms(mobile_number=mobile_number, message=sms_code, token=admin_api_client.auth_token) + return sms_code def send_email_code(user_id, email): email_code = create_verify_code() - try: - verify_codes_dao.add_code(user_id=user_id, code=email_code, code_type='email') - 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.') - + verify_codes_dao.add_code(user_id=user_id, code=email_code, code_type='email') + 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) return email_code + + +def send_change_password_email(email): + link_to_change_password = url_for('.new_password', token=generate_token(email), _external=True) + admin_api_client.send_email(email_address=email, + from_str='notify@digital.cabinet-office.gov.uk', + message=link_to_change_password, + subject='Reset password for GOV.UK Notify', + token=admin_api_client.auth_token) + + +def generate_token(email): + from itsdangerous import TimestampSigner + signer = TimestampSigner(current_app.config['SECRET_KEY']) + return signer.sign(email).decode('utf8') + + +def check_token(token): + from itsdangerous import TimestampSigner, SignatureExpired + signer = TimestampSigner(current_app.config['SECRET_KEY']) + try: + email = signer.unsign(token, max_age=current_app.config['TOKEN_MAX_AGE_SECONDS']) + return email + except SignatureExpired as e: + current_app.logger.info('token expired %s' % e) diff --git a/app/main/views/forgot_password.py b/app/main/views/forgot_password.py new file mode 100644 index 000000000..0e5c81bda --- /dev/null +++ b/app/main/views/forgot_password.py @@ -0,0 +1,19 @@ +from flask import render_template, flash, current_app +from app.main import main +from app.main.dao import users_dao +from app.main.forms import ForgotPasswordForm +from app.main.views import send_change_password_email + + +@main.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + form = ForgotPasswordForm() + if form.validate_on_submit(): + if users_dao.get_user_by_email(form.email_address.data): + users_dao.request_password_reset(form.email_address.data) + send_change_password_email(form.email_address.data) + return render_template('views/password-reset-sent.html') + else: + current_app.logger.info('The email address used does not exist.') + else: + return render_template('views/forgot-password.html', form=form) diff --git a/app/main/views/index.py b/app/main/views/index.py index dec87cf89..777dfa789 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -1,6 +1,4 @@ from flask import render_template -from flask_login import login_required - from app.main import main @@ -34,16 +32,6 @@ def checkemail(): return render_template('views/check-email.html') -@main.route("/forgot-password") -def forgotpassword(): - return render_template('views/forgot-password.html') - - -@main.route("/new-password") -def newpassword(): - return render_template('views/new-password.html') - - @main.route("/user-profile") def userprofile(): return render_template('views/user-profile.html') @@ -59,6 +47,11 @@ def apikeys(): return render_template('views/api-keys.html') -@main.route("/verification-not-received") -def verificationnotreceived(): - return render_template('views/verification-not-received.html') +@main.route("/manage-templates") +def managetemplates(): + return render_template('views/manage-templates.html') + + +@main.route("/edit-template") +def edittemplate(): + return render_template('views/edit-template.html') diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py new file mode 100644 index 000000000..c1b0e1e04 --- /dev/null +++ b/app/main/views/new_password.py @@ -0,0 +1,28 @@ +from flask import (render_template, url_for, redirect, flash) + +from app.main import main +from app.main.dao import users_dao +from app.main.forms import NewPasswordForm +from app.main.views import send_sms_code, check_token + + +@main.route('/new-password/', methods=['GET', 'POST']) +def new_password(token): + email_address = check_token(token) + if not email_address: + flash('The link in the email we sent you has expired. Enter your email address to resend.') + return redirect(url_for('.forgot_password')) + + user = users_dao.get_user_by_email(email_address=email_address.decode('utf-8')) + if user and user.state != 'request_password_reset': + flash('The link in the email we sent you has already been used.') + return redirect(url_for('.index')) + + form = NewPasswordForm() + + if form.validate_on_submit(): + users_dao.update_password(user, form.new_password.data) + send_sms_code(user.id, user.mobile_number) + return redirect(url_for('main.two_factor')) + else: + return render_template('views/new-password.html', token=token, form=form, user=user) diff --git a/app/main/views/register.py b/app/main/views/register.py index 2e1c7f309..9eef37a4a 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -1,12 +1,9 @@ from datetime import datetime, timedelta -from flask import render_template, redirect, jsonify, session -from sqlalchemy.exc import SQLAlchemyError +from flask import render_template, redirect, session 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.main.views import send_sms_code, send_email_code from app.models import User @@ -15,16 +12,8 @@ from app.models import User # TODO how do we handle duplicate unverifed email addresses? # malicious or otherwise. @main.route('/register', methods=['GET', 'POST']) -def process_register(): - try: - existing_emails, existing_mobiles = zip( - *[(x.email_address, x.mobile_number) for x in - users_dao.get_all_users()]) - except ValueError: - # Value error is raised if the db is empty. - existing_emails, existing_mobiles = [], [] - - form = RegisterUserForm(existing_emails, existing_mobiles) +def register(): + form = RegisterUserForm(users_dao.get_user_by_email) if form.validate_on_submit(): user = User(name=form.name.data, diff --git a/app/main/views/sign_out.py b/app/main/views/sign_out.py index d734916c8..fe1e47329 100644 --- a/app/main/views/sign_out.py +++ b/app/main/views/sign_out.py @@ -9,4 +9,4 @@ from app.main import main @login_required def sign_out(): logout_user() - return redirect(url_for('main.sign_in')) + return redirect(url_for('main.index')) diff --git a/app/templates/admin_template.html b/app/templates/admin_template.html index 4613ca442..a03e47612 100644 --- a/app/templates/admin_template.html +++ b/app/templates/admin_template.html @@ -43,6 +43,7 @@ {% endblock %} + {% set global_header_text = "GOV.UK Notify" %} @@ -53,9 +54,20 @@ {% endif %} {% block content %} -
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+
+ {% endif %} + {% endwith %} {% block fullwidth_content %}{% endblock %} -
+
{% endblock %} {% block body_end %} diff --git a/app/templates/components/form-field.html b/app/templates/components/form-field.html index 643099498..691b4c579 100644 --- a/app/templates/components/form-field.html +++ b/app/templates/components/form-field.html @@ -2,7 +2,7 @@
{{ field.label }}
{{ field(**kwargs)|safe }} {% if field.errors %} -