Repository: svthalia/concrexit

Issue:
Implement rate-limiting for password-forgot
### Describe the bug
There is no rate-limiting on the password-forgot view. It is thus possible to spam a user with known emailadress with a lot of password reset emails and perform some kind of DoS.

Also note that for password-forgot, we use uuid's, which are not secure! We should implement secure secret tokens.

### How to reproduce


### Expected behaviour
Check if a password reset token was sent in the past x minutes. 

### Screenshots

### Additional context
We were notified about this via responsible disclosure (the rate-limiting, not the uuid part) 


Discussion:

Resolving patch:
diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py
--- a/website/thaliawebsite/settings.py
+++ b/website/thaliawebsite/settings.py
@@ -501,6 +501,7 @@ def from_env(
     "django.contrib.auth.middleware.AuthenticationMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.locale.LocaleMiddleware",
+    "django_ratelimit.middleware.RatelimitMiddleware",
     # Our middleware
     "members.middleware.MemberMiddleware",
     "announcements.middleware.AnnouncementMiddleware",
@@ -603,6 +604,13 @@ def from_env(
     },
 }
 
+CACHES = {
+    "default": {
+        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
+        "LOCATION": "django_default_db_cache",
+    }
+}
+
 WSGI_APPLICATION = "thaliawebsite.wsgi.application"
 
 # Login pages
@@ -717,6 +725,10 @@ def from_env(
     ),
 }
 
+# Rate limiting
+
+RATELIMIT_VIEW = "thaliawebsite.views.rate_limited_view"
+
 # Internationalization
 # https://docs.djangoproject.com/en/dev/topics/i18n/
 
diff --git a/website/thaliawebsite/urls.py b/website/thaliawebsite/urls.py
--- a/website/thaliawebsite/urls.py
+++ b/website/thaliawebsite/urls.py
@@ -29,9 +29,9 @@
 from django.conf.urls import include
 from django.conf.urls.static import static
 from django.contrib import admin
-from django.contrib.auth.views import LoginView
 from django.contrib.sitemaps.views import sitemap
 from django.urls import path, re_path
+from django.views.generic import RedirectView
 from django.views.i18n import JavaScriptCatalog
 
 import debug_toolbar
@@ -53,7 +53,12 @@
 from singlepages.sitemaps import sitemap as singlepages_sitemap
 from thabloid.sitemaps import sitemap as thabloid_sitemap
 from thaliawebsite.forms import AuthenticationForm
-from thaliawebsite.views import IndexView, TestCrashView
+from thaliawebsite.views import (
+    IndexView,
+    RateLimitedLoginView,
+    RateLimitedPasswordResetView,
+    TestCrashView,
+)
 from utils.media.views import private_media
 
 from .sitemaps import StaticViewSitemap
@@ -73,6 +78,11 @@
 THALIA_SITEMAP.update(singlepages_sitemap)
 
 urlpatterns = [
+    path(
+        "admin/login/",
+        RedirectView.as_view(url="/user/login", query_string=True),
+        name="login-redirect",
+    ),
     path("admin/", admin.site.urls),
     path("", IndexView.as_view(), name="index"),
     # Default helpers
@@ -126,12 +136,17 @@
             [
                 path(
                     "login/",
-                    LoginView.as_view(
+                    RateLimitedLoginView.as_view(
                         authentication_form=AuthenticationForm,
                         redirect_authenticated_user=True,
                     ),
                     name="login",
                 ),
+                path(
+                    "password_reset/",
+                    RateLimitedPasswordResetView.as_view(),
+                    name="password_reset",
+                ),
                 path("", include("django.contrib.auth.urls")),
             ]
         ),
diff --git a/website/thaliawebsite/views.py b/website/thaliawebsite/views.py
--- a/website/thaliawebsite/views.py
+++ b/website/thaliawebsite/views.py
@@ -1,11 +1,14 @@
 """General views for the website."""
 
 from django.contrib.admin.views.decorators import staff_member_required
+from django.contrib.auth.views import LoginView, PasswordResetView
 from django.http import HttpResponse, HttpResponseForbidden
 from django.utils.decorators import method_decorator
 from django.views.generic import ListView, TemplateView
 from django.views.generic.base import View
 
+from django_ratelimit.decorators import ratelimit
+
 
 class IndexView(TemplateView):
     template_name = "index.html"
@@ -51,3 +54,19 @@ def get_context_data(self, **kwargs) -> dict:
         )
 
         return context
+
+
+class RateLimitedPasswordResetView(PasswordResetView):
+    @method_decorator(ratelimit(key="ip", rate="5/h"))
+    def post(self, request, *args, **kwargs):
+        return super().post(request, *args, **kwargs)
+
+
+class RateLimitedLoginView(LoginView):
+    @method_decorator(ratelimit(key="ip", rate="10/m"))
+    def post(self, request, *args, **kwargs):
+        return super().post(request, *args, **kwargs)
+
+
+def rate_limited_view(request, *args, **kwargs):
+    return HttpResponse("You are rate limited", status=429)


Test patch:
diff --git a/website/payments/tests/test_admin_views.py b/website/payments/tests/test_admin_views.py
--- a/website/payments/tests/test_admin_views.py
+++ b/website/payments/tests/test_admin_views.py
@@ -63,7 +63,8 @@ def test_redirect_without_permissions(self):
                 "type": "cash_payment",
             },
         )
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
     @mock.patch("django.contrib.messages.error")
     @mock.patch("django.contrib.messages.success")
@@ -243,7 +244,7 @@ def test_permissions(self):
 
         self._give_user_permissions()
 
-        response = self.client.post(url)
+        response = self.client.post(url, follow=True)
         self.assertRedirects(response, f"/admin/payments/batch/{self.batch.id}/change/")
 
     def test_next_validation(self):
@@ -322,11 +323,12 @@ def _give_user_permissions(self):
     def test_permission(self):
         url = f"/admin/payments/batch/{self.batch.id}/export/"
         response = self.client.post(url)
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
         self._give_user_permissions()
 
-        response = self.client.post(url)
+        response = self.client.post(url, follow=True)
         self.assertEqual(response.status_code, 200)
 
     def test_post(self):
@@ -419,7 +421,8 @@ def _give_user_permissions(self):
     def test_permission(self):
         url = f"/admin/payments/batch/{self.batch.id}/export-topic/"
         response = self.client.post(url)
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
         self._give_user_permissions()
 
@@ -533,7 +536,8 @@ def _give_user_permissions(self):
     def test_permission(self):
         url = f"/admin/payments/batch/{self.batch.id}/topic-description/"
         response = self.client.post(url)
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
         self._give_user_permissions()
 
@@ -644,7 +648,8 @@ def _give_user_permissions(self):
     def test_permission(self):
         url = "/admin/payments/batch/new_filled/"
         response = self.client.post(url)
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
         self._give_user_permissions()
 
diff --git a/website/registrations/tests/test_views.py b/website/registrations/tests/test_views.py
--- a/website/registrations/tests/test_views.py
+++ b/website/registrations/tests/test_views.py
@@ -86,7 +86,8 @@ def _give_user_permissions(self):
     def test_permissions(self):
         url = f"/registration/admin/process/{self.entry1.pk}/"
         response = self.client.post(url)
-        self.assertRedirects(response, f"/admin/login/?next={url}")
+        self.assertEqual(response.status_code, 302)
+        self.assertURLEqual(response.url, f"/admin/login/?next={url}")
 
         self._give_user_permissions()