diff --git a/app/assets/javascripts/authenticateSecurityKey.js b/app/assets/javascripts/authenticateSecurityKey.js new file mode 100644 index 000000000..1326c923b --- /dev/null +++ b/app/assets/javascripts/authenticateSecurityKey.js @@ -0,0 +1,63 @@ +(function (window) { + "use strict"; + + window.GOVUK.Modules.AuthenticateSecurityKey = function () { + this.start = function (component) { + $(component) + .on('click', function (event) { + event.preventDefault(); + + fetch('/webauthn/authenticate') + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + + return response.arrayBuffer(); + }) + .then(data => { + var options = window.CBOR.decode(data); + // triggers browser dialogue to login with authenticator + return window.navigator.credentials.get(options); + }) + .then(credential => { + return fetch('/webauthn/authenticate', { + method: 'POST', + headers: { 'X-CSRFToken': component.data('csrfToken') }, + body: window.CBOR.encode({ + credentialId: new Uint8Array(credential.rawId), + authenticatorData: new Uint8Array(credential.response.authenticatorData), + signature: new Uint8Array(credential.response.signature), + clientDataJSON: new Uint8Array(credential.response.clientDataJSON), + }) + }); + }) + .then(response => { + if (response.status === 403) { + // flask will have `flash`ed an error message up + window.location.reload(); + return; + } + + 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); + }); + }) + .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); + }); + }); + }; + }; +}) (window); diff --git a/app/main/views/code_not_received.py b/app/main/views/code_not_received.py index 25e2f060a..37269a7d2 100644 --- a/app/main/views/code_not_received.py +++ b/app/main/views/code_not_received.py @@ -43,7 +43,7 @@ def check_and_resend_verification_code(): if user.state == 'pending': return redirect(url_for('main.verify', next=redirect_url)) else: - return redirect(url_for('main.two_factor', next=redirect_url)) + return redirect(url_for('main.two_factor_sms', next=redirect_url)) @main.route('/email-not-received', methods=['GET']) diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py index 22f2a9719..5167d7ae2 100644 --- a/app/main/views/new_password.py +++ b/app/main/views/new_password.py @@ -45,10 +45,10 @@ def new_password(token): # they've just clicked an email link, so have done an email auth journey anyway. Just log them in. return log_in_user(user.id) elif user.webauthn_auth: - raise NotImplementedError('webauthn not supported yet') + return redirect(url_for('main.two_factor_webauthn', next=request.args.get('next'))) else: # send user a 2fa sms code user.send_verify_code() - return redirect(url_for('main.two_factor', next=request.args.get('next'))) + return redirect(url_for('main.two_factor_sms', next=request.args.get('next'))) else: return render_template('views/new-password.html', token=token, form=form, user=user) diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 6a1d9ba4b..a119faada 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -46,11 +46,11 @@ def sign_in(): invited_user.accept_invite() if user and user.sign_in(): if user.sms_auth: - return redirect(url_for('.two_factor', next=redirect_url)) + return redirect(url_for('.two_factor_sms', next=redirect_url)) if user.email_auth: return redirect(url_for('.two_factor_email_sent', next=redirect_url)) if user.webauthn_auth: - raise NotImplementedError('webauthn not supported yet') + return redirect(url_for('.two_factor_webauthn', next=redirect_url)) # Vague error message for login in case of user not known, locked, inactive or password not verified flash(Markup( diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 8bbbe0ba1..9ed4b45fd 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -60,9 +60,10 @@ def two_factor_email(token): return log_in_user(user_id) +@main.route('/two-factor-sms', methods=['GET', 'POST']) @main.route('/two-factor', methods=['GET', 'POST']) @redirect_to_sign_in -def two_factor(): +def two_factor_sms(): user_id = session['user_details']['id'] user = User.from_id(user_id) @@ -79,7 +80,15 @@ def two_factor(): user_api_client.send_verify_code(user.id, 'email', None, redirect_url) return redirect(url_for('.revalidate_email_sent', next=redirect_url)) - return render_template('views/two-factor.html', form=form, redirect_url=redirect_url) + return render_template('views/two-factor-sms.html', form=form, redirect_url=redirect_url) + + +@main.route('/two-factor-webauthn', methods=['GET']) +@redirect_to_sign_in +def two_factor_webauthn(): + # TODO: Return a sensible error page if the user isn't platform admin or doesn't have webauthn + redirect_url = request.args.get('next') + return render_template('views/two-factor-webauthn.html', redirect_url=redirect_url) @main.route('/re-validate-email', methods=['GET']) diff --git a/app/main/views/verify.py b/app/main/views/verify.py index b1d27ea10..87c8fa95d 100644 --- a/app/main/views/verify.py +++ b/app/main/views/verify.py @@ -36,7 +36,7 @@ def verify(): finally: session.pop('user_details', None) - return render_template('views/two-factor.html', form=form) + return render_template('views/two-factor-sms.html', form=form) @main.route('/verify-email/') diff --git a/app/main/views/webauthn_credentials.py b/app/main/views/webauthn_credentials.py index e212374d2..b4f12e63f 100644 --- a/app/main/views/webauthn_credentials.py +++ b/app/main/views/webauthn_credentials.py @@ -1,11 +1,20 @@ from fido2 import cbor -from flask import current_app, request, session +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.main.views.two_factor import log_in_user +from app.models.user import User from app.models.webauthn_credential import RegistrationError, WebAuthnCredential from app.notify_client.user_api_client import user_api_client -from app.utils import user_is_platform_admin +from app.utils import ( + is_less_than_days_ago, + redirect_to_sign_in, + user_is_platform_admin, +) @main.route('/webauthn/register') @@ -19,10 +28,7 @@ def webauthn_begin_register(): "name": current_user.email_address, "displayName": current_user.name, }, - credentials=[ - credential.to_credential_data() - for credential in current_user.webauthn_credentials - ], + credentials=current_user.webauthn_credentials_as_cbor, user_verification="discouraged", # don't ask for PIN authenticator_attachment="cross-platform", ) @@ -50,3 +56,103 @@ def webauthn_complete_register(): ) return cbor.encode('') + + +@main.route('/webauthn/authenticate', methods=['GET']) +@redirect_to_sign_in +def webauthn_begin_authentication(): + # get user from session + user_to_login = User.from_id(session['user_details']['id']) + + if not user_to_login.webauthn_auth: + abort(403) + + if not user_to_login.platform_admin: + abort(403) + + authentication_data, state = current_app.webauthn_server.authenticate_begin( + credentials=user_to_login.webauthn_credentials_as_cbor, + user_verification=None, # required, preferred, discouraged. sets whether to ask for PIN + ) + session["webauthn_authentication_state"] = state + return cbor.encode(authentication_data) + + +@main.route('/webauthn/authenticate', methods=['POST']) +@redirect_to_sign_in +def webauthn_complete_authentication(): + user_id = session['user_details']['id'] + user_to_login = User.from_id(user_id) + + if not user_to_login.webauthn_auth: + abort(403) + + if not user_to_login.platform_admin: + abort(403) + + 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 + + return cbor.encode({'redirect_url': redirect.location}), 200 + + +def _verify_webauthn_authentication(user): + """ + Check that the presented security key is valid, has signed the right challenge, and belongs to the user + we're trying to log in. + """ + state = session.pop("webauthn_authentication_state") + request_data = cbor.decode(request.get_data()) + + try: + current_app.webauthn_server.authenticate_complete( + state=state, + credentials=user.webauthn_credentials_as_cbor, + credential_id=request_data['credentialId'], + client_data=ClientData(request_data['clientDataJSON']), + auth_data=AuthenticatorData(request_data['authenticatorData']), + signature=request_data['signature'] + ) + except ValueError as exc: + 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) + + +def _complete_webauthn_login_attempt(user): + """ + * check the user hasn't gone over their max logins + * check that the user's email is validated + * if succesful, update current_session_id, log in date, and then redirect + """ + redirect_url = request.args.get('next') + + # normally API handles this when verifying an sms or email code but since the webauthn logic happens in the + # admin we need a separate call that just finalises the login in the database + logged_in, _ = user.complete_webauthn_login_attempt() + if not logged_in: + # user account is locked as too many failed logins + + abort(403) + + if not is_less_than_days_ago(user.email_access_validated_at, 90): + user_api_client.send_verify_code(user.id, 'email', None, redirect_url) + return redirect(url_for('.revalidate_email_sent', next=redirect_url)) + + return log_in_user(user.id) diff --git a/app/models/user.py b/app/models/user.py index 4e9dde722..54fbb85d2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -353,6 +353,13 @@ class User(JSONModel, UserMixin): return [WebAuthnCredential(json) for json in user_api_client.get_webauthn_credentials_for_user(self.id)] + @property + def webauthn_credentials_as_cbor(self): + return [ + credential.to_credential_data() + for credential in self.webauthn_credentials + ] + def serialize(self): dct = { "id": self.id, @@ -430,6 +437,9 @@ class User(JSONModel, UserMixin): self.id, ) + def complete_webauthn_login_attempt(self, is_successful=True): + return user_api_client.complete_webauthn_login_attempt(self.id, is_successful) + class InvitedUser(JSONModel): diff --git a/app/navigation.py b/app/navigation.py index 77e1f59e3..2ceda0b15 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -107,10 +107,11 @@ class HeaderNavigation(Navigation): 'sign-in': { 'revalidate_email_sent', 'sign_in', - 'two_factor', + 'two_factor_sms', 'two_factor_email', 'two_factor_email_sent', 'two_factor_email_interstitial', + 'two_factor_webauthn', 'verify', 'verify_email', }, diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 915b87a2f..4ab0305a8 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -125,6 +125,19 @@ class UserApiClient(NotifyAdminAPIClient): return False, e.message raise e + @cache.delete('user-{user_id}') + def complete_webauthn_login_attempt(self, user_id, is_successful): + data = {'successful': is_successful} + # TODO: Change this to `/complete/webauthn-login` + endpoint = f'/user/{user_id}/verify/webauthn-login' + try: + self.post(endpoint, data=data) + return True, '' + except HTTPError as e: + if e.status_code == 403: + return False, e.message + raise e + def get_users_for_service(self, service_id): endpoint = '/service/{}/users'.format(service_id) return self.get(endpoint)['data'] diff --git a/app/templates/views/two-factor.html b/app/templates/views/two-factor-sms.html similarity index 100% rename from app/templates/views/two-factor.html rename to app/templates/views/two-factor-sms.html diff --git a/app/templates/views/two-factor-webauthn.html b/app/templates/views/two-factor-webauthn.html new file mode 100644 index 000000000..a2758e76e --- /dev/null +++ b/app/templates/views/two-factor-webauthn.html @@ -0,0 +1,33 @@ +{% extends "withoutnav_template.html" %} +{% from "components/page-header.html" import page_header %} +{% from "components/button/macro.njk" import govukButton %} + +{% set page_title = 'Security keys' %} + +{% block per_page_title %} + {{ page_title }} +{% endblock %} + +{% block maincolumn_content %} + {{ page_header( + page_title, + back_link=url_for('.user_profile') + ) }} + +
+
+

Security key

+

When you are ready to authenticate, press the button below.

+ + {{ govukButton({ + "element": "button", + "text": "Webauthn authenticate click me click me", + "classes": "govuk-button--secondary", + "attributes": { + "data-module": "authenticate-security-key", + "data-csrf-token": csrf_token(), + } + }) }} +
+
+{% endblock %} diff --git a/gulpfile.js b/gulpfile.js index 88e55fc77..9dbd998ee 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -180,6 +180,7 @@ const javascripts = () => { paths.src + 'javascripts/collapsibleCheckboxes.js', paths.src + 'javascripts/radioSlider.js', paths.src + 'javascripts/registerSecurityKey.js', + paths.src + 'javascripts/authenticateSecurityKey.js', paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/homepage.js', paths.src + 'javascripts/main.js', diff --git a/tests/app/main/views/test_code_not_received.py b/tests/app/main/views/test_code_not_received.py index 11405790f..6343dd271 100644 --- a/tests/app/main/views/test_code_not_received.py +++ b/tests/app/main/views/test_code_not_received.py @@ -138,7 +138,7 @@ def test_check_and_redirect_to_two_factor_if_user_active( 'email': api_user_active['email_address']} response = client.get(url_for('main.check_and_resend_verification_code', next=redirect_url)) assert response.status_code == 302 - assert response.location == url_for('main.two_factor', _external=True, next=redirect_url) + assert response.location == url_for('main.two_factor_sms', _external=True, next=redirect_url) @pytest.mark.parametrize('redirect_url', [ diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py index 53cd87754..b0c690d1f 100644 --- a/tests/app/main/views/test_new_password.py +++ b/tests/app/main/views/test_new_password.py @@ -56,10 +56,36 @@ def test_should_redirect_to_two_factor_when_password_reset_is_successful( response = client.post(url_for_endpoint_with_token('.new_password', token=token, next=redirect_url), data={'new_password': 'a-new_password'}) assert response.status_code == 302 - assert response.location == url_for('.two_factor', _external=True, next=redirect_url) + assert response.location == url_for('.two_factor_sms', _external=True, next=redirect_url) mock_get_user_by_email_request_password_reset.assert_called_once_with(user['email_address']) +@pytest.mark.parametrize('redirect_url', [ + None, + f'/services/{SERVICE_ONE_ID}/templates', +]) +def test_should_redirect_to_two_factor_webauthn_when_password_reset_is_successful( + notify_admin, + client, + mock_get_user_by_email_request_password_reset, + mock_send_verify_code, + mock_reset_failed_login_count, + redirect_url +): + user = mock_get_user_by_email_request_password_reset.return_value + user['auth_type'] = 'webauthn_auth' + data = json.dumps({'email': user['email_address'], 'created_at': str(datetime.utcnow())}) + token = generate_token(data, notify_admin.config['SECRET_KEY'], notify_admin.config['DANGEROUS_SALT']) + response = client.post(url_for_endpoint_with_token('.new_password', token=token, next=redirect_url), + data={'new_password': 'a-new_password'}) + assert response.status_code == 302 + assert response.location == url_for('.two_factor_webauthn', _external=True, next=redirect_url) + mock_get_user_by_email_request_password_reset.assert_called_once_with(user['email_address']) + + assert not mock_send_verify_code.called + assert mock_reset_failed_login_count.called + + def test_should_redirect_index_if_user_has_already_changed_password( notify_admin, client, diff --git a/tests/app/main/views/test_sign_in.py b/tests/app/main/views/test_sign_in.py index 9d4aafbcf..4dfeaf7fe 100644 --- a/tests/app/main/views/test_sign_in.py +++ b/tests/app/main/views/test_sign_in.py @@ -130,7 +130,9 @@ def test_process_sms_auth_sign_in_return_2fa_template( 'email_address': email_address, 'password': password}) assert response.status_code == 302 - assert response.location == url_for('.two_factor', next=redirect_url, _external=True) + # TODO: remove this assert once we start defaulting to returning two_factor_sms first + assert '/two-factor-sms' not in response.location + assert response.location == url_for('.two_factor_sms', next=redirect_url, _external=True) mock_verify_password.assert_called_with(api_user_active['id'], password) mock_get_user_by_email.assert_called_with('valid@example.gov.uk') @@ -160,6 +162,34 @@ def test_process_email_auth_sign_in_return_2fa_template( mock_verify_password.assert_called_with(api_user_active_email_auth['id'], 'val1dPassw0rd!') +@pytest.mark.parametrize('redirect_url', [ + None, + f'/services/{SERVICE_ONE_ID}/templates', +]) +def test_process_webauthn_auth_sign_in_redirects_to_webauthn_with_next_redirect( + client, + api_user_active, + mocker, + mock_verify_password, + redirect_url +): + api_user_active['auth_type'] = 'webauthn_auth' + mock_get_user_by_email = mocker.patch('app.user_api_client.get_user_by_email', return_value=api_user_active) + + response = client.post( + url_for( + 'main.sign_in', next=redirect_url + ), + data={ + 'email_address': 'valid@example.gov.uk', + 'password': 'val1dPassw0rd!' + } + ) + mock_get_user_by_email.assert_called_once_with('valid@example.gov.uk') + assert response.status_code == 302 + assert response.location == url_for('.two_factor_webauthn', _external=True, next=redirect_url) + + def test_should_return_locked_out_true_when_user_is_locked( client, mock_get_user_by_email_locked, diff --git a/tests/app/main/views/test_two_factor.py b/tests/app/main/views/test_two_factor.py index 07ffabe17..f431a75fd 100644 --- a/tests/app/main/views/test_two_factor.py +++ b/tests/app/main/views/test_two_factor.py @@ -54,7 +54,7 @@ def test_should_render_two_factor_page( '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', next=redirect_url)) + response = client.get(url_for('main.two_factor_sms', next=redirect_url)) assert response.status_code == 200 page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') assert page.select_one('main p').text.strip() == ( @@ -86,7 +86,7 @@ def test_should_login_user_and_should_redirect_to_next_url( 'email': api_user_active['email_address']} api_user_active['email_access_validated_at'] = '2020-01-23T11:35:21.726132Z' - response = client.post(url_for('main.two_factor', next='/services/{}'.format(SERVICE_ONE_ID)), + response = client.post(url_for('main.two_factor_sms', next='/services/{}'.format(SERVICE_ONE_ID)), data={'sms_code': '12345'}) assert response.status_code == 302 assert response.location == url_for( @@ -112,7 +112,7 @@ def test_should_send_email_and_redirect_to_info_page_if_user_needs_to_revalidate session['user_details'] = { 'id': api_user_active['id'], 'email': api_user_active['email_address']} - response = client.post(url_for('main.two_factor', next=f'/services/{SERVICE_ONE_ID}'), + response = client.post(url_for('main.two_factor_sms', next=f'/services/{SERVICE_ONE_ID}'), data={'sms_code': '12345'}) assert response.status_code == 302 @@ -140,7 +140,7 @@ def test_should_login_user_and_not_redirect_to_external_url( 'email': api_user_active['email_address']} api_user_active['email_access_validated_at'] = '2020-01-23T11:35:21.726132Z' - response = client.post(url_for('main.two_factor', next='http://www.google.com'), + response = client.post(url_for('main.two_factor_sms', 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) @@ -166,7 +166,7 @@ def test_should_login_user_and_redirect_to_show_accounts( api_user_active['email_access_validated_at'] = '2020-01-23T11:35:21.726132Z' api_user_active['platform_admin'] = platform_admin - response = client.post(url_for('main.two_factor'), + response = client.post(url_for('main.two_factor_sms'), data={'sms_code': '12345'}) assert response.status_code == 302 @@ -186,7 +186,7 @@ def test_should_return_200_with_sms_code_error_when_sms_code_is_wrong( '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'), + response = client.post(url_for('main.two_factor_sms'), data={'sms_code': '23456'}) assert response.status_code == 200 assert 'Code not found' in response.get_data(as_text=True) @@ -208,7 +208,7 @@ def test_should_login_user_when_multiple_valid_codes_exist( 'email': api_user_active['email_address']} api_user_active['email_access_validated_at'] = '2020-01-23T11:35:21.726132Z' - response = client.post(url_for('main.two_factor'), + response = client.post(url_for('main.two_factor_sms'), data={'sms_code': '23456'}) assert response.status_code == 302 @@ -230,7 +230,7 @@ def test_two_factor_should_set_password_when_new_password_exists_in_session( 'password': 'changedpassword'} api_user_active['email_access_validated_at'] = '2020-01-23T11:35:21.726132Z' - response = client.post(url_for('main.two_factor'), + response = client.post(url_for('main.two_factor_sms'), data={'sms_code': '12345'}) assert response.status_code == 302 assert response.location == url_for('main.show_accounts_or_dashboard', _external=True) @@ -252,21 +252,31 @@ def test_two_factor_returns_error_when_user_is_locked( 'id': api_user_locked['id'], 'email': api_user_locked['email_address'], } - response = client.post(url_for('main.two_factor'), + response = client.post(url_for('main.two_factor_sms'), data={'sms_code': '12345'}) assert response.status_code == 200 assert 'Code not found' in response.get_data(as_text=True) -def test_two_factor_should_redirect_to_sign_in_if_user_not_in_session( - client, - api_user_active, - mock_get_user, +def test_two_factor_post_should_redirect_to_sign_in_if_user_not_in_session( + client_request, ): - response = client.post(url_for('main.two_factor'), - data={'sms_code': '12345'}) - assert response.status_code == 302 - assert response.location == url_for('main.sign_in', _external=True) + client_request.post( + 'main.two_factor_sms', + _data={'sms_code': '12345'}, + _expected_redirect=url_for('main.sign_in', _external=True) + ) + + +@pytest.mark.parametrize('endpoint', ['main.two_factor_webauthn', 'main.two_factor_sms']) +def test_two_factor_get_should_redirect_to_sign_in_if_user_not_in_session( + client_request, + endpoint, +): + client_request.get( + endpoint, + _expected_redirect=url_for('main.sign_in', _external=True) + ) @freeze_time('2020-01-27T12:00:00') @@ -286,7 +296,7 @@ def test_two_factor_should_activate_pending_user( 'id': api_user_pending['id'], 'email_address': api_user_pending['email_address'] } - client.post(url_for('main.two_factor'), data={'sms_code': '12345'}) + client.post(url_for('main.two_factor_sms'), data={'sms_code': '12345'}) assert mock_activate_user.called diff --git a/tests/app/main/views/test_webauthn_credentials.py b/tests/app/main/views/test_webauthn_credentials.py index c74b1685e..ccf6913d4 100644 --- a/tests/app/main/views/test_webauthn_credentials.py +++ b/tests/app/main/views/test_webauthn_credentials.py @@ -1,8 +1,37 @@ +import base64 +from unittest.mock import ANY, Mock + import pytest from fido2 import cbor from flask import url_for +from freezegun.api import freeze_time -from app.models.webauthn_credential import RegistrationError +from app.models.webauthn_credential import RegistrationError, WebAuthnCredential + + +@pytest.fixture +def webauthn_authentication_post_data(fake_uuid, webauthn_credential, client): + """ + Sets up session, challenge, etc as if a user with uuid `fake_uuid` has logged in and touched the webauthn token + as found in the `webauthn_credential` fixture. Sets up the session as if `begin_authentication` had been called + so that the challenge matches and the credential will validate (provided that the key belongs to the user referenced + in the session). + """ + with client.session_transaction() as session: + session['user_details'] = {'id': fake_uuid} + session['webauthn_authentication_state'] = { + "challenge": "e-g-nXaRxMagEiqTJSyD82RsEc5if_6jyfJDy8bNKlw", + "user_verification": None + } + + credential_id = WebAuthnCredential(webauthn_credential).to_credential_data().credential_id + + return cbor.encode({ + 'credentialId': credential_id, + 'authenticatorData': base64.b64decode(b'dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvABAAACfQ=='), + 'clientDataJSON': b'{"challenge":"e-g-nXaRxMagEiqTJSyD82RsEc5if_6jyfJDy8bNKlw","origin":"https://webauthn.io","type":"webauthn.get"}', # noqa + 'signature': bytes.fromhex('304502204a76f05cd52a778cdd4df1565e0004e5cc1ead360419d0f5c3a0143bf37e7f15022100932b5c308a560cfe4f244214843075b904b3eda64e85d64662a81198c386cdde'), # noqa + }) @pytest.mark.parametrize('endpoint', [ @@ -22,6 +51,7 @@ def test_begin_register_returns_encoded_options( webauthn_dev_server, ): mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[]) + response = platform_admin_client.get(url_for('main.webauthn_begin_register')) assert response.status_code == 200 @@ -157,3 +187,224 @@ def test_complete_register_handles_missing_state( assert response.status_code == 400 assert cbor.decode(response.data) == 'No registration in progress' + + +def test_begin_authentication_forbidden_for_non_platform_admins(client, api_user_active, mock_get_user): + # mock_get_user returns api_user_active so changes to the api user will reflect + api_user_active['auth_type'] = 'webauthn_auth' + + with client.session_transaction() as session: + session['user_details'] = {'id': '1'} + + response = client.get(url_for('main.webauthn_begin_authentication')) + assert response.status_code == 403 + + +def test_begin_authentication_forbidden_for_users_without_webauthn(client, mocker, platform_admin_user): + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + + with client.session_transaction() as session: + session['user_details'] = {'id': '1'} + + response = client.get(url_for('main.webauthn_begin_authentication')) + assert response.status_code == 403 + + +def test_begin_authentication_returns_encoded_options(client, mocker, webauthn_credential, platform_admin_user): + platform_admin_user['auth_type'] = 'webauthn_auth' + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + + with client.session_transaction() as session: + session['user_details'] = {'id': platform_admin_user['id']} + + get_creds_mock = mocker.patch( + 'app.user_api_client.get_webauthn_credentials_for_user', + return_value=[webauthn_credential] + ) + response = client.get(url_for('main.webauthn_begin_authentication')) + + decoded_data = cbor.decode(response.data) + allowed_credentials = decoded_data['publicKey']['allowCredentials'] + + assert len(allowed_credentials) == 1 + assert decoded_data['publicKey']['timeout'] == 30000 + get_creds_mock.assert_called_once_with(platform_admin_user['id']) + + +def test_begin_authentication_stores_state_in_session(client, mocker, webauthn_credential, platform_admin_user): + platform_admin_user['auth_type'] = 'webauthn_auth' + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + + with client.session_transaction() as session: + session['user_details'] = {'id': platform_admin_user['id']} + + mocker.patch( + 'app.user_api_client.get_webauthn_credentials_for_user', + return_value=[webauthn_credential] + ) + client.get(url_for('main.webauthn_begin_authentication')) + + with client.session_transaction() as session: + assert 'challenge' in session['webauthn_authentication_state'] + + +def test_complete_authentication_checks_credentials( + client, + mocker, + webauthn_credential, + webauthn_dev_server, + mock_create_event, + webauthn_authentication_post_data, + platform_admin_user +): + platform_admin_user['auth_type'] = 'webauthn_auth' + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[webauthn_credential]) + mocker.patch( + 'app.main.views.webauthn_credentials._complete_webauthn_login_attempt', + return_value=Mock(location='/foo') + ) + + response = client.post(url_for('main.webauthn_complete_authentication'), data=webauthn_authentication_post_data) + + assert response.status_code == 200 + assert cbor.decode(response.data) == {'redirect_url': '/foo'} + + +def test_complete_authentication_403s_if_key_isnt_in_users_credentials( + client, + mocker, + webauthn_credential, + webauthn_dev_server, + webauthn_authentication_post_data, + platform_admin_user +): + platform_admin_user['auth_type'] = 'webauthn_auth' + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + # user has no keys in the database + mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[]) + mock_verify_webauthn_login = mocker.patch('app.main.views.webauthn_credentials._complete_webauthn_login_attempt') + mock_unsuccesful_login_api_call = mocker.patch('app.user_api_client.complete_webauthn_login_attempt') + + response = client.post(url_for('main.webauthn_complete_authentication'), data=webauthn_authentication_post_data) + assert response.status_code == 403 + + with client.session_transaction() as session: + assert session['user_details']['id'] == platform_admin_user['id'] + # user not logged in + 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 + mock_unsuccesful_login_api_call.assert_called_once_with(platform_admin_user['id'], False) + + +def test_complete_authentication_clears_session( + client, + mocker, + webauthn_credential, + webauthn_dev_server, + webauthn_authentication_post_data, + mock_create_event, + platform_admin_user +): + platform_admin_user['auth_type'] = 'webauthn_auth' + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[webauthn_credential]) + mocker.patch( + 'app.main.views.webauthn_credentials._complete_webauthn_login_attempt', + return_value=Mock(location='/foo') + ) + + client.post(url_for('main.webauthn_complete_authentication'), data=webauthn_authentication_post_data) + + with client.session_transaction() as session: + # it's important that we clear the session to ensure that we don't re-use old login artifacts in future + assert 'webauthn_authentication_state' not in session + + +@freeze_time('2020-01-30') +def test_verify_webauthn_login_signs_user_in_signs_user_in(client, mocker, mock_create_event, platform_admin_user): + platform_admin_user['auth_type'] = 'webauthn_auth' + platform_admin_user['email_access_validated_at'] = '2020-01-25T00:00:00.000000Z' + + with client.session_transaction() as session: + session['user_details'] = { + 'id': platform_admin_user['id'], + 'email': platform_admin_user['email_address'] + } + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') + mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(True, None)) + + resp = client.post(url_for('main.webauthn_complete_authentication')) + + assert resp.status_code == 200 + assert cbor.decode(resp.data)['redirect_url'] == url_for('main.show_accounts_or_dashboard') + # removes stuff from session + with client.session_transaction() as session: + assert 'user_details' not in session + + mock_create_event.assert_called_once_with('sucessful_login', ANY) + + +def test_verify_webauthn_login_signs_user_in_doesnt_sign_user_in_if_api_rejects( + client, + mocker, + platform_admin_user, +): + platform_admin_user['auth_type'] = 'webauthn_auth' + + with client.session_transaction() as session: + session['user_details'] = { + 'id': platform_admin_user['id'], + 'email': platform_admin_user['email_address'] + } + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') + mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(False, None)) + + 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 + + +@freeze_time('2020-04-30') +def test_verify_webauthn_login_signs_user_in_sends_revalidation_email_if_needed( + client, + mocker, + mock_send_verify_code, + platform_admin_user, +): + platform_admin_user['auth_type'] = 'webauthn_auth' + platform_admin_user['email_access_validated_at'] = '2020-01-25T00:00:00.000000Z' + user_details = { + 'id': platform_admin_user['id'], + 'email': platform_admin_user['email_address'] + } + + with client.session_transaction() as session: + session['user_details'] = user_details + + mocker.patch('app.user_api_client.get_user', return_value=platform_admin_user) + mocker.patch('app.main.views.webauthn_credentials._verify_webauthn_authentication') + mocker.patch('app.user_api_client.complete_webauthn_login_attempt', return_value=(True, None)) + + resp = client.post(url_for('main.webauthn_complete_authentication')) + + assert resp.status_code == 200 + assert cbor.decode(resp.data)['redirect_url'] == url_for('main.revalidate_email_sent') + + with client.session_transaction() as session: + # stuff stays in session so we can log them in later when they validate their email + assert session['user_details'] == user_details + + mock_send_verify_code.assert_called_once_with(platform_admin_user['id'], 'email', ANY, ANY) diff --git a/tests/app/notify_client/test_user_client.py b/tests/app/notify_client/test_user_client.py index 5331de1a2..97dfce3cd 100644 --- a/tests/app/notify_client/test_user_client.py +++ b/tests/app/notify_client/test_user_client.py @@ -1,7 +1,8 @@ import uuid -from unittest.mock import call +from unittest.mock import Mock, call import pytest +from notifications_python_client.errors import HTTPError from app import invite_api_client, service_api_client, user_api_client from app.models.webauthn_credential import WebAuthnCredential @@ -191,6 +192,7 @@ def test_returns_value_from_cache( (user_api_client, 'update_password', [user_id, 'hunter2'], {}), (user_api_client, 'verify_password', [user_id, 'hunter2'], {}), (user_api_client, 'check_verify_code', [user_id, '', ''], {}), + (user_api_client, 'complete_webauthn_login_attempt', [user_id], {'is_successful': True}), (user_api_client, 'add_user_to_service', [SERVICE_ONE_ID, user_id, [], []], {}), (user_api_client, 'add_user_to_organisation', [sample_uuid(), user_id], {}), (user_api_client, 'set_user_permissions', [user_id, SERVICE_ONE_ID, []], {}), @@ -263,3 +265,44 @@ def test_create_webauthn_credential_for_user(mocker, webauthn_credential, fake_u user_api_client.create_webauthn_credential_for_user(fake_uuid, credential) mock_post.assert_called_once_with(expected_url, data=credential.serialize()) + + +def test_complete_webauthn_login_attempt_returns_true_and_no_message_normally(fake_uuid, mocker): + mock_post = mocker.patch('app.notify_client.user_api_client.UserApiClient.post') + + resp = user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) + + expected_data = {'successful': True} + mock_post.assert_called_once_with(f'/user/{fake_uuid}/verify/webauthn-login', data=expected_data) + assert resp == (True, '') + + +def test_complete_webauthn_login_attempt_returns_false_and_message_on_403(fake_uuid, mocker): + mock_post = mocker.patch( + 'app.notify_client.user_api_client.UserApiClient.post', + side_effect=HTTPError( + response=Mock( + status_code=403, + json=Mock( + return_value={'message': 'forbidden'} + ) + ) + ) + ) + + resp = user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) + + expected_data = {'successful': True} + mock_post.assert_called_once_with(f'/user/{fake_uuid}/verify/webauthn-login', data=expected_data) + + assert resp == (False, 'forbidden') + + +def test_complete_webauthn_login_attempt_raises_on_api_error(fake_uuid, mocker): + mocker.patch( + 'app.notify_client.user_api_client.UserApiClient.post', + side_effect=HTTPError(response=Mock(status_code=503, message='error')) + ) + + with pytest.raises(HTTPError): + user_api_client.complete_webauthn_login_attempt(fake_uuid, is_successful=True) diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index 9871491c2..f29ea0a0d 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -289,10 +289,11 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'trial_mode', 'trial_mode_new', 'trial_services', - 'two_factor', + 'two_factor_sms', 'two_factor_email', 'two_factor_email_interstitial', 'two_factor_email_sent', + 'two_factor_webauthn', 'update_email_branding', 'update_letter_branding', 'upload_a_letter', @@ -340,6 +341,8 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'view_template_versions', 'webauthn_begin_register', 'webauthn_complete_register', + 'webauthn_begin_authentication', + 'webauthn_complete_authentication', 'who_can_use_notify', 'who_its_for', 'write_new_broadcast', diff --git a/tests/conftest.py b/tests/conftest.py index ad3e3d02f..ab5f83e56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3963,7 +3963,7 @@ def create_active_user_manage_template_permissions(with_unique_id=False): } -def create_platform_admin_user(with_unique_id=False, permissions=None): +def create_platform_admin_user(with_unique_id=False, auth_type='sms_auth', permissions=None): return { 'id': str(uuid4()) if with_unique_id else sample_uuid(), 'name': 'Platform admin user', @@ -3974,7 +3974,7 @@ def create_platform_admin_user(with_unique_id=False, permissions=None): 'failed_login_count': 0, 'permissions': permissions or {}, 'platform_admin': True, - 'auth_type': 'sms_auth', + 'auth_type': auth_type, 'password_changed_at': str(datetime.utcnow()), 'services': [], 'organisations': [], @@ -4481,7 +4481,7 @@ def webauthn_credential(): return { 'id': str(uuid4()), 'name': 'Test credential', - 'credential_data': 'WJ0AAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6', # noqa + 'credential_data': 'WJ8AAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpQECAyYgASFYIDGeoB8RJc5iMpRzZYAK5dndyHQkfFXRUWutPKPKMgdcIlggWfHwfzsvhsClHgz6E9xX58d6EQ55b4oLJ3Qf5YZjyzo=', # noqa 'registration_response': 'anything', 'created_at': '2017-10-18T16:57:14.154185Z', } diff --git a/tests/javascripts/authenticateSecurityKey.test.js b/tests/javascripts/authenticateSecurityKey.test.js new file mode 100644 index 000000000..90bbf89f3 --- /dev/null +++ b/tests/javascripts/authenticateSecurityKey.test.js @@ -0,0 +1,232 @@ +beforeAll(() => { + window.CBOR = require('../../node_modules/cbor-js/cbor.js') + require('../../app/assets/javascripts/authenticateSecurityKey.js') + + // 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(() => { }) + + // populate missing values to allow consistent jest.spyOn() + window.fetch = () => { } + window.navigator.credentials = { get: () => { } } +}) + +afterAll(() => { + require('./support/teardown.js') + + // restore window attributes to their original undefined state + delete window.fetch + delete window.navigator.credentials +}) + +describe('Authenticate with security key', () => { + let button + + beforeEach(() => { + document.body.innerHTML = ` + + ` + button = document.querySelector('[data-module="authenticate-security-key"]') + window.GOVUK.modules.start() + }) + + test('authenticates a credential and redirects', (done) => { + + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + // initial fetch of options from the server + // fetch defaults to GET + // options from the server are CBOR-encoded + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + + // fake PublicKeyCredential response from WebAuthn API + // all of the array properties represent Array(Buffer) objects + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url, options = {}) => { + // subsequent POST of credential data to server + const decodedData = window.CBOR.decode(options.body) + expect(decodedData.credentialId).toEqual(new Uint8Array([1, 1, 1])) + expect(decodedData.authenticatorData).toEqual(new Uint8Array([2, 2, 2])) + expect(decodedData.signature).toEqual(new Uint8Array([3, 3, 3])) + expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4, 4, 4])) + expect(options.headers['X-CSRFToken']).toBe('abc123') + const loginResponse = window.CBOR.encode({ redirect_url: '/foo' }) + + return Promise.resolve({ + ok: true, arrayBuffer: () => Promise.resolve(loginResponse) + }) + }) + + jest.spyOn(window.location, 'assign').mockImplementation((href) => { + expect(href).toEqual("/foo") + done(); + }) + + // this will make the test fail if the alert is called + jest.spyOn(window, 'alert').mockImplementation((msg) => { + done(msg) + }) + + button.click() + }); + + test.each([ + ['network'], + ['server'], + ])('alerts if fetching WebAuthn fails (%s error)', (errorType, done) => { + jest.spyOn(window, 'fetch').mockImplementation((_url) => { + if (errorType == 'network') { + return Promise.reject('error') + } else { + return Promise.resolve({ ok: false, statusText: 'error' }) + } + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }) + + test('alerts if comms with the authenticator fails', (done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation(() => { + return Promise.reject(new DOMException('error')) + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }); + + test.each([ + ['network error'], + ['internal server error'], + ])('alerts if POSTing WebAuthn credentials fails (%s)', (errorType, done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + // subsequent POST of credential data to server + switch (errorType) { + case 'network error': + 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' }) + } + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }); + + + test('reloads page if POSTing WebAuthn credentials returns 403', (done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + return Promise.resolve( + { + ok: false, + status: 403, + }) + }) + + // assert that reload is called and the page is refreshed + jest.spyOn(window.location, 'reload').mockImplementation(() => { + done(); + }) + + // this will make the test fail if the alert is called + jest.spyOn(window, 'alert').mockImplementation((msg) => { + done(msg) + }) + + button.click() + }); + + +}); diff --git a/tests/javascripts/registerSecurityKey.test.js b/tests/javascripts/registerSecurityKey.test.js index 53e13e9d3..14fea8487 100644 --- a/tests/javascripts/registerSecurityKey.test.js +++ b/tests/javascripts/registerSecurityKey.test.js @@ -36,6 +36,17 @@ describe('Register security key', () => { }) test('creates a new credential and reloads', (done) => { + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + // initial fetch of options from the server + // options from the server are CBOR-encoded + const webauthnOptions = window.CBOR.encode('options') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }); + jest.spyOn(window.navigator.credentials, 'create').mockImplementation((options) => { expect(options).toEqual('options') @@ -49,29 +60,23 @@ describe('Register security key', () => { }) }) + jest.spyOn(window, 'fetch').mockImplementationOnce((_url, options) => { + // subsequent POST of credential data to server + const decodedData = window.CBOR.decode(options.body) + expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4,5,6])) + expect(decodedData.attestationObject).toEqual(new Uint8Array([1,2,3])) + expect(options.headers['X-CSRFToken']).toBe() + return Promise.resolve({ ok: true }) + }); + jest.spyOn(window.location, 'reload').mockImplementation(() => { // signal that the async promise chain was called done() }) - jest.spyOn(window, 'fetch').mockImplementation((_url, options = {}) => { - // initial fetch of options from the server - if (!options.method) { - // options from the server are CBOR-encoded - webauthnOptions = window.CBOR.encode('options') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - - // subsequent POST of credential data to server - } else { - decodedData = window.CBOR.decode(options.body) - expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4,5,6])) - expect(decodedData.attestationObject).toEqual(new Uint8Array([1,2,3])) - expect(options.headers['X-CSRFToken']).toBe() - return Promise.resolve({ ok: true }) - } + // this will make the test fail if the alert is called + jest.spyOn(window, 'alert').mockImplementation((msg) => { + done(msg) }) button.click() @@ -81,7 +86,7 @@ describe('Register security key', () => { ['network'], ['server'], ])('alerts if fetching WebAuthn options fails (%s error)', (errorType, done) => { - jest.spyOn(window, 'fetch').mockImplementation((_url, options = {}) => { + jest.spyOn(window, 'fetch').mockImplementation((_url) => { if (errorType == 'network') { return Promise.reject('error') } else { @@ -102,32 +107,32 @@ describe('Register security key', () => { ['internal server error'], ['bad request'], ])('alerts if sending WebAuthn credentials fails (%s)', (errorType, done) => { + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + // initial fetch of options from the server + const webauthnOptions = window.CBOR.encode('options') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + jest.spyOn(window.navigator.credentials, 'create').mockImplementation(() => { // fake PublicKeyCredential response from WebAuthn API return Promise.resolve({ response: {} }) }) - jest.spyOn(window, 'fetch').mockImplementation((_url, options = {}) => { - // initial fetch of options from the server - if (!options.method) { - webauthnOptions = window.CBOR.encode('options') - - return Promise.resolve({ - ok: true, arrayBuffer: () => webauthnOptions - }) - + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { // subsequent POST of credential data to server - } else { - switch (errorType) { - case 'network error': - 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' }) - } + switch (errorType) { + case 'network error': + 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' }) } }) @@ -144,9 +149,9 @@ describe('Register security key', () => { return Promise.reject(new DOMException('error')) }) - jest.spyOn(window, 'fetch').mockImplementation((_url, options) => { + jest.spyOn(window, 'fetch').mockImplementation((_url) => { // initial fetch of options from the server - webauthnOptions = window.CBOR.encode('options') + const webauthnOptions = window.CBOR.encode('options') return Promise.resolve({ ok: true, arrayBuffer: () => webauthnOptions