From 5a0f8f1827e60cc9bbf3efd6ef8d36cb026f88d8 Mon Sep 17 00:00:00 2001 From: Lauren Qurashi Date: Fri, 24 Mar 2023 16:18:02 +0000 Subject: [PATCH 1/5] Add template for locked out users --- common/jinja2/common/locked_out.jinja | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/jinja2/common/locked_out.jinja diff --git a/common/jinja2/common/locked_out.jinja b/common/jinja2/common/locked_out.jinja new file mode 100644 index 000000000..2f8701a14 --- /dev/null +++ b/common/jinja2/common/locked_out.jinja @@ -0,0 +1,10 @@ +{% extends "layouts/layout.jinja" %} + +{% set page_title = "Locked out" %} + +{% block content %} +

{{ page_title }}

+

You have been locked out of your account due to too many incorrect password + submissions. Please try again in 15 minutes +

+{% endblock %} From ec79b3d21f88cc1a3d51c703e47e2f89995035bc Mon Sep 17 00:00:00 2001 From: Lauren Qurashi Date: Fri, 24 Mar 2023 16:26:39 +0000 Subject: [PATCH 2/5] Install and configure Axes --- settings/common.py | 14 +++++++++++++- settings/dev.py | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/settings/common.py b/settings/common.py index 3e3b081ed..0ec1b2801 100644 --- a/settings/common.py +++ b/settings/common.py @@ -121,6 +121,7 @@ "exporter.apps.ExporterConfig", "crispy_forms", "crispy_forms_gds", + "axes", ] APPS_THAT_MUST_COME_LAST = ["django.forms"] @@ -144,6 +145,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", "common.models.utils.TransactionMiddleware", "csp.middleware.CSPMiddleware", + "axes.middleware.AxesMiddleware", ] if SSO_ENABLED: MIDDLEWARE += [ @@ -226,7 +228,11 @@ AUTHBROKER_CLIENT_ID = os.environ.get("AUTHBROKER_CLIENT_ID") AUTHBROKER_CLIENT_SECRET = os.environ.get("AUTHBROKER_CLIENT_SECRET") -AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] +AUTHENTICATION_BACKENDS = [ + # Axes must be at the top + "axes.backends.AxesStandaloneBackend", + "django.contrib.auth.backends.ModelBackend", +] if SSO_ENABLED: AUTHENTICATION_BACKENDS += [ "authbroker_client.backends.AuthbrokerBackend", @@ -659,3 +665,9 @@ BASE_SERVICE_URL = "https://" + VCAP_APPLICATION["application_uris"][0] else: BASE_SERVICE_URL = os.environ.get("BASE_SERVICE_URL") + + +AXES_ENABLED = True +AXES_FAILURE_LIMIT = 5 +AXES_COOLOFF_TIME = 0.05 +AXES_LOCKOUT_TEMPLATE = "/common/locked_out.jinja" diff --git a/settings/dev.py b/settings/dev.py index 8331cdbfa..f4b9c4029 100644 --- a/settings/dev.py +++ b/settings/dev.py @@ -11,7 +11,12 @@ # Enable Django debug toolbar if is_truthy(os.environ.get("ENABLE_DJANGO_DEBUG_TOOLBAR")): MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") - INSTALLED_APPS.extend(["debug_toolbar", "whitenoise.runserver_nostatic"]) + INSTALLED_APPS.extend( + [ + "debug_toolbar", + "whitenoise.runserver_nostatic", + ], + ) DEBUG_TOOLBAR_PANELS = [ "debug_toolbar.panels.versions.VersionsPanel", "debug_toolbar.panels.timer.TimerPanel", From bc0c9e041e7adb3c84c8d33696b2bc6c96475852 Mon Sep 17 00:00:00 2001 From: Lauren Qurashi Date: Fri, 24 Mar 2023 16:28:38 +0000 Subject: [PATCH 3/5] Broken test for Paul to peruse --- common/tests/test_views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/common/tests/test_views.py b/common/tests/test_views.py index 592ca1526..c95570abe 100644 --- a/common/tests/test_views.py +++ b/common/tests/test_views.py @@ -54,6 +54,18 @@ def test_index_displays_login_buttons_correctly_SSO_on(valid_user_client): assert not page.find_all("a", {"href": "/login"}) +def test_login_displays_lockout_page(valid_user_client): + settings.SSO_ENABLED = False + response = valid_user_client.get(reverse("login")) + + assert response.status_code == 200 + + page = BeautifulSoup(str(response.content), "html.parser") + assert 0 + assert not page.find_all("a", {"href": "/logout"}) + assert not page.find_all("a", {"href": "/login"}) + + @pytest.mark.parametrize( ("data", "response_url"), ( From ecaaeecd5828f9d8d3439fbaa9dc9df4155d982a Mon Sep 17 00:00:00 2001 From: Lauren Qurashi Date: Fri, 24 Mar 2023 16:40:14 +0000 Subject: [PATCH 4/5] Update requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 3c4eb58f0..8fec81760 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ crispy-forms-gds @ git+https://github.com/uktrade/crispy-forms-gds.git@b50168d0e defusedxml==0.7.* dj-database-url==0.5.0 django==3.2.18 +django-axes==5.40.1 django-crispy-forms==1.12.0 django-dotenv==1.4.2 drf-extra-fields==3.0.2 From e7b33ef7c1f9d0b9cf98284df742db46b68b1906 Mon Sep 17 00:00:00 2001 From: Lauren Qurashi Date: Mon, 3 Apr 2023 11:09:22 +0100 Subject: [PATCH 5/5] WIP: Add and amend test fixtures --- common/tests/test_views.py | 69 +++++++++++++++++++++++++++++--------- conftest.py | 21 +++++++++++- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/common/tests/test_views.py b/common/tests/test_views.py index c95570abe..2299b3fad 100644 --- a/common/tests/test_views.py +++ b/common/tests/test_views.py @@ -1,6 +1,8 @@ +import re + import pytest from bs4 import BeautifulSoup -from django.conf import settings +from django.http import HttpRequest from django.urls import reverse from common.tests import factories @@ -25,18 +27,52 @@ def test_index_displays_workbasket_action_form(valid_user_client): assert "Search the tariff" in page.select("label")[4].text -def test_index_displays_logout_buttons_correctly_SSO_off_logged_in(valid_user_client): - settings.SSO_ENABLED = False - response = valid_user_client.get(reverse("home")) +def test_index_displays_restricted_workbasket_action_form_invalid_user( + client, + disable_sso, +): + response = client.get(reverse("home")) assert response.status_code == 200 + page = BeautifulSoup(str(response.content), "html.parser") + assert "Search the tariff" in page.select("label")[0].text + + +def test_index_displays_auth_buttons_SSO_off(client, valid_user, disable_sso): + # Make sure login button is rendered when SSO is off + response = client.get(reverse("home")) + page = BeautifulSoup(str(response.content), "html.parser") + assert page.find_all("a", {"href": "/login"}) + + # Make sure logout button is rendered when SSO is off + user = factories.UserFactory.create( + username="GregPasty", + password="GottaHaveTwelveCharactersNow", + ) + HttpRequest() + client.post( + reverse("login"), + {"username": user.username, "passwords": user.password}, + ) + assert user.is_authenticated + assert 0 + response = client.get(reverse("home")) + page = BeautifulSoup(str(response.content), "html.parser") + assert page.find_all("a", {"href": "/logout"}) + + +def test_index_displays_logout_buttons_correctly_SSO_off_logged_in( + valid_user_client, + disable_sso, +): + response = valid_user_client.get(reverse("home")) + assert response.status_code == 200 page = BeautifulSoup(str(response.content), "html.parser") assert page.find_all("a", {"href": "/logout"}) -def test_index_redirects_to_login_page_logged_out_SSO_off(client): - settings.SSO_ENABLED = False +def test_index_redirects_to_login_page_logged_out_SSO_off(client, disable_sso): response = client.get(reverse("home")) assert response.status_code == 302 @@ -44,7 +80,6 @@ def test_index_redirects_to_login_page_logged_out_SSO_off(client): def test_index_displays_login_buttons_correctly_SSO_on(valid_user_client): - settings.SSO_ENABLED = True response = valid_user_client.get(reverse("home")) assert response.status_code == 200 @@ -54,16 +89,20 @@ def test_index_displays_login_buttons_correctly_SSO_on(valid_user_client): assert not page.find_all("a", {"href": "/login"}) -def test_login_displays_lockout_page(valid_user_client): - settings.SSO_ENABLED = False - response = valid_user_client.get(reverse("login")) - - assert response.status_code == 200 +def test_login_displays_lockout_page(client, disable_sso): + login_url = reverse("login") + form_data = { + "username": "wrong username", + "password": "wrong password", + } + for x in range(4): + client.post(login_url, form_data) + response = client.post(login_url, form_data) + assert response.status_code == 403 page = BeautifulSoup(str(response.content), "html.parser") - assert 0 - assert not page.find_all("a", {"href": "/logout"}) - assert not page.find_all("a", {"href": "/login"}) + assert page.find("h1").text == "Locked out" + assert page.find_all(string=re.compile("You have been locked out of your account")) @pytest.mark.parametrize( diff --git a/conftest.py b/conftest.py index 7067ac19a..454c6e377 100644 --- a/conftest.py +++ b/conftest.py @@ -263,7 +263,7 @@ def policy_group(db) -> Group: @pytest.fixture def valid_user(db, policy_group): - user = factories.UserFactory.create() + user = factories.UserFactory.create(password="GottaHaveTwelveCharactersNow") policy_group.user_set.add(user) return user @@ -1540,3 +1540,22 @@ def quotas_json(): def mock_aioresponse(): with aioresponses() as m: yield m + + +@pytest.fixture(autouse=False) +def disable_sso(settings): + settings.SSO_ENABLED = False + settings.INSTALLED_APPS.pop(settings.INSTALLED_APPS.index("authbroker_client")) + settings.MIDDLEWARE.pop( + settings.MIDDLEWARE.index( + "authbroker_client.middleware.ProtectAllViewsMiddleware", + ), + ) + settings.LOGIN_URL = "/login" + settings.AUTHBROKER_CLIENT_ID = None + settings.AUTHBROKER_CLIENT_SECRET = None + settings.AUTHENTICATION_BACKENDS.pop( + settings.AUTHENTICATION_BACKENDS.index( + "authbroker_client.backends.AuthbrokerBackend", + ), + )