Completion of forgot-password endpoints.

Start implementation for new-password endpoints.
Created PasswordResetToken model
ToDo: create and save token, send valid url to user,
check validity of token, update user's password, redirect to /two-factor.
This commit is contained in:
Rebecca Law
2016-01-05 17:52:09 +00:00
parent 6696426dbc
commit 2cb896fa81
15 changed files with 187 additions and 33 deletions

View File

@@ -4,5 +4,5 @@ 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, forgot_password
code_not_received, jobs, dashboard, templates, service_settings, forgot_password, new_password
)

View File

@@ -0,0 +1,14 @@
from datetime import datetime, timedelta
from app import db
from app.models import PasswordResetToken
def insert(token):
token.expiry_date = datetime.now() + timedelta(hours=1)
db.session.add(token)
db.session.commit()
def get_token(token):
return PasswordResetToken.query.filter_by(token=token).first()

View File

@@ -1,5 +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
@@ -63,3 +65,7 @@ def update_password(id, password):
user.password_changed_at = datetime.now()
db.session.add(user)
db.session.commit()
def find_all_email_address():
return [x.email_address for x in User.query.options(load_only("email_address")).all()]

View File

@@ -122,7 +122,11 @@ class AddServiceForm(Form):
raise ValidationError('Service name already exists')
class ForgotPassword(Form):
class ForgotPasswordForm(Form):
def __init__(self, email_addresses, *args, **kargs):
self.email_addresses = email_addresses
super(ForgotPasswordForm, self).__init__(*args, **kargs)
email_address = StringField('Email address',
validators=[Length(min=5, max=255),
DataRequired(message='Email cannot be empty'),
@@ -130,4 +134,8 @@ class ForgotPassword(Form):
Regexp(regex=gov_uk_email, message='Please enter a gov.uk email address')
])
def validate_email_address(self, a):
if self.email_address.data not in self.email_addresses:
raise ValidationError('Please enter the email address that you registered with')

View File

@@ -1,4 +1,9 @@
import traceback
import uuid
from random import randint
from flask import url_for
from app import admin_api_client
from app.main.exceptions import AdminApiClientException
from app.main.dao import verify_codes_dao
@@ -34,14 +39,13 @@ def send_email_code(user_id, email):
def send_change_password_email(email):
code = create_verify_code()
link_to_change_password = 'thelink' + code
# TODO needs an expiry date to check?
try:
link_to_change_password = url_for('.new_password', token=str(uuid.uuid4()))
admin_api_client.send_email(email_address=email,
from_str='notify@digital.cabinet-office.gov.uk',
message=link_to_change_password,
subject='Verification code',
token=admin_api_client.auth_token)
except:
traceback.print_exc()
raise AdminApiClientException('Exception when sending email.')

View File

@@ -1,21 +1,16 @@
from flask import render_template, jsonify
from flask import render_template
from app.main import main
from app.main.forms import ForgotPassword
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'])
def render_forgot_my_password():
return render_template('views/forgot-password.html', form=ForgotPassword())
@main.route('/forgot-password', methods=['POST'])
def change_password():
form = ForgotPassword()
@main.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
form = ForgotPasswordForm(users_dao.find_all_email_address())
if form.validate_on_submit():
send_change_password_email(form.email_address)
return 'You have been sent an email with a link to change your password'
send_change_password_email(form.email_address.data)
return render_template('views/password-reset-sent.html')
else:
return jsonify(form.errors), 400
return render_template('views/forgot-password.html', form=form)

View File

@@ -0,0 +1,16 @@
from flask import request
from app.main import main
@main.route('/new-password/<token>', methods=['GET', 'POST'])
def new_password():
# Validate token
token = request.args.get('token')
# get password token (better name)
# is it expired
# add NewPasswordForm
# update password
# create password_token table (id, token, user_id, expiry_date
return 'Got here'

View File

