Merge pull request #3973 from alphagov/error-handling-js-178466639

show error banner rather than alert when registering an invalid key
This commit is contained in:
Leo Hemsted
2021-09-15 13:57:59 +01:00
committed by GitHub
12 changed files with 170 additions and 113 deletions

View File

@@ -7,6 +7,9 @@
.on('click', function (event) {
event.preventDefault();
// hide any existing error prompt
window.GOVUK.ErrorBanner.hideBanner();
fetch('/webauthn/authenticate')
.then(response => {
if (!response.ok) {
@@ -47,29 +50,23 @@
});
})
.then(response => {
if (response.status === 403) {
// flask will have `flash`ed an error message up
window.location.reload();
return;
if (!response.ok) {
throw Error(response.statusText);
}
return response.arrayBuffer()
.then(cbor => {
return Promise.resolve(window.CBOR.decode(cbor));
})
.catch(() => {
throw Error(response.statusText);
})
.then(data => {
window.location.assign(data.redirect_url);
});
return response.arrayBuffer();
})
.then(cbor => {
return Promise.resolve(window.CBOR.decode(cbor));
})
.then(data => {
window.location.assign(data.redirect_url);
})
.catch(error => {
console.error(error);
// some browsers will show an error dialogue for some
// errors; to be safe we always pop up an alert
var message = error.message || error;
alert('Error during authentication.\n\n' + message);
// errors; to be safe we always display an error message on the page.
window.GOVUK.ErrorBanner.showBanner();
});
});
};

View File

@@ -0,0 +1,17 @@
(function (window) {
"use strict";
/*
This module is intended to be used to show and hide an error banner based on a javascript trigger. You should make
sure the banner has an appropriate aria-live attribute, and a tabindex of -1 so that screenreaders and keyboard users
are alerted to the change respectively.
This may behave in unexpected ways if you have more than one element with the `banner-dangerous` class on your page.
*/
window.GOVUK.ErrorBanner = {
hideBanner: () => $('.banner-dangerous').addClass('govuk-!-display-none'),
showBanner: () => $('.banner-dangerous')
.removeClass('govuk-!-display-none')
.trigger('focus'),
};
})(window);

View File

@@ -7,6 +7,9 @@
.on('click', function(event) {
event.preventDefault();
// hide any existing error prompt
window.GOVUK.ErrorBanner.hideBanner();
fetch('/webauthn/register')
.then((response) => {
if (!response.ok) {
@@ -27,16 +30,7 @@
})
.then((response) => {
if (!response.ok) {
return response.arrayBuffer()
.then((cbor) => {
return Promise.resolve(window.CBOR.decode(cbor));
})
.catch(() => {
throw Error(response.statusText);
})
.then((text) => {
throw Error(text);
});
throw Error(response.statusText);
}
window.location.reload();
@@ -44,9 +38,8 @@
.catch((error) => {
console.error(error);
// some browsers will show an error dialogue for some
// errors; to be safe we always pop up an alert
var message = error.message || error;
alert('Error during registration.\n\n' + message);
// errors; to be safe we always display an error message on the page.
window.GOVUK.ErrorBanner.showBanner();
});
});
};

View File

@@ -232,6 +232,7 @@ def user_profile_disable_platform_admin_view():
@main.route("/user-profile/security-keys", methods=['GET'])
@user_is_logged_in
def user_profile_security_keys():
if not current_user.can_use_webauthn:
abort(403)
@@ -251,6 +252,7 @@ def user_profile_security_keys():
methods=['GET'],
endpoint="user_profile_confirm_delete_security_key"
)
@user_is_logged_in
def user_profile_manage_security_key(key_id):
if not current_user.can_use_webauthn:
abort(403)
@@ -282,6 +284,7 @@ def user_profile_manage_security_key(key_id):
@main.route("/user-profile/security-keys/<uuid:key_id>/delete", methods=['POST'])
@user_is_logged_in
def user_profile_delete_security_key(key_id):
if not current_user.can_use_webauthn:
abort(403)

View File

@@ -3,7 +3,6 @@ from fido2.client import ClientData
from fido2.ctap2 import AuthenticatorData
from flask import abort, current_app, flash, redirect, request, session, url_for
from flask_login import current_user
from werkzeug.exceptions import Forbidden
from app.main import main
from app.models.user import User
@@ -50,7 +49,7 @@ def webauthn_complete_register():
)
except RegistrationError as e:
current_app.logger.info(f'User {current_user.id} could not register a new webauthn token - {e}')
return cbor.encode(str(e)), 400
abort(400)
current_user.create_webauthn_credential(credential)
current_user.update(auth_type='webauthn_auth')
@@ -102,24 +101,8 @@ def webauthn_complete_authentication():
user_id = session['user_details']['id']
user_to_login = User.from_id(user_id)
try:
_verify_webauthn_authentication(user_to_login)
redirect = _complete_webauthn_login_attempt(user_to_login)
except Forbidden:
# We don't expect to reach this case in normal situations - normally errors (such as using the wrong
# security key) will be caught in the browser inside `window.navigator.credentials.get`, and the js will
# error first meaning it doesn't send the POST request to this method. If this method is called but the key
# couldn't be authenticated, something went wrong along the way, probably:
# * The browser didn't implement the webauthn standard correctly, and let something through it shouldn't have
# * The key itself is in some way corrupted, or of lower security standard
flash('Security key not recognised')
# flash sets the error message in the user's session cookie, and flask renders it next time `render_template`
# is called. In authenticateSecurityKey.js we refresh the page if this POST returns a 403.
# we can't use `abort(403)` here, and just return an empty body instead as our 403 error handler would return
# an error page response containing the flash, but our javascript ignores the body of the error response and
# just looks at the error code
return '', 403
_verify_webauthn_authentication(user_to_login)
redirect = _complete_webauthn_login_attempt(user_to_login)
return cbor.encode({'redirect_url': redirect.location}), 200
@@ -142,6 +125,12 @@ def _verify_webauthn_authentication(user):
signature=request_data['signature']
)
except ValueError as exc:
# We don't expect to reach this case in normal situations - normally errors (such as using the wrong
# security key) will be caught in the browser inside `window.navigator.credentials.get`, and the js will
# error first meaning it doesn't send the POST request to this method. If this method is called but the key
# couldn't be authenticated, something went wrong along the way, probably:
# * The browser didn't implement the webauthn standard correctly, and let something through it shouldn't have
# * The key itself is in some way corrupted, or of lower security standard
current_app.logger.info(f'User {user.id} could not sign in using their webauthn token - {exc}')
user.complete_webauthn_login_attempt(is_successful=False)
abort(403)

