arthexis 0.1.9__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

pages/views.py CHANGED
@@ -1,7 +1,9 @@
1
+ import base64
1
2
  import logging
2
3
  from pathlib import Path
3
4
  import datetime
4
5
  import calendar
6
+ import io
5
7
  import shutil
6
8
  import re
7
9
  from html import escape
@@ -25,13 +27,16 @@ from django.utils import timezone
25
27
  from django.utils.encoding import force_bytes, force_str
26
28
  from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
27
29
  from core import mailer, public_wifi
30
+ from core.backends import TOTP_DEVICE_NAME
28
31
  from django.utils.translation import gettext as _
29
32
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
33
+ from django.core.cache import cache
30
34
  from django.views.decorators.cache import never_cache
31
35
  from django.utils.cache import patch_vary_headers
32
36
  from django.core.exceptions import PermissionDenied
33
37
  from django.utils.text import slugify
34
38
  from django.core.validators import EmailValidator
39
+ from django.db.models import Q
35
40
  from core.models import InviteLead, ClientReport, ClientReportSchedule
36
41
 
37
42
  try: # pragma: no cover - optional dependency guard
@@ -44,6 +49,10 @@ except ImportError: # pragma: no cover - handled gracefully in views
44
49
  import markdown
45
50
  from pages.utils import landing
46
51
  from core.liveupdate import live_update
52
+ from django_otp import login as otp_login
53
+ from django_otp.plugins.otp_totp.models import TOTPDevice
54
+ import qrcode
55
+ from .forms import AuthenticatorEnrollmentForm, AuthenticatorLoginForm
47
56
  from .models import Module
48
57
 
49
58
 
@@ -67,9 +76,13 @@ def _filter_models_for_request(models, request):
67
76
  model_admin = admin.site._registry.get(model)
68
77
  if model_admin is None:
69
78
  continue
70
- if not model_admin.has_module_permission(request):
79
+ if not model_admin.has_module_permission(request) and not getattr(
80
+ request.user, "is_staff", False
81
+ ):
71
82
  continue
72
- if not model_admin.has_view_permission(request, obj=None):
83
+ if not model_admin.has_view_permission(request, obj=None) and not getattr(
84
+ request.user, "is_staff", False
85
+ ):
73
86
  continue
74
87
  allowed.append(model)
75
88
  return allowed
@@ -80,8 +93,13 @@ def _admin_has_app_permission(request, app_label: str) -> bool:
80
93
 
81
94
  has_app_permission = getattr(admin.site, "has_app_permission", None)
82
95
  if callable(has_app_permission):
83
- return has_app_permission(request, app_label)
84
- return bool(admin.site.get_app_list(request, app_label))
96
+ allowed = has_app_permission(request, app_label)
97
+ else:
98
+ allowed = bool(admin.site.get_app_list(request, app_label))
99
+
100
+ if not allowed and getattr(request.user, "is_staff", False):
101
+ return True
102
+ return allowed
85
103
 
86
104
 
87
105
  def _resolve_related_model(field, default_app_label: str):
@@ -454,6 +472,7 @@ class CustomLoginView(LoginView):
454
472
  """Login view that redirects staff to the admin."""
455
473
 
456
474
  template_name = "pages/login.html"
475
+ form_class = AuthenticatorLoginForm
457
476
 
458
477
  def dispatch(self, request, *args, **kwargs):
459
478
  if request.user.is_authenticated:
@@ -481,13 +500,165 @@ class CustomLoginView(LoginView):
481
500
  return reverse("admin:index")
482
501
  return "/"
483
502
 
503
+ def form_valid(self, form):
504
+ response = super().form_valid(form)
505
+ device = form.get_verified_device()
506
+ if device is not None:
507
+ otp_login(self.request, device)
508
+ return response
509
+
484
510
 
485
511
  login_view = CustomLoginView.as_view()
486
512
 
487
513
 
