From c26a596839e638fb7afe6a7bbe7272e49685c1d3 Mon Sep 17 00:00:00 2001 From: Leo Hemsted Date: Fri, 14 May 2021 17:37:57 +0100 Subject: [PATCH] allow sign in via webauthn credentials The flow of the code is roughly as follows: user clicks button on webauthn page js sends GET request python reads GET request, sets up login challenge python returns login challenge in response js reads GET response, passes login challenge to browser browser asks user to touch yubikey browser returns yubikey challenge response data to js js sends POST request with yubikey challenge response data python reads yubikey challenge and compares with users creds from db if its a match, python signs user in The login challenge is a PublicKeyCredentialRequestOptions: [1] The browser function we call is navigator.credentials.get(): [2] The response to the challenge from the browser is a PublicKeyCredential: [3] The python server does all the work setting those up and tearing them back down again (and checking them against the values we have stored in the database), but we need to do work to convert them to-and-from CBOR. [1] https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions [2] https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get [3] https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential --- .../javascripts/authenticateSecurityKey.js | 58 +++++++++++++++---- app/main/views/webauthn_credentials.py | 53 ++++++++++++++++- .../main/views/test_webauthn_credentials.py | 24 ++++++++ tests/app/test_navigation.py | 2 + 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/authenticateSecurityKey.js b/app/assets/javascripts/authenticateSecurityKey.js index 9ac503223..f5f4c50ce 100644 --- a/app/assets/javascripts/authenticateSecurityKey.js +++ b/app/assets/javascripts/authenticateSecurityKey.js @@ -1,13 +1,51 @@ (function (window) { - "use strict"; + "use strict"; - window.GOVUK.Modules.AuthenticateSecurityKey = function () { - this.start = function (component) { - $(component) - .on('click', function (event) { - event.preventDefault(); - console.log('pretend you just logged in okay'); - }); - }; + 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.ok) { + throw Error(response.statusText); + } + // TODO: redirect + }) + .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); + }; +}) (window); diff --git a/app/main/views/webauthn_credentials.py b/app/main/views/webauthn_credentials.py index e212374d2..1f5cd273f 100644 --- a/app/main/views/webauthn_credentials.py +++ b/app/main/views/webauthn_credentials.py @@ -1,11 +1,14 @@ 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, request, session from flask_login import current_user from app.main import main +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 redirect_to_sign_in, user_is_platform_admin @main.route('/webauthn/register') @@ -50,3 +53,49 @@ 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']) + + authentication_data, state = current_app.webauthn_server.authenticate_begin( + credentials=[ + credential.to_credential_data() + for credential in user_to_login.webauthn_credentials + ], + 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(): + state = session.pop("webauthn_authentication_state") + request_data = cbor.decode(request.get_data()) + + user_id = session['user_details']['id'] + user_to_login = User.from_id(user_id) + + try: + current_app.webauthn_server.authenticate_complete( + state=state, + credentials=[ + credential.to_credential_data() + for credential in user_to_login.webauthn_credentials + ], + 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}') + abort(403) + + from app.main.views.two_factor import log_in_user + return log_in_user(user_id) diff --git a/tests/app/main/views/test_webauthn_credentials.py b/tests/app/main/views/test_webauthn_credentials.py index c74b1685e..4bfbfa904 100644 --- a/tests/app/main/views/test_webauthn_credentials.py +++ b/tests/app/main/views/test_webauthn_credentials.py @@ -157,3 +157,27 @@ 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_returns_encoded_options(client): + pass + + +def test_begin_authentication_includes_existing_credentials(client): + pass + + +def test_begin_authentication_stores_state_in_session(client): + pass + + +def test_complete_authentication_logs_user_in(client): + pass + + +def test_complete_authentication_403s_if_key_isnt_in_users_credentials(client): + pass + + +def test_complete_authentication_clears_session(client): + pass diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index 3e947de47..f29ea0a0d 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -341,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',