mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-17 04:59:37 -04: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 (
|
from app.main.views import (
|
||||||
index, sign_in, sign_out, register, two_factor, verify, sms, add_service,
|
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 datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import load_only
|
||||||
|
|
||||||
from app import db, login_manager
|
from app import db, login_manager
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.main.encryption import hashpw
|
from app.main.encryption import hashpw
|
||||||
@@ -63,3 +65,7 @@ def update_password(id, password):
|
|||||||
user.password_changed_at = datetime.now()
|
user.password_changed_at = datetime.now()
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
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')
|
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',
|
email_address = StringField('Email address',
|
||||||
validators=[Length(min=5, max=255),
|
validators=[Length(min=5, max=255),
|
||||||
DataRequired(message='Email cannot be empty'),
|
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')
|
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 random import randint
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
from app import admin_api_client
|
from app import admin_api_client
|
||||||
from app.main.exceptions import AdminApiClientException
|
from app.main.exceptions import AdminApiClientException
|
||||||
from app.main.dao import verify_codes_dao
|
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):
|
def send_change_password_email(email):
|
||||||
code = create_verify_code()
|
|
||||||
link_to_change_password = 'thelink' + code
|
|
||||||
# TODO needs an expiry date to check?
|
|
||||||
try:
|
try:
|
||||||
|
link_to_change_password = url_for('.new_password', token=str(uuid.uuid4()))
|
||||||
admin_api_client.send_email(email_address=email,
|
admin_api_client.send_email(email_address=email,
|
||||||
from_str='notify@digital.cabinet-office.gov.uk',
|
from_str='notify@digital.cabinet-office.gov.uk',
|
||||||
message=link_to_change_password,
|
message=link_to_change_password,
|
||||||
subject='Verification code',
|
subject='Verification code',
|
||||||
token=admin_api_client.auth_token)
|
token=admin_api_client.auth_token)
|
||||||
except:
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
raise AdminApiClientException('Exception when sending email.')
|
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 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
|
from app.main.views import send_change_password_email
|
||||||
|
|
||||||
|
|
||||||
@main.route('/forgot-password', methods=['GET'])
|
@main.route('/forgot-password', methods=['GET', 'POST'])
|
||||||
def render_forgot_my_password():
|
def forgot_password():
|
||||||
return render_template('views/forgot-password.html', form=ForgotPassword())
|
form = ForgotPasswordForm(users_dao.find_all_email_address())
|
||||||
|
|
||||||
|
|
||||||
@main.route('/forgot-password', methods=['POST'])
|
|
||||||
def change_password():
|
|
||||||
form = ForgotPassword()
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
send_change_password_email(form.email_address)
|
send_change_password_email(form.email_address.data)
|
||||||
|
return render_template('views/password-reset-sent.html')
|
||||||
return 'You have been sent an email with a link to change your password'
|
|
||||||
else:
|
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)
|
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):
|
def filter_null_value_fields(obj):
|
||||||
return dict(
|
return dict(
|
||||||
filter(lambda x: x[1] is not None, obj.items())
|
filter(lambda x: x[1] is not None, obj.items())
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ GOV.UK Notify
|
|||||||
|
|
||||||
<form autocomplete="off" action="" method="post">
|
<form autocomplete="off" action="" method="post">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<p>
|
{{ render_field(form.email_address, class='form-control-2-3') }}
|
||||||
<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>
|
|
||||||
<p>
|
<p>
|
||||||
<button class="button" href="sign-in" role="button">Send email</button>
|
<button class="button" href="sign-in" role="button">Send email</button>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Sign in
|
|||||||
{{ render_field(form.email_address, class='form-control-2-3') }}
|
{{ render_field(form.email_address, class='form-control-2-3') }}
|
||||||
{{ render_field(form.password, class='form-control-2-3') }}
|
{{ render_field(form.password, class='form-control-2-3') }}
|
||||||
<p>
|
<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>
|
</p>
|
||||||
{{ page_footer("Continue") }}
|
{{ page_footer("Continue") }}
|
||||||
</form>
|
</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
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from app.main.encryption import check_hash
|
from app.main.encryption import check_hash
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.main.dao import users_dao
|
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 check_hash('newpassword', updated.password)
|
||||||
assert updated.password_changed_at < datetime.now()
|
assert updated.password_changed_at < datetime.now()
|
||||||
assert updated.password_changed_at > start
|
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):
|
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)
|
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',
|
response = notifications_admin.test_client().post('/forgot-password',
|
||||||
data={'email_address': 'not_a_valid_email'})
|
data={'email_address': 'email_does_not@exist.gov.uk'})
|
||||||
x = current_app._get_current_object()
|
assert response.status_code == 200
|
||||||
assert response.status_code == 400
|
assert 'Please enter the email address that you registered with' in response.get_data(as_text=True)
|
||||||
assert 'Please enter a valid email address' 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