feat: Enhances registration validation and cleanup

Adds rules for username and email validation, preventing the use of blocked keywords or patterns.

Implements a cleanup routine for expired IP, email, and username blocks, ensuring outdated data is removed.

Introduces IP rate-limiting in registration views to mitigate abuse and server strain.

Updates UI text for improved user clarity.

These changes aim to improve security, reduce spam registrations, and enhance user experience.
This commit is contained in:
Kumi 2024-12-21 14:41:00 +01:00
parent 64cab4d53c
commit 9c6555b348
Signed by: kumi
GPG key ID: ECBCC9082395383F
5 changed files with 121 additions and 14 deletions

View file

@ -1,12 +1,16 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from .models import UserRegistration from .models import UserRegistration, EmailBlock, IPBlock, UsernameRule
admin.site.site_header = "Synapse Registration Administration" admin.site.site_header = "Synapse Registration Administration"
admin.site.site_title = "Synapse Registration Administration" admin.site.site_title = "Synapse Registration Administration"
admin.site.index_title = "Welcome to the Synapse Registration Administration" admin.site.index_title = "Welcome to the Synapse Registration Administration"
admin.site.register(EmailBlock)
admin.site.register(IPBlock)
admin.site.register(UsernameRule)
admin.site.unregister(Group) admin.site.unregister(Group)

View file

@ -1,5 +1,10 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils import timezone
import re
from .models import EmailBlock, UsernameRule, UserRegistration
class UsernameForm(forms.Form): class UsernameForm(forms.Form):
@ -25,9 +30,18 @@ class UsernameForm(forms.Form):
): ):
self.add_error( self.add_error(
"username", "username",
"Username can only contain the characters a-z, 0-9, ., _, =, -, and /.", "Sorry, your username can only contain the characters a-z, 0-9, ., _, =, -, and /.",
) )
for rule in UsernameRule.objects.filter(expires__gt=timezone.now()):
regex = re.compile(rule.regex)
if regex.match(username):
self.add_error(
"username", "Sorry, the provided username cannot be used."
)
break
cleaned_data["username"] = username cleaned_data["username"] = username
return cleaned_data return cleaned_data
@ -39,6 +53,26 @@ class EmailForm(forms.Form):
) )
) )
def clean(self):
cleaned_data = super().clean()
email = cleaned_data.get("email")
if UserRegistration.objects.filter(email=email).exists():
self.add_error(
"email", "You have recently registered with this email address."
)
for rule in EmailBlock.objects.filter(expires__gt=timezone.now()):
regex = re.compile(rule.regex)
if regex.match(email):
self.add_error(
"email", "Sorry, the provided email address/domain is blocked."
)
break
return cleaned_data
class RegistrationForm(forms.Form): class RegistrationForm(forms.Form):
password1 = forms.CharField( password1 = forms.CharField(
@ -58,7 +92,7 @@ class RegistrationForm(forms.Form):
widget=forms.Textarea( widget=forms.Textarea(
attrs={ attrs={
"class": "textarea", "class": "textarea",
"placeholder": "Why do you want to join our server? If you were referred by a current member, who referred you? If you found us through a different means, how did you find us?", "placeholder": "Please tell us a little bit about yourself. Why do you want to join our server? If you were referred by a current member, who referred you? If you found us through a different means, how did you find us?",
} }
), ),
) )

View file

@ -1,12 +1,12 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from ...models import UserRegistration from ...models import UserRegistration, IPBlock, EmailBlock, UsernameRule
from datetime import timedelta, datetime from datetime import timedelta, datetime
class Command(BaseCommand): class Command(BaseCommand):
help = "Clean up old user registrations" help = "Clean up old user registrations and blocks"
def handle(self, *args, **options): def handle(self, *args, **options):
# Remove all registrations that are still in the "started" state after 48 hours # Remove all registrations that are still in the "started" state after 48 hours
@ -17,10 +17,24 @@ class Command(BaseCommand):
# Remove all registrations that are denied or approved after 30 days # Remove all registrations that are denied or approved after 30 days
UserRegistration.objects.filter( UserRegistration.objects.filter(
status__in=[UserRegistration.STATUS_DENIED, UserRegistration.STATUS_APPROVED], status__in=[
UserRegistration.STATUS_DENIED,
UserRegistration.STATUS_APPROVED,
],
timestamp__lt=datetime.now() - timedelta(days=30), timestamp__lt=datetime.now() - timedelta(days=30),
).delete() ).delete()
self.stdout.write( self.stdout.write(
self.style.SUCCESS("Successfully cleaned up old user registrations") self.style.SUCCESS("Successfully cleaned up old user registrations")
) )
# Remove all IP blocks that have expired
IPBlock.objects.filter(expires__lt=datetime.now()).delete()
# Remove all email blocks that have expired
EmailBlock.objects.filter(expires__lt=datetime.now()).delete()
# Remove all username rules that have expired
UsernameRule.objects.filter(expires__lt=datetime.now()).delete()
self.stdout.write(self.style.SUCCESS("Successfully cleaned up old blocks"))

