Merge pull request #3894 from alphagov/webauthn-login-python-tests

Webauthn login
This commit is contained in:
David McDonald
2021-06-02 15:30:36 +01:00
committed by GitHub
23 changed files with 919 additions and 83 deletions

View File

@@ -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', [

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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)