View File

@@ -21,6 +21,21 @@
{% block maincolumn_content %}
{{ govukErrorMessage({
"classes": "banner-dangerous govuk-!-display-none",
"html": (
'Theres a problem with your security key' +
'<p class="govuk-body">Check you have the right key and try again. ' +
'If this does not work, ' +
'<a class="govuk-link govuk-link--no-visited-state" href=' + url_for('main.support') + ">contact us</a>." +
'</p>'
),
"attributes": {
"aria-live": "polite",
"tabindex": '-1'
}
}) }}
<div class="govuk-grid-row">
<div class="govuk-grid-column-one-half">
{{ page_header(page_title) }}

View File

@@ -46,6 +46,22 @@
{% endset %}
<div class="govuk-grid-row">
{{ govukErrorMessage({
"classes": "banner-dangerous govuk-!-display-none",
"html": (
'Theres a problem with your security key' +
'<p class="govuk-body">Check you have the right key and try again. ' +
'If this does not work, ' +
'<a class="govuk-link govuk-link--no-visited-state" href=' + url_for('main.support') + ">contact us</a>." +
'</p>'
),
"attributes": {
"aria-live": "polite",
"tabindex": '-1'
}
}) }}
{% if credentials %}
<div class="govuk-grid-column-five-sixths">
{{ page_header(page_title) }}

View File

@@ -182,6 +182,7 @@ const javascripts = () => {
paths.src + 'javascripts/registerSecurityKey.js',
paths.src + 'javascripts/authenticateSecurityKey.js',
paths.src + 'javascripts/updateStatus.js',
paths.src + 'javascripts/errorBanner.js',
paths.src + 'javascripts/homepage.js',
paths.src + 'javascripts/main.js',
])

View File

