Skip to content

Commit

Permalink
Merge pull request #519 from rcpch/anchit/auto-logout
Browse files Browse the repository at this point in the history
[Auto Logout Middleware] Adds session logout based on idle time
  • Loading branch information
mbarton authored Feb 4, 2025
2 parents 43e31a2 + 04ba910 commit f8d2d8a
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 17 deletions.
3 changes: 3 additions & 0 deletions project/npda/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
{% block toasts %}
{% include 'toasts.html' %}
{% endblock %}

<!-- ADDS AUTO LOGOUT REDIRECT TO LOGIN PAGE SCRIPT -->
{{ redirect_to_login_immediately }}
</body>
</html>
<script>
Expand Down
59 changes: 59 additions & 0 deletions project/npda/tests/general_tests/test_autologout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import logging
from datetime import datetime, timedelta
from http import HTTPStatus

# Python imports
import pytest
from django.conf import settings

# 3rd party imports
from django.urls import reverse
from freezegun import freeze_time

# E12 imports
from project.npda.models import NPDAUser
from project.npda.tests.model_tests.test_submissions import ALDER_HEY_PZ_CODE
from project.npda.tests.utils import login_and_verify_user

logger = logging.getLogger(__name__)


@pytest.mark.django_db
def test_auto_logout_django_auto_logout(
seed_groups_fixture,
seed_users_fixture,
client,
):
# Get any user
user = NPDAUser.objects.filter(organisation_employers__pz_code=ALDER_HEY_PZ_CODE).first()

client = login_and_verify_user(client, user)

# Try accessing authenticated page
response = client.get(reverse("dashboard"))
assert response.status_code == HTTPStatus.OK, "User unable to access home"

# Simulate session expiry with freezegun
future_time = (
datetime.now()
+ timedelta(seconds=settings.AUTO_LOGOUT_IDLE_TIME_SECONDS)
+ timedelta(milliseconds=1)
)
with freeze_time(future_time):
response = client.get(reverse("dashboard"))

assert (
response.status_code == HTTPStatus.FOUND
), f"User not redirected after expected auto-logout; {response.status_code=}"
assert response.url == reverse(
"two_factor:setup"
), f"User not redirected to login page ({reverse('two_factor:setup')}), instead to {response.url=}"

# Finally try to access a protected page, now as an anon user
response = client.get(reverse("dashboard"))
assert (
response.status_code == HTTPStatus.FOUND
), f"Anon user tried accessing protected page after autologout, did not receive 302 response; {response.status_code=}"
assert response.url == reverse(
"two_factor:setup"
), f"User not redirected to login page ({reverse('two_factor:setup')}), instead to {response.url=}"
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,8 @@
logger = logging.getLogger(__name__)

ALDER_HEY_PZ_CODE = "PZ074"
ALDER_HEY_ODS_CODE = "RBS25"

GOSH_PZ_CODE = "PZ196"
GOSH_ODS_CODE = "RP401"


def check_all_users_in_pdu(user, users, pz_code):
Expand Down
12 changes: 10 additions & 2 deletions project/npda/views/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ def check_otp(view, request):
)
return True

# Prevent unverified users
if not user.is_verified():
if not user.is_authenticated:
logger.info(
"User %s is not authenticated. Tried accessing %s",
user,
view.__qualname__,
)
return False

