From f0bd3ffcc5571302dd14ffee38db61b7e66234af Mon Sep 17 00:00:00 2001 From: Anastasia Gradova <108748167+anagradova@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:34:08 -0700 Subject: [PATCH 1/2] 4574 add session check redirection (#4636) * Added check in preprocessors for session status implimented modal in header.html and changed mixin to use session check. * Updated to handle request structure * Updated format with isort and black * Removed unused import * Updated url reference for django * refactored to simplify control logic and updated tests to handle new test case * Removed unused import * Refactored modals and js into seperate files * Added custom exceptions, middlware handlers * Updated static pathing for js file * Updated tests to handle new exception and realigned naming * Fixed formatting * Removed unused import * Fixed formatting * Removed unused helper function --- backend/audit/exceptions.py | 10 ++++ backend/audit/mixins.py | 13 ++--- .../test_manage_submission_access_view.py | 6 ++- backend/audit/test_manage_submission_view.py | 3 +- backend/audit/test_mixins.py | 7 +-- .../audit/test_submission_progress_view.py | 3 +- backend/audit/test_views.py | 12 +++-- backend/config/context_processors.py | 3 +- backend/config/middleware.py | 23 ++++++++- backend/config/settings.py | 19 ++++--- backend/static/js/session-expired-modal.js | 15 ++++++ backend/static/js/session-warning-modal.js | 14 +++++ backend/templates/includes/header.html | 2 +- backend/templates/includes/nav_primary.html | 7 ++- .../includes/session_expired_modal.html | 34 +++++++++++++ .../includes/session_warning_modal.html | 51 +++++++++++++++++++ 16 files changed, 192 insertions(+), 30 deletions(-) create mode 100644 backend/audit/exceptions.py create mode 100644 backend/static/js/session-expired-modal.js create mode 100644 backend/static/js/session-warning-modal.js create mode 100644 backend/templates/includes/session_expired_modal.html create mode 100644 backend/templates/includes/session_warning_modal.html diff --git a/backend/audit/exceptions.py b/backend/audit/exceptions.py new file mode 100644 index 0000000000..ecaf98d507 --- /dev/null +++ b/backend/audit/exceptions.py @@ -0,0 +1,10 @@ +class SessionExpiredException(Exception): + def __init__(self, message="Your session has expired. Please log in again."): + self.message = message + super().__init__(self.message) + + +class SessionWarningException(Exception): + def __init__(self, message="Your session is about to expire."): + self.message = message + super().__init__(self.message) diff --git a/backend/audit/mixins.py b/backend/audit/mixins.py index 19387a0c1a..76990e2925 100644 --- a/backend/audit/mixins.py +++ b/backend/audit/mixins.py @@ -1,10 +1,12 @@ from typing import Any + +from audit.exceptions import SessionExpiredException +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied from django.http.request import HttpRequest from django.http.response import HttpResponse -from django.core.exceptions import PermissionDenied -from django.conf import settings from .models import Access, SingleAuditChecklist @@ -22,12 +24,10 @@ def __init__(self, message, eligible_users): def check_authenticated(request): - if not hasattr(request, "user"): - raise PermissionDenied(PERMISSION_DENIED_MESSAGE) - if not request.user: + if not hasattr(request, "user") or not request.user: raise PermissionDenied(PERMISSION_DENIED_MESSAGE) if not request.user.is_authenticated: - raise PermissionDenied(PERMISSION_DENIED_MESSAGE) + raise SessionExpiredException() def has_access(sac, user): @@ -100,6 +100,7 @@ class CertifyingAuditorRequiredMixin(LoginRequiredMixin): def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: role = "certifying_auditor_contact" try: + check_authenticated(request) sac = SingleAuditChecklist.objects.get(report_id=kwargs["report_id"]) diff --git a/backend/audit/test_manage_submission_access_view.py b/backend/audit/test_manage_submission_access_view.py index 8770dacbdd..8112e806ac 100644 --- a/backend/audit/test_manage_submission_access_view.py +++ b/backend/audit/test_manage_submission_access_view.py @@ -84,7 +84,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_bad_report_id_returns_403(self): """ @@ -267,7 +268,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_bad_report_id_returns_403(self): """ diff --git a/backend/audit/test_manage_submission_view.py b/backend/audit/test_manage_submission_view.py index e11b55db48..9859472fcc 100644 --- a/backend/audit/test_manage_submission_view.py +++ b/backend/audit/test_manage_submission_view.py @@ -82,7 +82,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_bad_report_id_returns_403(self): """ diff --git a/backend/audit/test_mixins.py b/backend/audit/test_mixins.py index 45b51756b4..73ff9b84cd 100644 --- a/backend/audit/test_mixins.py +++ b/backend/audit/test_mixins.py @@ -1,3 +1,4 @@ +from audit.exceptions import SessionExpiredException from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied @@ -56,7 +57,7 @@ def test_anonymous_raises(self): view = self.ViewStub() self.assertRaises( - PermissionDenied, view.dispatch, request, report_id="not-logged-in" + SessionExpiredException, view.dispatch, request, report_id="not-logged-in" ) def test_no_access_raises(self): @@ -122,7 +123,7 @@ def test_anonymous_raises(self): view = self.ViewStub() self.assertRaises( - PermissionDenied, view.dispatch, request, report_id="not-logged-in" + SessionExpiredException, view.dispatch, request, report_id="not-logged-in" ) def test_no_access_raises(self): @@ -217,7 +218,7 @@ def test_anonymous_raises(self): view = self.ViewStub() self.assertRaises( - PermissionDenied, view.dispatch, request, report_id="not-logged-in" + SessionExpiredException, view.dispatch, request, report_id="not-logged-in" ) def test_no_access_raises(self): diff --git a/backend/audit/test_submission_progress_view.py b/backend/audit/test_submission_progress_view.py index 2e28ad4949..e48fa4e58e 100644 --- a/backend/audit/test_submission_progress_view.py +++ b/backend/audit/test_submission_progress_view.py @@ -40,7 +40,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_phrase_in_page(self): """Check for 'General Information form'.""" diff --git a/backend/audit/test_views.py b/backend/audit/test_views.py index 7301d2b359..20aec950f1 100644 --- a/backend/audit/test_views.py +++ b/backend/audit/test_views.py @@ -350,7 +350,8 @@ def test_get_access_denied_for_unauthorized_user(self): """Test that GET returns 403 if user is unauthorized""" self.client.logout() response = self.client.get(self.url) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) @patch("audit.models.SingleAuditChecklist.validate_full") @patch("audit.views.views.sac_transition") @@ -888,7 +889,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_bad_report_id_returns_403(self): """When a request is made for a malformed or nonexistent report_id, a 403 error should be returned""" @@ -1525,7 +1527,8 @@ def test_get_login_required(self): kwargs={"report_id": "12345", "form_section": form_section}, ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_get_bad_report_id_returns_403(self): """Test that uploading with a malformed or nonexistant report_id reutrns 403""" @@ -1556,7 +1559,8 @@ def test_login_required(self): ) ) - self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "home.html") + self.assertTrue(response.context["session_expired"]) def test_bad_report_id_returns_403(self): """When a request is made for a malformed or nonexistent report_id, a 403 error should be returned""" diff --git a/backend/config/context_processors.py b/backend/config/context_processors.py index 83f16185e8..aa637d7f16 100644 --- a/backend/config/context_processors.py +++ b/backend/config/context_processors.py @@ -1,6 +1,7 @@ -from config import settings from datetime import datetime, timezone +from config import settings + def static_site_url(request): """ diff --git a/backend/config/middleware.py b/backend/config/middleware.py index 0efd68cdce..c555cda962 100644 --- a/backend/config/middleware.py +++ b/backend/config/middleware.py @@ -1,7 +1,8 @@ +import boto3 +from audit.exceptions import SessionExpiredException, SessionWarningException from dissemination.file_downloads import file_exists from django.conf import settings -from django.shortcuts import redirect -import boto3 +from django.shortcuts import redirect, render LOCAL_FILENAME = "./runtime/MAINTENANCE_MODE" S3_FILENAME = "runtime/MAINTENANCE_MODE" @@ -77,3 +78,21 @@ def __call__(self, request): response = self.get_response(request) return response + + +class HandleSessionException: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_exception(self, request, exception): + if isinstance(exception, SessionExpiredException): + context = {"session_expired": True} + return render(request, "home.html", context) + elif isinstance(exception, SessionWarningException): + context = {"show_session_warning_banner": True} + return render(request, "home.html", context) + return None diff --git a/backend/config/settings.py b/backend/config/settings.py index e4bd33b71f..052d5dd479 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -10,18 +10,20 @@ https://docs.djangoproject.com/en/4.0/ref/settings/ """ -from base64 import b64decode -from datetime import datetime, timezone +import json +import logging import os import sys -import logging -import json -from .db_url import get_db_url_from_vcap_services -import environs -from cfenv import AppEnv -from audit.get_agency_names import get_agency_names, get_audit_info_lists +from base64 import b64decode +from datetime import datetime, timezone + import dj_database_url +import environs import newrelic.agent +from audit.get_agency_names import get_agency_names, get_audit_info_lists +from cfenv import AppEnv + +from .db_url import get_db_url_from_vcap_services newrelic.agent.initialize() @@ -141,6 +143,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "config.middleware.HandleSessionException", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "config.middleware.MaintenanceCheck", diff --git a/backend/static/js/session-expired-modal.js b/backend/static/js/session-expired-modal.js new file mode 100644 index 0000000000..dc0268c406 --- /dev/null +++ b/backend/static/js/session-expired-modal.js @@ -0,0 +1,15 @@ +document.addEventListener("DOMContentLoaded", function () { + + const modalTrigger = document.createElement("a"); + modalTrigger.setAttribute("href", `#session-expired-modal`); + modalTrigger.setAttribute("data-open-modal", ""); + modalTrigger.setAttribute("aria-controls", 'session-expired-modal'); + modalTrigger.setAttribute("role", "button"); + modalTrigger.className = "sign-in display-flex flex-row"; + document.body.appendChild(modalTrigger); + + setTimeout(() => { + modalTrigger.click(); + modalTrigger.remove(); + }, 100); +}); diff --git a/backend/static/js/session-warning-modal.js b/backend/static/js/session-warning-modal.js new file mode 100644 index 0000000000..adaf32feeb --- /dev/null +++ b/backend/static/js/session-warning-modal.js @@ -0,0 +1,14 @@ +document.addEventListener("DOMContentLoaded", function () { + const modalTrigger = document.createElement("a"); + modalTrigger.setAttribute("href", `#session-warning-modal`); + modalTrigger.setAttribute("data-open-modal", ""); + modalTrigger.setAttribute("aria-controls", 'session-warning-modal'); + modalTrigger.setAttribute("role", "button"); + modalTrigger.className = "sign-in display-flex flex-row"; + document.body.appendChild(modalTrigger); + + setTimeout(() => { + modalTrigger.click(); + modalTrigger.remove(); + }, 100); +}); \ No newline at end of file diff --git a/backend/templates/includes/header.html b/backend/templates/includes/header.html index 348c0f69f4..2e8f7a5316 100644 --- a/backend/templates/includes/header.html +++ b/backend/templates/includes/header.html @@ -89,4 +89,4 @@ -{% include "includes/nav_primary.html" %} +{% include "includes/nav_primary.html" %} \ No newline at end of file diff --git a/backend/templates/includes/nav_primary.html b/backend/templates/includes/nav_primary.html index 7703fcb9e4..8426c84bde 100644 --- a/backend/templates/includes/nav_primary.html +++ b/backend/templates/includes/nav_primary.html @@ -196,7 +196,7 @@
+
Submitting information to the Federal Audit Clearinghouse requires authentication
which will now be handled by Login.gov.
You cannot use your old Census FAC credentials to access the new GSA
@@ -235,5 +235,10 @@ You must log in to conti
{% endif %}
To protect your account, you have been automatically signed out.
+Your session is about to expire due to inactivity.
+