From c905074782ac341779f35a43f7bf8208d1ce254a Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Thu, 11 Apr 2024 18:08:29 +0530 Subject: [PATCH 1/9] wip: capture access logs --- backend/src/zelthy/apps/accesslogs/apps.py | 9 +++ backend/src/zelthy/apps/accesslogs/models.py | 15 +++++ backend/src/zelthy/apps/accesslogs/signals.py | 64 +++++++++++++++++++ backend/src/zelthy/config/settings/base.py | 16 ++++- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 backend/src/zelthy/apps/accesslogs/apps.py create mode 100644 backend/src/zelthy/apps/accesslogs/models.py create mode 100644 backend/src/zelthy/apps/accesslogs/signals.py diff --git a/backend/src/zelthy/apps/accesslogs/apps.py b/backend/src/zelthy/apps/accesslogs/apps.py new file mode 100644 index 000000000..de7eef27f --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CompanyusersAppConfig(AppConfig): + + name = "zelthy.apps.accesslogs" + + def ready(self): + import zelthy.apps.accesslogs.signals diff --git a/backend/src/zelthy/apps/accesslogs/models.py b/backend/src/zelthy/apps/accesslogs/models.py new file mode 100644 index 000000000..cbbd43f2a --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/models.py @@ -0,0 +1,15 @@ +from django.db import models +from axes.models import AccessBase + +from ..appauth.models import AppUserModel, UserRoleModel + + +class AppUserAccessLogs(AccessBase): + + user = models.ForeignKey(AppUserModel, null=True, on_delete=models.CASCADE) + role = models.ForeignKey( + UserRoleModel, null=True, blank=True, on_delete=models.CASCADE + ) + attempt_type = models.CharField(max_length=20, null=True) + is_login_successful = models.BooleanField(default=False) + session_expired_at = models.DateTimeField(null=True, blank=True) diff --git a/backend/src/zelthy/apps/accesslogs/signals.py b/backend/src/zelthy/apps/accesslogs/signals.py new file mode 100644 index 000000000..2b0b8289b --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/signals.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +from ipware import get_client_ip +from django.dispatch import receiver +from axes.helpers import get_client_user_agent +from django.contrib.auth.signals import ( + user_logged_out, + user_login_failed, + user_logged_in, +) + +from .models import AppUserAccessLogs +from ..appauth.models import UserRoleModel +from zelthy.core.utils import get_current_request, get_current_role + + +@receiver(user_login_failed) +def login_failure_handler(sender, **kwargs): + creds = kwargs.get("credentials", {}) + request = kwargs.get("request", get_current_request()) + client_ip, is_routable = get_client_ip(request) + access_log = AppUserAccessLogs.objects.create( + ip_address=client_ip, + http_accept=request.META.get("HTTP_ACCEPT", ""), + path_info=request.META.get("PATH_INFO", ""), + user_agent=get_client_user_agent(request), + username=creds.get("username"), + attempt_time=datetime.now(), + attempt_type="login", + is_login_successful=False, + ) + + +@receiver(user_logged_in) +def user_logged_in_handler(sender, request, user, **kwargs): + + client_ip, is_routable = get_client_ip(request) + username = request.POST.get("auth-username") or request.data.get("username") + access_log = AppUserAccessLogs.objects.create( + ip_address=client_ip, + http_accept=request.META.get("HTTP_ACCEPT", ""), + path_info=request.META.get("PATH_INFO", ""), + username=username, + user_agent=get_client_user_agent(request), + attempt_time=datetime.now(), + attempt_type="login", + is_login_successful=True, + user=user, + role=UserRoleModel.objects.filter( + id=getattr(request, "selected_role_id") + ).last(), + ) + + +@receiver(user_logged_out) +def user_logged_out_handler(sender, user, **kwargs): + access_log = AppUserAccessLogs.objects.filter( + user=user, session_expired_at__isnull=True + ).last() + + if access_log: + access_log.session_expired_at = datetime.now() + access_log.save() diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index ee95bef43..3a5ff200c 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -1,6 +1,6 @@ import sys import os - +from datetime import timedelta import zelthy # SECURITY WARNING: don't run with debug turned on in production! @@ -48,6 +48,7 @@ "zelthy.apps.object_store", "zelthy.apps.dynamic_models", "zelthy.apps.tasks", + "zelthy.apps.accesslogs", "corsheaders", "crispy_forms", "crispy_bootstrap5", @@ -55,6 +56,7 @@ "crispy_forms", "django_celery_results", # "cachalot", + "axes", ] INSTALLED_APPS = list(SHARED_APPS) + [ @@ -83,10 +85,12 @@ "corsheaders.middleware.CorsMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", "zelthy.middleware.tenant.TimezoneMiddleware", + "axes.middleware.AxesMiddleware", ] AUTHENTICATION_BACKENDS = ( + "axes.backends.AxesStandaloneBackend", "zelthy.apps.shared.platformauth.auth_backend.PlatformUserModelBackend", "zelthy.apps.appauth.auth_backend.AppUserModelBackend", ) @@ -163,3 +167,13 @@ PACKAGE_BUCKET_NAME = "zelthy3-packages" CODEASSIST_ENABLED = True + +# Axes lockout +AXES_BEHIND_REVERSE_PROXY = True +AXES_COOLOFF_TIME = timedelta(seconds=100) +AXES_LOCK_OUT_AT_FAILURE = True +AXES_LOGIN_FAILURE_LIMIT = 5 +AXES_ENABLED = True +AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = True +AXES_LOCKOUT_PARAMETERS = ["username"] +AXES_ENABLE_ACCESS_FAILURE_LOG = True From f06be1d3a973918d24e41dc6858618f83a2ebcc7 Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Thu, 11 Apr 2024 18:28:38 +0530 Subject: [PATCH 2/9] wip(accesslogs): add lockout page --- .../templates/account_lock_out.html | 38 +++++ .../accesslogs/templates/exception_base.html | 40 ++++++ .../error_pages/css/LockedAccountStyle.css | 91 ++++++++++++ .../assets/error_pages/images/ImgLocked.svg | 135 ++++++++++++++++++ backend/src/zelthy/config/settings/base.py | 8 +- 5 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html create mode 100644 backend/src/zelthy/apps/accesslogs/templates/exception_base.html create mode 100644 backend/src/zelthy/assets/error_pages/css/LockedAccountStyle.css create mode 100644 backend/src/zelthy/assets/error_pages/images/ImgLocked.svg diff --git a/backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html b/backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html new file mode 100644 index 000000000..5d2ecef50 --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html @@ -0,0 +1,38 @@ +{% extends 'exception_base.html' %} + +{% load i18n static %} + +{% block title %}Zelthy{% endblock %} + + +{% block head %} + + + +{% endblock %} + + + +{% block content %} + +
+
+ +
+
+
Account Locked
+
+ Oops, your account/ IP address has been temporarily locked as there has been too many incorrect password attempts. Don't worry, this will be automatically reset after 15 minutes. +
+
+
+ +
+
+ +
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/backend/src/zelthy/apps/accesslogs/templates/exception_base.html b/backend/src/zelthy/apps/accesslogs/templates/exception_base.html new file mode 100644 index 000000000..b5a1768d3 --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/templates/exception_base.html @@ -0,0 +1,40 @@ +{% load i18n %} +{% load i18n static %} + + + + + + + + + + + + + + + + + + + + {% block head %} + + {% endblock %} + + + {% block content %} + + {% endblock %} + + \ No newline at end of file diff --git a/backend/src/zelthy/assets/error_pages/css/LockedAccountStyle.css b/backend/src/zelthy/assets/error_pages/css/LockedAccountStyle.css new file mode 100644 index 000000000..8b7e6bea2 --- /dev/null +++ b/backend/src/zelthy/assets/error_pages/css/LockedAccountStyle.css @@ -0,0 +1,91 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; + } + body { + color: white; + background: #161622; + } + .container { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + position: relative; + } + .error-content { + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + + margin-top: 22px; + } + .img-container { + margin-top: 115px; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + } + + .not-found { + font-family: Source Sans Pro; + font-style: normal; + font-weight: bold; + font-size: 48px; + line-height: 120%; + + text-align: center; + letter-spacing: 0.25px; + + color: rgba(255, 255, 255, 0.9); + } + .error-status { + width: 854.47px; + height: 322.89px; + position: absolute; + top: 167px; + } + .error-img { + /* width: 241.23px; */ + /* height: 269.41px; */ + /* margin-top: 111px; */ + z-index: 1; + } + .not-found-title { + font-family: Source Sans Pro; + font-weight: 400; + font-size: 16px; + line-height: 22.4px; + line-height: 140%; + width: 374.99px; + + display: flex; + align-items: center; + text-align: center; + border: none; + + color: rgba(255, 255, 255, 0.75); + } + + button[type='submit'] { + background: #5048ed; + border-radius: 4px; + color: white; + margin: 24px 0 0 0; + border: none; + cursor: pointer; + font-size: 16px; + font-weight: bold; + font-family: Source Sans Pro; + padding: 14.5px 32px; + outline: none; + } + .button-container { + display: flex; + } + .each-button { + margin: 10px; + } \ No newline at end of file diff --git a/backend/src/zelthy/assets/error_pages/images/ImgLocked.svg b/backend/src/zelthy/assets/error_pages/images/ImgLocked.svg new file mode 100644 index 000000000..d39d2d61a --- /dev/null +++ b/backend/src/zelthy/assets/error_pages/images/ImgLocked.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index 3a5ff200c..bd7a4ce6a 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -170,10 +170,12 @@ # Axes lockout AXES_BEHIND_REVERSE_PROXY = True -AXES_COOLOFF_TIME = timedelta(seconds=100) +AXES_COOLOFF_TIME = timedelta(seconds=900) AXES_LOCK_OUT_AT_FAILURE = True -AXES_LOGIN_FAILURE_LIMIT = 5 +AXES_LOGIN_FAILURE_LIMIT = 6 AXES_ENABLED = True AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = True -AXES_LOCKOUT_PARAMETERS = ["username"] +AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"] AXES_ENABLE_ACCESS_FAILURE_LOG = True +AXES_DISABLE_SUCCESS_ACCESS_LOG = True +AXES_LOCKOUT_TEMPLATE = "account_lock_out.html" From b0e178c4c5939a39fb1b4a7c98961b8a34501339 Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Tue, 16 Apr 2024 18:07:33 +0530 Subject: [PATCH 3/9] wip(accesslogs): rest api based login changes --- .../src/zelthy/apps/accesslogs/__init__.py | 0 backend/src/zelthy/apps/accesslogs/apps.py | 2 +- .../accesslogs/migrations/0001_accesslogs.py | 86 +++++++++++++++++++ .../apps/accesslogs/migrations/__init__.py | 0 backend/src/zelthy/apps/accesslogs/models.py | 5 +- backend/src/zelthy/apps/accesslogs/signals.py | 56 +++++++----- backend/src/zelthy/apps/accesslogs/utils.py | 48 +++++++++++ .../src/zelthy/apps/dynamic_models/views.py | 2 + backend/src/zelthy/config/settings/base.py | 22 +++-- 9 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 backend/src/zelthy/apps/accesslogs/__init__.py create mode 100644 backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py create mode 100644 backend/src/zelthy/apps/accesslogs/migrations/__init__.py create mode 100644 backend/src/zelthy/apps/accesslogs/utils.py diff --git a/backend/src/zelthy/apps/accesslogs/__init__.py b/backend/src/zelthy/apps/accesslogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zelthy/apps/accesslogs/apps.py b/backend/src/zelthy/apps/accesslogs/apps.py index de7eef27f..77fb838f3 100644 --- a/backend/src/zelthy/apps/accesslogs/apps.py +++ b/backend/src/zelthy/apps/accesslogs/apps.py @@ -1,7 +1,7 @@ from django.apps import AppConfig -class CompanyusersAppConfig(AppConfig): +class AccesslogsAppConfig(AppConfig): name = "zelthy.apps.accesslogs" diff --git a/backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py b/backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py new file mode 100644 index 000000000..768803d0f --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2.11 on 2024-04-12 09:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("appauth", "0006_appusermodel_app_objects"), + ] + + operations = [ + migrations.CreateModel( + name="AppAccessLogs", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "user_agent", + models.CharField( + db_index=True, max_length=255, verbose_name="User Agent" + ), + ), + ( + "ip_address", + models.GenericIPAddressField( + db_index=True, null=True, verbose_name="IP Address" + ), + ), + ( + "username", + models.CharField( + db_index=True, + max_length=255, + null=True, + verbose_name="Username", + ), + ), + ( + "http_accept", + models.CharField(max_length=1025, verbose_name="HTTP Accept"), + ), + ("path_info", models.CharField(max_length=255, verbose_name="Path")), + ( + "attempt_time", + models.DateTimeField( + auto_now_add=True, verbose_name="Attempt Time" + ), + ), + ("attempt_type", models.CharField(max_length=20, null=True)), + ("is_login_successful", models.BooleanField(default=False)), + ("session_expired_at", models.DateTimeField(blank=True, null=True)), + ( + "role", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="appauth.userrolemodel", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="appauth.appusermodel", + ), + ), + ], + options={ + "ordering": ["-attempt_time"], + "abstract": False, + }, + ), + ] diff --git a/backend/src/zelthy/apps/accesslogs/migrations/__init__.py b/backend/src/zelthy/apps/accesslogs/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zelthy/apps/accesslogs/models.py b/backend/src/zelthy/apps/accesslogs/models.py index cbbd43f2a..e8d57b8f9 100644 --- a/backend/src/zelthy/apps/accesslogs/models.py +++ b/backend/src/zelthy/apps/accesslogs/models.py @@ -4,7 +4,7 @@ from ..appauth.models import AppUserModel, UserRoleModel -class AppUserAccessLogs(AccessBase): +class AppAccessLogs(AccessBase): user = models.ForeignKey(AppUserModel, null=True, on_delete=models.CASCADE) role = models.ForeignKey( @@ -13,3 +13,6 @@ class AppUserAccessLogs(AccessBase): attempt_type = models.CharField(max_length=20, null=True) is_login_successful = models.BooleanField(default=False) session_expired_at = models.DateTimeField(null=True, blank=True) + + class Meta(AccessBase.Meta): + app_label = "accesslogs" diff --git a/backend/src/zelthy/apps/accesslogs/signals.py b/backend/src/zelthy/apps/accesslogs/signals.py index 2b0b8289b..7d5c0f23f 100644 --- a/backend/src/zelthy/apps/accesslogs/signals.py +++ b/backend/src/zelthy/apps/accesslogs/signals.py @@ -10,9 +10,9 @@ user_logged_in, ) -from .models import AppUserAccessLogs +from .models import AppAccessLogs from ..appauth.models import UserRoleModel -from zelthy.core.utils import get_current_request, get_current_role +from zelthy.core.utils import get_current_request @receiver(user_login_failed) @@ -20,7 +20,7 @@ def login_failure_handler(sender, **kwargs): creds = kwargs.get("credentials", {}) request = kwargs.get("request", get_current_request()) client_ip, is_routable = get_client_ip(request) - access_log = AppUserAccessLogs.objects.create( + access_log = AppAccessLogs.objects.create( ip_address=client_ip, http_accept=request.META.get("HTTP_ACCEPT", ""), path_info=request.META.get("PATH_INFO", ""), @@ -34,28 +34,44 @@ def login_failure_handler(sender, **kwargs): @receiver(user_logged_in) def user_logged_in_handler(sender, request, user, **kwargs): + try: + client_ip, is_routable = get_client_ip(request) + username = request.POST.get("auth-username") or request.data.get("username") + user_role = None + access_log = AppAccessLogs.objects.create( + ip_address=client_ip, + http_accept=request.META.get("HTTP_ACCEPT", ""), + path_info=request.META.get("PATH_INFO", ""), + username=username, + user_agent=get_client_user_agent(request), + attempt_time=datetime.now(), + attempt_type="login", + is_login_successful=True, + user=user, + ) - client_ip, is_routable = get_client_ip(request) - username = request.POST.get("auth-username") or request.data.get("username") - access_log = AppUserAccessLogs.objects.create( - ip_address=client_ip, - http_accept=request.META.get("HTTP_ACCEPT", ""), - path_info=request.META.get("PATH_INFO", ""), - username=username, - user_agent=get_client_user_agent(request), - attempt_time=datetime.now(), - attempt_type="login", - is_login_successful=True, - user=user, - role=UserRoleModel.objects.filter( - id=getattr(request, "selected_role_id") - ).last(), - ) + if getattr(request, "selected_role_id", ""): + user_role = UserRoleModel.objects.filter( + id=getattr(request, "selected_role_id") + ).last() + + elif getattr(request, "parser_context", ""): + user_role = UserRoleModel.objects.filter( + name=request.parser_context.get("kwargs", {}).get("role_name") + ).last() + + if user_role: + access_log.role = user_role + access_log.save() + except: + import traceback + + print(traceback.format_exc()) @receiver(user_logged_out) def user_logged_out_handler(sender, user, **kwargs): - access_log = AppUserAccessLogs.objects.filter( + access_log = AppAccessLogs.objects.filter( user=user, session_expired_at__isnull=True ).last() diff --git a/backend/src/zelthy/apps/accesslogs/utils.py b/backend/src/zelthy/apps/accesslogs/utils.py new file mode 100644 index 000000000..a1df56921 --- /dev/null +++ b/backend/src/zelthy/apps/accesslogs/utils.py @@ -0,0 +1,48 @@ +from django.contrib.auth import signals + +from axes.handlers.proxy import AxesProxyHandler +from django.http import HttpResponseForbidden, HttpResponse + +from ..appauth.models import AppUserModel + + +def capture_failed_login_attempt(request, credentials): + try: + + username_type, username = "", credentials.get("username", "") + if "@" in username and "." in username: + username_type = "email" + + elif username.replace("+", "").isdigit(): + username_type = "mobile" + + if username_type: + filter_query = {username_type: username} + app_user = AppUserModel.objects.get(**filter_query) + signals.user_login_failed.send( + sender=app_user, + request=request, + credentials=credentials, + ) + except: + import traceback + + print(traceback.format_exc()) + + +def user_authentication_failed(request, credentials): + + capture_failed_login_attempt(request, credentials) + + if not AxesProxyHandler.is_allowed(request): + return { + "is_locked": True, + "message": "Account locked", + "status": HttpResponseForbidden.status_code, + } + else: + return { + "is_locked": False, + "message": "This account is permitted to log in", + "status": HttpResponse.status_code, + } diff --git a/backend/src/zelthy/apps/dynamic_models/views.py b/backend/src/zelthy/apps/dynamic_models/views.py index 7c1561c35..c30b3f88b 100644 --- a/backend/src/zelthy/apps/dynamic_models/views.py +++ b/backend/src/zelthy/apps/dynamic_models/views.py @@ -8,6 +8,7 @@ from django.views.generic import View from django.http import Http404 +from axes.decorators import axes_dispatch from zelthy.core.utils import get_current_role from zelthy.apps.dynamic_models.permissions import is_platform_user @@ -44,6 +45,7 @@ def default_landing_view(request): @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(axes_dispatch, name="dispatch") class DynamicView(View, PermMixin): """ this class is responsible for building the diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index bd7a4ce6a..774efef71 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -1,5 +1,6 @@ import sys import os +import environ from datetime import timedelta import zelthy @@ -168,14 +169,19 @@ PACKAGE_BUCKET_NAME = "zelthy3-packages" CODEASSIST_ENABLED = True -# Axes lockout -AXES_BEHIND_REVERSE_PROXY = True -AXES_COOLOFF_TIME = timedelta(seconds=900) -AXES_LOCK_OUT_AT_FAILURE = True -AXES_LOGIN_FAILURE_LIMIT = 6 + +# Axes Lockout +env = environ.Env( + AXES_BEHIND_REVERSE_PROXY=(bool, False), + AXES_COOLOFF_TIME=(int, 900), + AXES_LOCK_OUT_AT_FAILURE=(bool, True), + AXES_LOGIN_FAILURE_LIMIT=(int, 6), +) + AXES_ENABLED = True -AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = True -AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"] -AXES_ENABLE_ACCESS_FAILURE_LOG = True AXES_DISABLE_SUCCESS_ACCESS_LOG = True AXES_LOCKOUT_TEMPLATE = "account_lock_out.html" +AXES_BEHIND_REVERSE_PROXY = env("AXES_BEHIND_REVERSE_PROXY") +AXES_LOGIN_FAILURE_LIMIT = env("AXES_LOGIN_FAILURE_LIMIT") +AXES_LOCK_OUT_AT_FAILURE = env("AXES_LOCK_OUT_AT_FAILURE") +AXES_COOLOFF_TIME = timedelta(seconds=env("AXES_COOLOFF_TIME")) From c9ca3ff8220ef4515db0c984848a59293eee321a Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Mon, 22 Apr 2024 23:42:52 +0530 Subject: [PATCH 4/9] wip: accesslogs code review changes --- .../{accesslogs => access_logs}/__init__.py | 0 .../apps/{accesslogs => access_logs}/apps.py | 4 +- .../migrations/0001_accesslogs.py | 2 +- .../migrations/__init__.py | 0 .../{accesslogs => access_logs}/models.py | 6 +- .../src/zelthy/apps/access_logs/signals.py | 85 +++++++++++++++++++ .../templates/account_lock_out.html | 2 +- backend/src/zelthy/apps/access_logs/utils.py | 80 +++++++++++++++++ backend/src/zelthy/apps/accesslogs/signals.py | 80 ----------------- backend/src/zelthy/apps/accesslogs/utils.py | 48 ----------- .../src/zelthy/apps/appauth/auth_backend.py | 20 ++--- .../project_template/project_name/settings.py | 12 +++ backend/src/zelthy/config/settings/base.py | 20 ++--- .../templates/exception_base.html | 0 14 files changed, 200 insertions(+), 159 deletions(-) rename backend/src/zelthy/apps/{accesslogs => access_logs}/__init__.py (100%) rename backend/src/zelthy/apps/{accesslogs => access_logs}/apps.py (53%) rename backend/src/zelthy/apps/{accesslogs => access_logs}/migrations/0001_accesslogs.py (98%) rename backend/src/zelthy/apps/{accesslogs => access_logs}/migrations/__init__.py (100%) rename backend/src/zelthy/apps/{accesslogs => access_logs}/models.py (78%) create mode 100644 backend/src/zelthy/apps/access_logs/signals.py rename backend/src/zelthy/apps/{accesslogs => access_logs}/templates/account_lock_out.html (95%) create mode 100644 backend/src/zelthy/apps/access_logs/utils.py delete mode 100644 backend/src/zelthy/apps/accesslogs/signals.py delete mode 100644 backend/src/zelthy/apps/accesslogs/utils.py rename backend/src/zelthy/{apps/accesslogs => core}/templates/exception_base.html (100%) diff --git a/backend/src/zelthy/apps/accesslogs/__init__.py b/backend/src/zelthy/apps/access_logs/__init__.py similarity index 100% rename from backend/src/zelthy/apps/accesslogs/__init__.py rename to backend/src/zelthy/apps/access_logs/__init__.py diff --git a/backend/src/zelthy/apps/accesslogs/apps.py b/backend/src/zelthy/apps/access_logs/apps.py similarity index 53% rename from backend/src/zelthy/apps/accesslogs/apps.py rename to backend/src/zelthy/apps/access_logs/apps.py index 77fb838f3..c470e2759 100644 --- a/backend/src/zelthy/apps/accesslogs/apps.py +++ b/backend/src/zelthy/apps/access_logs/apps.py @@ -3,7 +3,7 @@ class AccesslogsAppConfig(AppConfig): - name = "zelthy.apps.accesslogs" + name = "zelthy.apps.access_logs" def ready(self): - import zelthy.apps.accesslogs.signals + import zelthy.apps.access_logs.signals diff --git a/backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py b/backend/src/zelthy/apps/access_logs/migrations/0001_accesslogs.py similarity index 98% rename from backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py rename to backend/src/zelthy/apps/access_logs/migrations/0001_accesslogs.py index 768803d0f..dbb19b854 100644 --- a/backend/src/zelthy/apps/accesslogs/migrations/0001_accesslogs.py +++ b/backend/src/zelthy/apps/access_logs/migrations/0001_accesslogs.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="AppAccessLogs", + name="AppAccessLog", fields=[ ( "id", diff --git a/backend/src/zelthy/apps/accesslogs/migrations/__init__.py b/backend/src/zelthy/apps/access_logs/migrations/__init__.py similarity index 100% rename from backend/src/zelthy/apps/accesslogs/migrations/__init__.py rename to backend/src/zelthy/apps/access_logs/migrations/__init__.py diff --git a/backend/src/zelthy/apps/accesslogs/models.py b/backend/src/zelthy/apps/access_logs/models.py similarity index 78% rename from backend/src/zelthy/apps/accesslogs/models.py rename to backend/src/zelthy/apps/access_logs/models.py index e8d57b8f9..17e308d52 100644 --- a/backend/src/zelthy/apps/accesslogs/models.py +++ b/backend/src/zelthy/apps/access_logs/models.py @@ -1,10 +1,10 @@ from django.db import models from axes.models import AccessBase -from ..appauth.models import AppUserModel, UserRoleModel +from zelthy.apps.appauth.models import AppUserModel, UserRoleModel -class AppAccessLogs(AccessBase): +class AppAccessLog(AccessBase): user = models.ForeignKey(AppUserModel, null=True, on_delete=models.CASCADE) role = models.ForeignKey( @@ -15,4 +15,4 @@ class AppAccessLogs(AccessBase): session_expired_at = models.DateTimeField(null=True, blank=True) class Meta(AccessBase.Meta): - app_label = "accesslogs" + app_label = "access_logs" diff --git a/backend/src/zelthy/apps/access_logs/signals.py b/backend/src/zelthy/apps/access_logs/signals.py new file mode 100644 index 000000000..d688a27e2 --- /dev/null +++ b/backend/src/zelthy/apps/access_logs/signals.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +from django.db import connection +from ipware import get_client_ip +from django.dispatch import receiver +from axes.helpers import get_client_user_agent +from django.contrib.auth.signals import ( + user_logged_out, + user_login_failed, + user_logged_in, +) + +from .models import AppAccessLog +from zelthy.apps.appauth.models import UserRoleModel +from zelthy.core.utils import get_current_request + + +@receiver(user_login_failed) +def login_failure_handler(sender, **kwargs): + if connection.schema_name != "public": + creds = kwargs.get("credentials", {}) + request = kwargs.get("request", get_current_request()) + client_ip, is_routable = get_client_ip(request) + access_log = AppAccessLog.objects.create( + ip_address=client_ip, + http_accept=request.META.get("HTTP_ACCEPT", ""), + path_info=request.META.get("PATH_INFO", ""), + user_agent=get_client_user_agent(request), + username=creds.get("username"), + attempt_time=datetime.now(), + attempt_type="login", + is_login_successful=False, + ) + + +@receiver(user_logged_in) +def user_logged_in_handler(sender, request, user, **kwargs): + + if connection.schema_name != "public": + try: + client_ip, is_routable = get_client_ip(request) + username = request.POST.get("auth-username") or request.data.get("username") + user_role = None + access_log = AppAccessLog.objects.create( + ip_address=client_ip, + http_accept=request.META.get("HTTP_ACCEPT", ""), + path_info=request.META.get("PATH_INFO", ""), + username=username, + user_agent=get_client_user_agent(request), + attempt_time=datetime.now(), + attempt_type="login", + is_login_successful=True, + user=user, + ) + + if getattr(request, "selected_role_id", ""): + user_role = UserRoleModel.objects.filter( + id=getattr(request, "selected_role_id") + ).last() + + elif getattr(request, "parser_context", ""): + user_role = UserRoleModel.objects.filter( + name=request.parser_context.get("kwargs", {}).get("role_name") + ).last() + + if user_role: + access_log.role = user_role + access_log.save() + except: + import traceback + + print(traceback.format_exc()) + + +@receiver(user_logged_out) +def user_logged_out_handler(sender, user, **kwargs): + if connection.schema_name != "public": + access_log = AppAccessLog.objects.filter( + user=user, session_expired_at__isnull=True + ).last() + + if access_log: + access_log.session_expired_at = datetime.now() + access_log.save() diff --git a/backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html b/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html similarity index 95% rename from backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html rename to backend/src/zelthy/apps/access_logs/templates/account_lock_out.html index 5d2ecef50..f381e314c 100644 --- a/backend/src/zelthy/apps/accesslogs/templates/account_lock_out.html +++ b/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html @@ -1,4 +1,4 @@ -{% extends 'exception_base.html' %} +{% extends 'core/templates/exception_base.html' %} {% load i18n static %} diff --git a/backend/src/zelthy/apps/access_logs/utils.py b/backend/src/zelthy/apps/access_logs/utils.py new file mode 100644 index 000000000..1fe3b752b --- /dev/null +++ b/backend/src/zelthy/apps/access_logs/utils.py @@ -0,0 +1,80 @@ +from django.contrib.auth import signals +from axes.handlers.proxy import AxesProxyHandler +from django.http import HttpResponseForbidden, HttpResponse + +from zelthy.apps.appauth.models import AppUserModel + + +def capture_failed_login_attempt(request, credentials): + """ + Capture and handle a failed login attempt. + + Parameters: + - request (HttpRequest): The HTTP request object. + - credentials (dict): A dictionary containing the login credentials, with the username as the key. + + Returns: + None + + """ + + try: + + username_type, username = "", credentials.get("username", "") + if "@" in username and "." in username: + username_type = "email" + + elif username.replace("+", "").isdigit(): + username_type = "mobile" + + if username_type: + filter_query = {username_type: username} + app_user = AppUserModel.objects.filter(**filter_query).last() + if app_user: + signals.user_login_failed.send( + sender=app_user, + request=request, + credentials=credentials, + ) + except: + import traceback + + print(traceback.format_exc()) + + +def user_authentication_failed(request, credentials): + """ + This function handles the case when user authentication fails. + It captures the failed login attempt by calling the 'capture_failed_login_attempt' function with + the given request and credentials. + + Parameters: + - request: The HTTP request object. + - credentials: A dictionary containing the user's credentials. + + Returns: + If the request is not allowed by the AxesProxyHandler, it returns a dictionary with the following keys: + - 'is_locked': True + - 'message': "Account locked" + - 'status': The HTTP status code for HttpResponseForbidden + + If the request is allowed by the AxesProxyHandler, it returns a dictionary with the following keys: + - 'is_locked': False + - 'message': "This account is permitted to log in" + - 'status': The HTTP status code for HttpResponse + + """ + capture_failed_login_attempt(request, credentials) + + if not AxesProxyHandler.is_allowed(request): + return { + "is_locked": True, + "message": "Account locked", + "status": HttpResponseForbidden.status_code, + } + else: + return { + "is_locked": False, + "message": "This account is permitted to log in", + "status": HttpResponse.status_code, + } diff --git a/backend/src/zelthy/apps/accesslogs/signals.py b/backend/src/zelthy/apps/accesslogs/signals.py deleted file mode 100644 index 7d5c0f23f..000000000 --- a/backend/src/zelthy/apps/accesslogs/signals.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -from datetime import datetime - -from ipware import get_client_ip -from django.dispatch import receiver -from axes.helpers import get_client_user_agent -from django.contrib.auth.signals import ( - user_logged_out, - user_login_failed, - user_logged_in, -) - -from .models import AppAccessLogs -from ..appauth.models import UserRoleModel -from zelthy.core.utils import get_current_request - - -@receiver(user_login_failed) -def login_failure_handler(sender, **kwargs): - creds = kwargs.get("credentials", {}) - request = kwargs.get("request", get_current_request()) - client_ip, is_routable = get_client_ip(request) - access_log = AppAccessLogs.objects.create( - ip_address=client_ip, - http_accept=request.META.get("HTTP_ACCEPT", ""), - path_info=request.META.get("PATH_INFO", ""), - user_agent=get_client_user_agent(request), - username=creds.get("username"), - attempt_time=datetime.now(), - attempt_type="login", - is_login_successful=False, - ) - - -@receiver(user_logged_in) -def user_logged_in_handler(sender, request, user, **kwargs): - try: - client_ip, is_routable = get_client_ip(request) - username = request.POST.get("auth-username") or request.data.get("username") - user_role = None - access_log = AppAccessLogs.objects.create( - ip_address=client_ip, - http_accept=request.META.get("HTTP_ACCEPT", ""), - path_info=request.META.get("PATH_INFO", ""), - username=username, - user_agent=get_client_user_agent(request), - attempt_time=datetime.now(), - attempt_type="login", - is_login_successful=True, - user=user, - ) - - if getattr(request, "selected_role_id", ""): - user_role = UserRoleModel.objects.filter( - id=getattr(request, "selected_role_id") - ).last() - - elif getattr(request, "parser_context", ""): - user_role = UserRoleModel.objects.filter( - name=request.parser_context.get("kwargs", {}).get("role_name") - ).last() - - if user_role: - access_log.role = user_role - access_log.save() - except: - import traceback - - print(traceback.format_exc()) - - -@receiver(user_logged_out) -def user_logged_out_handler(sender, user, **kwargs): - access_log = AppAccessLogs.objects.filter( - user=user, session_expired_at__isnull=True - ).last() - - if access_log: - access_log.session_expired_at = datetime.now() - access_log.save() diff --git a/backend/src/zelthy/apps/accesslogs/utils.py b/backend/src/zelthy/apps/accesslogs/utils.py deleted file mode 100644 index a1df56921..000000000 --- a/backend/src/zelthy/apps/accesslogs/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.contrib.auth import signals - -from axes.handlers.proxy import AxesProxyHandler -from django.http import HttpResponseForbidden, HttpResponse - -from ..appauth.models import AppUserModel - - -def capture_failed_login_attempt(request, credentials): - try: - - username_type, username = "", credentials.get("username", "") - if "@" in username and "." in username: - username_type = "email" - - elif username.replace("+", "").isdigit(): - username_type = "mobile" - - if username_type: - filter_query = {username_type: username} - app_user = AppUserModel.objects.get(**filter_query) - signals.user_login_failed.send( - sender=app_user, - request=request, - credentials=credentials, - ) - except: - import traceback - - print(traceback.format_exc()) - - -def user_authentication_failed(request, credentials): - - capture_failed_login_attempt(request, credentials) - - if not AxesProxyHandler.is_allowed(request): - return { - "is_locked": True, - "message": "Account locked", - "status": HttpResponseForbidden.status_code, - } - else: - return { - "is_locked": False, - "message": "This account is permitted to log in", - "status": HttpResponse.status_code, - } diff --git a/backend/src/zelthy/apps/appauth/auth_backend.py b/backend/src/zelthy/apps/appauth/auth_backend.py index 95843f830..e5704d20b 100644 --- a/backend/src/zelthy/apps/appauth/auth_backend.py +++ b/backend/src/zelthy/apps/appauth/auth_backend.py @@ -1,4 +1,5 @@ """Defines which authentication backend for app users.""" + from django.contrib.auth.backends import ModelBackend from django.db.models import Q from django.db import connection @@ -9,16 +10,15 @@ class AppUserModelBackend(ModelBackend): def authenticate(self, request, username=None, password=None): - - try: - user = AppUserModel.objects.get(Q(email=username) | Q(mobile=username)) - pwd_valid = user.check_password(password) - if pwd_valid and user.is_active: - return user - return None - except AppUserModel.DoesNotExist: - return None - + if request and request.tenant.tenant_type == "app": + try: + user = AppUserModel.objects.get(Q(email=username) | Q(mobile=username)) + pwd_valid = user.check_password(password) + if pwd_valid and user.is_active: + return user + return None + except AppUserModel.DoesNotExist: + return None def get_user(self, user_id): """ diff --git a/backend/src/zelthy/cli/project_template/project_name/settings.py b/backend/src/zelthy/cli/project_template/project_name/settings.py index 4b844b0cd..b82000b1d 100644 --- a/backend/src/zelthy/cli/project_template/project_name/settings.py +++ b/backend/src/zelthy/cli/project_template/project_name/settings.py @@ -116,3 +116,15 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_URL = "static/" STATICFILES_DIRS += [os.path.join(BASE_DIR, "assets")] + +# Axes Lockout +env = environ.Env( + AXES_BEHIND_REVERSE_PROXY=(bool, False), + AXES_COOLOFF_TIME=(int, 900), + AXES_LOCK_OUT_AT_FAILURE=(bool, True), + AXES_LOGIN_FAILURE_LIMIT=(int, 6), +) +AXES_BEHIND_REVERSE_PROXY = env("AXES_BEHIND_REVERSE_PROXY") +AXES_LOGIN_FAILURE_LIMIT = env("AXES_LOGIN_FAILURE_LIMIT") +AXES_LOCK_OUT_AT_FAILURE = env("AXES_LOCK_OUT_AT_FAILURE") +AXES_COOLOFF_TIME = timedelta(seconds=env("AXES_COOLOFF_TIME")) diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index 774efef71..0e4180176 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -29,7 +29,7 @@ # 'django_otp', # 'django_otp.plugins.otp_static', # 'django_otp.plugins.otp_totp', - # 'axes', + "axes", # 'session_security', "django_celery_beat", "django_celery_results", @@ -49,7 +49,7 @@ "zelthy.apps.object_store", "zelthy.apps.dynamic_models", "zelthy.apps.tasks", - "zelthy.apps.accesslogs", + "zelthy.apps.access_logs", "corsheaders", "crispy_forms", "crispy_bootstrap5", @@ -170,18 +170,10 @@ CODEASSIST_ENABLED = True -# Axes Lockout -env = environ.Env( - AXES_BEHIND_REVERSE_PROXY=(bool, False), - AXES_COOLOFF_TIME=(int, 900), - AXES_LOCK_OUT_AT_FAILURE=(bool, True), - AXES_LOGIN_FAILURE_LIMIT=(int, 6), -) - AXES_ENABLED = True AXES_DISABLE_SUCCESS_ACCESS_LOG = True AXES_LOCKOUT_TEMPLATE = "account_lock_out.html" -AXES_BEHIND_REVERSE_PROXY = env("AXES_BEHIND_REVERSE_PROXY") -AXES_LOGIN_FAILURE_LIMIT = env("AXES_LOGIN_FAILURE_LIMIT") -AXES_LOCK_OUT_AT_FAILURE = env("AXES_LOCK_OUT_AT_FAILURE") -AXES_COOLOFF_TIME = timedelta(seconds=env("AXES_COOLOFF_TIME")) +AXES_BEHIND_REVERSE_PROXY = False +AXES_LOGIN_FAILURE_LIMIT = 6 +AXES_LOCK_OUT_AT_FAILURE = True +AXES_COOLOFF_TIME = timedelta(seconds=900) diff --git a/backend/src/zelthy/apps/accesslogs/templates/exception_base.html b/backend/src/zelthy/core/templates/exception_base.html similarity index 100% rename from backend/src/zelthy/apps/accesslogs/templates/exception_base.html rename to backend/src/zelthy/core/templates/exception_base.html From f46292cd133ea8391abd9ee979db627705250f9a Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Thu, 25 Apr 2024 10:37:12 +0530 Subject: [PATCH 5/9] wip: accesslogs --- .../src/zelthy/apps/access_logs/signals.py | 5 --- .../templates/account_lock_out.html | 2 +- .../access_logs/templates/exception_base.html | 40 +++++++++++++++++++ .../project_template/project_name/settings.py | 4 +- backend/src/zelthy/config/settings/base.py | 4 +- 5 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 backend/src/zelthy/apps/access_logs/templates/exception_base.html diff --git a/backend/src/zelthy/apps/access_logs/signals.py b/backend/src/zelthy/apps/access_logs/signals.py index d688a27e2..b335674a1 100644 --- a/backend/src/zelthy/apps/access_logs/signals.py +++ b/backend/src/zelthy/apps/access_logs/signals.py @@ -59,11 +59,6 @@ def user_logged_in_handler(sender, request, user, **kwargs): id=getattr(request, "selected_role_id") ).last() - elif getattr(request, "parser_context", ""): - user_role = UserRoleModel.objects.filter( - name=request.parser_context.get("kwargs", {}).get("role_name") - ).last() - if user_role: access_log.role = user_role access_log.save() diff --git a/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html b/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html index f381e314c..5d2ecef50 100644 --- a/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html +++ b/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html @@ -1,4 +1,4 @@ -{% extends 'core/templates/exception_base.html' %} +{% extends 'exception_base.html' %} {% load i18n static %} diff --git a/backend/src/zelthy/apps/access_logs/templates/exception_base.html b/backend/src/zelthy/apps/access_logs/templates/exception_base.html new file mode 100644 index 000000000..b5a1768d3 --- /dev/null +++ b/backend/src/zelthy/apps/access_logs/templates/exception_base.html @@ -0,0 +1,40 @@ +{% load i18n %} +{% load i18n static %} + + + + + + + + + + + + + + + + + + + + {% block head %} + + {% endblock %} + + + {% block content %} + + {% endblock %} + + \ No newline at end of file diff --git a/backend/src/zelthy/cli/project_template/project_name/settings.py b/backend/src/zelthy/cli/project_template/project_name/settings.py index b82000b1d..d277b43c8 100644 --- a/backend/src/zelthy/cli/project_template/project_name/settings.py +++ b/backend/src/zelthy/cli/project_template/project_name/settings.py @@ -122,9 +122,9 @@ AXES_BEHIND_REVERSE_PROXY=(bool, False), AXES_COOLOFF_TIME=(int, 900), AXES_LOCK_OUT_AT_FAILURE=(bool, True), - AXES_LOGIN_FAILURE_LIMIT=(int, 6), + AXES_FAILURE_LIMIT=(int, 6), ) AXES_BEHIND_REVERSE_PROXY = env("AXES_BEHIND_REVERSE_PROXY") -AXES_LOGIN_FAILURE_LIMIT = env("AXES_LOGIN_FAILURE_LIMIT") +AXES_FAILURE_LIMIT = env("AXES_FAILURE_LIMIT") AXES_LOCK_OUT_AT_FAILURE = env("AXES_LOCK_OUT_AT_FAILURE") AXES_COOLOFF_TIME = timedelta(seconds=env("AXES_COOLOFF_TIME")) diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index 0e4180176..3878074a6 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -174,6 +174,6 @@ AXES_DISABLE_SUCCESS_ACCESS_LOG = True AXES_LOCKOUT_TEMPLATE = "account_lock_out.html" AXES_BEHIND_REVERSE_PROXY = False -AXES_LOGIN_FAILURE_LIMIT = 6 +AXES_FAILURE_LIMIT = 6 AXES_LOCK_OUT_AT_FAILURE = True -AXES_COOLOFF_TIME = timedelta(seconds=900) +AXES_COOLOFF_TIME = 900 From 3a4bc6da8e17c32a8077e92eb0734016ccfb15d1 Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Thu, 25 Apr 2024 15:17:11 +0530 Subject: [PATCH 6/9] Add access logs api view --- .../api/platform/access_logs/v1/__init__.py | 0 .../platform/access_logs/v1/serializers.py | 53 ++++++ .../api/platform/access_logs/v1/urls.py | 11 ++ .../api/platform/access_logs/v1/views.py | 161 ++++++++++++++++++ .../zelthy/api/platform/tenancy/v1/urls.py | 2 + 5 files changed, 227 insertions(+) create mode 100644 backend/src/zelthy/api/platform/access_logs/v1/__init__.py create mode 100644 backend/src/zelthy/api/platform/access_logs/v1/serializers.py create mode 100644 backend/src/zelthy/api/platform/access_logs/v1/urls.py create mode 100644 backend/src/zelthy/api/platform/access_logs/v1/views.py diff --git a/backend/src/zelthy/api/platform/access_logs/v1/__init__.py b/backend/src/zelthy/api/platform/access_logs/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zelthy/api/platform/access_logs/v1/serializers.py b/backend/src/zelthy/api/platform/access_logs/v1/serializers.py new file mode 100644 index 000000000..826b0b0c8 --- /dev/null +++ b/backend/src/zelthy/api/platform/access_logs/v1/serializers.py @@ -0,0 +1,53 @@ +import importlib + +from rest_framework import serializers + +from zelthy.api.platform.tenancy.v1.serializers import AppUserModelSerializerModel +from zelthy.apps.access_logs.models import AppAccessLog +from zelthy.core.utils import get_datetime_str_in_tenant_timezone, get_current_request + + +class AccessLogSerializerModel(serializers.ModelSerializer): + user = serializers.SerializerMethodField() + role = serializers.SerializerMethodField() + attempt_time = serializers.SerializerMethodField() + session_expired_at = serializers.SerializerMethodField() + is_login_successful = serializers.SerializerMethodField() + + def get_attempt_time(self, obj): + if obj.attempt_time: + return get_datetime_str_in_tenant_timezone( + obj.attempt_time, self.context["tenant"] + ) + return "NA" + + def get_session_expired_at(self, obj): + if obj.session_expired_at: + return get_datetime_str_in_tenant_timezone( + obj.session_expired_at, self.context["tenant"] + ) + + return "NA" + + def get_user(self, obj): + return obj.user.name if obj.user else "NA" + + def get_role(self, obj): + return obj.role.name if obj.role else "NA" + + def get_is_login_successful(self, obj): + return "Successful" if obj.is_login_successful else "Failed" + + class Meta: + model = AppAccessLog + fields = [ + "id", + "ip_address", + "user", + "attempt_type", + "attempt_time", + "role", + "user_agent", + "is_login_successful", + "session_expired_at", + ] diff --git a/backend/src/zelthy/api/platform/access_logs/v1/urls.py b/backend/src/zelthy/api/platform/access_logs/v1/urls.py new file mode 100644 index 000000000..3cac6c537 --- /dev/null +++ b/backend/src/zelthy/api/platform/access_logs/v1/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from .views import AccessLogViewAPIV1 + +urlpatterns = [ + path( + "", + AccessLogViewAPIV1.as_view(), + name="accesslog-apiv1-accessloglistview", + ), +] diff --git a/backend/src/zelthy/api/platform/access_logs/v1/views.py b/backend/src/zelthy/api/platform/access_logs/v1/views.py new file mode 100644 index 000000000..160e2f392 --- /dev/null +++ b/backend/src/zelthy/api/platform/access_logs/v1/views.py @@ -0,0 +1,161 @@ +import csv +import json +import pytz +import traceback +from datetime import datetime + +from django.db.models import Q +from django.utils.decorators import method_decorator + +from zelthy.core.utils import get_search_columns +from zelthy.core.api.utils import ZelthyAPIPagination +from zelthy.apps.access_logs.models import AppAccessLog +from zelthy.core.common_utils import set_app_schema_path +from zelthy.apps.shared.tenancy.models import TenantModel +from zelthy.core.api import get_api_response, ZelthyGenericPlatformAPIView + +from .serializers import AccessLogSerializerModel + + +@method_decorator(set_app_schema_path, name="dispatch") +class AccessLogViewAPIV1(ZelthyGenericPlatformAPIView, ZelthyAPIPagination): + pagination_class = ZelthyAPIPagination + + def process_timestamp(self, timestamp, timezone): + try: + ts = json.loads(timestamp) + tz = pytz.timezone(timezone) + ts["start"] = tz.localize( + datetime.strptime(ts["start"] + "-" + "00:00", "%Y-%m-%d-%H:%M"), + is_dst=None, + ) + ts["end"] = tz.localize( + datetime.strptime(ts["end"] + "-" + "23:59", "%Y-%m-%d-%H:%M"), + is_dst=None, + ) + return ts + except Exception: + return None + + def process_id(self, id): + try: + return int(id) + except ValueError: + return None + + def get_queryset(self, search, tenant, columns={}): + + field_name_query_mapping = { + "id": "id", + "user": "user__name__icontains", + "user_agent": "user_agent__icontains", + } + search_filters = { + "id": self.process_id, + "attempt_time": self.process_timestamp, + } + + records = AppAccessLog.objects.all().order_by("-id") + + if search == "" and columns == {}: + return records + + filters = Q() + for field_name, query in field_name_query_mapping.items(): + if search: + if search_filters.get(field_name, None): + filters |= Q(**{query: search_filters[field_name](search)}) + else: + filters |= Q(**{query: search}) + records = records.filter(filters).distinct() + + if columns.get("attempt_time"): + processed = self.process_timestamp( + columns.get("attempt_time"), tenant.timezone + ) + if processed is not None: + records = records.filter( + attempt_time__gte=processed["start"], + attempt_time__lte=processed["end"], + ) + if columns.get("attempt_type"): + records = records.filter(attempt_type=columns.get("attempt_type")) + + if columns.get("is_login_successful") != None: + records = records.filter( + is_login_successful=columns.get("is_login_successful") + ) + + if columns.get("role"): + records = records.filter(role=columns.get("role")) + + return records + + def get_dropdown_options(self): + options = {} + options["attempt_type"] = [ + { + "id": "login", + "label": "Login", + }, + { + "id": "switch_role", + "label": "Switch Role", + }, + ] + options["is_login_successful"] = [ + { + "id": True, + "label": "Successful", + }, + { + "id": False, + "label": "Failed", + }, + ] + role_list = list( + AppAccessLog.objects.all() + .values_list("role__id", "role__name") + .order_by("role__name") + .distinct() + ) + for user_role in role_list: + options["role"].append( + { + "id": user_role[0], + "label": user_role[1], + } + ) + return options + + def get(self, request, *args, **kwargs): + try: + app_uuid = kwargs.get("app_uuid") + tenant = TenantModel.objects.get(uuid=app_uuid) + include_dropdown_options = request.GET.get("include_dropdown_options") + search = request.GET.get("search", None) + columns = get_search_columns(request) + access_logs = self.get_queryset(search, tenant, columns) + paginated_access_logs = self.paginate_queryset( + access_logs, request, view=self + ) + serializer = AccessLogSerializerModel( + paginated_access_logs, many=True, context={"tenant": tenant} + ) + accesslogs = self.get_paginated_response_data(serializer.data) + success = True + response = { + "audit_logs": accesslogs, + "message": "Access logs fetched successfully", + } + if include_dropdown_options: + response["dropdown_options"] = self.get_dropdown_options() + + status = 200 + + except Exception as e: + traceback.print_exc() + success = False + response = {"message": str(e)} + status = 500 + return get_api_response(success, response, status) diff --git a/backend/src/zelthy/api/platform/tenancy/v1/urls.py b/backend/src/zelthy/api/platform/tenancy/v1/urls.py index e7b4921f8..b9de9b28f 100644 --- a/backend/src/zelthy/api/platform/tenancy/v1/urls.py +++ b/backend/src/zelthy/api/platform/tenancy/v1/urls.py @@ -15,6 +15,7 @@ from zelthy.api.platform.packages.v1 import urls as packages_v1_urls from zelthy.api.platform.tasks.v1 import urls as tasks_v1_urls from zelthy.api.platform.codeassist.v1 import urls as codeassist_v1_urls +from zelthy.api.platform.access_logs.v1 import urls as access_logs_v1_urls urlpatterns = [ @@ -57,5 +58,6 @@ re_path(r"^(?P[\w-]+)/packages/$", include(packages_v1_urls)), re_path(r"^(?P[\w-]+)/tasks/", include(tasks_v1_urls)), re_path(r"^(?P[\w-]+)/code-assist/", include(codeassist_v1_urls)), + re_path(r"^(?P[\w-]+)/access-logs/", include(access_logs_v1_urls)), path("", include(permissions_v1_urls)), ] From b1f520eec34c73d60b883f95d1df377ac6f9123f Mon Sep 17 00:00:00 2001 From: Harsh Shah Date: Sat, 27 Apr 2024 01:54:16 +0530 Subject: [PATCH 7/9] refactor: error_pages moved to core templates feat: cooloff time dynamic in lockout template --- .../tenancy/templatetags/zcore_filters.py | 39 ++++++++++++++++++ backend/src/zelthy/config/settings/base.py | 9 +++-- .../zelthy/core/templates/exception_base.html | 40 ------------------- .../core/error_pages/account_lockout.html} | 9 ++--- .../core/error_pages/base.html} | 0 5 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py delete mode 100644 backend/src/zelthy/core/templates/exception_base.html rename backend/src/zelthy/{apps/access_logs/templates/account_lock_out.html => templates/core/error_pages/account_lockout.html} (82%) rename backend/src/zelthy/{apps/access_logs/templates/exception_base.html => templates/core/error_pages/base.html} (100%) diff --git a/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py b/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py new file mode 100644 index 000000000..0f26042cb --- /dev/null +++ b/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py @@ -0,0 +1,39 @@ +""" +Module containing custom template filters +""" +from django import template + +register = template.Library() + + +@register.filter() +def humanize_timedelta(timedeltaobj): + """ + Calculate the total number of days, hours, minutes, and seconds from the given timedelta object. + + Parameters: + timedeltaobj (timedelta): A timedelta object representing a duration of time. + + Returns: + str: A formatted string with the total days, hours, minutes, and seconds calculated from the timedelta object. + """ + secs = timedeltaobj.total_seconds() + timetot = "" + if secs > 86400: # 60sec * 60min * 24hrs + days = secs // 86400 + timetot += "{} days".format(int(days)) + secs = secs - days * 86400 + + if secs > 3600: + hrs = secs // 3600 + timetot += " {} hours".format(int(hrs)) + secs = secs - hrs * 3600 + + if secs > 60: + mins = secs // 60 + timetot += " {} minutes".format(int(mins)) + secs = secs - mins * 60 + + if secs > 0: + timetot += " {} seconds".format(int(secs)) + return timetot diff --git a/backend/src/zelthy/config/settings/base.py b/backend/src/zelthy/config/settings/base.py index 3878074a6..fd0a07234 100644 --- a/backend/src/zelthy/config/settings/base.py +++ b/backend/src/zelthy/config/settings/base.py @@ -113,7 +113,10 @@ # ], "loaders": [ "zelthy.core.template_loader.AppTemplateLoader", - "django.template.loaders.filesystem.Loader", + ( + "django.template.loaders.filesystem.Loader", + [os.path.join(os.path.dirname(zelthy.__file__), "templates")], + ), "django.template.loaders.app_directories.Loader", ], }, @@ -172,8 +175,8 @@ AXES_ENABLED = True AXES_DISABLE_SUCCESS_ACCESS_LOG = True -AXES_LOCKOUT_TEMPLATE = "account_lock_out.html" +AXES_LOCKOUT_TEMPLATE = "core/error_pages/account_lockout.html" AXES_BEHIND_REVERSE_PROXY = False AXES_FAILURE_LIMIT = 6 AXES_LOCK_OUT_AT_FAILURE = True -AXES_COOLOFF_TIME = 900 +AXES_COOLOFF_TIME = timedelta(seconds=900) diff --git a/backend/src/zelthy/core/templates/exception_base.html b/backend/src/zelthy/core/templates/exception_base.html deleted file mode 100644 index b5a1768d3..000000000 --- a/backend/src/zelthy/core/templates/exception_base.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load i18n %} -{% load i18n static %} - - - - - - - - - - - - - - - - - - - - {% block head %} - - {% endblock %} - - - {% block content %} - - {% endblock %} - - \ No newline at end of file diff --git a/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html b/backend/src/zelthy/templates/core/error_pages/account_lockout.html similarity index 82% rename from backend/src/zelthy/apps/access_logs/templates/account_lock_out.html rename to backend/src/zelthy/templates/core/error_pages/account_lockout.html index 5d2ecef50..8b25dfedb 100644 --- a/backend/src/zelthy/apps/access_logs/templates/account_lock_out.html +++ b/backend/src/zelthy/templates/core/error_pages/account_lockout.html @@ -1,14 +1,13 @@ -{% extends 'exception_base.html' %} +{% extends 'core/error_pages/base.html' %} {% load i18n static %} - -{% block title %}Zelthy{% endblock %} - +{% load zcore_filters %} {% block head %} +{% block title %}Account Locked{% endblock %} {% endblock %} @@ -22,7 +21,7 @@
Account Locked
- Oops, your account/ IP address has been temporarily locked as there has been too many incorrect password attempts. Don't worry, this will be automatically reset after 15 minutes. + Oops, your account/ IP address has been temporarily locked as there has been too many incorrect password attempts. Don't worry, this will be automatically reset after {{cooloff_timedelta|humanize_timedelta}}.
diff --git a/backend/src/zelthy/apps/access_logs/templates/exception_base.html b/backend/src/zelthy/templates/core/error_pages/base.html similarity index 100% rename from backend/src/zelthy/apps/access_logs/templates/exception_base.html rename to backend/src/zelthy/templates/core/error_pages/base.html From 545813f5d6b47575b869823670affc4b51dc5166 Mon Sep 17 00:00:00 2001 From: Harsh Shah Date: Sat, 27 Apr 2024 02:02:04 +0530 Subject: [PATCH 8/9] rename variables --- .../tenancy/templatetags/zcore_filters.py | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py b/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py index 0f26042cb..ae8e8375a 100644 --- a/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py +++ b/backend/src/zelthy/apps/shared/tenancy/templatetags/zcore_filters.py @@ -7,33 +7,33 @@ @register.filter() -def humanize_timedelta(timedeltaobj): +def humanize_timedelta(timedelta_obj): """ Calculate the total number of days, hours, minutes, and seconds from the given timedelta object. Parameters: - timedeltaobj (timedelta): A timedelta object representing a duration of time. + timedelta_obj (timedelta): A timedelta object representing a duration of time. Returns: str: A formatted string with the total days, hours, minutes, and seconds calculated from the timedelta object. """ - secs = timedeltaobj.total_seconds() - timetot = "" - if secs > 86400: # 60sec * 60min * 24hrs - days = secs // 86400 - timetot += "{} days".format(int(days)) - secs = secs - days * 86400 - - if secs > 3600: - hrs = secs // 3600 - timetot += " {} hours".format(int(hrs)) - secs = secs - hrs * 3600 - - if secs > 60: - mins = secs // 60 - timetot += " {} minutes".format(int(mins)) - secs = secs - mins * 60 - - if secs > 0: - timetot += " {} seconds".format(int(secs)) - return timetot + seconds = timedelta_obj.total_seconds() + time_total = "" + if seconds > 86400: # 60sec * 60min * 24hrs + days = seconds // 86400 + time_total += "{} days".format(int(days)) + seconds = seconds - days * 86400 + + if seconds > 3600: + hrs = seconds // 3600 + time_total += " {} hours".format(int(hrs)) + seconds = seconds - hrs * 3600 + + if seconds > 60: + mins = seconds // 60 + time_total += " {} minutess".format(int(mins)) + seconds = seconds - mins * 60 + + if seconds > 0: + time_total += " {} seconds".format(int(seconds)) + return time_total From 9b06f03cfe0443bf821605145ad75bd03e8f6f09 Mon Sep 17 00:00:00 2001 From: deepaksuresh2411 Date: Sat, 27 Apr 2024 12:47:07 +0530 Subject: [PATCH 9/9] wip(accesslogs): update requirements --- backend/requirements/base.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index be0abaf45..e1fbe239a 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -27,4 +27,6 @@ XlsxWriter==3.1.9 pydub==0.25.1 django-celery-results==2.5.1 django-environ==0.11.2 -pytz==2024.1 \ No newline at end of file +pytz==2024.1 +django-axes==6.4.0 +django-ipware==6.0.4 \ No newline at end of file