514
+ @staff_member_required
515
+ def authenticator_setup(request):
516
+ """Allow staff to enroll an authenticator app for TOTP logins."""
517
+
518
+ user = request.user
519
+ device_qs = TOTPDevice.objects.filter(user=user)
520
+ if TOTP_DEVICE_NAME:
521
+ device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
522
+
523
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
524
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
525
+ enrollment_form = AuthenticatorEnrollmentForm(device=pending_device)
526
+
527
+ if request.method == "POST":
528
+ action = request.POST.get("action")
529
+ if action == "generate":
530
+ device = pending_device or confirmed_device or TOTPDevice(user=user)
531
+ if TOTP_DEVICE_NAME:
532
+ device.name = TOTP_DEVICE_NAME
533
+ if device.pk is None:
534
+ device.save()
535
+ device.key = TOTPDevice._meta.get_field("key").get_default()
536
+ device.confirmed = False
537
+ device.drift = 0
538
+ device.last_t = -1
539
+ device.throttling_failure_count = 0
540
+ device.throttling_failure_timestamp = None
541
+ device.throttle_reset(commit=False)
542
+ device.save()
543
+ messages.success(
544
+ request,
545
+ _(
546
+ "Scan the QR code with your authenticator app, then "
547
+ "enter a code below to confirm enrollment."
548
+ ),
549
+ )
550
+ return redirect("pages:authenticator-setup")
551
+ if action == "confirm" and pending_device is not None:
552
+ enrollment_form = AuthenticatorEnrollmentForm(
553
+ request.POST, device=pending_device
554
+ )
555
+ if enrollment_form.is_valid():
556
+ pending_device.confirmed = True
557
+ pending_device.save(update_fields=["confirmed"])
558
+ messages.success(
559
+ request,
560
+ _(
561
+ "Authenticator app confirmed. You can now log in "
562
+ "with codes from your device."
563
+ ),
564
+ )
565
+ return redirect("pages:authenticator-setup")
566
+ if action == "remove":
567
+ if device_qs.exists():
568
+ device_qs.delete()
569
+ messages.success(
570
+ request,
571
+ _(
572
+ "Authenticator enrollment removed. Password logins "
573
+ "remain available."
574
+ ),
575
+ )
576
+ return redirect("pages:authenticator-setup")
577
+
578
+ pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
579
+ confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
580
+
581
+ qr_data_uri = None
582
+ manual_key = None
583
+ if pending_device is not None:
584
+ config_url = pending_device.config_url
585
+ qr = qrcode.QRCode(box_size=10, border=4)
586
+ qr.add_data(config_url)
587
+ qr.make(fit=True)
588
+ image = qr.make_image(fill_color="black", back_color="white")
589
+ buffer = io.BytesIO()
590
+ image.save(buffer, format="PNG")
591
+ qr_data_uri = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode(
592
+ "ascii"
593
+ )
594
+ secret = pending_device.key or ""
595
+ manual_key = " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
596
+
597
+ context = {
598
+ "pending_device": pending_device,
599
+ "confirmed_device": confirmed_device,
600
+ "qr_data_uri": qr_data_uri,
601
+ "manual_key": manual_key,
602
+ "enrollment_form": enrollment_form,
603
+ }
604
+ return TemplateResponse(request, "pages/authenticator_setup.html", context)
605
+
606
+
607
+ INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL = datetime.timedelta(seconds=3)
608
+ INVITATION_REQUEST_THROTTLE_LIMIT = 3
609
+ INVITATION_REQUEST_THROTTLE_WINDOW = datetime.timedelta(hours=1)
610
+ INVITATION_REQUEST_HONEYPOT_MESSAGE = _(
611
+ "We could not process your request. Please try again."
612
+ )
613
+ INVITATION_REQUEST_TOO_FAST_MESSAGE = _(
614
+ "That was a little too fast. Please wait a moment and try again."
615
+ )
616
+ INVITATION_REQUEST_TIMESTAMP_ERROR = _(
617
+ "We could not verify your submission. Please reload the page and try again."
618
+ )
619
+ INVITATION_REQUEST_THROTTLE_MESSAGE = _(
620
+ "We've already received a few requests. Please try again later."
621
+ )
622
+
623
+
488
624
  class InvitationRequestForm(forms.Form):
489
625
  email = forms.EmailField()
