mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-02-05 19:03:30 -05:00
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:
@@ -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
|
||||
)
|
||||
|
||||
14
app/main/dao/password_reset_token_dao.py
Normal file
14
app/main/dao/password_reset_token_dao.py
Normal 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()
|
||||
@@ -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()]
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
app/main/views/new_password.py
Normal file
16
app/main/views/new_password.py
Normal 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'
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
migrations/versions/80_password_reset_token.py
Normal file
35
migrations/versions/80_password_reset_token.py
Normal 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 ###
|
||||
16
tests/app/main/dao/test_password_reset_token.py
Normal file
16
tests/app/main/dao/test_password_reset_token.py
Normal 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)
|
||||
@@ -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]
|
||||
|
||||
13
tests/app/main/test_forgot_password_form.py
Normal file
13
tests/app/main/test_forgot_password_form.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user