Compare commits

..

8 Commits

Author SHA1 Message Date
mpeltriaux da5def5d6f # OAuth Token removal
* adds deletion of oauth token stored on konova db on user logout
2026-10-06 11:55:37 +02:00
mpeltriaux cc0f4007f3 # Extended revocation
* refactors revocation of oauth2 tokens by adding revocation of access_tokens which will result in automatic removal on sso portal side
* drops unused return statement on same method
* adds assertions for correct revocation responses
2026-10-06 11:44:56 +02:00
mpeltriaux 87aea5e19b # OAUTH_CODE_VERIFIER
* drops unused env OATUH_CODE_VERIFIER
2026-10-06 11:29:03 +02:00
mpeltriaux e3d5ee82ee # OAuth2 update
* integrates authlib into project
* adds authlib package to requirements.txt
* refactors internal konova/utils/generators/generate_token method to use authlib's generate_token method and act as wrapper for backwards compatibility
* refactors OAuth views to be based on authlib's oauth client implementation
* refactors oauth model to work with authlib's oauth2 tokens
2026-10-06 11:27:50 +02:00
mpeltriaux 8de467ec24 Merge pull request '# OGR failure' (#590) from bugfix_malicious_geometry into master
Reviewed-on: #590
2026-09-17 16:29:20 +02:00
mpeltriaux 238a9c4db8 # OGR failure
* catches error in case of malicious input geometry
* updates translations
2026-09-17 16:28:39 +02:00
mpeltriaux 7fe5340995 Merge pull request '# Bugfix' (#588) from bugfix_empty_geometry_error into master
Reviewed-on: #588
2026-09-15 18:02:35 +02:00
mpeltriaux 606f1ed311 # Bugfix
* fixes bug where empty geometry on SimpleGeomForm could lead to error
2026-09-15 18:01:35 +02:00
16 changed files with 133 additions and 278 deletions
-1
View File
@@ -35,7 +35,6 @@ SCHNEIDER_AUTH_HEADER=auth
# SSO
SSO_SERVER_BASE_URL=https://login.naturschutz.rlp.de
OAUTH_CODE_VERIFIER=CHANGE_ME
OAUTH_CLIENT_ID=CHANGE_ME
OAUTH_CLIENT_SECRET=CHANGE_ME
PROPAGATION_SECRET=CHANGE_ME
-36
View File
@@ -1,36 +0,0 @@
# Nutze ein schlankes Python-Image
FROM python:3.13-slim-trixie
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.
## Background processes
### !!! For non-docker run
Konova uses celery for background processing. To start the worker you need to run
```shell
$ 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).
# 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;
}
}
```
+31 -22
View File
@@ -1,7 +1,9 @@
import datetime
import json
from datetime import timedelta
import requests
from authlib.oauth2.rfc6749 import OAuth2Token
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
@@ -78,27 +80,25 @@ class OAuthToken(UuidModel):
return str(self.access_token)
@staticmethod
def from_access_token_response(access_token_data: str, received_on):
def create_from_oauth2_token(oauth2_token: OAuth2Token):
"""
Creates an OAuthToken based on retrieved access token data (OAuth2.0 specification)
Args:
access_token_data (str): OAuth2.0 response data
received_on (): Timestamp when the response has been received
oauth2_token (str): OAuth2.0 response data
Returns:
"""
oauth_token = OAuthToken()
data = json.loads(access_token_data)
oauth_token.access_token = data.get("access_token")
oauth_token.refresh_token = data.get("refresh_token")
oauth_token.access_token = oauth2_token.get("access_token")
oauth_token.refresh_token = oauth2_token.get("refresh_token")
oauth_token.expires_on = datetime.datetime.fromtimestamp(oauth2_token.get("expires_at"))
expires_on = received_on + timedelta(
seconds=(data.get("expires_in") + OAuthToken.ASSUMED_LATENCY)
)
oauth_token.expires_on = expires_on
assert (oauth_token.access_token is not None and
oauth_token.refresh_token is not None and
oauth_token.expires_on is not None)
return oauth_token
@@ -155,25 +155,34 @@ class OAuthToken(UuidModel):
return user
def revoke(self) -> int:
def revoke(self) -> None:
""" Revokes the OAuth2 token of the user
(/o/revoke_token/ indeed removes the corresponding access token on provider side and invalidates the
submitted refresh token in one step)
Returns:
revocation_status_code (int): HTTP status code for revocation of refresh_token
"""
revoke_url = f"{SSO_SERVER_BASE}o/revoke_token/"
token = self.refresh_token
revocation_status_code = requests.post(
revoke_url,
data={
'token': token,
'token_type_hint': "refresh_token",
},
auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET),
).status_code
token_types = [
(self.refresh_token, "refresh_token"),
(self.access_token, "access_token"),
]
status_codes = []
for entry in token_types:
status_codes.append(
requests.post(
revoke_url,
data={
'token': entry[0],
'token_type_hint': entry[1],
},
auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET),
).status_code
)
assert len(status_codes) == 2
assert status_codes[0] == 200 and status_codes[1] == 200
return revocation_status_code
-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"'
+3 -2
View File
@@ -191,10 +191,11 @@ STATICFILES_DIRS = [
]
# EMAIL (see https://docs.djangoproject.com/en/dev/topics/email/)
# CHANGE_ME !!! ONLY FOR DEVELOPMENT !!!
if DEBUG:
# ONLY FOR DEVELOPMENT NEEDED
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
SERVER_EMAIL = DEFAULT_FROM_EMAIL # The default email sender address, which is used by Django to send errors via mail
-2
View File
@@ -12,8 +12,6 @@ SSO_SERVER_BASE = env("SSO_SERVER_BASE_URL")
SSO_SERVER = f"{SSO_SERVER_BASE}sso/"
# OAuth settings
OAUTH_CODE_VERIFIER = env("OAUTH_CODE_VERIFIER")
OAUTH_CLIENT_ID = env("OAUTH_CLIENT_ID")
OAUTH_CLIENT_SECRET = env("OAUTH_CLIENT_SECRET")
+4 -5
View File
@@ -8,6 +8,9 @@ Created on: 09.11.20
import secrets
import string
from authlib.common.security import generate_token as authlib_generate_token
def generate_token(length: int = 64) -> str:
""" Shortcut for default generating of e.g. API token
@@ -15,11 +18,7 @@ def generate_token(length: int = 64) -> str:
Returns:
token (str)
"""
return generate_random_string(
length=length,
use_numbers=True,
use_letters_lc=True
)
return authlib_generate_token(length)
def generate_random_string(length: int, use_numbers: bool = False, use_letters_lc: bool = False, use_letters_uc: bool = False) -> str:
+24 -3
View File
@@ -180,6 +180,24 @@ class GeometryProcessor:
is_area_valid = geom.area > 1 # > 1m² (SRID:25832)
return is_area_valid
@staticmethod
def is_valid_geometry(geom: gdal.OGRGeometry) -> bool:
""" Checks whether the geometry is technically valid
Args:
geom (OGRGeometry): The geom
Returns:
"""
try:
# Check geometry validity by triggering some low cost processing like centroid calculation
geom.centroid
geom.boundary
return True
except gdal.GDALException as e:
return False
class GeoJsonValidator:
""" GeoJson Validator validates geojson (e.g. from API or form input)
@@ -257,10 +275,10 @@ class GeoJsonValidator:
Returns:
"""
features = self._input_geojson.get("features", None)
features = self._input_geojson.get("features", [])
is_input_geojson_empty = len(self._input_geojson) == 0
no_features_in_input_found = features is None
no_features_in_input_found = len(features) == 0
if not is_input_geojson_empty and no_features_in_input_found:
# check if _input_geojson is a feature itself
@@ -273,7 +291,6 @@ class GeoJsonValidator:
else:
self.__add_error("Input does not seem to be geojson")
return
try:
validated_features = self.__validate_single_features(features)
except AssertionError as e:
@@ -306,6 +323,10 @@ class GeoJsonValidator:
g = gdal.OGRGeometry(feature_geom, srs=self._srs)
if g.empty:
continue
if not GeometryProcessor.is_valid_geometry(g):
raise AssertionError(_("This feature holds malicious parts and can not be processed!"))
g = GeometryProcessor.cast_to_rlp_srid(g)
if not GeometryProcessor.is_valid_25832(g):
raise AssertionError(_("This feature does not hold valid EPSG:25832 coordinates:\n {}".format(g.geojson)))
+1
View File
@@ -29,6 +29,7 @@ class LogoutView(View):
oauth_token = user.oauth_token
if oauth_token:
oauth_token.revoke()
oauth_token.delete()
except AttributeError:
pass
+61 -75
View File
@@ -7,33 +7,52 @@ Created on: 26.04.24
"""
import base64
import hashlib
from urllib.parse import urlencode
import requests
from authlib.common.security import generate_token
from authlib.integrations.base_client import OAuthError
from authlib.integrations.django_client import OAuth
from django.contrib.auth import login
from django.http import HttpRequest
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import now
from django.views import View
from api.models import OAuthToken
from konova.sub_settings.django_settings import BASE_URL
from konova.sub_settings.sso_settings import SSO_SERVER_BASE, OAUTH_CODE_VERIFIER, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET
from konova.sub_settings.sso_settings import SSO_SERVER_BASE, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET
_OAUTH = OAuth()
_OAUTH.register(
name="sso",
client_id=OAUTH_CLIENT_ID,
client_secret=OAUTH_CLIENT_SECRET,
authorize_url=f"{SSO_SERVER_BASE}o/authorize/",
access_token_url=f"{SSO_SERVER_BASE}o/token/",
code_challenge_method="S256",
client_kwargs={},
)
class OAuthLoginView(View):
"""
Starts OAuth Login procedure
-> AnonymousUser is redirected to SSO component using specific parameters
-> After successful login (in SSO component), user will be redirected to a specific callback url (OAuthCallbackView)
-> Callback view uses retrieved authorization token to get a proper access token from SSO component
-> SSO component answers with access token
-> OAuthCallbackView uses token in Authorization header to access user data of logged-in user in SSO component
-> OAuthCallbackView creates/updates user
-> OAuthCallbackView logs in user and redirects to default home view
""" View for OAuth2 login step
"""
def get(self, request: HttpRequest, *args, **kwargs):
redirect_uri = f'{BASE_URL}{reverse("oauth-callback")}'
# create new PKCE-Verifier
code_verifier, code_challenge = self.__create_code_challenge()
# keep verifier in session, so it can be read out for callback
request.session["oauth_code_verifier"] = code_verifier
return _OAUTH.sso.authorize_redirect(
request,
redirect_uri,
code_verifier=code_verifier,
)
def __create_code_challenge(self):
"""
@@ -44,82 +63,49 @@ class OAuthLoginView(View):
Returns:
"""
code_verifier = OAUTH_CODE_VERIFIER
code_verifier = generate_token(128)
code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '')
return code_verifier, code_challenge
def get(self, request: HttpRequest, *args, **kwargs):
"""
Redirects user to OAuth SSO webservice for credential based login there
Args:
request ():
*args ():
**kwargs ():
Returns:
"""
oauth_authentication_code_url = f"{SSO_SERVER_BASE}o/authorize/"
redirect_uri = f'{BASE_URL}{reverse("oauth-callback")}'
code_verifier, code_challenge = self.__create_code_challenge()
urlencode_params = urlencode(
{
"response_type": "code",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"client_id": OAUTH_CLIENT_ID,
"redirect_uri": redirect_uri,
}
)
url = f"{oauth_authentication_code_url}?{urlencode_params}"
return redirect(url)
class OAuthCallbackView(View):
"""
Callback view for OAuth2.0 authentication token.
Authentication tokens will be exchanged for access token.
Access Token will be used for fetching user data from SSO component.
User data will be used for creating/updating user data inside this app.
User will be logged-in and redirected to default home view.
""" View for OAuth2 callback step
"""
def get(self, request: HttpRequest, *args, **kwargs):
authentication_code = request.GET.get("code")
oauth_acces_token_url = f"{SSO_SERVER_BASE}o/token/"
callback_url = f'{BASE_URL}{reverse("oauth-callback")}'
params = {
"grant_type": "authorization_code",
"code": authentication_code,
"redirect_uri": callback_url,
"code_verifier": OAUTH_CODE_VERIFIER,
"client_id": OAUTH_CLIENT_ID,
"client_secret": OAUTH_CLIENT_SECRET
}
access_code_response = requests.post(
oauth_acces_token_url,
data=params
# Get code verifier (created on oatuh-login step) from session
code_verifier = request.session.pop(
"oauth_code_verifier",
None,
)
received_on = now()
if not code_verifier:
return HttpResponse(
"PKCE code_verifier missing or is invalid.",
status=400,
)
access_code_response_body = access_code_response.content.decode("utf-8")
status_code_invalid = access_code_response.status_code != 200
if status_code_invalid:
raise RuntimeError(f"OAuth access token could not be fetched: {access_code_response.text}")
try:
token = _OAUTH.sso.authorize_access_token(
request
)
except OAuthError as e:
print(e)
return HttpResponse(
"OAuth-Login failed while authorizing with OAuth.",
status=400,
)
try:
oauth_token = OAuthToken.create_from_oauth2_token(token)
except AssertionError:
return HttpResponse(
"OAuth-Login failed due to unexpected token content.",
status=400,
)
oauth_token = OAuthToken.from_access_token_response(access_code_response_body, received_on)
oauth_token.save()
user = oauth_token.update_and_get_user()
user.oauth_replace_token(oauth_token)
login(request, user)
return redirect("home")
Binary file not shown.
+7 -3
View File
@@ -45,7 +45,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-13 12:06+0200\n"
"POT-Creation-Date: 2026-09-17 16:25+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2012,7 +2012,11 @@ msgstr "In Zwischenablage kopiert"
msgid "Search"
msgstr "Suchen"
#: konova/utils/geometry/geometry_validator.py:311
#: konova/utils/geometry/geometry_validator.py:328
msgid "This feature holds malicious parts and can not be processed!"
msgstr "Teile der Geometrie sind ungültig und können nicht verarbeitet werden!"
#: konova/utils/geometry/geometry_validator.py:332
msgid ""
"This feature does not hold valid EPSG:25832 coordinates:\n"
" {}"
@@ -2020,7 +2024,7 @@ msgstr ""
"Dieses Feature enthält keine validen EPSG:25832 Koordinaten:\n"
" {}"
#: konova/utils/geometry/geometry_validator.py:319
#: konova/utils/geometry/geometry_validator.py:340
msgid "Only surfaces allowed. Points or lines must be buffered."
msgstr ""
"Nur Flächen erlaubt. Punkte oder Linien müssen zu Flächen gepuffert werden."
-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;
}
+2
View File
@@ -1,6 +1,7 @@
amqp==5.3.1
asgiref==3.12.1
async-timeout==5.0.1
Authlib==1.8.0
beautifulsoup4==4.15.0
billiard==4.2.4
cached-property==2.0.1
@@ -30,6 +31,7 @@ gunicorn==26.2.0
idna==3.19
importlib_metadata==9.0.1
itsdangerous==2.2.0
joserfc==1.7.5
jwcrypto==1.6.0
kombu==5.6.2
oauthlib==3.3.1