490
- comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
626
+ comment = forms.CharField(
627
+ required=False, widget=forms.Textarea, label=_("Comment")
628
+ )
629
+ honeypot = forms.CharField(
630
+ required=False,
631
+ label=_("Leave blank"),
632
+ widget=forms.TextInput(attrs={"autocomplete": "off"}),
633
+ )
634
+ timestamp = forms.DateTimeField(required=False, widget=forms.HiddenInput())
635
+
636
+ min_submission_interval = INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL
637
+
638
+ def __init__(self, *args, **kwargs):
639
+ super().__init__(*args, **kwargs)
640
+ if not self.is_bound:
641
+ self.fields["timestamp"].initial = timezone.now()
642
+ self.fields["honeypot"].widget.attrs.setdefault("aria-hidden", "true")
643
+ self.fields["honeypot"].widget.attrs.setdefault("tabindex", "-1")
644
+
645
+ def clean(self):
646
+ cleaned = super().clean()
647
+
648
+ honeypot_value = cleaned.get("honeypot", "")
649
+ if honeypot_value:
650
+ raise forms.ValidationError(INVITATION_REQUEST_HONEYPOT_MESSAGE)
651
+
652
+ timestamp = cleaned.get("timestamp")
653
+ if timestamp is None:
654
+ cleaned["timestamp"] = timezone.now()
655
+ return cleaned
656
+
657
+ now = timezone.now()
658
+ if timestamp > now or (now - timestamp) < self.min_submission_interval:
659
+ raise forms.ValidationError(INVITATION_REQUEST_TOO_FAST_MESSAGE)
660
+
661
+ return cleaned
491
662
 
492
663
 
493
664
  @csrf_exempt
@@ -499,68 +670,78 @@ def request_invite(request):
499
670
  email = form.cleaned_data["email"]
500
671
  comment = form.cleaned_data.get("comment", "")
501
672
  ip_address = request.META.get("REMOTE_ADDR")