View file

@ -29,3 +29,39 @@ class UserRegistration(models.Model):
def __str__(self): def __str__(self):
return self.username return self.username
class IPBlock(models.Model):
network = models.GenericIPAddressField()
netmask = models.SmallIntegerField(default=-1)
reason = models.TextField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
if self.netmask == -1:
self.netmask = 32 if self.network.version == 4 else 128
super().save(*args, **kwargs)
def __str__(self):
return f"{self.network}/{self.netmask}"
class EmailBlock(models.Model):
regex = models.CharField(max_length=1024)
reason = models.TextField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.email
class UsernameRule(models.Model):
regex = models.CharField(max_length=1024)
reason = models.TextField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.regex

View file

@ -3,15 +3,31 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from django.utils import timezone
from .forms import UsernameForm, EmailForm, RegistrationForm from .forms import UsernameForm, EmailForm, RegistrationForm
from .models import UserRegistration from .models import UserRegistration, IPBlock
import requests import requests
from secrets import token_urlsafe from secrets import token_urlsafe
from datetime import datetime, timedelta from datetime import timedelta
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from ipaddress import ip_network
class RateLimitMixin:
def dispatch(self, request, *args, **kwargs):
if not settings.TRUST_PROXY:
ip_address = request.META.get("REMOTE_ADDR")
else:
ip_address = request.META.get("HTTP_X_FORWARDED_FOR")
for block in IPBlock.objects.filter(expiry__gt=timezone.now()):
if ip_network(ip_address) in ip_network(f"{block.network}/{block.netmask}"):
return render(request, "registration/ratelimit.html", status=429)
return super().dispatch(request, *args, **kwargs)
class LandingPageView(TemplateView): class LandingPageView(TemplateView):
@ -22,7 +38,7 @@ class ErrorPageView(TemplateView):
template_name = "error_page.html" template_name = "error_page.html"
class CheckUsernameView(FormView): class CheckUsernameView(RateLimitMixin, FormView):
template_name = "registration/username_form.html" template_name = "registration/username_form.html"
form_class = UsernameForm form_class = UsernameForm
success_url = reverse_lazy("email_input") success_url = reverse_lazy("email_input")
@ -42,7 +58,7 @@ class CheckUsernameView(FormView):
return self.form_invalid(form) return self.form_invalid(form)
class EmailInputView(FormView): class EmailInputView(RateLimitMixin, FormView):
template_name = "registration/email_form.html" template_name = "registration/email_form.html"
form_class = EmailForm form_class = EmailForm
@ -69,7 +85,7 @@ class EmailInputView(FormView):
if ( if (
UserRegistration.objects.filter( UserRegistration.objects.filter(
ip_address=ip_address, ip_address=ip_address,
timestamp__gte=datetime.now() - timedelta(hours=24), timestamp__gte=timezone.now() - timedelta(hours=24),
).count() ).count()
>= 5 >= 5
): ):
@ -98,12 +114,15 @@ class EmailInputView(FormView):
return render(self.request, "registration/email_sent.html") return render(self.request, "registration/email_sent.html")
except SMTPRecipientsRefused: except SMTPRecipientsRefused:
form.add_error("email", "Your email address is invalid, not accepting mail, or blocked by our mail server.") form.add_error(
"email",
"Your email address is invalid, not accepting mail, or blocked by our mail server.",
)
registration.delete() registration.delete()
return self.form_invalid(form) return self.form_invalid(form)
class VerifyEmailView(View): class VerifyEmailView(RateLimitMixin, View):
def get(self, request, token): def get(self, request, token):
try: try:
registration = UserRegistration.objects.get(token=token) registration = UserRegistration.objects.get(token=token)
@ -123,7 +142,7 @@ class VerifyEmailView(View):
return redirect("complete_registration") return redirect("complete_registration")
class CompleteRegistrationView(FormView): class CompleteRegistrationView(RateLimitMixin, FormView):
template_name = "registration/complete_registration.html" template_name = "registration/complete_registration.html"
form_class = RegistrationForm form_class = RegistrationForm
success_url = reverse_lazy("registration_complete") success_url = reverse_lazy("registration_complete")