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

You must log in to continue

-

+

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 %}

+ {% if session_expired %} + {% include "includes/session_expired_modal.html" %} + {% elif show_session_warning_banner %} + {% include "includes/session_warning_modal.html" %} + {% endif %} diff --git a/backend/templates/includes/session_expired_modal.html b/backend/templates/includes/session_expired_modal.html new file mode 100644 index 0000000000..ae326e3232 --- /dev/null +++ b/backend/templates/includes/session_expired_modal.html @@ -0,0 +1,34 @@ +{% load static %} + +
+
+
+

Your session has expired

+
+

To protect your account, you have been automatically signed out.

+
+ +
+
+
\ No newline at end of file diff --git a/backend/templates/includes/session_warning_modal.html b/backend/templates/includes/session_warning_modal.html new file mode 100644 index 0000000000..0a04e9ccfb --- /dev/null +++ b/backend/templates/includes/session_warning_modal.html @@ -0,0 +1,51 @@ +{% load static %} + +
+
+
+

You will be signed out soon

+
+

Your session is about to expire due to inactivity.

+
  • +
    + +
    +
    + Time Remaining: {{ formatted_time }} +
    +
  • +
    + +
    + +
    +
    \ No newline at end of file From 32d112e249f7e28add0d7ae49ba9304c30499f07 Mon Sep 17 00:00:00 2001 From: Sudha Kumar <135276194+gsa-suk@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:05:26 -0800 Subject: [PATCH 2/2] Remove api_historic_v0_1_0_alpha folder (#4659) --- .../api/api_historic_v0_1_0_alpha/base.sql | 5 -- .../create_functions.sql | 5 -- .../create_schema.sql | 42 ---------------- .../create_views.sql | 5 -- .../api_historic_v0_1_0_alpha/drop_schema.sql | 5 -- .../api_historic_v0_1_0_alpha/drop_views.sql | 5 -- .../api/api_historic_v0_1_0_alpha/views.py | 49 ------------------- 7 files changed, 116 deletions(-) delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/base.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/create_functions.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/create_schema.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/create_views.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/drop_schema.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/drop_views.sql delete mode 100644 backend/dissemination/api/api_historic_v0_1_0_alpha/views.py diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/base.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/base.sql deleted file mode 100644 index 37e9d7347f..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/base.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; -select 1; -commit; - -notify pgrst, 'reload schema'; diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_functions.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/create_functions.sql deleted file mode 100644 index 37e9d7347f..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_functions.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; -select 1; -commit; - -notify pgrst, 'reload schema'; diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_schema.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/create_schema.sql deleted file mode 100644 index 127b6b4319..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_schema.sql +++ /dev/null @@ -1,42 +0,0 @@ --- This schema is handled external to the app. --- Why? --- It relies on static tables that are loaded before the app exists. --- Therefore, we assume those tables are loaded. Or, mostly assume. --- This grants permissions, nothing more. - -begin; - -do -$$ -begin - -- If it exists, grant permissions. - if exists (select schema_name from information_schema.schemata where schema_name = 'api_historic_v0_1_0_alpha') then - -- Grant access to tables and views - alter default privileges - in schema api_historic_v0_1_0_alpha - grant select - -- this includes views - on tables - to api_fac_gov; - - -- Grant access to sequences, if we have them - grant usage on schema api_historic_v0_1_0_alpha to api_fac_gov; - grant select, usage on all sequences in schema api_historic_v0_1_0_alpha to api_fac_gov; - alter default privileges - in schema api_historic_v0_1_0_alpha - grant select, usage - on sequences - to api_fac_gov; - - GRANT SELECT ON ALL TABLES IN SCHEMA api_historic_v0_1_0_alpha TO api_fac_gov; - end if; -end -$$ -; - -select 1; - -commit; - -notify pgrst, 'reload schema'; - diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_views.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/create_views.sql deleted file mode 100644 index 37e9d7347f..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/create_views.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; -select 1; -commit; - -notify pgrst, 'reload schema'; diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_schema.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_schema.sql deleted file mode 100644 index 37e9d7347f..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_schema.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; -select 1; -commit; - -notify pgrst, 'reload schema'; diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_views.sql b/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_views.sql deleted file mode 100644 index 37e9d7347f..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/drop_views.sql +++ /dev/null @@ -1,5 +0,0 @@ -begin; -select 1; -commit; - -notify pgrst, 'reload schema'; diff --git a/backend/dissemination/api/api_historic_v0_1_0_alpha/views.py b/backend/dissemination/api/api_historic_v0_1_0_alpha/views.py deleted file mode 100644 index 993dd3be76..0000000000 --- a/backend/dissemination/api/api_historic_v0_1_0_alpha/views.py +++ /dev/null @@ -1,49 +0,0 @@ -schema = "api_historic_v0_1_0_alpha" -prefix = "census_" - -tables = { - "agency": (16, 22), - "captext": (19, 22), - "captext_formatted": (19, 22), - "cfda": (16, 22), - "cpas": (16, 22), - "duns": (16, 22), - "eins": (16, 22), - "findings": (16, 22), - "findingstext": (19, 22), - "findingstext_formatted": (19, 22), - "gen": (16, 22), - "notes": (19, 22), - "passthrough": (16, 22), - "revisions": (19, 22), - "ueis": (22, 22), -} - - -def just_table_names(lot): - return list(tables.keys()) - - -def generate_views(tbs): - print("begin;\n") - for t, rng in tbs.items(): - # Range is exclusive on the second value - for v in range(rng[0], rng[1] + 1): - print(f"create view {schema}.{t}{v} as") - print("\tselect *") - print(f"\tfrom {prefix}{t}{v}") - print(f"\torder by {prefix}{t}{v}.id") - print(";\n") - print("commit;") - print("notify pgrst, 'reload schema';") - - -if __name__ in "__main__": - generate_views(tables) - -# (define (generate-drops lot) -# (printf "begin;~n~n") -# (for ([t lot]) -# (printf "drop table if exists ~a.~a;~n" schema t)) -# (printf "commit;~n") -# (printf "notify pgrst, 'reload schema';~n"))