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