@@ -181,7 +181,6 @@ def test_complete_register_handles_library_errors(
)
assert response.status_code == 400
assert cbor.decode(response.data) == 'error'
def test_complete_register_handles_missing_state(
@@ -289,8 +288,6 @@ def test_complete_authentication_403s_if_key_isnt_in_users_credentials(
assert 'user_id' not in session
# webauthn state reset so can't replay
assert 'webauthn_authentication_state' not in session
# make sure there's an error message to show when the page reloads
assert '_flashes' in session
assert mock_verify_webauthn_login.called is False
# make sure we incremented the failed login count
@@ -370,10 +367,6 @@ def test_verify_webauthn_login_signs_user_in_doesnt_sign_user_in_if_api_rejects(
resp = client.post(url_for('main.webauthn_complete_authentication'))
with client.session_transaction() as session:
# make sure there's an error message to show when the page reloads
assert '_flashes' in session
assert resp.status_code == 403

View File

@@ -5,6 +5,10 @@ beforeAll(() => {
// populate missing values to allow consistent jest.spyOn()
window.fetch = () => { }
window.navigator.credentials = { get: () => { } }
window.GOVUK.ErrorBanner = {
showBanner: () => {},
hideBanner: () => {}
};
})
afterAll(() => {
@@ -21,10 +25,7 @@ describe('Authenticate with security key', () => {
beforeEach(() => {
// disable console.error() so we don't see it in test output
// you might need to comment this out to debug some failures
jest.spyOn(console, 'error').mockImplementation(() => { })
// ensure window.alert() is implemented to simplify errors
jest.spyOn(window, 'alert').mockImplementation(() => { })
jest.spyOn(console, 'error').mockImplementation(() => {})
document.body.innerHTML = `
<button type="submit" data-module="authenticate-security-key" data-csrf-token="abc123"></button>`
@@ -89,9 +90,9 @@ describe('Authenticate with security key', () => {
done()
})
// this will make the test fail if the alert is called
jest.spyOn(window, 'alert').mockImplementation((msg) => {
done(msg)
// this will make the test fail if the error banner is displayed
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done('didnt expect the banner to be shown')
})
button.click()
@@ -132,17 +133,24 @@ describe('Authenticate with security key', () => {
expect(url.toString()).toEqual(
'https://www.notifications.service.gov.uk/webauthn/authenticate?next=%2Ffoo%3Fbar%3Dbaz'
)
done()
return Promise.resolve({
ok: true, arrayBuffer: () => Promise.resolve(window.CBOR.encode({ redirect_url: '/foo' }))
})
})
jest.spyOn(window.location, 'assign').mockImplementation((href) => {
// ensure that the fetch mock implementation above was called
expect.assertions(1)
done()
})
button.click()
})
test.each([
['network'],
['server'],
])('alerts if fetching WebAuthn fails (%s error)', (errorType, done) => {
])('errors if fetching WebAuthn fails (%s error)', (errorType, done) => {
jest.spyOn(window, 'fetch').mockImplementation((_url) => {
if (errorType == 'network') {
return Promise.reject('error')
@@ -151,15 +159,14 @@ describe('Authenticate with security key', () => {
}
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during authentication.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})
button.click()
})
test('alerts if comms with the authenticator fails', (done) => {
test('errors if comms with the authenticator fails', (done) => {
jest.spyOn(window, 'fetch')
.mockImplementationOnce((_url) => {
const webauthnOptions = window.CBOR.encode('someArbitraryOptions')
@@ -173,8 +180,7 @@ describe('Authenticate with security key', () => {
return Promise.reject(new DOMException('error'))
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during authentication.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})
@@ -182,9 +188,9 @@ describe('Authenticate with security key', () => {
})
test.each([
['network error'],
['internal server error'],
])('alerts if POSTing WebAuthn credentials fails (%s)', (errorType, done) => {
['network'],
['server'],
])('errors if POSTing WebAuthn credentials fails (%s)', (errorType, done) => {
jest.spyOn(window, 'fetch')
.mockImplementationOnce((_url) => {
const webauthnOptions = window.CBOR.encode('someArbitraryOptions')
@@ -211,17 +217,15 @@ describe('Authenticate with security key', () => {
jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => {
// subsequent POST of credential data to server
switch (errorType) {
case 'network error':
case 'network':
return Promise.reject('error')
case 'internal server error':
// dont need this becuase we dont cbor return errors
const message = Promise.reject('encoding error')
return Promise.resolve({ ok: false, arrayBuffer: () => message, statusText: 'error' })
case 'server':
return Promise.resolve({ ok: false, statusText: 'FORBIDDEN' })
}
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during authentication.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})
@@ -230,9 +234,8 @@ describe('Authenticate with security key', () => {
test('reloads page if POSTing WebAuthn credentials returns 403', (done) => {
jest.spyOn(window, 'fetch')
.mockImplementationOnce((_url) => {
const webauthnOptions = window.CBOR.encode('someArbitraryOptions')
jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => {
const webauthnOptions = window.CBOR.encode('someArbitraryOptions')
return Promise.resolve({
ok: true, arrayBuffer: () => webauthnOptions
@@ -266,8 +269,8 @@ describe('Authenticate with security key', () => {
done()
})
// this will make the test fail if the alert is called
jest.spyOn(window, 'alert').mockImplementation((msg) => {
// this will make the test fail if the error banner is displayed
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation((msg) => {
done(msg)
})

View File

@@ -0,0 +1,37 @@
beforeAll(() => {
require('../../app/assets/javascripts/errorBanner.js')
});
afterAll(() => {
require('./support/teardown.js');
});
describe("Error Banner", () => {
afterEach(() => {
document.body.innerHTML = '';
});
describe("The `hideBanner` method", () => {
test("Will hide the element", () => {
document.body.innerHTML = `
<span class="govuk-error-message banner-dangerous js-error-visible">
</span>`;
window.GOVUK.ErrorBanner.hideBanner();
expect(document.querySelector('.banner-dangerous').classList).toContain('govuk-!-display-none')
});
});
describe("The `showBanner` method", () => {
beforeEach(() => {
document.body.innerHTML = `
<span class="govuk-error-message banner-dangerous js-error-visible govuk-!-display-none">
</span>`;
window.GOVUK.ErrorBanner.showBanner('Some Err');
});
test("Will show the element", () => {
expect(document.querySelector('.banner-dangerous').classList).not.toContain('govuk-!-display-none')
});
});
});

View File

@@ -4,7 +4,11 @@ beforeAll(() => {
// populate missing values to allow consistent jest.spyOn()
window.fetch = () => {}
window.navigator.credentials = { create: () => {} }
window.navigator.credentials = { create: () => { } }
window.GOVUK.ErrorBanner = {
showBanner: () => { },
hideBanner: () => { }
};
})
afterAll(() => {
@@ -23,9 +27,6 @@ describe('Register security key', () => {
// you might need to comment this out to debug some failures
jest.spyOn(console, 'error').mockImplementation(() => {})
// ensure window.alert() is implemented to simplify errors
jest.spyOn(window, 'alert').mockImplementation(() => {})
document.body.innerHTML = `
<a href="#" role="button" draggable="false" class="govuk-button govuk-button--secondary" data-module="register-security-key">
Register a key
@@ -78,9 +79,9 @@ describe('Register security key', () => {
done()
})
// this will make the test fail if the alert is called
jest.spyOn(window, 'alert').mockImplementation((msg) => {
done(msg)
// this will make the test fail if the error banner is displayed
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done('didnt expect the banner to be shown')
})
button.click()
@@ -89,7 +90,7 @@ describe('Register security key', () => {
test.each([
['network'],
['server'],
])('alerts if fetching WebAuthn options fails (%s error)', (errorType, done) => {
])('errors if fetching WebAuthn options fails (%s error)', (errorType, done) => {
jest.spyOn(window, 'fetch').mockImplementation((_url) => {
if (errorType == 'network') {
return Promise.reject('error')
@@ -98,8 +99,7 @@ describe('Register security key', () => {
}
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during registration.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})
@@ -107,10 +107,9 @@ describe('Register security key', () => {
})
test.each([
['network error'],
['internal server error'],
['bad request'],
])('alerts if sending WebAuthn credentials fails (%s)', (errorType, done) => {
['network'],
['server'],
])('errors if sending WebAuthn credentials fails (%s)', (errorType, done) => {
jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => {
// initial fetch of options from the server
@@ -129,26 +128,21 @@ describe('Register security key', () => {
jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => {
// subsequent POST of credential data to server
switch (errorType) {
case 'network error':
case 'network':
return Promise.reject('error')
case 'bad request':
message = Promise.resolve(window.CBOR.encode('error'))
return Promise.resolve({ ok: false, arrayBuffer: () => message })
case 'internal server error':
message = Promise.reject('encoding error')
return Promise.resolve({ ok: false, arrayBuffer: () => message, statusText: 'error' })
case 'server':
return Promise.resolve({ ok: false, statusText: 'FORBIDDEN' })
}
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during registration.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})
button.click()
})
test('alerts if comms with the authenticator fails', (done) => {
test('errors if comms with the authenticator fails', (done) => {
jest.spyOn(window.navigator.credentials, 'create').mockImplementation(() => {
return Promise.reject(new DOMException('error'))
})
@@ -162,8 +156,7 @@ describe('Register security key', () => {
})
})
jest.spyOn(window, 'alert').mockImplementation((msg) => {
expect(msg).toEqual('Error during registration.\n\nerror')
jest.spyOn(window.GOVUK.ErrorBanner, 'showBanner').mockImplementation(() => {
done()
})