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')
+ })
+})