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 %} +
You have been locked out of your account due to too many incorrect password + submissions. Please try again in 15 minutes +
+{% endblock %} diff --git a/common/tests/test_views.py b/common/tests/test_views.py index 592ca1526..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,6 +89,22 @@ 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(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 page.find("h1").text == "Locked out" + assert page.find_all(string=re.compile("You have been locked out of your account")) + + @pytest.mark.parametrize( ("data", "response_url"), ( 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", + ), + ) 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 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",