@@ -111,6 +111,13 @@ class Service(db.Model):
return filter_null_value_fields(serialized)
class PasswordResetToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
token = db.Column(db.String, unique=True, index=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=False, nullable=False)
expiry_date = db.Column(db.DateTime, nullable=False)
def filter_null_value_fields(obj):
return dict(
filter(lambda x: x[1] is not None, obj.items())

View File

@@ -14,11 +14,7 @@ GOV.UK Notify
<form autocomplete="off" action="" method="post">
{{ form.hidden_tag() }}
<p>
<label class="form-label">Email address </label>
{{ form.email_address(class="form-control-2-3", autocomplete="off") }} <br>
<span class="font-xsmall">Your email address must end in .gov.uk</span>
</p>
{{ render_field(form.email_address, class='form-control-2-3') }}
<p>
<button class="button" href="sign-in" role="button">Send email</button>
</p>

View File

@@ -18,7 +18,7 @@ Sign in
{{ render_field(form.email_address, class='form-control-2-3') }}
{{ render_field(form.password, class='form-control-2-3') }}
<p>
<span class="font-xsmall"><a href="">Forgotten password?</a></span>
<span class="font-xsmall"><a href="{{url_for('main.forgot_password')}}">Forgotten password?</a></span>
</p>
{{ page_footer("Continue") }}
</form>

View File

@@ -0,0 +1,35 @@
"""empty message
Revision ID: 80_password_reset_token
Revises: 70_unique_email
Create Date: 2016-01-05 17:47:46.395959
"""
# revision identifiers, used by Alembic.
revision = '80_password_reset_token'
down_revision = '70_unique_email'
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('password_reset_token',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('expiry_date', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_token_token'), 'password_reset_token', ['token'], unique=True)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_password_reset_token_token'), table_name='password_reset_token')
op.drop_table('password_reset_token')
### end Alembic commands ###

View File

@@ -0,0 +1,16 @@
import uuid
from app.main.dao import password_reset_token_dao
from app.models import PasswordResetToken
from tests.app.main import create_test_user
def test_should_insert_and_return_token(notifications_admin, notifications_admin_db, notify_db_session):
user = create_test_user('active')
token_id = uuid.uuid4()
reset_token = PasswordResetToken(token=str(token_id),
user_id=user.id)
password_reset_token_dao.insert(reset_token)
saved_token = password_reset_token_dao.get_token(str(token_id))
assert saved_token.token == str(token_id)

View File

@@ -1,8 +1,6 @@
from datetime import datetime
import pytest
import sqlalchemy
from app.main.encryption import check_hash
from app.models import User
from app.main.dao import users_dao
@@ -183,3 +181,26 @@ def test_should_update_password(notifications_admin, notifications_admin_db, not
assert check_hash('newpassword', updated.password)
assert updated.password_changed_at < datetime.now()
assert updated.password_changed_at > start
def test_should_return_list_of_all_email_addresses(notifications_admin, notifications_admin_db, notify_db_session):
first = User(name='First Person',
password='somepassword',
email_address='first@it.gov.uk',
mobile_number='+441234123412',
created_at=datetime.now(),
role_id=1,
state='active')
second = User(name='Second Person',
password='somepassword',
email_address='second@it.gov.uk',
mobile_number='+441234123412',
created_at=datetime.now(),
role_id=1,
state='active')
users_dao.insert_user(first)
users_dao.insert_user(second)
email_addresses = users_dao.get_all_users()
expected = [first.email_address, second.email_address]
assert expected == [x.email_address for x in email_addresses]

View File

@@ -0,0 +1,13 @@
from werkzeug.datastructures import MultiDict
from app.main.forms import ForgotPasswordForm
def test_should_return_validation_error_if_email_address_does_not_exist(notifications_admin,
notifications_admin_db,
notify_db_session):
with notifications_admin.test_request_context():
form = ForgotPasswordForm(['first@it.gov.uk', 'second@it.gov.uk'],
formdata=MultiDict([('email_address', 'not_found@it.gov.uk')]))
form.validate()
assert {'email_address': ['Please enter the email address that you registered with']} == form.errors

View File

@@ -1,4 +1,6 @@
from flask import current_app
import uuid
from tests.app.main import create_test_user
def test_should_render_forgot_password(notifications_admin, notifications_admin_db, notify_db_session):
@@ -8,9 +10,30 @@ def test_should_render_forgot_password(notifications_admin, notifications_admin_
in response.get_data(as_text=True)
def test_should_return_400_when_email_is_invalid(notifications_admin, notifications_admin_db, notify_db_session):
def test_should_have_validate_error_when_email_does_not_exist(notifications_admin,
notifications_admin_db,
notify_db_session):
create_test_user('active')
response = notifications_admin.test_client().post('/forgot-password',
data={'email_address': 'not_a_valid_email'})
x = current_app._get_current_object()
assert response.status_code == 400
assert 'Please enter a valid email address' in response.get_data(as_text=True)
data={'email_address': 'email_does_not@exist.gov.uk'})
assert response.status_code == 200
assert 'Please enter the email address that you registered with' in response.get_data(as_text=True)
def test_should_redirect_to_password_reset_sent(notifications_admin,
notifications_admin_db,
mocker,
notify_db_session,
):
_set_up_mocker(mocker)
create_test_user('active')
response = notifications_admin.test_client().post('/forgot-password',
data={'email_address': 'test@user.gov.uk'})
assert response.status_code == 200
assert 'You have been sent an email containing a url to reset your password.' in response.get_data(as_text=True)
def _set_up_mocker(mocker):
mocker.patch("app.admin_api_client.send_sms")
mocker.patch("app.admin_api_client.send_email")