diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py
index 7fa829dae..26bc0ff10 100644
--- a/app/main/views/two_factor.py
+++ b/app/main/views/two_factor.py
@@ -1,4 +1,5 @@
import json
+from datetime import datetime
from flask import (
current_app,
@@ -57,6 +58,7 @@ def two_factor_email(token):
@redirect_to_sign_in
def two_factor():
user_id = session['user_details']['id']
+ user = User.from_id(user_id)
def _check_code(code):
return user_api_client.check_verify_code(user_id, code, "sms")
@@ -64,11 +66,23 @@ def two_factor():
form = TwoFactorForm(_check_code)
if form.validate_on_submit():
- return log_in_user(user_id)
+ if (datetime.utcnow() - datetime.strptime(
+ user.email_access_validated_at, '%a, %d %b %Y %X %Z'
+ )).days < 90:
+ return log_in_user(user_id)
+ else:
+ user_api_client.send_verify_code(user.id, 'email', None, request.args.get('next'))
+ return redirect(url_for('.revalidate_email_sent'))
return render_template('views/two-factor.html', form=form)
+@main.route('/re-validate-email', methods=['GET'])
+def revalidate_email_sent():
+ title = 'Email resent' if request.args.get('email_resent') else 'Check your email'
+ return render_template('views/re-validate-email-sent.html', title=title)
+
+
# see http://flask.pocoo.org/snippets/62/
def _is_safe_redirect_url(target):
from urllib.parse import urlparse, urljoin
@@ -85,7 +99,7 @@ def log_in_user(user_id):
session['current_session_id'] = user.current_session_id
# Check if coming from new password page
if 'password' in session.get('user_details', {}):
- user.update_password(session['user_details']['password'])
+ user.update_password(session['user_details']['password'], from_email=True)
user.activate()
user.login()
finally:
diff --git a/app/models/user.py b/app/models/user.py
index 0b2b25ea8..28fab973f 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -34,6 +34,7 @@ class User(JSONModel, UserMixin):
'auth_type',
'current_session_id',
'failed_login_count',
+ 'email_access_validated_at',
'logged_in_at',
'mobile_number',
'password_changed_at',
@@ -105,8 +106,8 @@ class User(JSONModel, UserMixin):
response = user_api_client.update_user_attribute(self.id, **kwargs)
self.__init__(response)
- def update_password(self, password):
- response = user_api_client.update_password(self.id, password)
+ def update_password(self, password, from_email=False):
+ response = user_api_client.update_password(self.id, password, from_email=from_email)
self.__init__(response)
def password_changed_more_recently_than(self, datetime_string):
diff --git a/app/navigation.py b/app/navigation.py
index e85cb9b0f..503e47ebe 100644
--- a/app/navigation.py
+++ b/app/navigation.py
@@ -112,6 +112,7 @@ class HeaderNavigation(Navigation):
'view_providers',
},
'sign-in': {
+ 'revalidate_email_sent',
'sign_in',
'two_factor',
'two_factor_email',
@@ -583,6 +584,7 @@ class MainNavigation(Navigation):
'returned_letter_summary',
'returned_letters',
'returned_letters_report',
+ 'revalidate_email_sent',
'roadmap',
'robots',
'security',
@@ -829,6 +831,7 @@ class CaseworkNavigation(Navigation):
'returned_letter_summary',
'returned_letters',
'returned_letters_report',
+ 'revalidate_email_sent',
'revoke_api_key',
'roadmap',
'robots',
@@ -1114,6 +1117,7 @@ class OrgNavigation(Navigation):
'returned_letter_summary',
'returned_letters',
'returned_letters_report',
+ 'revalidate_email_sent',
'revoke_api_key',
'roadmap',
'robots',
diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py
index 67cd572e7..014b5330a 100644
--- a/app/notify_client/user_api_client.py
+++ b/app/notify_client/user_api_client.py
@@ -75,8 +75,10 @@ class UserApiClient(NotifyAdminAPIClient):
return user_data['data']
@cache.delete('user-{user_id}')
- def update_password(self, user_id, password):
+ def update_password(self, user_id, password, from_email=False):
data = {"_password": password}
+ if from_email:
+ data["from_email"] = from_email
url = "/user/{}/update-password".format(user_id)
user_data = self.post(url, data=data)
return user_data['data']
diff --git a/app/templates/views/re-validate-email-sent.html b/app/templates/views/re-validate-email-sent.html
new file mode 100644
index 000000000..6bc122740
--- /dev/null
+++ b/app/templates/views/re-validate-email-sent.html
@@ -0,0 +1,24 @@
+{% extends "withoutnav_template.html" %}
+{% from "components/page-footer.html" import page_footer %}
+
+{% block per_page_title %}
+ {{ title }}
+{% endblock %}
+
+{% block maincolumn_content %}
+
+
+
+
{{ title }}
+
For security reasons we need to check if you still have access to your email.
+ Hence, we have emailed you a link to sign in to Notify.
+
If your email address has changed, ask a member of your team to update your email address. Then click re-send.
+
Clicking the link will open Notify in a new browser window, so you can close this one.
+ {{ page_footer(
+ secondary_link=url_for('main.email_not_received'),
+ secondary_link_text='Not received an email?'
+ ) }}
+
+
+
+{% endblock %}
diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py
index 5d70cd40e..618c0527f 100644
--- a/tests/app/main/views/test_new_password.py
+++ b/tests/app/main/views/test_new_password.py
@@ -112,6 +112,6 @@ def test_should_sign_in_when_password_reset_is_successful_for_email_auth(
# the log-in flow makes a couple of calls
mock_get_user.assert_called_once_with(user['id'])
- mock_update_user_password.assert_called_once_with(user['id'], 'a-new_password')
+ mock_update_user_password.assert_called_once_with(user['id'], 'a-new_password', from_email=True)
assert not mock_send_verify_code.called
diff --git a/tests/app/main/views/test_two_factor.py b/tests/app/main/views/test_two_factor.py
index 9a3775e9d..213066a5e 100644
--- a/tests/app/main/views/test_two_factor.py
+++ b/tests/app/main/views/test_two_factor.py
@@ -1,5 +1,6 @@
from bs4 import BeautifulSoup
from flask import url_for
+from freezegun import freeze_time
from tests.conftest import (
SERVICE_ONE_ID,
@@ -13,6 +14,7 @@ def test_should_render_two_factor_page(
client,
api_user_active,
mock_get_user_by_email,
+ mocker
):
# TODO this lives here until we work out how to
# reassign the session after it is lost mid register process
@@ -20,6 +22,7 @@ def test_should_render_two_factor_page(
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ mocker.patch('app.user_api_client.get_user', return_value=api_user_active)
response = client.get(url_for('main.two_factor'))
assert response.status_code == 200
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
@@ -33,6 +36,7 @@ def test_should_render_two_factor_page(
assert page.select_one('input')['pattern'] == '[0-9]*'
+@freeze_time('2020-01-27T12:00:00')
def test_should_login_user_and_should_redirect_to_next_url(
client,
api_user_active,
@@ -45,6 +49,8 @@ def test_should_login_user_and_should_redirect_to_next_url(
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ api_user_active['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
+
response = client.post(url_for('main.two_factor', next='/services/{}'.format(SERVICE_ONE_ID)),
data={'sms_code': '12345'})
assert response.status_code == 302
@@ -55,6 +61,31 @@ def test_should_login_user_and_should_redirect_to_next_url(
)
+@freeze_time('2020-01-27T12:00:00')
+def test_should_send_email_and_redirect_to_info_page_if_user_needs_to_revalidate_email(
+ client,
+ api_user_active,
+ mock_get_user,
+ mock_check_verify_code,
+ mock_create_event,
+ mock_send_verify_code,
+ mocker
+):
+ mocker.patch('app.user_api_client.get_user', return_value=api_user_active)
+ api_user_active['email_access_validated_at'] = 'Sun, 03 Mar 2019 11:28:25 GMT'
+ with client.session_transaction() as session:
+ session['user_details'] = {
+ 'id': api_user_active['id'],
+ 'email': api_user_active['email_address']}
+ response = client.post(url_for('main.two_factor', next='/services/{}'.format(SERVICE_ONE_ID)),
+ data={'sms_code': '12345'})
+
+ assert response.status_code == 302
+ assert response.location == url_for('main.revalidate_email_sent', _external=True)
+ mock_send_verify_code.assert_called_with(api_user_active['id'], 'email', None, mocker.ANY)
+
+
+@freeze_time('2020-01-27T12:00:00')
def test_should_login_user_and_not_redirect_to_external_url(
client,
api_user_active,
@@ -68,12 +99,15 @@ def test_should_login_user_and_not_redirect_to_external_url(
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ api_user_active['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
+
response = client.post(url_for('main.two_factor', next='http://www.google.com'),
data={'sms_code': '12345'})
assert response.status_code == 302
assert response.location == url_for('main.show_accounts_or_dashboard', _external=True)
+@freeze_time('2020-01-27T12:00:00')
def test_should_login_user_and_redirect_to_show_accounts(
client,
api_user_active,
@@ -86,6 +120,8 @@ def test_should_login_user_and_redirect_to_show_accounts(
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ api_user_active['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
+
response = client.post(url_for('main.two_factor'),
data={'sms_code': '12345'})
@@ -98,17 +134,21 @@ def test_should_return_200_with_sms_code_error_when_sms_code_is_wrong(
api_user_active,
mock_get_user_by_email,
mock_check_verify_code_code_not_found,
+ mocker
):
with client.session_transaction() as session:
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ mocker.patch('app.user_api_client.get_user', return_value=api_user_active)
+
response = client.post(url_for('main.two_factor'),
data={'sms_code': '23456'})
assert response.status_code == 200
assert 'Code not found' in response.get_data(as_text=True)
+@freeze_time('2020-01-27T12:00:00')
def test_should_login_user_when_multiple_valid_codes_exist(
client,
api_user_active,
@@ -122,11 +162,14 @@ def test_should_login_user_when_multiple_valid_codes_exist(
session['user_details'] = {
'id': api_user_active['id'],
'email': api_user_active['email_address']}
+ api_user_active['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
+
response = client.post(url_for('main.two_factor'),
data={'sms_code': '23456'})
assert response.status_code == 302
+@freeze_time('2020-01-27T12:00:00')
def test_two_factor_should_set_password_when_new_password_exists_in_session(
client,
api_user_active,
@@ -141,13 +184,14 @@ def test_two_factor_should_set_password_when_new_password_exists_in_session(
'id': api_user_active['id'],
'email': api_user_active['email_address'],
'password': 'changedpassword'}
+ api_user_active['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
response = client.post(url_for('main.two_factor'),
data={'sms_code': '12345'})
assert response.status_code == 302
assert response.location == url_for('main.show_accounts_or_dashboard', _external=True)
- mock_update_user_password.assert_called_once_with(api_user_active['id'], 'changedpassword')
+ mock_update_user_password.assert_called_once_with(api_user_active['id'], 'changedpassword', from_email=True)
def test_two_factor_returns_error_when_user_is_locked(
@@ -179,6 +223,7 @@ def test_two_factor_should_redirect_to_sign_in_if_user_not_in_session(
assert response.location == url_for('main.sign_in', _external=True)
+@freeze_time('2020-01-27T12:00:00')
def test_two_factor_should_activate_pending_user(
client,
mocker,
@@ -189,6 +234,7 @@ def test_two_factor_should_activate_pending_user(
):
mocker.patch('app.user_api_client.get_user', return_value=api_user_pending)
mocker.patch('app.service_api_client.get_services', return_value={'data': []})
+ api_user_pending['email_access_validated_at'] = 'Sun, 23 Jan 2020 11:28:25 GMT'
with client.session_transaction() as session:
session['user_details'] = {
'id': api_user_pending['id'],
diff --git a/tests/conftest.py b/tests/conftest.py
index 4adadb726..c2aa8d3ad 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1102,6 +1102,7 @@ def api_user_active(fake_uuid):
'organisations': [],
'current_session_id': None,
'logged_in_at': None,
+ 'email_access_validated_at': None
}
return user_data
@@ -1514,7 +1515,7 @@ def mock_verify_password(mocker):
@pytest.fixture(scope='function')
def mock_update_user_password(mocker, api_user_active):
- def _update(user_id, password):
+ def _update(user_id, password, from_email=False):
api_user_active['id'] = user_id
return api_user_active