Compare commits

..

4 Commits

Author SHA1 Message Date
mpeltriaux 6f2b6c44d9 Merge pull request '541 security improvements' (#542) from 541_Security_improvements into master
Reviewed-on: #542
2026-06-13 13:42:46 +02:00
mpeltriaux 494e80a4ac # Intervention remove compensation
* adds default role check for intervention's compensation removing endpoint
2026-06-13 13:41:45 +02:00
mpeltriaux 1f6c81874b # User propagation
* adds exception catching if gibberish data is sent to POST endpoint of user data propagation
* commits 'changed' manage.py and jspdf.debug.js despite being identical with repo files (git wants it, git gets it)
2026-06-13 13:33:17 +02:00
mpeltriaux 9ee016a8bb # Token generator
* improves reliability of generated token randomness
2026-06-13 12:57:19 +02:00
12 changed files with 284 additions and 397 deletions
-36
View File
@@ -1,36 +0,0 @@
# Nutze ein schlankes Python-Image
FROM python:3.13-slim-bullseye
ENV PYTHONUNBUFFERED 1
WORKDIR /konova
# Installiere System-Abhängigkeiten
RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin redis-server nginx \
&& rm -rf /var/lib/apt/lists/* # Platz sparen
# Erstelle benötigte Verzeichnisse & setze Berechtigungen
RUN mkdir -p /var/log/nginx /var/log/gunicorn /var/lib/nginx /tmp/nginx_client_body \
&& touch /var/log/nginx/access.log /var/log/nginx/error.log \
&& chown -R root:root /var/log/nginx /var/lib/nginx /tmp/nginx_client_body
# Kopiere und installiere Python-Abhängigkeiten
COPY ./requirements.txt /konova/
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
# Entferne Standard-Nginx-Site und ersetze sie durch eigene Config
RUN rm -rf /etc/nginx/sites-enabled/default
COPY ./nginx.conf /etc/nginx/conf.d
# Kopiere restliche Projektdateien
COPY . /konova/
# Sammle statische Dateien
RUN python manage.py collectstatic --noinput
# Exponiere Ports
#EXPOSE 80 6379 8000
# Setze Entrypoint
ENTRYPOINT ["/konova/docker-entrypoint.sh"]
-56
View File
@@ -4,7 +4,6 @@ the database postgresql and the css library bootstrap as well as the icon packag
fontawesome for a modern look, following best practices from the industry. fontawesome for a modern look, following best practices from the industry.
## Background processes ## Background processes
### !!! For non-docker run
Konova uses celery for background processing. To start the worker you need to run Konova uses celery for background processing. To start the worker you need to run
```shell ```shell
$ celery -A konova worker -l INFO $ celery -A konova worker -l INFO
@@ -19,58 +18,3 @@ Technical documention is provided in the projects git wiki.
A user documentation is not available (and not needed, yet). A user documentation is not available (and not needed, yet).
# Docker
To run the docker-compose as expected, you need to take the following steps:
1. Create a database containing docker, using an appropriate Dockerfile, e.g. the following
```
version: '3.3'
services:
postgis:
image: postgis/postgis
restart: always
container_name: postgis-docker
ports:
- 5433:5432
volumes:
- db-volume:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
networks:
- db-network-bridge
networks:
db-network-bridge:
driver: "bridge"
volumes:
db-volume:
```
This Dockerfile creates a Docker container running postgresql and postgis, creates the default superuser postgres,
creates a named volume for persisting the database and creates a new network bridge, which **must be used by any other
container, which wants to write/read on this database**.
2. Make sure the name of the network bridge above matches the network in the konova docker-compose.yml
3. Get into the running postgis container (`docker exec -it postgis-docker bash`) and create new databases, users and so on. Make sure the database `konova` exists now!
4. Replace all `CHANGE_ME_xy` values inside of konova/docker-compose.yml for your installation. Make sure the `SSO_HOST` holds the proper SSO host, e.g. for the arnova project `arnova.example.org` (Arnova must be installed and the webserver configured as well, of course)
5. Take a look on konova/settings.py and konova/sub_settings/django_settings.py. Again: Replace all occurences of `CHANGE_ME` with proper values for your installation.
1. Make sure you have the proper host strings added to `ALLOWED_HOSTS` inside of django_settings.py.
6. Build and run the docker setup using `docker-compose build` and `docker-compose start` from the main directory of this project (where the docker-compose.yml lives)
7. Run migrations! To do so, get into the konova service container (`docker exec -it konova-docker bash`) and run the needed commands (`python manage.py makemigrations LIST_OF_ALL_MIGRATABLE_APPS`, then `python manage.py migrate`)
8. Run the setup command `python manage.py setup` and follow the instructions on the CLI
9. To enable **SMTP** mail support, make sure your host machine (the one where the docker container run) has the postfix service configured properly. Make sure the `mynetworks` variable is xtended using the docker network bridge ip, created in the postgis container and used by the konova services.
1. **Hint**: You can find out this easily by trying to perform a test mail in the running konova web application (which will fail, of course). Then take a look to the latest entries in `/var/log/mail.log` on your host machine. The failed IP will be displayed there.
2. **Please note**: This installation guide is based on SMTP using postfix!
3. Restart the postfix service on your host machine to reload the new configuration (`service postfix restart`)
10. Finally, make sure your host machine webserver passes incoming requests properly to the docker nginx webserver of konova. A proper nginx config for the host machine may look like this:
```
server {
server_name konova.domain.org;
location / {
proxy_pass http://localhost:KONOVA_NGINX_DOCKER_PORT/;
proxy_set_header Host $host;
}
}
```
-21
View File
@@ -1,21 +0,0 @@
services:
konova:
external_links:
- postgis:db
- arnova-nginx-server:arnova
build: .
image: "ksp/konova:x.y"
container_name: "konova-docker"
command: ./docker-entrypoint.sh
restart: always
volumes:
- /data/apps/konova/uploaded_files:/konova_uploaded_files
ports:
- "1337:80"
# Instead of an own, new network, we need to connect to the existing one, which is provided by the postgis container
# NOTE: THIS NETWORK MUST EXIST
networks:
default:
name: postgis_nat_it_backend
external: true
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
set -e # Beende Skript bei Fehlern
set -o pipefail # Fehler in Pipelines nicht ignorieren
# Starte Redis
redis-server --daemonize yes
# Starte Celery Worker im Hintergrund
celery -A konova worker --loglevel=info &
# Starte Nginx als Hintergrundprozess
nginx -g "daemon off;" &
# Setze Gunicorn Worker-Anzahl (Standard: (2*CPUs)+1)
WORKERS=${GUNICORN_WORKERS:-$((2 * $(nproc) + 1))}
# Stelle sicher, dass Logs existieren
mkdir -p /var/log/gunicorn
touch /var/log/gunicorn/access.log /var/log/gunicorn/error.log
# Starte Gunicorn als Hauptprozess
exec gunicorn --workers="$WORKERS" konova.wsgi:application \
--bind=0.0.0.0:8000 \
--access-logfile /var/log/gunicorn/access.log \
--error-logfile /var/log/gunicorn/error.log \
--access-logformat '%({x-real-ip}i)s via %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
+9
View File
@@ -36,6 +36,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.run_check_url = reverse("intervention:check", args=(self.intervention.id,)) self.run_check_url = reverse("intervention:check", args=(self.intervention.id,))
self.record_url = reverse("intervention:record", args=(self.intervention.id,)) self.record_url = reverse("intervention:record", args=(self.intervention.id,))
self.report_url = reverse("intervention:report", args=(self.intervention.id,)) self.report_url = reverse("intervention:report", args=(self.intervention.id,))
self.compensation_remove_url = reverse("intervention:remove-compensation", args=(self.compensation.intervention.id, self.compensation.id))
self.deduction.intervention = self.intervention self.deduction.intervention = self.intervention
self.deduction.save() self.deduction.save()
@@ -83,6 +84,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.revocation_new_url: f"{login_redirect_base}{self.revocation_new_url}", self.revocation_new_url: f"{login_redirect_base}{self.revocation_new_url}",
self.revocation_edit_url: f"{login_redirect_base}{self.revocation_edit_url}", self.revocation_edit_url: f"{login_redirect_base}{self.revocation_edit_url}",
self.revocation_remove_url: f"{login_redirect_base}{self.revocation_remove_url}", self.revocation_remove_url: f"{login_redirect_base}{self.revocation_remove_url}",
self.compensation_remove_url: f"{login_redirect_base}{self.compensation_remove_url}",
} }
self.assert_url_success(client, success_urls) self.assert_url_success(client, success_urls)
@@ -124,6 +126,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
self.assert_url_success(client, success_urls) self.assert_url_success(client, success_urls)
@@ -162,6 +165,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
fail_urls = [ fail_urls = [
self.run_check_url, self.run_check_url,
@@ -212,6 +216,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
success_urls_redirect = { success_urls_redirect = {
self.share_url: self.detail_url self.share_url: self.detail_url
@@ -258,6 +263,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
success_urls_redirect = { success_urls_redirect = {
self.share_url: self.detail_url self.share_url: self.detail_url
@@ -304,6 +310,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
success_urls_redirect = { success_urls_redirect = {
self.share_url: self.detail_url self.share_url: self.detail_url
@@ -350,6 +357,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
success_urls_redirect = { success_urls_redirect = {
self.share_url: self.detail_url self.share_url: self.detail_url
@@ -396,6 +404,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url, self.deduction_new_url,
self.deduction_edit_url, self.deduction_edit_url,
self.deduction_remove_url, self.deduction_remove_url,
self.compensation_remove_url,
] ]
# Define urls where a redirect to a specific location is the proper response # Define urls where a redirect to a specific location is the proper response
success_urls_redirect = { success_urls_redirect = {
+3 -1
View File
@@ -14,7 +14,7 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from intervention.models import Intervention from intervention.models import Intervention
from konova.decorators import shared_access_required from konova.decorators import shared_access_required, default_group_required
from konova.forms.modals import RemoveModalForm from konova.forms.modals import RemoveModalForm
from konova.utils.message_templates import COMPENSATION_REMOVED_TEMPLATE from konova.utils.message_templates import COMPENSATION_REMOVED_TEMPLATE
@@ -45,10 +45,12 @@ class RemoveCompensationFromInterventionView(LoginRequiredMixin, View):
redirect_url=reverse("intervention:detail", args=(id,)) + "#related_data", redirect_url=reverse("intervention:detail", args=(id,)) + "#related_data",
) )
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id")) @method_decorator(shared_access_required(Intervention, "id"))
def get(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse: def get(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse:
return self.__process_request(request, id, comp_id, *args, **kwargs) return self.__process_request(request, id, comp_id, *args, **kwargs)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id")) @method_decorator(shared_access_required(Intervention, "id"))
def post(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse: def post(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse:
return self.__process_request(request, id, comp_id, *args, **kwargs) return self.__process_request(request, id, comp_id, *args, **kwargs)
+3 -2
View File
@@ -191,10 +191,11 @@ STATICFILES_DIRS = [
] ]
# EMAIL (see https://docs.djangoproject.com/en/dev/topics/email/) # EMAIL (see https://docs.djangoproject.com/en/dev/topics/email/)
# CHANGE_ME !!! ONLY FOR DEVELOPMENT !!!
if DEBUG: if DEBUG:
# ONLY FOR DEVELOPMENT NEEDED
EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
EMAIL_FILE_PATH = '/tmp/app-messages' EMAIL_FILE_PATH = '/tmp/app-messages' # change this to a proper location
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") # The default email address for the 'from' element DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") # The default email address for the 'from' element
SERVER_EMAIL = DEFAULT_FROM_EMAIL # The default email sender address, which is used by Django to send errors via mail SERVER_EMAIL = DEFAULT_FROM_EMAIL # The default email sender address, which is used by Django to send errors via mail
+4 -8
View File
@@ -5,22 +5,18 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 09.11.20 Created on: 09.11.20
""" """
import random import secrets
import string import string
import qrcode
import qrcode.image.svg
from io import BytesIO
def generate_token() -> str: def generate_token(length: int = 64) -> str:
""" Shortcut for default generating of e.g. API token """ Shortcut for default generating of e.g. API token
Returns: Returns:
token (str) token (str)
""" """
return generate_random_string( return generate_random_string(
length=64, length=length,
use_numbers=True, use_numbers=True,
use_letters_lc=True use_letters_lc=True
) )
@@ -39,7 +35,7 @@ def generate_random_string(length: int, use_numbers: bool = False, use_letters_l
elements.append(string.ascii_uppercase) elements.append(string.ascii_uppercase)
elements = "".join(elements) elements = "".join(elements)
ret_val = "".join(random.choice(elements) for i in range(length)) ret_val = "".join(secrets.choice(elements) for i in range(length))
return ret_val return ret_val
class IdentifierGenerator: class IdentifierGenerator:
Executable → Regular
View File
-25
View File
@@ -1,25 +0,0 @@
server {
listen 80;
client_max_body_size 25M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_redirect off;
proxy_cache_bypass $http_upgrade;
}
location /static/ {
alias /konova/static/;
access_log /var/log/nginx/access.log;
autoindex off;
types {
text/css css;
application/javascript js;
}
}
error_log /var/log/nginx/error.log;
}
+59 -15
View File
@@ -7,9 +7,10 @@ Created on: 10.05.24
""" """
import base64 import base64
import hashlib import hashlib
import http
import json import json
from cryptography.fernet import Fernet from cryptography.fernet import Fernet, InvalidToken
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@@ -27,34 +28,77 @@ class PropagateUserView(View):
proper rights management) proper rights management)
""" """
class PropagateStatus:
UNPROCESSED = "unprocessed"
UPDATED = "updated"
CREATED = "created"
@method_decorator(csrf_exempt) @method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs): def post(self, request: HttpRequest, *args, **kwargs):
response_data = {
"success": None,
"status": None
}
# Decrypt # Decrypt
encrypted_body = request.body encrypted_body = request.body
_hash = hashlib.md5() _hash = hashlib.md5()
_hash.update(PROPAGATION_SECRET.encode("utf-8")) _hash.update(PROPAGATION_SECRET.encode("utf-8"))
key = base64.urlsafe_b64encode(_hash.hexdigest().encode("utf-8")) key = base64.urlsafe_b64encode(_hash.hexdigest().encode("utf-8"))
fernet = Fernet(key) fernet = Fernet(key)
body = fernet.decrypt(encrypted_body).decode("utf-8")
body = json.loads(body)
try: try:
status = "updated" body = fernet.decrypt(encrypted_body).decode("utf-8")
user = User.resolve_user_using_propagation_data(body) body = json.loads(body)
user = user.update_user_using_propagation_data(body) except InvalidToken:
except ObjectDoesNotExist: response_data["error"] = "Invalid Token"
user = User(**body) response_data["success"] = False
status = "created" response_data["status"] = self.PropagateStatus.UNPROCESSED
return JsonResponse(
status=http.HTTPStatus.UNPROCESSABLE_CONTENT,
data=response_data
)
except (json.JSONDecodeError) as e:
response_data["error"] = str(e)
response_data["success"] = False
response_data["status"] = self.PropagateStatus.UNPROCESSED
return JsonResponse(
status=http.HTTPStatus.UNPROCESSABLE_CONTENT,
data=response_data
)
# Process decrypted user data
processing_ret_vals = self.__process_user_data(body)
response_data["success"] = processing_ret_vals[0]
response_data["status"] = processing_ret_vals[1]
user = processing_ret_vals[2]
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
data = { return JsonResponse(
"success": True, status=http.HTTPStatus.OK,
"status": status data=response_data
} )
return JsonResponse(data) def __process_user_data(self, body: dict) -> (bool, str, User):
""" Process decrypted user data
Args:
body:
Returns:
success (bool): Whether the processing was successful
status (str): In which way the data was used ('created' | 'updated')
user (User): Processed user object
"""
try:
user = User.resolve_user_using_propagation_data(body)
user = user.update_user_using_propagation_data(body)
status = self.PropagateStatus.UPDATED
except ObjectDoesNotExist:
user = User(**body)
status = self.PropagateStatus.CREATED
return True, status, user