diff --git a/app/assets/javascripts/registerSecurityKey.js b/app/assets/javascripts/registerSecurityKey.js new file mode 100644 index 000000000..b4678741c --- /dev/null +++ b/app/assets/javascripts/registerSecurityKey.js @@ -0,0 +1,14 @@ +(function(window) { + "use strict"; + + window.GOVUK.Modules.RegisterSecurityKey = function() { + this.start = function(component) { + + $(component) + .on('click', function(event) { + event.preventDefault(); + alert('not implemented'); + }); + }; + }; +})(window); diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index 9aaff0717..f75624df6 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -23,7 +23,11 @@ from app.main.forms import ( TwoFactorForm, ) from app.models.user import User -from app.utils import user_is_gov_user, user_is_logged_in +from app.utils import ( + user_is_gov_user, + user_is_logged_in, + user_is_platform_admin, +) NEW_EMAIL = 'new-email' NEW_MOBILE = 'new-mob' @@ -225,3 +229,11 @@ def user_profile_disable_platform_admin_view(): 'views/user-profile/disable-platform-admin-view.html', form=form ) + + +@main.route("/user-profile/security-keys", methods=['GET']) +@user_is_platform_admin +def user_profile_security_keys(): + return render_template( + 'views/user-profile/security-keys.html', + ) diff --git a/app/models/user.py b/app/models/user.py index b5888e9c8..7b0c58b78 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -10,6 +10,7 @@ from app.models.roles_and_permissions import ( all_permissions, translate_permissions_from_db_to_admin_roles, ) +from app.models.webauthn_credential import WebAuthnCredential from app.notify_client import InviteTokenError from app.notify_client.invite_api_client import invite_api_client from app.notify_client.org_invite_api_client import org_invite_api_client @@ -343,6 +344,11 @@ class User(JSONModel, UserMixin): '@nhs.uk', '.nhs.uk', '@nhs.net', '.nhs.net', )) + @property + def webauthn_credentials(self): + return [WebAuthnCredential(json) for json in + user_api_client.get_webauthn_credentials_for_user(self.id)] + def serialize(self): dct = { "id": self.id, diff --git a/app/models/webauthn_credential.py b/app/models/webauthn_credential.py new file mode 100644 index 000000000..5b40b8148 --- /dev/null +++ b/app/models/webauthn_credential.py @@ -0,0 +1,11 @@ +from app.models import JSONModel + + +class WebAuthnCredential(JSONModel): + ALLOWED_PROPERTIES = { + 'id', + 'name', + 'credential_data', + 'created_at', + 'updated_at' + } diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index c5283ca43..6585a9d2e 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -191,5 +191,13 @@ class UserApiClient(NotifyAdminAPIClient): endpoint = '/user/{}/organisations-and-services'.format(user_id) return self.get(endpoint) + def get_webauthn_credentials_for_user(self, user_id): + from datetime import datetime + + return [{ + 'name': 'Ben test', + 'created_at': datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ") + }] + user_api_client = UserApiClient() diff --git a/app/templates/views/user-profile.html b/app/templates/views/user-profile.html index a39853c32..cb54c8131 100644 --- a/app/templates/views/user-profile.html +++ b/app/templates/views/user-profile.html @@ -45,6 +45,14 @@ {{ edit_field('Change', url_for('.user_profile_password')) }} {% endcall %} + {% if current_user.platform_admin %} + {% call row(id='security-keys') %} + {{ text_field('Security keys') }} + {{ text_field(current_user.webauthn_credentials|length) }} + {{ edit_field('Change', url_for('.user_profile_security_keys')) }} + {% endcall %} + {% endif %} + {% if current_user.platform_admin or session.get('disable_platform_admin_view') %} {% call row() %} {{ text_field('Use platform admin view') }} diff --git a/app/templates/views/user-profile/security-keys.html b/app/templates/views/user-profile/security-keys.html new file mode 100644 index 000000000..133adfa48 --- /dev/null +++ b/app/templates/views/user-profile/security-keys.html @@ -0,0 +1,58 @@ +{% extends "withoutnav_template.html" %} +{% from "components/page-header.html" import page_header %} +{% from "components/button/macro.njk" import govukButton %} +{% from "components/back-link/macro.njk" import govukBackLink %} +{% from "components/table.html" import mapping_table, row, field, row_heading %} + +{% set page_title = 'Security keys' %} +{% set credentials = current_user.webauthn_credentials %} + +{% block per_page_title %} + {{ page_title }} +{% endblock %} + +{% block maincolumn_content %} + {{ page_header( + page_title, + back_link=url_for('.user_profile') + ) }} + +
+
+ {% if credentials %} + + {% call mapping_table( + caption=page_title, + field_headings=['Security key'], + field_headings_visible=False, + caption_visible=False, + ) %} + {% for credential in credentials %} + {% call row() %} + {% call field() %} +
{{ credential.name }}
+
Registered {{ credential.created_at|format_delta }}
+ {% endcall %} + {% endcall %} + {% endfor %} + {% endcall %} + + {% else %} + +

+ Security keys are an alternative way of signing in to Notify. +

+ + {% endif %} + + {{ govukButton({ + "element": "button", + "text": "Register a key", + "classes": "govuk-button--secondary", + "attributes": { + "data-module": "register-security-key", + } + }) }} +
+
+{% endblock %} diff --git a/gulpfile.js b/gulpfile.js index b803c83f1..0465d1a1c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -178,6 +178,7 @@ const javascripts = () => { paths.src + 'javascripts/templateFolderForm.js', paths.src + 'javascripts/collapsibleCheckboxes.js', paths.src + 'javascripts/radioSlider.js', + paths.src + 'javascripts/registerSecurityKey.js', paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/homepage.js', paths.src + 'javascripts/main.js', diff --git a/tests/app/main/views/test_user_profile.py b/tests/app/main/views/test_user_profile.py index ba1b862ea..bf08fdc46 100644 --- a/tests/app/main/views/test_user_profile.py +++ b/tests/app/main/views/test_user_profile.py @@ -11,9 +11,10 @@ from tests.conftest import create_api_user_active, url_for_endpoint_with_token def test_should_show_overview_page( client_request, ): - page = client_request.get(('main.user_profile')) + page = client_request.get('main.user_profile') assert page.select_one('h1').text.strip() == 'Your profile' assert 'Use platform admin view' not in page + assert 'Security keys' not in page def test_overview_page_shows_disable_for_platform_admin( @@ -21,12 +22,28 @@ def test_overview_page_shows_disable_for_platform_admin( platform_admin_user ): client_request.login(platform_admin_user) - page = client_request.get(('main.user_profile')) + page = client_request.get('main.user_profile') assert page.select_one('h1').text.strip() == 'Your profile' disable_platform_admin_row = page.select('tr')[-1] assert ' '.join(disable_platform_admin_row.text.split()) == 'Use platform admin view Yes Change' +@pytest.mark.parametrize('has_keys', [False, True]) +def test_overview_page_shows_security_keys_for_platform_admin( + mocker, + client_request, + platform_admin_user, + has_keys, + webauthn_credential, +): + client_request.login(platform_admin_user) + credentials = [webauthn_credential] if has_keys else [] + mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=credentials) + page = client_request.get('main.user_profile') + security_keys_row = page.select_one('#security-keys') + assert ' '.join(security_keys_row.text.split()) == f'Security keys {len(credentials)} Change' + + def test_should_show_name_page( client_request ): @@ -320,3 +337,33 @@ def test_can_reenable_platform_admin(client_request, platform_admin_user): with client_request.session_transaction() as session: assert session['disable_platform_admin_view'] is False + + +def test_normal_user_doesnt_see_security_keys(client_request): + client_request.get( + '.user_profile_security_keys', + _expected_status=403, + ) + + +def test_should_show_security_keys_page( + mocker, + client_request, + platform_admin_user, + webauthn_credential, +): + client_request.login(platform_admin_user) + + mocker.patch( + 'app.user_api_client.get_webauthn_credentials_for_user', + return_value=[webauthn_credential], + ) + + page = client_request.get('.user_profile_security_keys') + assert page.select_one('h1').text.strip() == 'Security keys' + + credential_row = page.select('tr')[-1] + assert 'Test credential' in credential_row.text + + register_button = page.select_one("[data-module='register-security-key']") + assert register_button.text.strip() == 'Register a key' diff --git a/tests/app/notify_client/test_user_client.py b/tests/app/notify_client/test_user_client.py index f539b159c..a54ebc7b0 100644 --- a/tests/app/notify_client/test_user_client.py +++ b/tests/app/notify_client/test_user_client.py @@ -238,3 +238,8 @@ def test_add_user_to_service_calls_correct_endpoint_and_deletes_keys_from_cache( call('service-{service_id}-template-folders'.format(service_id=service_id)), call('service-{service_id}'.format(service_id=service_id)), ] + + +def test_get_webauthn_credentials_for_user_returns_stubbed_data(): + credentials = user_api_client.get_webauthn_credentials_for_user('id') + assert credentials[0]['name'] == 'Ben test' diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index 15b25c089..ea1a46b82 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -310,6 +310,7 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'user_profile_mobile_number_confirm', 'user_profile_name', 'user_profile_password', + 'user_profile_security_keys', 'using_notify', 'verify', 'verify_email', diff --git a/tests/conftest.py b/tests/conftest.py index c20d6d38b..35b5a3785 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4479,3 +4479,11 @@ def mock_get_invited_org_user_by_id(mocker, sample_org_invite): 'app.org_invite_api_client.get_invited_user', side_effect=_get, ) + + +@pytest.fixture +def webauthn_credential(): + return { + 'name': 'Test credential', + 'created_at': '2017-10-18T16:57:14.154185Z', + } diff --git a/tests/javascripts/registerSecurityKey.test.js b/tests/javascripts/registerSecurityKey.test.js new file mode 100644 index 000000000..2487fa287 --- /dev/null +++ b/tests/javascripts/registerSecurityKey.test.js @@ -0,0 +1,26 @@ +beforeAll(() => { + require('../../app/assets/javascripts/registerSecurityKey.js'); +}) + +afterAll(() => { + require('./support/teardown.js'); +}) + +describe('Register security key', () => { + beforeEach(() => { + document.body.innerHTML = ` + + Register a key + `; + }) + + test('it is not implemented yet', () => { + window.GOVUK.modules.start(); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + button = document.querySelector('[data-module="register-security-key"]'); + button.click(); + + expect(window.alert).toBeCalledWith('not implemented') + }) +})