diff --git a/env/bitbucket/Dockerfile b/env/bitbucket/Dockerfile index d892093e..8d3a6b1a 100644 --- a/env/bitbucket/Dockerfile +++ b/env/bitbucket/Dockerfile @@ -54,7 +54,7 @@ RUN apt-get update && \ fonts-arphic-uming \ ttf-wqy-zenhei \ ttf-wqy-microhei \ - xfonts-wqy \ + xfonts-wqy RUN npm --version diff --git a/env_secrets/caprover.env b/env_secrets/caprover.env index e32c2e07..6f8edb42 100644 Binary files a/env_secrets/caprover.env and b/env_secrets/caprover.env differ diff --git a/prepare_server_cypress.sh b/prepare_server_cypress.sh index 023ac515..49612f88 100755 --- a/prepare_server_cypress.sh +++ b/prepare_server_cypress.sh @@ -82,18 +82,12 @@ if [ "$PROXY_VUE" = true ]; then fi # install cypress here to avoid problems with `npm install` on the iesc servers -CYPRESS_INSTALLED=0 -#npx --no-install cypress --version || CYPRESS_INSTALLED=$? -if [ $CYPRESS_INSTALLED -ne 0 ]; then - echo "install cypress" -# npm install cypress@5.6.0 @testing-library/cypress@7.0.2 --no-save -fi - - -# the sftp server is currently not needed -# rm -rf test_sftp -# mkdir -p test_sftp -# (cd test_sftp && mkdir -p outbox && sftpserver -p 3373 -k ../src/myservice/apps/export/sftp_test_key.pem -l INFO &) +#CYPRESS_INSTALLED=0 +##npx --no-install cypress --version || CYPRESS_INSTALLED=$? +#if [ $CYPRESS_INSTALLED -ne 0 ]; then +# echo "install cypress" +## npm install cypress@5.6.0 @testing-library/cypress@7.0.2 --no-save +#fi if [ "$START_BACKGROUND" = true ]; then python3 server/manage.py runserver 8001 --settings="$DJANGO_SETTINGS_MODULE" > /dev/null & diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 5fabe732..5417e5c5 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -372,6 +372,17 @@ REST_FRAMEWORK = { "rest_framework.authentication.TokenAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "TEST_REQUEST_DEFAULT_FORMAT": "json", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "vbv_lernwelt.core.utils.HourUserRateThrottle", + "vbv_lernwelt.core.utils.DayUserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "200/day", + "hour-throttle": "400/hour", + "day-throttle": "2000/day", + }, } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup @@ -465,7 +476,7 @@ if DJANGO_DEV_MODE == "development": # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration INSTALLED_APPS += ["django_extensions"] # noqa F405 -if DJANGO_DEV_MODE == "production": +if DJANGO_DEV_MODE in ["production", "caprover"]: # SECURITY # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header diff --git a/server/config/settings/test_cypress.py b/server/config/settings/test_cypress.py index 076e11dd..dd94cb1f 100644 --- a/server/config/settings/test_cypress.py +++ b/server/config/settings/test_cypress.py @@ -35,11 +35,10 @@ DATABASES = { # Your stuff... # ------------------------------------------------------------------------------ -# REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { -# 'anon': '100/day', -# 'hour-throttle': '40000/hour', -# 'day-throttle': '2000000/day', -# 'easy-throttle': '50000/day', -# } -# -# RATELIMIT_ENABLE = False +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { + "anon": "10000/day", + "hour-throttle": "40000/hour", + "day-throttle": "2000000/day", +} + +RATELIMIT_ENABLE = False diff --git a/server/config/urls.py b/server/config/urls.py index 23311735..eb415853 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -2,23 +2,39 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth import views as auth_views +from django.contrib.auth.decorators import user_passes_test from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path from django.views import defaults as default_views from django.views.generic import TemplateView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from ratelimit.exceptions import Ratelimited from rest_framework.authtoken.views import obtain_auth_token from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt +from vbv_lernwelt.core.views import ( + rate_limit_exceeded_view, + permission_denied_view, + check_rate_limit, +) + + +def raise_example_error(request): + """ + raise error to check if it gets logged + """ + raise Exception("Test Error: I know python!") + # pylint: disable=unreachable + return HttpResponse("no error?") + # fmt: off urlpatterns = [ path("", django_view_authentication_exempt(TemplateView.as_view(template_name="pages/home.html")), name="home"), - path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), - # Django Admin, use {% url 'admin:index' %} + path('admin/raise_error/', user_passes_test(lambda u: u.is_superuser, login_url='/login/')(raise_example_error), ), path(settings.ADMIN_URL, admin.site.urls), - # Your stuff: custom urls includes go here path("login/", django_view_authentication_exempt(auth_views.LoginView.as_view(template_name="core/login.html"))), + path("checkratelimit/", check_rate_limit), path("todo/", include("vbv_lernwelt.simpletodo.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: @@ -36,6 +52,18 @@ urlpatterns += [ ] # fmt: on + +def handler403(request, exception=None): + if isinstance(exception, Ratelimited): + # if request.path.startswith("/swisscom/customer"): + # return SwisscomCustomerLandingPageErrorView.as_view()(request) + return rate_limit_exceeded_view(request, exception) + return permission_denied_view(request, exception) + + +handler500 = "vbv_lernwelt.core.views.server_json_error" + + if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. diff --git a/server/integration_tests/__init__.py b/server/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/integration_tests/ratelimit/__init__.py b/server/integration_tests/ratelimit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/integration_tests/ratelimit/test_ratelimit.py b/server/integration_tests/ratelimit/test_ratelimit.py new file mode 100644 index 00000000..20e9c0a8 --- /dev/null +++ b/server/integration_tests/ratelimit/test_ratelimit.py @@ -0,0 +1,17 @@ +from django.test import TestCase, override_settings + + +class RateLimitTest(TestCase): + def setUp(self): + self.url = "/checkratelimit/" + + @override_settings(RATELIMIT_ENABLE=True) + def test_checkView_rateLimitAfter5Requests(self): + for i in range(10): + response = self.client.get(self.url) + + if i < 5: + self.assertEqual(response.status_code, 200) + else: + # der 6. Zugriff wird gesperrt + self.assertEqual(response.status_code, 429) diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 571f75a8..45cb530a 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -91,6 +91,8 @@ django-htmx==1.8.0 # via -r requirements.in django-model-utils==4.2.0 # via -r requirements.in +django-ratelimit==3.0.1 + # via -r requirements.in django-redis==5.2.0 # via -r requirements.in django-stubs==1.9.0 diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in index 0c591bb3..e4912c88 100644 --- a/server/requirements/requirements.in +++ b/server/requirements/requirements.in @@ -21,6 +21,7 @@ drf-spectacular django-htmx dj-database-url django-click +django-ratelimit psycopg2-binary gunicorn diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt index cf3df98a..832163d7 100644 --- a/server/requirements/requirements.txt +++ b/server/requirements/requirements.txt @@ -45,6 +45,8 @@ django-htmx==1.8.0 # via -r requirements.in django-model-utils==4.2.0 # via -r requirements.in +django-ratelimit==3.0.1 + # via -r requirements.in django-redis==5.2.0 # via -r requirements.in djangorestframework==3.13.1 diff --git a/server/vbv_lernwelt/core/management/commands/cypress_reset.py b/server/vbv_lernwelt/core/management/commands/cypress_reset.py index 8d1ba152..76dbf7cb 100644 --- a/server/vbv_lernwelt/core/management/commands/cypress_reset.py +++ b/server/vbv_lernwelt/core/management/commands/cypress_reset.py @@ -5,13 +5,15 @@ from django.contrib.auth import get_user_model @click.command() -@click.option('--customer_language', default='de') +@click.option("--customer_language", default="de") def command(customer_language): print("cypress reset data") User = get_user_model() - users = ['cypress@example.com', ] + users = [ + "cypress@example.com", + ] for user in users: User.objects.filter(username=user).delete() diff --git a/server/vbv_lernwelt/core/utils.py b/server/vbv_lernwelt/core/utils.py index af2d91a5..c1011162 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -1,6 +1,7 @@ import logging from django.conf import settings +from rest_framework.throttling import UserRateThrottle from structlog.types import EventDict @@ -11,3 +12,11 @@ def add_app_info( event_dict["django_dev_mode"] = settings.DJANGO_DEV_MODE return event_dict + + +class HourUserRateThrottle(UserRateThrottle): + scope = "hour-throttle" + + +class DayUserRateThrottle(UserRateThrottle): + scope = "day-throttle" diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index 60f00ef0..69fe7e5c 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -1 +1,32 @@ # Create your views here. +from django.http import JsonResponse, HttpResponse +from django.shortcuts import render +from ratelimit.decorators import ratelimit + +from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt + + +def permission_denied_view(request, exception): + return render(request, "403.html", status=403) + + +def rate_limit_exceeded_view(request, exception): + return render(request, "429.html", status=429) + + +@django_view_authentication_exempt +def server_json_error(request, *args, **kwargs): + """ + Generic 500 error handler. + """ + data = { + "detail": "Server Error (500)", + "status_code": 500, + } + return JsonResponse(data, status=500) + + +@ratelimit(key="ip", rate="5/m", block=True) +@django_view_authentication_exempt +def check_rate_limit(request): + return HttpResponse(content=b"Hello") diff --git a/server/vbv_lernwelt/simpletodo/serializers.py b/server/vbv_lernwelt/simpletodo/serializers.py index bb58d995..4415b904 100644 --- a/server/vbv_lernwelt/simpletodo/serializers.py +++ b/server/vbv_lernwelt/simpletodo/serializers.py @@ -9,15 +9,22 @@ class SimpleTaskSerializer(ModelSerializer): class Meta: model = SimpleTask - fields = ['id', 'title', 'text', 'done', 'deadline', 'list_title', ] + fields = [ + "id", + "title", + "text", + "done", + "deadline", + "list_title", + ] def create(self, validated_data): - user = validated_data.pop('user', None) + user = validated_data.pop("user", None) if user is None: - raise serializers.ValidationError('User is required') + raise serializers.ValidationError("User is required") - list_title = validated_data.pop('list_title') + list_title = validated_data.pop("list_title") simple_list, _ = SimpleList.objects.get_or_create(title=list_title, user=user) - validated_data['list'] = simple_list + validated_data["list"] = simple_list return super().create(validated_data) diff --git a/server/vbv_lernwelt/simpletodo/tests/test_serializers.py b/server/vbv_lernwelt/simpletodo/tests/test_serializers.py index 4399b3bb..40893365 100644 --- a/server/vbv_lernwelt/simpletodo/tests/test_serializers.py +++ b/server/vbv_lernwelt/simpletodo/tests/test_serializers.py @@ -10,15 +10,17 @@ class SimpleTaskSerializerTestCase(TestCase): self.user = UserFactory() def test_serializer(self): - serializer = SimpleTaskSerializer(data={ - 'title': 'Test', - 'list_title': 'Todos', - }) + serializer = SimpleTaskSerializer( + data={ + "title": "Test", + "list_title": "Todos", + } + ) serializer.is_valid(raise_exception=True) serializer.save(user=self.user) task = SimpleTask.objects.first() - self.assertEqual(task.title, 'Test') - self.assertEqual(task.list.title, 'Todos') + self.assertEqual(task.title, "Test") + self.assertEqual(task.list.title, "Todos") diff --git a/server/vbv_lernwelt/simpletodo/urls.py b/server/vbv_lernwelt/simpletodo/urls.py index b5810b6d..f4cafd9a 100644 --- a/server/vbv_lernwelt/simpletodo/urls.py +++ b/server/vbv_lernwelt/simpletodo/urls.py @@ -5,9 +5,9 @@ from rest_framework.routers import DefaultRouter from . import views router = DefaultRouter() -router.register(r'tasks', views.SimpleTaskViewSet, basename='tasks') +router.register(r"tasks", views.SimpleTaskViewSet, basename="tasks") urlpatterns = [ path("", views.index, name="index"), - url(r'^api/', include(router.urls)), + url(r"^api/", include(router.urls)), ] diff --git a/server/vbv_lernwelt/simpletodo/views.py b/server/vbv_lernwelt/simpletodo/views.py index 53d064cd..71b57b9d 100644 --- a/server/vbv_lernwelt/simpletodo/views.py +++ b/server/vbv_lernwelt/simpletodo/views.py @@ -15,9 +15,7 @@ def index(request): if simple_lists.count() == 0: simple_lists = [SimpleList.objects.create(user=request.user, title="Todos")] - return render(request, 'simpletodo/index.html', { - 'simple_lists': simple_lists - }) + return render(request, "simpletodo/index.html", {"simple_lists": simple_lists}) class SimpleTaskViewSet(viewsets.ModelViewSet): @@ -31,38 +29,43 @@ class SimpleTaskViewSet(viewsets.ModelViewSet): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) - if request.accepted_renderer.format == 'html': + if request.accepted_renderer.format == "html": serializer.is_valid(raise_exception=True) else: serializer.is_valid(raise_exception=True) serializer.save(user=request.user) - if request.accepted_renderer.format == 'html': + if request.accepted_renderer.format == "html": return redirect("/todo/") headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def destroy(self, request, *args, **kwargs): instance = self.get_object() instance.delete() if request.htmx: - return HttpResponse(status=200, content='') + return HttpResponse(status=200, content="") return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=['post',]) + @action( + detail=True, + methods=[ + "post", + ], + ) def toggle_done(self, request, pk=None): task = self.get_object() task.done = not task.done task.save() if request.htmx: - return render(request, 'simpletodo/partials/task.html', { - 'task': task - }) + return render(request, "simpletodo/partials/task.html", {"task": task}) return Response(self.get_serializer(task), status=status.HTTP_200_OK) diff --git a/server/vbv_lernwelt/templates/403.html b/server/vbv_lernwelt/templates/403.html index 4356d932..4ed30cca 100644 --- a/server/vbv_lernwelt/templates/403.html +++ b/server/vbv_lernwelt/templates/403.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% block title %}Forbidden (403){% endblock %} - {% block content %}

Forbidden (403)

diff --git a/server/vbv_lernwelt/templates/429.html b/server/vbv_lernwelt/templates/429.html new file mode 100644 index 00000000..592f46e6 --- /dev/null +++ b/server/vbv_lernwelt/templates/429.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block content %} +

Rate Limit (429)

+{% endblock content %} diff --git a/server/vbv_lernwelt/templates/500.html b/server/vbv_lernwelt/templates/500.html index 46e43a9c..9d263f61 100644 --- a/server/vbv_lernwelt/templates/500.html +++ b/server/vbv_lernwelt/templates/500.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% block title %}Server Error{% endblock %} - {% block content %}

Ooops!!! 500