Integrate with the API for adding and getting webauthn creds

This links up the `get_webauthn_credentials_for_user` and
`create_webauthn_credential_for_user` methods of the user api client to
notifications-api.

To send data to the API we need strings to be unicode, so we call
decode('utf-8') on base64 objects.

Co-authored-by: Leo Hemsted <leo.hemsted@digital.cabinet-office.gov.uk>
This commit is contained in:
Katie Smith
2021-05-13 15:54:05 +01:00
parent 362189d562
commit bafcc02b7d
7 changed files with 60 additions and 24 deletions

View File

@@ -32,15 +32,15 @@ class WebAuthnCredential(JSONModel):
'name': 'Unnamed key', 'name': 'Unnamed key',
'credential_data': base64.b64encode( 'credential_data': base64.b64encode(
cbor.encode(auth_data.credential_data), cbor.encode(auth_data.credential_data),
), ).decode('utf-8'),
'registration_response': base64.b64encode( 'registration_response': base64.b64encode(
cbor.encode(response), cbor.encode(response),
) ).decode('utf-8')
}) })
def to_credential_data(self): def to_credential_data(self):
return AttestedCredentialData( return AttestedCredentialData(
cbor.decode(base64.b64decode(self.credential_data)) cbor.decode(base64.b64decode(self.credential_data.encode()))
) )
def serialize(self): def serialize(self):

View File

@@ -192,19 +192,14 @@ class UserApiClient(NotifyAdminAPIClient):
return self.get(endpoint) return self.get(endpoint)
def get_webauthn_credentials_for_user(self, user_id): def get_webauthn_credentials_for_user(self, user_id):
# TODO: remove when using real API endpoint = f'/user/{user_id}/webauthn'
self.credentials = getattr(self, 'credentials', [])
return self.credentials return self.get(endpoint)['data']
def create_webauthn_credential_for_user(self, user_id, credential): def create_webauthn_credential_for_user(self, user_id, credential):
self.credentials = getattr(self, 'credentials', []) endpoint = f'/user/{user_id}/webauthn'
credential_dict = credential.serialize()
# TODO: remove when using real API return self.post(endpoint, data=credential.serialize())
from datetime import datetime
credential_dict['created_at'] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
self.credentials += [credential_dict]
user_api_client = UserApiClient() user_api_client = UserApiClient()

View File