502
- mac_address = public_wifi.resolve_mac_address(ip_address)
503
- lead = InviteLead.objects.create(
504
- email=email,
505
- comment=comment,
506
- user=request.user if request.user.is_authenticated else None,
507
- path=request.path,
508
- referer=request.META.get("HTTP_REFERER", ""),
509
- user_agent=request.META.get("HTTP_USER_AGENT", ""),
510
- ip_address=ip_address,
511
- mac_address=mac_address or "",
673
+ throttle_filters = Q(email__iexact=email)
674
+ if ip_address:
675
+ throttle_filters |= Q(ip_address=ip_address)
676
+ window_start = timezone.now() - INVITATION_REQUEST_THROTTLE_WINDOW
677
+ recent_requests = InviteLead.objects.filter(
678
+ throttle_filters, created_on__gte=window_start
512
679
  )
513
- logger.info("Invitation requested for %s", email)
514
- User = get_user_model()
515
- users = list(User.objects.filter(email__iexact=email))
516
- if not users:
517
- logger.warning("Invitation requested for unknown email %s", email)
518
- for user in users:
519
- uid = urlsafe_base64_encode(force_bytes(user.pk))
520
- token = default_token_generator.make_token(user)
521
- link = request.build_absolute_uri(
522
- reverse("pages:invitation-login", args=[uid, token])
680
+ if recent_requests.count() >= INVITATION_REQUEST_THROTTLE_LIMIT:
681
+ form.add_error(None, INVITATION_REQUEST_THROTTLE_MESSAGE)
682
+ else:
683
+ mac_address = public_wifi.resolve_mac_address(ip_address)
684
+ lead = InviteLead.objects.create(
685
+ email=email,
686
+ comment=comment,
687
+ user=request.user if request.user.is_authenticated else None,
688
+ path=request.path,
689
+ referer=request.META.get("HTTP_REFERER", ""),
690
+ user_agent=request.META.get("HTTP_USER_AGENT", ""),
691
+ ip_address=ip_address,
692
+ mac_address=mac_address or "",
523
693
  )
524
- subject = _("Your invitation link")
525
- body = _("Use the following link to access your account: %(link)s") % {
526
- "link": link
527
- }
528
- try:
529
- node_error = None
530
- node = Node.get_local()
531
- if node:
532
- try:
533
- result = node.send_mail(subject, body, [email])
534
- except Exception as exc:
535
- node_error = exc
536
- logger.exception(
537
- "Node send_mail failed, falling back to default backend"
538
- )
694
+ logger.info("Invitation requested for %s", email)
695
+ User = get_user_model()
696
+ users = list(User.objects.filter(email__iexact=email))
697
+ if not users:
698
+ logger.warning("Invitation requested for unknown email %s", email)
699
+ for user in users:
700
+ uid = urlsafe_base64_encode(force_bytes(user.pk))
701
+ token = default_token_generator.make_token(user)
702
+ link = request.build_absolute_uri(
703
+ reverse("pages:invitation-login", args=[uid, token])
704
+ )
705
+ subject = _("Your invitation link")
706
+ body = _("Use the following link to access your account: %(link)s") % {
707
+ "link": link
708
+ }
709
+ try:
710
+ node_error = None
711
+ node = Node.get_local()
712
+ if node:
713
+ try:
714
+ result = node.send_mail(subject, body, [email])
715
+ except Exception as exc:
716
+ node_error = exc
717
+ logger.exception(
718
+ "Node send_mail failed, falling back to default backend"
719
+ )
720
+ result = mailer.send(
721
+ subject, body, [email], settings.DEFAULT_FROM_EMAIL
722
+ )
723
+ else:
539
724
  result = mailer.send(
540
725
  subject, body, [email], settings.DEFAULT_FROM_EMAIL
541
726
  )
542
- else:
543
- result = mailer.send(
544
- subject, body, [email], settings.DEFAULT_FROM_EMAIL
545
- )
546
- lead.sent_on = timezone.now()
547
- if node_error:
548
- lead.error = (
549
- f"Node email send failed: {node_error}. "
550
- "Invite was sent using default mail backend; ensure the "
551
- "node's email service is running or check its configuration."
727
+ lead.sent_on = timezone.now()
728
+ if node_error:
729
+ lead.error = (
730
+ f"Node email send failed: {node_error}. "
731
+ "Invite was sent using default mail backend; ensure the "
732
+ "node's email service is running or check its configuration."
733
+ )
734
+ else:
735
+ lead.error = ""
736
+ logger.info(
737
+ "Invitation email sent to %s (user %s): %s", email, user.pk, result
552
738
  )
553
- else:
554
- lead.error = ""
555
- logger.info(
556
- "Invitation email sent to %s (user %s): %s", email, user.pk, result
557
- )
558
- except Exception as exc:
559
- lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
560
- logger.exception("Failed to send invitation email to %s", email)
561
- if lead.sent_on or lead.error:
562
- lead.save(update_fields=["sent_on", "error"])
563
- sent = True
739
+ except Exception as exc:
740
+ lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
741
+ logger.exception("Failed to send invitation email to %s", email)
742
+ if lead.sent_on or lead.error:
743
+ lead.save(update_fields=["sent_on", "error"])
744
+ sent = True
564
745
  return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
565
746
 
566
747
 
@@ -626,30 +807,41 @@ class ClientReportForm(forms.Form):
626
807
  ]
627
808
  RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
628
809
  period = forms.ChoiceField(
629
- choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
810
+ choices=PERIOD_CHOICES,
811
+ widget=forms.RadioSelect,
812
+ initial="range",
813
+ help_text=_("Choose how the reporting window will be calculated."),
630
814
  )
631
815
  start = forms.DateField(
632
816
  label=_("Start date"),
633
817
  required=False,
634
818
  widget=forms.DateInput(attrs={"type": "date"}),
819
+ help_text=_("First day included when using a custom date range."),
635
820
  )
636
821
  end = forms.DateField(
637
822
  label=_("End date"),
638
823
  required=False,
639
824
  widget=forms.DateInput(attrs={"type": "date"}),
825
+ help_text=_("Last day included when using a custom date range."),
640
826
  )
641
827
  week = forms.CharField(
642
828
  label=_("Week"),
643
829
  required=False,
644
830
  widget=forms.TextInput(attrs={"type": "week"}),
831
+ help_text=_("Generates the report for the ISO week that you select."),
645
832
  )
646
833
  month = forms.DateField(
647
834
  label=_("Month"),
648
835
  required=False,
649
836
  widget=forms.DateInput(attrs={"type": "month"}),
837
+ help_text=_("Generates the report for the calendar month that you select."),
650
838
  )
651
839
  owner = forms.ModelChoiceField(
652
- queryset=get_user_model().objects.all(), required=False
840
+ queryset=get_user_model().objects.all(),
841
+ required=False,
842
+ help_text=_(
843
+ "Sets who owns the report schedule and is listed as the requestor."
844
+ ),
653
845
  )
654
846
  destinations = forms.CharField(
655
847
  label=_("Email destinations"),
@@ -661,6 +853,7 @@ class ClientReportForm(forms.Form):
661
853
  label=_("Recurrency"),
662
854
  choices=RECURRENCE_CHOICES,
663
855
  initial=ClientReportSchedule.PERIODICITY_NONE,
856
+ help_text=_("Defines how often the report should be generated automatically."),
664
857
  )
665
858
  disable_emails = forms.BooleanField(
666
859
  label=_("Disable email delivery"),
@@ -723,36 +916,85 @@ def client_report(request):
723
916
  form = ClientReportForm(request.POST or None, request=request)
724
917
  report = None
725
918
  schedule = None
726
- if request.method == "POST" and form.is_valid():
727
- owner = form.cleaned_data.get("owner")
728
- if not owner and request.user.is_authenticated:
729
- owner = request.user
730
- report = ClientReport.generate(
731
- form.cleaned_data["start"],
732
- form.cleaned_data["end"],
733
- owner=owner,
734
- recipients=form.cleaned_data.get("destinations"),
735
- disable_emails=form.cleaned_data.get("disable_emails", False),
736
- )
737
- report.store_local_copy()
738
- recurrence = form.cleaned_data.get("recurrence")
739
- if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
740
- schedule = ClientReportSchedule.objects.create(
741
- owner=owner,
742
- created_by=request.user if request.user.is_authenticated else None,
743
- periodicity=recurrence,
744
- email_recipients=form.cleaned_data.get("destinations", []),
745
- disable_emails=form.cleaned_data.get("disable_emails", False),
919
+ if request.method == "POST":
920
+ if not request.user.is_authenticated:
921
+ form.is_valid() # Run validation to surface field errors alongside auth error.
922
+ form.add_error(
923
+ None, _("You must log in to generate client reports."),
746
924
  )
747
- report.schedule = schedule
748
- report.save(update_fields=["schedule"])
749
- messages.success(
750
- request,
751
- _(
752
- "Client report schedule created; future reports will be generated automatically."
753
- ),
754
- )
755
- context = {"form": form, "report": report, "schedule": schedule}
925
+ elif form.is_valid():
926
+ throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
927
+ throttle_keys = []
928
+ if request.user.is_authenticated:
929
+ throttle_keys.append(f"client-report:user:{request.user.pk}")
930
+ remote_addr = request.META.get("HTTP_X_FORWARDED_FOR")
931
+ if remote_addr:
932
+ remote_addr = remote_addr.split(",")[0].strip()
933
+ remote_addr = remote_addr or request.META.get("REMOTE_ADDR")
934
+ if remote_addr:
935
+ throttle_keys.append(f"client-report:ip:{remote_addr}")
936
+
937
+ added_keys = []
938
+ blocked = False
939
+ for key in throttle_keys:
940
+ if cache.add(key, timezone.now(), throttle_seconds):
941
+ added_keys.append(key)
942
+ else:
943
+ blocked = True
944
+ break
945
+
946
+ if blocked:
947
+ for key in added_keys:
948
+ cache.delete(key)
949
+ form.add_error(
950
+ None,
951
+ _(
952
+ "Client reports can only be generated periodically. Please wait before trying again."
953
+ ),
954
+ )
955
+ else:
956
+ owner = form.cleaned_data.get("owner")
957
+ if not owner and request.user.is_authenticated:
958
+ owner = request.user
959
+ report = ClientReport.generate(
960
+ form.cleaned_data["start"],
961
+ form.cleaned_data["end"],
962
+ owner=owner,
963
+ recipients=form.cleaned_data.get("destinations"),
964
+ disable_emails=form.cleaned_data.get("disable_emails", False),
965
+ )
966
+ report.store_local_copy()
967
+ recurrence = form.cleaned_data.get("recurrence")
968
+ if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
969
+ schedule = ClientReportSchedule.objects.create(
970
+ owner=owner,
971
+ created_by=request.user if request.user.is_authenticated else None,
972
+ periodicity=recurrence,
973
+ email_recipients=form.cleaned_data.get("destinations", []),
974
+ disable_emails=form.cleaned_data.get("disable_emails", False),
975
+ )
976
+ report.schedule = schedule
977
+ report.save(update_fields=["schedule"])
978
+ messages.success(
979
+ request,
980
+ _(
981
+ "Client report schedule created; future reports will be generated automatically."
982
+ ),
983
+ )
984
+ try:
985
+ login_url = reverse("pages:login")
986
+ except NoReverseMatch:
987
+ try:
988
+ login_url = reverse("login")
989
+ except NoReverseMatch:
990
+ login_url = getattr(settings, "LOGIN_URL", None)
991
+
992
+ context = {
993
+ "form": form,
994
+ "report": report,
995
+ "schedule": schedule,
996
+ "login_url": login_url,
997
+ }
756
998
  return render(request, "pages/client_report.html", context)
757
999
 
758
1000