From e3d5ee82eed4d691b2541e77c3f3218f34bb64f8 Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Tue, 6 Oct 2026 11:27:50 +0200 Subject: [PATCH 1/4] # 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 --- api/models/token.py | 20 +++--- konova/utils/generators.py | 9 ++- konova/views/oauth.py | 136 +++++++++++++++++-------------------- requirements.txt | 2 + 4 files changed, 77 insertions(+), 90 deletions(-) diff --git a/api/models/token.py b/api/models/token.py index 5e52bf2e..dacadb08 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -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 diff --git a/konova/utils/generators.py b/konova/utils/generators.py index e68354af..d75ffa4c 100644 --- a/konova/utils/generators.py +++ b/konova/utils/generators.py @@ -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: diff --git a/konova/views/oauth.py b/konova/views/oauth.py index 8649e164..8e72840e 100644 --- a/konova/views/oauth.py +++ b/konova/views/oauth.py @@ -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") - diff --git a/requirements.txt b/requirements.txt index fa5ff5a3..a5c8b5fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 -- 2.52.0 From 87aea5e19b9b4793bc3e3e959896b64c305d2361 Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Tue, 6 Oct 2026 11:29:03 +0200 Subject: [PATCH 2/4] # OAUTH_CODE_VERIFIER * drops unused env OATUH_CODE_VERIFIER --- .env.sample | 1 - konova/sub_settings/sso_settings.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/.env.sample b/.env.sample index 31c0ac71..be782996 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/konova/sub_settings/sso_settings.py b/konova/sub_settings/sso_settings.py index 2a50e60c..3e80cf15 100644 --- a/konova/sub_settings/sso_settings.py +++ b/konova/sub_settings/sso_settings.py @@ -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") -- 2.52.0 From cc0f4007f3ea4dbd87216a4a1fa1faf95e0c7f2f Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Tue, 6 Oct 2026 11:44:56 +0200 Subject: [PATCH 3/4] # 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 --- api/models/token.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/api/models/token.py b/api/models/token.py index dacadb08..42b2b030 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -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 -- 2.52.0 From da5def5d6f534b280999b7818ea796ddf251428a Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Tue, 6 Oct 2026 11:55:37 +0200 Subject: [PATCH 4/4] # OAuth Token removal * adds deletion of oauth token stored on konova db on user logout --- konova/views/logout.py | 1 + 1 file changed, 1 insertion(+) diff --git a/konova/views/logout.py b/konova/views/logout.py index abeb9b3e..9cc81445 100644 --- a/konova/views/logout.py +++ b/konova/views/logout.py @@ -29,6 +29,7 @@ class LogoutView(View): oauth_token = user.oauth_token if oauth_token: oauth_token.revoke() + oauth_token.delete() except AttributeError: pass -- 2.52.0