Implement basic rate limiting

This commit is contained in:
Kumi 2020-05-23 18:48:37 +02:00
parent 7955f19684
commit 77d5b771d5
5 changed files with 78 additions and 18 deletions

View file

@ -1,7 +1,10 @@
from django.db.models import Model, ForeignKey, CharField, DateTimeField, UUIDField, CASCADE, SET_NULL, BooleanField, GenericIPAddressField
from django.contrib.auth import get_user_model
from dbsettings.functions import getValue
from uuid import uuid4
from datetime import timedelta
class LoginSession(Model):
uuid = UUIDField(default=uuid4, primary_key=True)
@ -18,3 +21,12 @@ class LoginLog(Model):
ip = GenericIPAddressField()
success = BooleanField()
timestamp = DateTimeField(auto_now_add=True)
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

View file

@ -11,6 +11,7 @@ from core.views import (
OTPValidatorView,
PWResetView,
PWRequestView,
RateLimitedView,
BackendNotImplementedView,
AdminListView,
AdminDeleteView,
@ -32,6 +33,7 @@ URLPATTERNS.append(path('login/otp/validate/', OTPValidatorView.as_view(), name=
URLPATTERNS.append(path('logout/', LogoutView.as_view(), name="logout"))
URLPATTERNS.append(path('login/reset/', PWRequestView.as_view(), name="pwrequest"))
URLPATTERNS.append(path('login/reset/<pk>/', PWResetView.as_view(), name="pwreset"))
URLPATTERNS.append(path('login/ratelimit/', RateLimitedView.as_view(), name="ratelimited"))
# Base Backend URLs

View file

@ -1,5 +1,5 @@
from django.conf import settings
from django.views.generic import FormView, View
from django.views.generic import FormView, View, TemplateView
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.shortcuts import redirect
from django.core.exceptions import PermissionDenied
@ -7,14 +7,38 @@ from django.contrib import messages
from django.utils import timezone
from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm, PWRequestForm
from core.models.auth import LoginSession, PWResetToken
from core.models.auth import LoginSession, PWResetToken, IPLimit, LoginLog
from core.helpers.otp import get_user_otps, get_otp_choices, get_otp_by_name
from core.helpers.mail import simple_send_mail
from core.helpers.auth import generate_pwreset_mail, login_fail, login_success
from core.helpers.request import get_client_ip
from dbsettings.functions import getValue
class LoginView(FormView):
class RateLimitedView(TemplateView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/ratelimit.html"
def dispatch(self, request, *args, **kwargs):
if not IPLimit.objects.filter(ip=get_client_ip(request)):
return redirect("login")
return super().dispatch(request, *args, **kwargs)
class AuthView(FormView):
def dispatch(self, request, *args, **kwargs):
limits = list(IPLimit.objects.filter(ip=get_client_ip(request)))
if not limits:
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)):
limits.append(IPLimit.objects.create(ip=get_client_ip(request)))
for limit in limits:
if limit.end > timezone.now():
messages.error(request, f"Sorry, there have been to many failed login attempts from your IP. Please try again after {str(limit.end)}, or contact support if you need help getting into your account.")
return redirect("ratelimited")
return super().dispatch(request, *args, **kwargs)
class LoginView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/login.html"
form_class = LoginForm
@ -30,7 +54,7 @@ class LoginView(FormView):
def form_invalid(self, form):
try:
user = get_user_model().objects.get(username=form.cleaned_data["email"])
user = get_user_model().objects.get(username=form.cleaned_data.get("email", ""))
except get_user_model().DoesNotExist:
user = None
login_fail(self.request, user, "The credentials you entered are invalid. Please try again.")
@ -48,7 +72,7 @@ class LoginView(FormView):
return redirect("otpselector")
return self.form_invalid(form)
class OTPSelectorView(FormView):
class OTPSelectorView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_selector.html"
form_class = OTPSelectorForm
@ -86,7 +110,7 @@ class OTPSelectorView(FormView):
context["first_name"] = user.profile.get_internal_name
return context
class OTPValidatorView(FormView):
class OTPValidatorView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_verifier.html"
form_class = OTPVerificationForm
@ -147,7 +171,7 @@ class LogoutView(View):
logout(request)
return redirect("login")
class PWResetView(FormView):
class PWResetView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwreset.html"
form_class = PWResetForm
@ -171,7 +195,7 @@ class PWResetView(FormView):
messages.success(self.request, "Your password has been changed. You can now login with your new password.")
return redirect("login")
class PWRequestView(FormView):
class PWRequestView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwrequest.html"
form_class = PWRequestForm

View file

@ -20,8 +20,6 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** Expephalon
---
## Authentication system
### core.auth.otp.max_age
@ -36,7 +34,23 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** 86400
---
### core.auth.ratelimit.attempts
**Description:** Maximum number of invalid login attempts in last core.auth.ratelimit.period seconds from individual IP before blocking
**Default value:** 5
### core.auth.ratelimit.block
**Description:** Period for which to block further login attempts from individual IP after c.a.r.attempts failures in c.a.r.period seconds (in seconds)
**Default value:** 3600
### core.auth.ratelimit.period
**Description:** Period in which to check for previous failed login attempts for ratelimiting (in seconds)
**Default value:** 600
## Mail
@ -64,9 +78,6 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** (None)
---
## SMS
### core.sms.default
@ -74,6 +85,3 @@ The custom.\* is reserved for settings created manually by administrators for us
**Description:** Name of the default SMS provider to be used by Expephalon - must be the unique return value of the provider's get_name property
**Default value:** (None, effectively disabling SMS - doesn't make sense without an SMS provider module installed)
---

View file

@ -0,0 +1,14 @@
{% extends "backend/auth/base.html" %}
{% load bootstrap4 %}
{% block content %}
<div class="mx-auto app-login-box col-sm-12 col-md-10 col-lg-9">
<div class="app-logo"></div>
<h4 class="mb-0">
<span class="d-block">Oops,</span>
<span>Seems like you're not getting in.</span></h4>
{% bootstrap_messages %}
<div class="divider row"></div>
<div>
</div>
</div>
{% endblock %}