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.db.models import Model, ForeignKey, CharField, DateTimeField, UUIDField, CASCADE, SET_NULL, BooleanField, GenericIPAddressField
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from dbsettings.functions import getValue
from uuid import uuid4 from uuid import uuid4
from datetime import timedelta
class LoginSession(Model): class LoginSession(Model):
uuid = UUIDField(default=uuid4, primary_key=True) uuid = UUIDField(default=uuid4, primary_key=True)
@ -18,3 +21,12 @@ class LoginLog(Model):
ip = GenericIPAddressField() ip = GenericIPAddressField()
success = BooleanField() success = BooleanField()
timestamp = DateTimeField(auto_now_add=True) 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, OTPValidatorView,
PWResetView, PWResetView,
PWRequestView, PWRequestView,
RateLimitedView,
BackendNotImplementedView, BackendNotImplementedView,
AdminListView, AdminListView,
AdminDeleteView, 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('logout/', LogoutView.as_view(), name="logout"))
URLPATTERNS.append(path('login/reset/', PWRequestView.as_view(), name="pwrequest")) 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/reset/<pk>/', PWResetView.as_view(), name="pwreset"))
URLPATTERNS.append(path('login/ratelimit/', RateLimitedView.as_view(), name="ratelimited"))
# Base Backend URLs # Base Backend URLs

View file

@ -1,5 +1,5 @@
from django.conf import settings 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.contrib.auth import authenticate, login, logout, get_user_model
from django.shortcuts import redirect from django.shortcuts import redirect
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -7,14 +7,38 @@ from django.contrib import messages
from django.utils import timezone from django.utils import timezone
from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm, PWRequestForm 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.otp import get_user_otps, get_otp_choices, get_otp_by_name
from core.helpers.mail import simple_send_mail from core.helpers.mail import simple_send_mail
from core.helpers.auth import generate_pwreset_mail, login_fail, login_success 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 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" template_name = f"{settings.EXPEPHALON_BACKEND}/auth/login.html"
form_class = LoginForm form_class = LoginForm
@ -30,7 +54,7 @@ class LoginView(FormView):
def form_invalid(self, form): def form_invalid(self, form):
try: 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: except get_user_model().DoesNotExist:
user = None user = None
login_fail(self.request, user, "The credentials you entered are invalid. Please try again.") 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 redirect("otpselector")
return self.form_invalid(form) return self.form_invalid(form)
class OTPSelectorView(FormView): class OTPSelectorView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_selector.html" template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_selector.html"
form_class = OTPSelectorForm form_class = OTPSelectorForm
@ -86,7 +110,7 @@ class OTPSelectorView(FormView):
context["first_name"] = user.profile.get_internal_name context["first_name"] = user.profile.get_internal_name
return context return context
class OTPValidatorView(FormView): class OTPValidatorView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_verifier.html" template_name = f"{settings.EXPEPHALON_BACKEND}/auth/otp_verifier.html"
form_class = OTPVerificationForm form_class = OTPVerificationForm
@ -147,7 +171,7 @@ class LogoutView(View):
logout(request) logout(request)
return redirect("login") return redirect("login")
class PWResetView(FormView): class PWResetView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwreset.html" template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwreset.html"
form_class = PWResetForm 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.") messages.success(self.request, "Your password has been changed. You can now login with your new password.")
return redirect("login") return redirect("login")
class PWRequestView(FormView): class PWRequestView(AuthView):
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwrequest.html" template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwrequest.html"
form_class = PWRequestForm form_class = PWRequestForm

View file

@ -20,8 +20,6 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** Expephalon **Default value:** Expephalon
---
## Authentication system ## Authentication system
### core.auth.otp.max_age ### core.auth.otp.max_age
@ -36,7 +34,23 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** 86400 **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 ## Mail
@ -64,9 +78,6 @@ The custom.\* is reserved for settings created manually by administrators for us
**Default value:** (None) **Default value:** (None)
---
## SMS ## SMS
### core.sms.default ### 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 **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) **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 %}