mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-07-30 19:29:43 -04:00
Merge pull request #3894 from alphagov/webauthn-login-python-tests
Webauthn login
This commit is contained in:
@@ -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', [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
232
tests/javascripts/authenticateSecurityKey.test.js
Normal file
232
tests/javascripts/authenticateSecurityKey.test.js
Normal file
@@ -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 type="submit" data-module="authenticate-security-key" data-csrf-token="abc123"></button>
|
||||
`
|
||||
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()
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user