@@ -19,8 +19,10 @@ def test_should_show_overview_page(
def test_overview_page_shows_disable_for_platform_admin( def test_overview_page_shows_disable_for_platform_admin(
client_request, client_request,
platform_admin_user platform_admin_user,
mocker
): ):
mocker.patch('app.user_api_client.get_webauthn_credentials_for_user')
client_request.login(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' assert page.select_one('h1').text.strip() == 'Your profile'

View File

@@ -27,6 +27,7 @@ def test_begin_register_returns_encoded_options(
values={'ADMIN_BASE_URL': 'http://localhost:6012'} values={'ADMIN_BASE_URL': 'http://localhost:6012'}
) )
webauthn_server.init_app(app_) webauthn_server.init_app(app_)
mocker.patch('app.user_api_client.get_webauthn_credentials_for_user', return_value=[])
response = platform_admin_client.get( response = platform_admin_client.get(
url_for('main.webauthn_begin_register') url_for('main.webauthn_begin_register')
@@ -71,11 +72,18 @@ def test_begin_register_includes_existing_credentials(
def test_begin_register_stores_state_in_session( def test_begin_register_stores_state_in_session(
platform_admin_client, platform_admin_client,
mocker,
): ):
platform_admin_client.get( mocker.patch(
'app.user_api_client.get_webauthn_credentials_for_user',
return_value=[])
response = platform_admin_client.get(
url_for('main.webauthn_begin_register') url_for('main.webauthn_begin_register')
) )
assert response.status_code == 200
with platform_admin_client.session_transaction() as session: with platform_admin_client.session_transaction() as session:
assert session['webauthn_registration_state'] is not None assert session['webauthn_registration_state'] is not None

View File

@@ -1,5 +1,6 @@
import base64 import base64
import pytest
from fido2 import cbor from fido2 import cbor
from fido2.cose import ES256 from fido2.cose import ES256
@@ -14,7 +15,8 @@ CLIENT_DATA_JSON = b'{"type": "webauthn.create", "clientExtensions": {}, "challe
ATTESTATION_OBJECT = base64.b64decode(b'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAI1qbvWibQos/t3zsTU05IXw1Ek3SDApATok09uc4UBwAiEAv0fB/lgb5Ot3zJ691Vje6iQLAtLhJDiA8zDxaGjcE3hjeDVjgVkCUzCCAk8wggE3oAMCAQICBDxoKU0wDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMDExLzAtBgNVBAMMJll1YmljbyBVMkYgRUUgU2VyaWFsIDIzOTI1NzM0ODExMTE3OTAxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvd9nk9t3lMNQMXHtLE1FStlzZnUaSLql2fm1ajoggXlrTt8rzXuSehSTEPvEaEdv/FeSqX22L6Aoa8ajIAIOY6M7MDkwIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjUwEwYLKwYBBAGC5RwCAQEEBAMCBSAwDQYJKoZIhvcNAQELBQADggEBAKrADVEJfuwVpIazebzEg0D4Z9OXLs5qZ/ukcONgxkRZ8K04QtP/CB5x6olTlxsj+SXArQDCRzEYUgbws6kZKfuRt2a1P+EzUiqDWLjRILSr+3/o7yR7ZP/GpiFKwdm+czb94POoGD+TS1IYdfXj94mAr5cKWx4EKjh210uovu/pLdLjc8xkQciUrXzZpPR9rT2k/q9HkZhHU+NaCJzky+PTyDbq0KKnzqVhWtfkSBCGw3ezZkTS+5lrvOKbIa24lfeTgu7FST5OwTPCFn8HcfWZMXMSD/KNU+iBqJdAwTLPPDRoLLvPTl29weCAIh+HUpmBQd0UltcPOrA/LFvAf61oYXV0aERhdGFYwnSm6pITyZwvdLIkkrMgz0AmKpTBqVCgOX8pJQtghB7wQQAAAAAAAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6') # noqa ATTESTATION_OBJECT = base64.b64decode(b'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAI1qbvWibQos/t3zsTU05IXw1Ek3SDApATok09uc4UBwAiEAv0fB/lgb5Ot3zJ691Vje6iQLAtLhJDiA8zDxaGjcE3hjeDVjgVkCUzCCAk8wggE3oAMCAQICBDxoKU0wDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMDExLzAtBgNVBAMMJll1YmljbyBVMkYgRUUgU2VyaWFsIDIzOTI1NzM0ODExMTE3OTAxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvd9nk9t3lMNQMXHtLE1FStlzZnUaSLql2fm1ajoggXlrTt8rzXuSehSTEPvEaEdv/FeSqX22L6Aoa8ajIAIOY6M7MDkwIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjUwEwYLKwYBBAGC5RwCAQEEBAMCBSAwDQYJKoZIhvcNAQELBQADggEBAKrADVEJfuwVpIazebzEg0D4Z9OXLs5qZ/ukcONgxkRZ8K04QtP/CB5x6olTlxsj+SXArQDCRzEYUgbws6kZKfuRt2a1P+EzUiqDWLjRILSr+3/o7yR7ZP/GpiFKwdm+czb94POoGD+TS1IYdfXj94mAr5cKWx4EKjh210uovu/pLdLjc8xkQciUrXzZpPR9rT2k/q9HkZhHU+NaCJzky+PTyDbq0KKnzqVhWtfkSBCGw3ezZkTS+5lrvOKbIa24lfeTgu7FST5OwTPCFn8HcfWZMXMSD/KNU+iBqJdAwTLPPDRoLLvPTl29weCAIh+HUpmBQd0UltcPOrA/LFvAf61oYXV0aERhdGFYwnSm6pITyZwvdLIkkrMgz0AmKpTBqVCgOX8pJQtghB7wQQAAAAAAAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6') # noqa
def test_from_registration_verifies_response(app_, mocker): @pytest.fixture
def disable_webauthn_origin_verification(app_, mocker):
mocker.patch.dict( mocker.patch.dict(
app_.config, values={ app_.config, values={
'NOTIFY_ENVIRONMENT': 'development', 'NOTIFY_ENVIRONMENT': 'development',
@@ -25,6 +27,8 @@ def test_from_registration_verifies_response(app_, mocker):
# disable origin verification for non-HTTPS test # disable origin verification for non-HTTPS test
webauthn_server.init_app(app_) webauthn_server.init_app(app_)
def test_from_registration_verifies_response(disable_webauthn_origin_verification):
registration_response = { registration_response = {
'clientDataJSON': CLIENT_DATA_JSON, 'clientDataJSON': CLIENT_DATA_JSON,
'attestationObject': ATTESTATION_OBJECT, 'attestationObject': ATTESTATION_OBJECT,
@@ -32,9 +36,23 @@ def test_from_registration_verifies_response(app_, mocker):
credential = WebAuthnCredential.from_registration(SESSION_STATE, registration_response) credential = WebAuthnCredential.from_registration(SESSION_STATE, registration_response)
assert credential.name == 'Unnamed key' assert credential.name == 'Unnamed key'
assert credential.registration_response == base64.b64encode(cbor.encode(registration_response)) assert credential.registration_response == base64.b64encode(cbor.encode(registration_response)).decode('utf-8')
credential_data = credential.to_credential_data() credential_data = credential.to_credential_data()
assert type(credential_data.credential_id) is bytes assert type(credential_data.credential_id) is bytes
assert type(credential_data.aaguid) is bytes assert type(credential_data.aaguid) is bytes
assert credential_data.public_key[3] == ES256.ALGORITHM assert credential_data.public_key[3] == ES256.ALGORITHM
def test_from_registration_encodes_as_unicode(disable_webauthn_origin_verification):
registration_response = {
'clientDataJSON': CLIENT_DATA_JSON,
'attestationObject': ATTESTATION_OBJECT,
}
credential = WebAuthnCredential.from_registration(SESSION_STATE, registration_response)
serialized_credential = credential.serialize()
assert type(serialized_credential['credential_data']) == str
assert type(serialized_credential['registration_response']) == str

View File

@@ -241,12 +241,25 @@ def test_add_user_to_service_calls_correct_endpoint_and_deletes_keys_from_cache(
] ]
def test_get_webauthn_credentials_for_user_returns_stubbed_data(): def test_get_webauthn_credentials_for_user(mocker, webauthn_credential, fake_uuid):
credentials = user_api_client.get_webauthn_credentials_for_user('id')
assert len(credentials) == 0 mock_get = mocker.patch(
'app.notify_client.user_api_client.UserApiClient.get',
return_value={'data': [webauthn_credential]}
)
credentials = user_api_client.get_webauthn_credentials_for_user(fake_uuid)
mock_get.assert_called_once_with(f'/user/{fake_uuid}/webauthn')
assert len(credentials) == 1
assert credentials[0]['name'] == 'Test credential'
def test_create_webauthn_credential_for_user_stores_stubbed_data(webauthn_credential): def test_create_webauthn_credential_for_user(mocker, webauthn_credential, fake_uuid):
credential = WebAuthnCredential(webauthn_credential) credential = WebAuthnCredential(webauthn_credential)
user_api_client.create_webauthn_credential_for_user('id', credential)
assert len(user_api_client.credentials) == 1 mock_post = mocker.patch('app.notify_client.user_api_client.UserApiClient.post')
expected_url = f'/user/{fake_uuid}/webauthn'
user_api_client.create_webauthn_credential_for_user(fake_uuid, credential)
mock_post.assert_called_once_with(expected_url, data=credential.serialize())

View File

@@ -4485,7 +4485,7 @@ def mock_get_invited_org_user_by_id(mocker, sample_org_invite):
def webauthn_credential(): def webauthn_credential():
return { return {
'name': 'Test credential', 'name': 'Test credential',
'credential_data': b'WJ0AAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6', # noqa 'credential_data': 'WJ0AAAAAAAAAAAAAAAAAAAAAAECKU1ppjl9gmhHWyDkgHsUvZmhr6oF3/lD3llzLE2SaOSgOGIsIuAQqgp8JQSUu3r/oOaP8RS44dlQjrH+ALfYtpAECAyYhWCAxnqAfESXOYjKUc2WACuXZ3ch0JHxV0VFrrTyjyjIHXCJYIFnx8H87L4bApR4M+hPcV+fHehEOeW+KCyd0H+WGY8s6', # noqa
'registration_response': 'anything', 'registration_response': 'anything',
'created_at': '2017-10-18T16:57:14.154185Z', 'created_at': '2017-10-18T16:57:14.154185Z',
} }