From d3348cc4deee2f3674efd4b02ae88380364be820 Mon Sep 17 00:00:00 2001 From: Klaus-Uwe Mitterer Date: Mon, 1 Jun 2020 18:29:22 +0200 Subject: [PATCH] Preparing for actual products... IP Rate Limit "firewall" --- core/classes/products.py | 40 +++++++++------- core/helpers/auth.py | 4 +- core/mixins/auth.py | 2 + core/models/auth.py | 10 ++-- core/models/products.py | 10 ++-- core/modules/navigation.py | 2 +- core/modules/products.py | 15 ++++++ core/modules/urls.py | 11 +++++ core/navigation.py | 5 +- core/products.py | 17 +++++++ core/views/__init__.py | 1 + core/views/auth.py | 24 +++++----- core/views/brands.py | 12 ++--- core/views/firewall.py | 46 +++++++++++++++++++ start_celery.sh | 1 + start_celery_beat.sh | 1 + templates/backend/firewall/create.html | 57 +++++++++++++++++++++++ templates/backend/firewall/delete.html | 57 +++++++++++++++++++++++ templates/backend/firewall/index.html | 63 ++++++++++++++++++++++++++ templates/backend/firewall/update.html | 57 +++++++++++++++++++++++ 20 files changed, 387 insertions(+), 48 deletions(-) create mode 100644 core/modules/products.py create mode 100644 core/products.py create mode 100644 core/views/firewall.py create mode 100644 start_celery.sh create mode 100644 start_celery_beat.sh create mode 100644 templates/backend/firewall/create.html create mode 100644 templates/backend/firewall/delete.html create mode 100644 templates/backend/firewall/index.html create mode 100644 templates/backend/firewall/update.html diff --git a/core/classes/products.py b/core/classes/products.py index a17a1bb..1f0e353 100644 --- a/core/classes/products.py +++ b/core/classes/products.py @@ -1,23 +1,31 @@ -class BaseProductHandler: - def __init__(self, product_id): - self.product_id = product_id +class BaseProductType: + def __init__(self, product_type_id): + self.id = product_type_id @property + def name(self): + return "Base Product Type" + + @property + def id(self): + return "" + def is_creatable(self): - '''Returns True if this product can be created right now, or False if it cannot. Should return True if the module is correctly configured, the product is in stock (if physical), etc.''' + '''Returns True if a product can be created right now using this type, or False if it cannot. Should return True if the module is correctly configured, etc.''' return False - @property - def setup_view(self): - '''Returns a view in which the product can be configured, or None if no configuration is possible/necessary.''' - return None - -class FallbackProductHandler(BaseProductHandler): - pass - -class CoreProductHandler(BaseProductHandler): - @property - def is_creatable(self): + def is_orderable(self, product): + '''Returns True if this product can be ordered right now. Should return True if the product is in stock, etc.''' return True -ProductHandler = CoreProductHandler \ No newline at end of file + def orderable_by_user(self, product): + '''Returns a tuple of boolean values (user is allowed to order this, override by administrator allowed). First boolean is True if the user can order this product. If the first boolean is False, the second boolean indicates if an administrator can provide the product manually nonetheless.''' + return self.is_orderable() + + def product_setup_view(self): + '''Returns a view in which a new product can be configured, or None if no configuration is possible/necessary.''' + return None + + def service_setup_view(self, product): + '''Returns a view in which a new service can be configured, or None if no configuration is possible/necessary.''' + return True diff --git a/core/helpers/auth.py b/core/helpers/auth.py index 2615ec5..d5f13ca 100644 --- a/core/helpers/auth.py +++ b/core/helpers/auth.py @@ -1,6 +1,6 @@ from core.helpers.mail import get_template, simple_send_mail from core.helpers.urls import relative_to_absolute as reltoabs -from core.models.auth import LoginLog, PWResetToken +from core.models.auth import LoginLog, PWResetToken, IPLimit from core.helpers.request import get_client_ip from django.urls import reverse @@ -28,7 +28,7 @@ def clear_login_log(maxage=int(getValue("core.auth.ratelimit.period", 600))): def clear_ratelimits(maxage=int(getValue("core.auth.ratelimit.block", 3600))): timestamp = timezone.now() - timezone.timedelta(seconds=maxage) - LoginLog.objects.filter(timestamp__lt=timestamp).delete() + IPLimit.objects.filter(end__lt=timestamp).delete() def request_password(user): token = PWResetToken.objects.create(user=user) diff --git a/core/mixins/auth.py b/core/mixins/auth.py index 03977d9..717cf47 100644 --- a/core/mixins/auth.py +++ b/core/mixins/auth.py @@ -1,9 +1,11 @@ from django.contrib.auth.mixins import AccessMixin from django.contrib.messages import error +from django.views.decorators.cache import never_cache from core.models.profiles import AdminProfile class AdminMixin(AccessMixin): + @never_cache def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: self.permission_denied_message = "You must be logged in to access this area." diff --git a/core/models/auth.py b/core/models/auth.py index 2f3eded..41cba74 100644 --- a/core/models/auth.py +++ b/core/models/auth.py @@ -3,6 +3,8 @@ from django.contrib.auth import get_user_model from dbsettings.functions import getValue +from core.fields.base import LongCharField + from uuid import uuid4 from datetime import timedelta @@ -24,9 +26,5 @@ class LoginLog(Model): class IPLimit(Model): ip = GenericIPAddressField() - start = DateTimeField(auto_now_add=True) - - @property - def end(self): - delta = timedelta(seconds=int(getValue("core.auth.ratelimit.block", 3600))) - return self.start + delta \ No newline at end of file + end = DateTimeField() + reason = LongCharField(null=True, blank=True) diff --git a/core/models/products.py b/core/models/products.py index 75ed463..7cf3a58 100644 --- a/core/models/products.py +++ b/core/models/products.py @@ -15,17 +15,17 @@ class ProductGroup(Model): class Product(Model): name = LongCharField() description = TextField(null=True, blank=True) - handler_module = LongCharField(null=True, blank=True) + product_type = LongCharField(null=True, blank=True) product_groups = ManyToManyField(ProductGroup) @property def handler(self): - if self.handler_module: + if self.product_type: try: - handler_module = import_module(self.handler_module) - return handler_module.ProductRouter(self.id) + product_type = import_module(self.product_type) + return product_type.ProductRouter(self.id) except Exception as e: - logger.error(f"Could not load product handler {self.handler_module} for product {self.id}: {e}") + logger.error(f"Could not load product type {self.product_type} for product {self.id}: {e}") return None class ProductPlan(Model): diff --git a/core/modules/navigation.py b/core/modules/navigation.py index 15bf1b6..89b5d79 100644 --- a/core/modules/navigation.py +++ b/core/modules/navigation.py @@ -2,7 +2,7 @@ import importlib from django.conf import settings -from core.classes.navigation import NavItem, NavSection, Navigation +from core.classes.navigation import Navigation navigations = { "backend_main": Navigation(), diff --git a/core/modules/products.py b/core/modules/products.py new file mode 100644 index 0000000..63a80c1 --- /dev/null +++ b/core/modules/products.py @@ -0,0 +1,15 @@ +import importlib + +from django.conf import settings + +producttypes = {} + +for module in ["core"] + settings.EXPEPHALON_MODULES: + try: + mop = importlib.import_module(f"{module}.products") + for name, ptype in mop.PRODUCTTYPES: + if name in producttypes.keys: + raise ValueError(f"Error in {module}: Product type of name {name} already exists!") + producttypes[name] = ptype + except (AttributeError, ModuleNotFoundError): + continue \ No newline at end of file diff --git a/core/modules/urls.py b/core/modules/urls.py index d661053..52beaed 100644 --- a/core/modules/urls.py +++ b/core/modules/urls.py @@ -25,6 +25,10 @@ from core.views import ( BrandDeleteView, BrandEditView, BrandListView, + RateLimitCreateView, + RateLimitDeleteView, + RateLimitEditView, + RateLimitListView, ) URLPATTERNS = [] @@ -65,6 +69,13 @@ URLPATTERNS.append(path("admin/brands//delete/", BrandDeleteView.as_view(), URLPATTERNS.append(path("admin/brands//edit/", BrandEditView.as_view(), name="brands_edit")) URLPATTERNS.append(path("admin/brands/create/", BrandCreateView.as_view(), name="brands_create")) +# Rate Limit Administration URLs + +URLPATTERNS.append(path('admin/firewall/', RateLimitListView.as_view(), name="ratelimits")) +URLPATTERNS.append(path("admin/firewall//delete/", RateLimitDeleteView.as_view(), name="ratelimits_delete")) +URLPATTERNS.append(path("admin/firewall//edit/", RateLimitEditView.as_view(), name="ratelimits_edit")) +URLPATTERNS.append(path("admin/firewall/create/", RateLimitCreateView.as_view(), name="ratelimits_create")) + # External Module URLs for module in settings.EXPEPHALON_MODULES: diff --git a/core/navigation.py b/core/navigation.py index 2261a18..e800ac4 100644 --- a/core/navigation.py +++ b/core/navigation.py @@ -1,4 +1,5 @@ -from core.modules.navigation import navigations, NavSection, NavItem +from core.modules.navigation import navigations +from core.classes.navigation import NavSection, NavItem from django.conf import settings @@ -90,6 +91,7 @@ administration_section = NavSection("Administration", "") user_administration_item = NavItem("Administrator Users", "fa-users-cog", "admins") brand_administration_item = NavItem("Brands", "fa-code-branch", "brands") +ratelimit_administration_item = NavItem("Firewall", "fa-shield-alt", "ratelimits") sms_administration_item = NavItem("SMS Gateway", "fa-sms", "backendni") otp_administration_item = NavItem("Two-Factor Authentication", "fa-id-badge", "backendni") backup_administration_item = NavItem("Backups", "fa-shield-alt", "backendni") @@ -100,6 +102,7 @@ dbsettings_item = NavItem("Database Settings", "fa-database", "dbsettings") administration_section.add_item(user_administration_item) administration_section.add_item(brand_administration_item) +administration_section.add_item(ratelimit_administration_item) administration_section.add_item(sms_administration_item) administration_section.add_item(otp_administration_item) administration_section.add_item(backup_administration_item) diff --git a/core/products.py b/core/products.py new file mode 100644 index 0000000..243e159 --- /dev/null +++ b/core/products.py @@ -0,0 +1,17 @@ +from core.classes.products import BaseProductType + +PRODUCTTYPES = {} + +class CoreProductType(BaseProductType): + @property + def name(self): + return "Basic Product" + + @property + def id(self): + return "core.products.core" + + def is_creatable(self): + return True + +PRODUCTTYPES[CoreProductType().id()] = CoreProductType \ No newline at end of file diff --git a/core/views/__init__.py b/core/views/__init__.py index 69dfb47..8c2b25a 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -7,6 +7,7 @@ from core.views.auth import * from core.views.profiles import * from core.views.generic import * from core.views.brands import * +from core.views.firewall import * from core.mixins.auth import AdminMixin # Create your views here. diff --git a/core/views/auth.py b/core/views/auth.py index 2ef9222..da4ba48 100644 --- a/core/views/auth.py +++ b/core/views/auth.py @@ -5,6 +5,8 @@ from django.shortcuts import redirect from django.core.exceptions import PermissionDenied from django.contrib import messages from django.utils import timezone +from django.db.models import Max +from django.views.decorators.cache import never_cache from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm, PWRequestForm from core.models.auth import LoginSession, PWResetToken, IPLimit, LoginLog @@ -18,25 +20,25 @@ from dbsettings.functions import getValue class RateLimitedView(TemplateView): template_name = f"{settings.EXPEPHALON_BACKEND}/auth/ratelimit.html" + @never_cache def dispatch(self, request, *args, **kwargs): - for iplimit in list(IPLimit.objects.filter(ip=get_client_ip(request))): - if iplimit.end >= timezone.now(): - messages.error(request, f"Sorry, there have been to many failed login attempts from your IP. Please try again after {str(iplimit.end)}, or contact support if you need help getting into your account.") - return super().dispatch(request, *args, **kwargs) - return redirect("login") + try: + limit = IPLimit.objects.filter(ip=get_client_ip(request), end__gte=timezone.now()).first() + messages.error(request, f"Sorry, your IP has been blocked, so you cannot login at the moment.{f' Reason: {limit.reason}' if limit.reason else ''} Please try again after {str(iplimit.end)}, or contact support if you need help getting into your account.") + return super().dispatch(request, *args, **kwargs) + except: + return redirect("login") class AuthView(FormView): + @never_cache def dispatch(self, request, *args, **kwargs): - limits = list(IPLimit.objects.filter(ip=get_client_ip(request))) - - for limit in limits: - if limit.end > timezone.now(): - return redirect("ratelimited") + if IPLimit.objects.filter(ip=get_client_ip(request), end__gte=timezone.now()): + return redirect("ratelimited") period = timezone.now() - timezone.timedelta(seconds=int(getValue("core.auth.ratelimit.period", 600))) failures = LoginLog.objects.filter(ip=get_client_ip(request), success=False, timestamp__gte=period) if len(failures) >= int(getValue("core.auth.ratelimit.attempts", 5)): - IPLimit.objects.create(ip=get_client_ip(request)) + IPLimit.objects.create(ip=get_client_ip(request), end=timezone.now() + timezone.timedelta(seconds=getValue("core.auth.ratelimit.block", 3600)), reason="Too many failed login attempts.") return redirect("ratelimited") return super().dispatch(request, *args, **kwargs) diff --git a/core/views/brands.py b/core/views/brands.py index ebd9f22..72e3a85 100644 --- a/core/views/brands.py +++ b/core/views/brands.py @@ -16,8 +16,8 @@ class BrandListView(BackendListView): class BrandEditView(BackendUpdateView): template_name = f"{settings.EXPEPHALON_BACKEND}/brands/update.html" model = Brand - success_url = reverse_lazy("dbsettings") - fields = ["key", "value"] + success_url = reverse_lazy("brands") + fields = ["name", "logo", "address1", "address2", "zip", "city", "state", "country", "vat_id", "company_id"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -27,7 +27,7 @@ class BrandEditView(BackendUpdateView): class BrandDeleteView(BackendDeleteView): template_name = f"{settings.EXPEPHALON_BACKEND}/brands/delete.html" model = Brand - success_url = reverse_lazy("dbsettings") + success_url = reverse_lazy("brands") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -35,10 +35,10 @@ class BrandDeleteView(BackendDeleteView): return context class BrandCreateView(BackendCreateView): - template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings/create.html" + template_name = f"{settings.EXPEPHALON_BACKEND}/brands/create.html" model = Brand - success_url = reverse_lazy("dbsettings") - fields = ["key", "value"] + success_url = reverse_lazy("brands") + fields = ["name", "logo", "address1", "address2", "zip", "city", "state", "country", "vat_id", "company_id"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/core/views/firewall.py b/core/views/firewall.py new file mode 100644 index 0000000..43eef93 --- /dev/null +++ b/core/views/firewall.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.urls import reverse_lazy + +from core.models.auth import IPLimit +from core.views.generic import BackendListView, BackendUpdateView, BackendDeleteView, BackendCreateView + +class RateLimitListView(BackendListView): + template_name = f"{settings.EXPEPHALON_BACKEND}/firewall/index.html" + model = IPLimit + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = "Firewall Settings" + return context + +class RateLimitEditView(BackendUpdateView): + template_name = f"{settings.EXPEPHALON_BACKEND}/firewall/update.html" + model = IPLimit + success_url = reverse_lazy("ratelimits") + fields = ["ip", "end", "reason"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = "Edit Rule" + return context + +class RateLimitDeleteView(BackendDeleteView): + template_name = f"{settings.EXPEPHALON_BACKEND}/firewall/delete.html" + model = IPLimit + success_url = reverse_lazy("ratelimits") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = "Delete Rule" + return context + +class RateLimitCreateView(BackendCreateView): + template_name = f"{settings.EXPEPHALON_BACKEND}/firewall/create.html" + model = IPLimit + success_url = reverse_lazy("ratelimits") + fields = ["name", "logo", "address1", "address2", "zip", "city", "state", "country", "vat_id", "company_id"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = "Create Rule" + return context diff --git a/start_celery.sh b/start_celery.sh new file mode 100644 index 0000000..f84cab9 --- /dev/null +++ b/start_celery.sh @@ -0,0 +1 @@ +celery -A expephalon worker --loglevel=info diff --git a/start_celery_beat.sh b/start_celery_beat.sh new file mode 100644 index 0000000..205fa71 --- /dev/null +++ b/start_celery_beat.sh @@ -0,0 +1 @@ +celery -A expephalon beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler diff --git a/templates/backend/firewall/create.html b/templates/backend/firewall/create.html new file mode 100644 index 0000000..69cfc3e --- /dev/null +++ b/templates/backend/firewall/create.html @@ -0,0 +1,57 @@ +{% extends "backend/base.html" %} +{% load bootstrap4 %} +{% block content %} +
+
+
+
+ + +
+
Firewall - Create Rule +
Create a new firewall rule +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + Create Rule +
+
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + + Cancel + + {% endbuttons %} +
+
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/backend/firewall/delete.html b/templates/backend/firewall/delete.html new file mode 100644 index 0000000..c8714f7 --- /dev/null +++ b/templates/backend/firewall/delete.html @@ -0,0 +1,57 @@ +{% extends "backend/base.html" %} +{% load bootstrap4 %} +{% block content %} +
+
+
+
+ + +
+
Firewall Rules - Delete Rule +
Delete a rule from the system +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + Deleting rule for {{ object.ip }} +
+
+
+
+
+
+ {% csrf_token %} + Are you sure you wish to delete the rule for {{ object.ip }}? + {% buttons %} + + + Cancel + + {% endbuttons %} +
+
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/backend/firewall/index.html b/templates/backend/firewall/index.html new file mode 100644 index 0000000..9e04695 --- /dev/null +++ b/templates/backend/firewall/index.html @@ -0,0 +1,63 @@ +{% extends "backend/base.html" %} +{% block content %} +
+
+
+
+ + +
+
Firewall Rules +
Create, edit and delete rules +
+
+
+ +
+
+
+
+
+
+
+ + Active Rules +
+
+
+
+
+
+ + + + + + + + + + + {% for rule in object_list %} + + + + + + + {% endfor %} + +
IPEnd of blockReasonOptions
{{ rule.ip }}{{ rule.end }}{{ rule.reason }}
+
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/backend/firewall/update.html b/templates/backend/firewall/update.html new file mode 100644 index 0000000..5ebb4b0 --- /dev/null +++ b/templates/backend/firewall/update.html @@ -0,0 +1,57 @@ +{% extends "backend/base.html" %} +{% load bootstrap4 %} +{% block content %} +
+
+
+
+ + +
+
Brands - Edit Brand +
Edit brand properties +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + Editing {{ object.name }} +
+
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + + Cancel + + {% endbuttons %} +
+
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file