diff --git a/api/admin.py b/api/admin.py
index 2d214d07..3cad8630 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from api.models.token import APIUserToken
+from api.models.token import APIUserToken, OAuthToken
class APITokenAdmin(admin.ModelAdmin):
@@ -17,4 +17,17 @@ class APITokenAdmin(admin.ModelAdmin):
]
+class OAuthTokenAdmin(admin.ModelAdmin):
+ list_display = [
+ "access_token",
+ "refresh_token",
+ "expires_on",
+ ]
+ search_fields = [
+ "access_token",
+ "refresh_token",
+ ]
+
+
admin.site.register(APIUserToken, APITokenAdmin)
+admin.site.register(OAuthToken, OAuthTokenAdmin)
diff --git a/api/migrations/0003_oauthtoken.py b/api/migrations/0003_oauthtoken.py
new file mode 100644
index 00000000..e0eba434
--- /dev/null
+++ b/api/migrations/0003_oauthtoken.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.0.4 on 2024-04-30 07:20
+
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0002_alter_apiusertoken_valid_until'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='OAuthToken',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('access_token', models.CharField(db_comment='OAuth access token', max_length=255)),
+ ('refresh_token', models.CharField(db_comment='OAuth refresh token', max_length=255)),
+ ('expires_on', models.DateTimeField(db_comment='When the token will be expired')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/api/models/token.py b/api/models/token.py
index c528528c..dac0b4a7 100644
--- a/api/models/token.py
+++ b/api/models/token.py
@@ -1,7 +1,14 @@
+import json
+from datetime import timedelta
+
+import requests
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
+from django.utils.timezone import now
+from konova.models import UuidModel
+from konova.sub_settings.sso_settings import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, SSO_SERVER_BASE
from konova.utils.generators import generate_token
@@ -46,3 +53,105 @@ class APIUserToken(models.Model):
except ObjectDoesNotExist:
raise PermissionError("Credentials invalid")
return token_obj.user
+
+
+class OAuthToken(UuidModel):
+ access_token = models.CharField(
+ max_length=255,
+ blank=False,
+ null=False,
+ db_comment="OAuth access token"
+ )
+ refresh_token = models.CharField(
+ max_length=255,
+ blank=False,
+ null=False,
+ db_comment="OAuth refresh token"
+ )
+ expires_on = models.DateTimeField(
+ db_comment="When the token will be expired"
+ )
+
+ ASSUMED_LATENCY = 1000 # assumed latency between creation and receiving of an access token
+
+ def __str__(self):
+ return str(self.access_token)
+
+ @staticmethod
+ def from_access_token_response(access_token_data: str, received_on):
+ """
+ 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
+
+ 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")
+
+ expires_on = received_on + timedelta(
+ seconds=(data.get("expires_in") + OAuthToken.ASSUMED_LATENCY)
+ )
+ oauth_token.expires_on = expires_on
+
+ return oauth_token
+
+ def refresh(self):
+ url = f"{SSO_SERVER_BASE}o/token/"
+ params = {
+ "grant_type": "refresh_token",
+ "refresh_token": self.refresh_token,
+ "client_id": OAUTH_CLIENT_ID,
+ "client_secret": OAUTH_CLIENT_SECRET
+ }
+ response = requests.post(
+ url,
+ params
+ )
+ _now = now()
+ is_response_invalid = response.status_code != 200
+ if is_response_invalid:
+ raise RuntimeError(f"Refreshing token not possible: {response.status_code}")
+
+ response_content = response.content.decode("utf-8")
+ response_content = json.loads(response_content)
+
+ access_token = response_content.get("access_token")
+ refresh_token = response_content.get("refresh_token")
+ expires_in = response_content.get("expires")
+
+ self.access_token = access_token
+ self.refresh_token = refresh_token
+ self.expires_in = expires_in
+ self.save()
+
+ return self
+
+ def update_and_get_user(self):
+ from user.models import User
+ url = f"{SSO_SERVER_BASE}users/oauth/data/"
+
+ access_token = self.access_token
+ response = requests.get(
+ url,
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ }
+ )
+
+ is_response_code_invalid = response.status_code != 200
+ if is_response_code_invalid:
+ raise RuntimeError(f"OAuth user data fetching unsuccessful: {response.status_code}")
+
+ response_content = response.content.decode("utf-8")
+ response_content = json.loads(response_content)
+ user = User.oauth_update_user(response_content)
+
+ return user
+
diff --git a/api/utils/serializer/serializer.py b/api/utils/serializer/serializer.py
index 04a03123..c3096cc1 100644
--- a/api/utils/serializer/serializer.py
+++ b/api/utils/serializer/serializer.py
@@ -11,6 +11,7 @@ from abc import abstractmethod
from django.contrib.gis import geos
from django.contrib.gis.geos import GEOSGeometry
from django.core.paginator import Paginator
+from django.db.models import Q
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from konova.utils.message_templates import DATA_UNSHARED
@@ -32,8 +33,8 @@ class AbstractModelAPISerializer:
self.lookup = {
"id": None, # must be set
"deleted__isnull": True,
- "users__in": [], # must be set
}
+ self.shared_lookup = Q() # must be set, so user or team based share will be used properly
super().__init__(*args, **kwargs)
@abstractmethod
@@ -76,7 +77,11 @@ class AbstractModelAPISerializer:
else:
# Return certain object
self.lookup["id"] = _id
- self.lookup["users__in"] = [user]
+
+ self.shared_lookup = Q(
+ Q(users__in=[user]) |
+ Q(teams__in=list(user.shared_teams))
+ )
def fetch_and_serialize(self):
""" Serializes the model entry according to the given lookup data
@@ -86,7 +91,13 @@ class AbstractModelAPISerializer:
Returns:
serialized_data (dict)
"""
- entries = self.model.objects.filter(**self.lookup).order_by("id")
+ entries = self.model.objects.filter(
+ **self.lookup
+ ).filter(
+ self.shared_lookup
+ ).order_by(
+ "id"
+ ).distinct()
self.paginator = Paginator(entries, self.rpp)
requested_entries = self.paginator.page(self.page_number)
diff --git a/api/utils/serializer/v1/compensation.py b/api/utils/serializer/v1/compensation.py
index 5b38b4cd..81ea46c3 100644
--- a/api/utils/serializer/v1/compensation.py
+++ b/api/utils/serializer/v1/compensation.py
@@ -6,6 +6,7 @@ Created on: 24.01.22
"""
from django.db import transaction
+from django.db.models import Q
from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin
from compensation.models import Compensation
@@ -21,8 +22,10 @@ class CompensationAPISerializerV1(AbstractModelAPISerializerV1, AbstractCompensa
def prepare_lookup(self, id, user):
super().prepare_lookup(id, user)
- del self.lookup["users__in"]
- self.lookup["intervention__users__in"] = [user]
+ self.shared_lookup = Q(
+ Q(intervention__users__in=[user]) |
+ Q(intervention__teams__in=user.shared_teams)
+ )
def intervention_to_json(self, entry):
return {
diff --git a/api/utils/serializer/v1/deduction.py b/api/utils/serializer/v1/deduction.py
index c66a2129..708b3775 100644
--- a/api/utils/serializer/v1/deduction.py
+++ b/api/utils/serializer/v1/deduction.py
@@ -6,6 +6,7 @@ Created on: 28.01.22
"""
from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
from api.utils.serializer.v1.serializer import DeductableAPISerializerV1Mixin, AbstractModelAPISerializerV1
from compensation.models import EcoAccountDeduction, EcoAccount
@@ -28,9 +29,11 @@ class DeductionAPISerializerV1(AbstractModelAPISerializerV1,
"""
super().prepare_lookup(_id, user)
- del self.lookup["users__in"]
del self.lookup["deleted__isnull"]
- self.lookup["intervention__users__in"] = [user]
+ self.shared_lookup = Q(
+ Q(intervention__users__in=[user]) |
+ Q(intervention__teams__in=user.shared_teams)
+ )
def _model_to_geo_json(self, entry):
""" Adds the basic data
diff --git a/api/views/v1/views.py b/api/views/v1/views.py
index 8da5d49e..5ab9f4b9 100644
--- a/api/views/v1/views.py
+++ b/api/views/v1/views.py
@@ -23,11 +23,6 @@ class AbstractAPIViewV1(AbstractAPIView):
"""
def __init__(self, *args, **kwargs):
- self.lookup = {
- "id": None, # must be set in subclasses
- "deleted__isnull": True,
- "users__in": [], # must be set in subclasses
- }
super().__init__(*args, **kwargs)
self.serializer = self.serializer()
diff --git a/konova/sso/sso.py b/konova/sso/sso.py
deleted file mode 100644
index f3038428..00000000
--- a/konova/sso/sso.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""
-Author: Michel Peltriaux
-Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
-Contact: michel.peltriaux@sgdnord.rlp.de
-Created on: 17.08.21
-
-"""
-from django.http import HttpResponse
-from django.urls import re_path
-from django.views import View
-from django.views.decorators.csrf import csrf_exempt
-from itsdangerous import TimedSerializer
-from simple_sso.sso_client.client import Client
-
-from user.models import User
-
-
-class PropagateView(View):
- """ View used to receive propagated sso-server user data
-
- """
- client = None
- signer = None
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.signer = TimedSerializer(self.client.private_key)
-
- @csrf_exempt
- def dispatch(self, request, *args, **kwargs):
- return super().dispatch(request, *args, **kwargs)
-
- def post(self, request):
- user_data = request.body
- user_data = self.signer.loads(user_data)
- self.client.build_user(user_data)
- return HttpResponse(status=200)
-
-
-class KonovaSSOClient(Client):
- """ Konova specialized derivative of general sso.Client.
-
- Adds some custom behaviour for konova usage.
-
- """
- propagate_view = PropagateView
-
- def get_urls(self):
- urls = super().get_urls()
- urls += re_path(r'^propagate/$', self.propagate_view.as_view(client=self), name='simple-sso-propagate'),
- return urls
-
- def build_user(self, user_data):
- """ Creates a user or updates user data
-
- Args:
- user_data ():
-
- Returns:
-
- """
- try:
- user = User.objects.get(username=user_data['username'])
- # Update user data, excluding some changes
- skipable_attrs = {
- "username",
- "is_staff",
- "is_superuser",
- }
- for _attr, _val in user_data.items():
- if _attr in skipable_attrs:
- continue
- setattr(user, _attr, _val)
- except User.DoesNotExist:
- user = User(**user_data)
- user.set_unusable_password()
- user.save()
- return user
\ No newline at end of file
diff --git a/konova/sub_settings/django_settings.py b/konova/sub_settings/django_settings.py
index b4b0a03a..3cd9dc98 100644
--- a/konova/sub_settings/django_settings.py
+++ b/konova/sub_settings/django_settings.py
@@ -47,7 +47,7 @@ CSRF_TRUSTED_ORIGINS = [
]
# Authentication settings
-LOGIN_URL = "/login/"
+LOGIN_URL = "/oauth/login/"
# Session settings
SESSION_COOKIE_AGE = 60 * 60 # 60 minutes
@@ -81,10 +81,6 @@ INSTALLED_APPS = [
'analysis',
'api',
]
-if DEBUG:
- INSTALLED_APPS += [
- 'debug_toolbar',
- ]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@@ -96,10 +92,6 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
-if DEBUG:
- MIDDLEWARE += [
- "debug_toolbar.middleware.DebugToolbarMiddleware",
- ]
ROOT_URLCONF = 'konova.urls'
@@ -200,28 +192,6 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'templates/map/client/libs'), # NETGIS map client files
]
-# DJANGO DEBUG TOOLBAR
-INTERNAL_IPS = [
- "127.0.0.1"
-]
-DEBUG_TOOLBAR_CONFIG = {
- "DISABLE_PANELS": {
- 'debug_toolbar.panels.versions.VersionsPanel',
- 'debug_toolbar.panels.timer.TimerPanel',
- 'debug_toolbar.panels.settings.SettingsPanel',
- 'debug_toolbar.panels.headers.HeadersPanel',
- 'debug_toolbar.panels.request.RequestPanel',
- 'debug_toolbar.panels.sql.SQLPanel',
- 'debug_toolbar.panels.staticfiles.StaticFilesPanel',
- 'debug_toolbar.panels.templates.TemplatesPanel',
- 'debug_toolbar.panels.cache.CachePanel',
- 'debug_toolbar.panels.signals.SignalsPanel',
- 'debug_toolbar.panels.logging.LoggingPanel',
- 'debug_toolbar.panels.redirects.RedirectsPanel',
- 'debug_toolbar.panels.profiling.ProfilingPanel',
- }
-}
-
# EMAIL (see https://docs.djangoproject.com/en/dev/topics/email/)
# CHANGE_ME !!! ONLY FOR DEVELOPMENT !!!
diff --git a/konova/sub_settings/sso_settings.py b/konova/sub_settings/sso_settings.py
index 0d9a069d..03eb92d9 100644
--- a/konova/sub_settings/sso_settings.py
+++ b/konova/sub_settings/sso_settings.py
@@ -10,5 +10,9 @@ from konova.sub_settings.django_settings import env
# SSO settings
SSO_SERVER_BASE = env("SSO_SERVER_BASE_URL")
SSO_SERVER = f"{SSO_SERVER_BASE}sso/"
-SSO_PRIVATE_KEY = env("SSO_PRIVATE_KEY")
-SSO_PUBLIC_KEY = env("SSO_PUBLIC_KEY")
+
+# OAuth settings
+OAUTH_CODE_VERIFIER = env("OAUTH_CODE_VERIFIER")
+
+OAUTH_CLIENT_ID = env("OAUTH_CLIENT_ID")
+OAUTH_CLIENT_SECRET = env("OAUTH_CLIENT_SECRET")
\ No newline at end of file
diff --git a/konova/templates/konova/includes/comment_card.html b/konova/templates/konova/includes/comment_card.html
index d9ea59bc..51a5667a 100644
--- a/konova/templates/konova/includes/comment_card.html
+++ b/konova/templates/konova/includes/comment_card.html
@@ -20,7 +20,7 @@
- {{obj.comment}}
+ {{obj.comment|linebreaks}}
diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py
index 860b3616..95fb8367 100644
--- a/konova/tests/test_views.py
+++ b/konova/tests/test_views.py
@@ -509,7 +509,7 @@ class BaseViewTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
- self.login_url = reverse("simple-sso-login")
+ self.login_url = reverse("oauth-login")
def assert_url_success(self, client: Client, urls: list):
""" Assert for all given urls a direct 200 response
diff --git a/konova/urls.py b/konova/urls.py
index 260251ad..8dc9a01f 100644
--- a/konova/urls.py
+++ b/konova/urls.py
@@ -13,21 +13,19 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
-import debug_toolbar
from django.contrib import admin
from django.urls import path, include
-from konova.settings import SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY, DEBUG
-from konova.sso.sso import KonovaSSOClient
from konova.views.logout import LogoutView
from konova.views.geometry import GeomParcelsView, GeomParcelsContentView
from konova.views.home import HomeView
from konova.views.map_proxy import ClientProxyParcelSearch, ClientProxyParcelWFS
+from konova.views.oauth import OAuthLoginView, OAuthCallbackView
-sso_client = KonovaSSOClient(SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY)
urlpatterns = [
path('admin/', admin.site.urls),
- path('login/', include(sso_client.get_urls())),
+ path('oauth/callback/', OAuthCallbackView.as_view(), name="oauth-callback"),
+ path('oauth/login/', OAuthLoginView.as_view(), name="oauth-login"),
path('logout/', LogoutView.as_view(), name="logout"),
path('', HomeView.as_view(), name="home"),
path('intervention/', include("intervention.urls")),
@@ -44,10 +42,5 @@ urlpatterns = [
path('client/proxy/wfs', ClientProxyParcelWFS.as_view(), name="client-proxy-wfs"),
]
-if DEBUG:
- urlpatterns += [
- path('__debug__/', include(debug_toolbar.urls)),
- ]
-
handler404 = "konova.views.error.get_404_view"
handler500 = "konova.views.error.get_500_view"
diff --git a/konova/views/oauth.py b/konova/views/oauth.py
new file mode 100644
index 00000000..5748d482
--- /dev/null
+++ b/konova/views/oauth.py
@@ -0,0 +1,125 @@
+"""
+Author: Michel Peltriaux
+Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
+Contact: ksp-servicestelle@sgdnord.rlp.de
+Created on: 26.04.24
+
+"""
+import base64
+import hashlib
+from urllib.parse import urlencode
+
+import requests
+from django.contrib.auth import login
+from django.http import HttpRequest
+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
+
+
+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
+
+ """
+
+ def __create_code_challenge(self):
+ """
+ Creates a code verifier and code challenge for extra security.
+ See https://django-oauth-toolkit.readthedocs.io/en/latest/getting_started.html#authorization-code for further
+ information
+
+ Returns:
+
+ """
+ code_verifier = OAUTH_CODE_VERIFIER
+
+ 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.
+
+ """
+
+ 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
+ )
+ received_on = now()
+
+ 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}")
+
+ oauth_access_token = OAuthToken.from_access_token_response(access_code_response_body, received_on)
+ oauth_access_token.save()
+ user = oauth_access_token.update_and_get_user()
+ user.oauth_replace_token(oauth_access_token)
+
+ login(request, user)
+ return redirect("home")
+
diff --git a/requirements.txt b/requirements.txt
index 6d031927..e1815ae8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,40 +1,45 @@
amqp==5.2.0
-asgiref==3.7.2
+asgiref==3.8.1
async-timeout==4.0.3
-beautifulsoup4==4.12.3
+beautifulsoup4==4.13.0b2
billiard==4.2.0
cached-property==1.5.2
-celery==5.3.6
-certifi==2024.2.2
+celery==5.4.0
+certifi==2024.6.2
+cffi==1.17.0rc1
chardet==5.2.0
charset-normalizer==3.3.2
click==8.1.7
-click-didyoumean==0.3.0
+click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
-coverage==7.3.3
+coverage==7.5.3
+cryptography==42.0.8
Deprecated==1.2.14
-Django==5.0.3
+Django==5.0.6
django-autocomplete-light==3.11.0
django-bootstrap-modal-forms==3.0.4
-django-bootstrap4==24.1
-django-debug-toolbar==4.2.0
+django-bootstrap4==24.3
django-environ==0.11.2
-django-filter==24.1
+django-filter==24.2
django-fontawesome-5==1.0.18
-django-simple-sso==1.2.0
+django-oauth-toolkit==2.4.0
django-tables2==2.7.0
et-xmlfile==1.1.0
-idna==3.6
-importlib_metadata==7.0.2
-itsdangerous==0.24
-kombu==5.3.5
+gunicorn==22.0.0
+idna==3.7
+importlib_metadata==7.1.0
+jwcrypto==1.5.6
+kombu==5.3.7
+oauthlib==3.2.2
openpyxl==3.2.0b1
-packaging==24.0
+packaging==24.1
pika==1.3.2
-prompt-toolkit==3.0.43
-psycopg==3.1.18
-psycopg-binary==3.1.18
+pillow==10.3.0
+prompt_toolkit==3.0.47
+psycopg==3.1.19
+psycopg-binary==3.1.19
+pycparser==2.22
pyparsing==3.1.2
pypng==0.20220715.0
pyproj==3.6.1
@@ -42,17 +47,16 @@ python-dateutil==2.9.0.post0
pytz==2024.1
PyYAML==6.0.1
qrcode==7.3.1
-redis==5.1.0a1
-requests==2.31.0
+redis==5.1.0b6
+requests==2.32.3
six==1.16.0
soupsieve==2.5
-sqlparse==0.4.4
-typing_extensions==4.10.0
+sqlparse==0.5.0
+typing_extensions==4.12.2
tzdata==2024.1
urllib3==2.2.1
vine==5.1.0
-wcwidth==0.2.12
-webservices==0.7
+wcwidth==0.2.13
wrapt==1.16.0
xmltodict==0.13.0
-zipp==3.17.0
+zipp==3.19.2
diff --git a/user/admin.py b/user/admin.py
index bf5f5f86..ff848e53 100644
--- a/user/admin.py
+++ b/user/admin.py
@@ -29,6 +29,7 @@ class UserAdmin(admin.ModelAdmin):
"is_staff",
"is_superuser",
"api_token",
+ "oauth_token",
"groups",
"notifications",
"date_joined",
diff --git a/user/migrations/0009_user_oauth_token.py b/user/migrations/0009_user_oauth_token.py
new file mode 100644
index 00000000..6d4f11cd
--- /dev/null
+++ b/user/migrations/0009_user_oauth_token.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.0.4 on 2024-04-30 07:20
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0003_oauthtoken'),
+ ('user', '0008_alter_user_id'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='oauth_token',
+ field=models.ForeignKey(blank=True, db_comment='OAuth token for the user', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='api.oauthtoken'),
+ ),
+ ]
diff --git a/user/models/user.py b/user/models/user.py
index dde88dcd..33390885 100644
--- a/user/models/user.py
+++ b/user/models/user.py
@@ -9,7 +9,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
-from api.models import APIUserToken
+from api.models import APIUserToken, OAuthToken
from konova.settings import ZB_GROUP, DEFAULT_GROUP, ETS_GROUP
from konova.utils.mailer import Mailer
from user.enums import UserNotificationEnum
@@ -24,6 +24,14 @@ class User(AbstractUser):
help_text="The user's API token",
on_delete=models.SET_NULL
)
+ oauth_token = models.ForeignKey(
+ "api.OAuthToken",
+ blank=True,
+ null=True,
+ on_delete=models.SET_NULL,
+ db_comment="OAuth token for the user",
+ related_name="+"
+ )
def is_notification_setting_set(self, notification_enum: UserNotificationEnum):
return self.notifications.filter(
@@ -214,4 +222,46 @@ class User(AbstractUser):
shared_teams = self.teams.filter(
deleted__isnull=True
)
- return shared_teams
\ No newline at end of file
+ return shared_teams
+
+ @staticmethod
+ def oauth_update_user(user_data: dict):
+ """
+ Get or create a user depending on given user_data.
+ If the user record already exists, it's data will be updated using user_data.
+
+ Args:
+ user_data (dict): User data from OAuth SSO component
+
+ Returns:
+ user (User): The resolved user
+ """
+ username = user_data.get("username")
+ user, is_created = User.objects.get_or_create(
+ username=username
+ )
+
+ if is_created:
+ user.set_unusable_password()
+
+ user.first_name = user_data.get("first_name")
+ user.last_name = user_data.get("last_name")
+ user.email = user_data.get("email")
+
+ return user
+
+ def oauth_replace_token(self, token: OAuthToken):
+ """
+ Drops old token (if existing) and stores given token.
+
+ Args:
+ token (OAuthToken): New token
+
+ Returns:
+ user (User)
+ """
+ if self.oauth_token:
+ self.oauth_token.delete()
+ self.oauth_token = token
+ self.save()
+ return self
\ No newline at end of file
diff --git a/user/urls.py b/user/urls.py
index ed975488..cb6e20b0 100644
--- a/user/urls.py
+++ b/user/urls.py
@@ -9,11 +9,13 @@ from django.urls import path
from user.autocomplete.share import ShareUserAutocomplete, ShareTeamAutocomplete
from user.autocomplete.team import TeamAdminAutocomplete
-from user.views import *
+from user.views.propagate import PropagateUserView
+from user.views.views import *
app_name = "user"
urlpatterns = [
path("", index_view, name="index"),
+ path("propagate/", PropagateUserView.as_view(), name="propagate"),
path("notifications/", notifications_view, name="notifications"),
path("token/api", api_token_view, name="api-token"),
path("contact/", contact_view, name="contact"),
diff --git a/user/views/__init__.py b/user/views/__init__.py
new file mode 100644
index 00000000..1f81b60d
--- /dev/null
+++ b/user/views/__init__.py
@@ -0,0 +1,7 @@
+"""
+Author: Michel Peltriaux
+Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
+Contact: ksp-servicestelle@sgdnord.rlp.de
+Created on: 10.05.24
+
+"""
diff --git a/user/views/propagate.py b/user/views/propagate.py
new file mode 100644
index 00000000..b285dcf0
--- /dev/null
+++ b/user/views/propagate.py
@@ -0,0 +1,69 @@
+"""
+Author: Michel Peltriaux
+Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
+Contact: ksp-servicestelle@sgdnord.rlp.de
+Created on: 10.05.24
+
+"""
+import base64
+import hashlib
+import json
+
+from cryptography.fernet import Fernet
+from django.core.exceptions import ObjectDoesNotExist
+from django.http import HttpRequest, JsonResponse
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+
+from konova.sub_settings.sso_settings import OAUTH_CLIENT_ID
+from user.models import User
+
+
+class PropagateUserView(View):
+ """ Receives user data to be stored in db
+
+ Used if new user gets access to application and needs to be created in application before first login (e.g.
+ proper rights management)
+
+ """
+
+ @method_decorator(csrf_exempt)
+ def dispatch(self, request, *args, **kwargs):
+ return super().dispatch(request, *args, **kwargs)
+
+ def post(self, request: HttpRequest, *args, **kwargs):
+ # Decrypt
+ encrypted_body = request.body
+ hash = hashlib.md5()
+ hash.update(OAUTH_CLIENT_ID.encode("utf-8"))
+ key = base64.urlsafe_b64encode(hash.hexdigest().encode("utf-8"))
+ fernet = Fernet(key)
+ body = fernet.decrypt(encrypted_body).decode("utf-8")
+ body = json.loads(body)
+
+ try:
+ status = "updated"
+ user = User.objects.get(username=body.get('username'))
+ # Update user data, excluding some changes
+ skipable_attrs = {
+ "username",
+ "is_staff",
+ "is_superuser",
+ }
+ for _attr, _val in body.items():
+ if _attr in skipable_attrs:
+ continue
+ setattr(user, _attr, _val)
+ except ObjectDoesNotExist:
+ user = User(**body)
+ status = "created"
+ user.set_unusable_password()
+ user.save()
+
+ data = {
+ "success": True,
+ "status": status
+ }
+
+ return JsonResponse(data)
diff --git a/user/views.py b/user/views/views.py
similarity index 100%
rename from user/views.py
rename to user/views/views.py