# Prevent unverified (from otp) users
if hasattr(user, "is_verified") and not user.is_verified():
logger.info(
"User %s is unverified. Tried accessing %s",
user,
Expand Down
36 changes: 24 additions & 12 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
https://docs.djangoproject.com/en/4.2/ref/settings/
"""

from datetime import timedelta
from pathlib import Path
import os
import logging
Expand Down Expand Up @@ -81,9 +82,7 @@
LOCAL_DEV_ADMIN_EMAIL = os.getenv("LOCAL_DEV_ADMIN_EMAIL")
LOCAL_DEV_ADMIN_PASSWORD = os.getenv("LOCAL_DEV_ADMIN_PASSWORD")

if (
os.environ.get("RUN_MAIN") == "true"
): # Prevent double execution during reloading
if os.environ.get("RUN_MAIN") == "true": # Prevent double execution during reloading
import debugpy

DEBUGPY_PORT = os.getenv("DEBUGPY_PORT", None)
Expand All @@ -92,16 +91,12 @@
else:
try:
DEBUGPY_PORT = int(DEBUGPY_PORT) # Convert to integer
debugpy.listen(
("0.0.0.0", DEBUGPY_PORT)
) # Ensure port matches in VSCode config
debugpy.listen(("0.0.0.0", DEBUGPY_PORT)) # Ensure port matches in VSCode config
logger.debug(
f"Debugging is enabled on port {DEBUGPY_PORT}, waiting for debugger to attach..."
)
except ValueError:
logger.error(
f"Invalid DEBUGPY_PORT value: {DEBUGPY_PORT}. Must be an integer."
)
logger.error(f"Invalid DEBUGPY_PORT value: {DEBUGPY_PORT}. Must be an integer.")


# GENERAL CAPTCHA SETTINGS
Expand Down Expand Up @@ -152,6 +147,8 @@
"django_htmx.middleware.HtmxMiddleware",
# 2 factor authentication
"django_otp.middleware.OTPMiddleware",
# autologout
"django_auto_logout.middleware.auto_logout",
]

MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
Expand All @@ -177,6 +174,8 @@
"project.npda.context_processors.session_data",
"project.npda.context_processors.can_alter_this_audit_year_submission",
"project.npda.context_processors.can_use_questionnaire",
# Autologout
"django_auto_logout.context_processors.auto_logout_client",
],
},
},
Expand All @@ -190,6 +189,21 @@
SESSION_COOKIE_HTTPONLY = True # cannot access session cookie on client-side using JS
SESSION_EXPIRE_AT_BROWSER_CLOSE = True # session expires on browser close

# Auto-logout
if not (env_auto_logout_idle_time_seconds := os.environ.get("AUTO_LOGOUT_IDLE_TIME_SECONDS")):
env_auto_logout_idle_time_seconds = 60 * 30 # Default: 30 minutes
logger.warning(
"ENV VAR AUTO_LOGOUT_IDLE_TIME_SECONDS MISSING: SETTING DEFAULT TIME: "
f"{env_auto_logout_idle_time_seconds=}"
)
AUTO_LOGOUT_IDLE_TIME_SECONDS = int(env_auto_logout_idle_time_seconds)
logger.info(f"AUTO_LOGOUT_IDLE_TIME_SECONDS: {AUTO_LOGOUT_IDLE_TIME_SECONDS}")
AUTO_LOGOUT = {
"IDLE_TIME": timedelta(seconds=AUTO_LOGOUT_IDLE_TIME_SECONDS),
"REDIRECT_TO_LOGIN_IMMEDIATELY": True,
"MESSAGE": "You have been automatically logged out as there was no activity for "
f"{AUTO_LOGOUT_IDLE_TIME_SECONDS / 60} minutes. Please login again to continue.",
}

# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
Expand All @@ -211,9 +225,7 @@

DATABASES = {"default": database_config}

AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend", # this is default
)
AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # this is default


# Password validation
Expand Down
5 changes: 4 additions & 1 deletion requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ django-htmx==1.17.3
django-two-factor-auth==1.16.0
django-simple-captcha==0.6.0
django-citext==1.0.0
django-auto-logout==0.5.1
docutils==0.20.1
geopandas==1.0.1
markdown
Expand Down Expand Up @@ -39,12 +40,14 @@ gunicorn>=22.0.0
autopep8==2.0.4
black>=24.3.0
djlint==1.36.3
isort==5.13.2

# testing and code analysis
coverage==7.4.3
pytest-django==4.8.0
pytest-factoryboy==2.7.0
pytest-asyncio==0.24.0
freezegun==1.5.1

# versioning
bump2version
Expand All @@ -68,4 +71,4 @@ mkdocs-with-pdf==0.9.3 # PDF export feature
colorlog==6.8.2

# Vscode debugger
debugpy==1.8.8
debugpy==1.8.8

0 comments on commit f8d2d8a

Please sign in to comment.