Files
notifications-admin/tests/app/main/views/test_webauthn_credentials.py
Leo Hemsted bb7343d846 pass nextUrl through yubikey flow
the next url comes from sign in via a query param, and needs to go to
the POST /webauthn/authenticate endpoint. That endpoint logs the user
in and returns the redirect to the browser, and will take the next from
the request query params to get there.

also moving the window mocks to beforeEach/afterEach ensures that
promise callbacks from previous tests aren't still associated in future
tests to ensure good test isolation.

unfortunately i couldn't get mocking location for a single js test to
work, but by changing the global config i was able to add some query
params that i can expect to be passed through. Don't love this at all
but not quite sure of a good way round this. I think we're not
practicing very good hygiene and best practices with our mocking and
it's really confounding me here.
2021-06-04 12:52:40 +01:00

426 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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', [
'webauthn_begin_register',
])
def test_register_forbidden_for_non_platform_admins(
client_request,
endpoint,
):
client_request.get(f'main.{endpoint}', _expected_status=403)
def test_begin_register_returns_encoded_options(
mocker,
platform_admin_user,
platform_admin_client,
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
webauthn_options = cbor.decode(response.data)['publicKey']
assert webauthn_options['attestation'] == 'direct'
assert webauthn_options['timeout'] == 30_000
auth_selection = webauthn_options['authenticatorSelection']
assert auth_selection['authenticatorAttachment'] == 'cross-platform'
assert auth_selection['userVerification'] == 'discouraged'
user_options = webauthn_options['user']
assert user_options['name'] == platform_admin_user['email_address']
assert user_options['id'] == bytes(platform_admin_user['id'], 'utf-8')
relying_party_options = webauthn_options['rp']
assert relying_party_options['name'] == 'GOV.UK Notify'
assert relying_party_options['id'] == 'webauthn.io'
def test_begin_register_includes_existing_credentials(
platform_admin_client,
webauthn_credential,
mocker,
):
mocker.patch(
'app.user_api_client.get_webauthn_credentials_for_user',
return_value=[webauthn_credential, webauthn_credential]
)
response = platform_admin_client.get(
url_for('main.webauthn_begin_register')
)
webauthn_options = cbor.decode(response.data)['publicKey']
assert len(webauthn_options['excludeCredentials']) == 2
def test_begin_register_stores_state_in_session(
platform_admin_client,
mocker,
):
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
with platform_admin_client.session_transaction() as session:
assert session['webauthn_registration_state'] is not None
def test_complete_register_creates_credential(
platform_admin_user,
platform_admin_client,
mocker,
):
with platform_admin_client.session_transaction() as session:
session['webauthn_registration_state'] = 'state'
user_api_mock = mocker.patch(
'app.user_api_client.create_webauthn_credential_for_user'
)
credential_mock = mocker.patch(
'app.models.webauthn_credential.WebAuthnCredential.from_registration',
return_value='cred'
)
response = platform_admin_client.post(
url_for('main.webauthn_complete_register'),
data=cbor.encode('public_key_credential'),
)
assert response.status_code == 200
credential_mock.assert_called_once_with('state', 'public_key_credential')
user_api_mock.assert_called_once_with(platform_admin_user['id'], 'cred')
def test_complete_register_clears_session(
platform_admin_client,
mocker,
):
with platform_admin_client.session_transaction() as session:
session['webauthn_registration_state'] = 'state'
mocker.patch('app.user_api_client.create_webauthn_credential_for_user')
mocker.patch('app.models.webauthn_credential.WebAuthnCredential.from_registration')
platform_admin_client.post(
url_for('main.webauthn_complete_register'),
data=cbor.encode('public_key_credential'),
)
with platform_admin_client.session_transaction() as session:
assert 'webauthn_registration_state' not in session
assert session['_flashes'] == [('default_with_tick', (
'Registration complete. Next time you sign in to Notify '
'youll be asked to use your security key.'
))]
def test_complete_register_handles_library_errors(
platform_admin_client,
mocker,
):
with platform_admin_client.session_transaction() as session:
session['webauthn_registration_state'] = 'state'
mocker.patch(
'app.models.webauthn_credential.WebAuthnCredential.from_registration',
side_effect=RegistrationError('error')
)
response = platform_admin_client.post(
url_for('main.webauthn_complete_register'),
data=cbor.encode('public_key_credential'),
)
assert response.status_code == 400
assert cbor.decode(response.data) == 'error'
def test_complete_register_handles_missing_state(
platform_admin_client,
mocker,
):
response = platform_admin_client.post(
url_for('main.webauthn_complete_register'),
data=cbor.encode('public_key_credential'),
)
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')
@pytest.mark.parametrize('url_kwargs, expected_redirect', [
({}, '/accounts-or-dashboard'),
({'next': '/bar'}, '/bar'),
])
def test_verify_webauthn_login_signs_user_in(
client,
mocker,
mock_create_event,
platform_admin_user,
url_kwargs,
expected_redirect,
):
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', **url_kwargs))
assert resp.status_code == 200
assert cbor.decode(resp.data)['redirect_url'] == expected_redirect
# 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)