From e21bc082f0e8b1091e07062b8ac89d4a4ae5a51a Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:06:10 +0100 Subject: [PATCH 1/9] token validation * use keycloak, use jwks * use keycloak, use jwks * disable permission check from token * PermissionService instead permissions in token * get permissions from auth-ms * parametrize permissions helper * python package version bump * python package grisera2 * python publish adjustment * VERIFY ISS env --- .github/workflows/python-publish.yml | 4 +- grisera/auth/auth_bearer.py | 4 +- grisera/auth/auth_config.py | 10 +++- grisera/auth/auth_handler.py | 74 +++++++++++++++++++++++----- grisera/dataset/dataset_router.py | 8 ++- grisera/helpers/helpers.py | 36 +++++++++++++- requirements.txt | 5 +- setup.py | 8 +-- 8 files changed, 123 insertions(+), 26 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index e9d3822..b42e3ca 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,7 +10,7 @@ name: Upload Python Package on: push: - branches: [ "development_1.x" ] + branches: [ "development_2.1" ] permissions: contents: read @@ -37,7 +37,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.THIS_GITHUB_TOKEN }} DEFAULT_BRANCH: ${{ github.ref_name }} - DEFAULT_BUMP: minor + DEFAULT_BUMP: patch WITH_V: false - name: Build and publish env: diff --git a/grisera/auth/auth_bearer.py b/grisera/auth/auth_bearer.py index f02bf66..cd18d22 100644 --- a/grisera/auth/auth_bearer.py +++ b/grisera/auth/auth_bearer.py @@ -1,7 +1,7 @@ from fastapi import Request, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from grisera.auth.auth_handler import decodeJWT, verify_jwt +from grisera.auth.auth_handler import decode_jwt, verify_jwt class JWTBearer(HTTPBearer): @@ -15,6 +15,6 @@ async def __call__(self, request: Request): raise HTTPException(status_code=403, detail="Invalid authentication scheme.") if not verify_jwt(credentials.credentials): raise HTTPException(status_code=403, detail="Invalid token or expired token.") - return decodeJWT(credentials.credentials) + return decode_jwt(credentials.credentials) else: raise HTTPException(status_code=403, detail="Invalid authorization code.") diff --git a/grisera/auth/auth_config.py b/grisera/auth/auth_config.py index 57463f4..aef885f 100644 --- a/grisera/auth/auth_config.py +++ b/grisera/auth/auth_config.py @@ -1,4 +1,10 @@ import os -JWT_SECRET = os.environ.get("JWT_SECRET") or "jwtsecret" -JWT_ALGORITHM = os.environ.get("JWT_ALGORITHM") or "HS256" +KEYCLOAK_SERVER = os.environ.get("KEYCLOAK_SERVER") or "http://localhost:8090" +REALM = os.environ.get("REALM") or "grisera" +JWKS_URL = os.environ.get("JWKS_URL") or f"{KEYCLOAK_SERVER}/realms/{REALM}/protocol/openid-connect/certs" +JWT_ALGORITHM = os.environ.get("JWT_ALGORITHM") or "RS256" +CLIENT_ID = os.environ.get("CLIENT_ID") or "grisera-api" +CLIENT_SECRET = os.environ.get("CLIENT_SECRET") or "6UkCrp7UqFy78vh5TVhkaYP0OuVagNTd" +PERMISSIONS_ENDPOINT = os.environ.get("PERMISSIONS_ENDPOINT") or "http://localhost:8085/api/permissions" +VERIFY_ISS = os.environ.get("VERIFY_ISS") or False diff --git a/grisera/auth/auth_handler.py b/grisera/auth/auth_handler.py index acd91c4..f2d65c9 100644 --- a/grisera/auth/auth_handler.py +++ b/grisera/auth/auth_handler.py @@ -1,25 +1,73 @@ import time +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat import jwt -from grisera.auth.auth_config import JWT_SECRET, JWT_ALGORITHM +from grisera.auth.auth_config import JWT_ALGORITHM, JWKS_URL, VERIFY_ISS +import requests -def verify_jwt(jwtoken: str) -> bool: - is_token_valid: bool = False +def get_jwks(): + response = requests.get(JWKS_URL) + response.raise_for_status() + return response.json()["keys"] + + +def get_key_by_kid(kid: str): + jwks = get_jwks() + for key in jwks: + if key["kid"] == kid: + return key + raise ValueError("Key not found") + +def verify_jwt(jwtoken: str) -> bool: try: - payload = decodeJWT(jwtoken) - except: - payload = None - if payload: - is_token_valid = True - return is_token_valid + payload = decode_jwt(jwtoken) + return payload is not None + except Exception as e: + return False + +def construct_pem_key(key): + if key["kty"] != "RSA": + raise ValueError("Only RSA keys are supported") -def decodeJWT(token: str) -> dict: + exponent = int.from_bytes(jwt.utils.base64url_decode(key["e"]), "big") + modulus = int.from_bytes(jwt.utils.base64url_decode(key["n"]), "big") + + public_key = rsa.RSAPublicNumbers(exponent, modulus).public_key() + return public_key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + + +def decode_jwt(token: str) -> dict: try: - decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - return decoded_token if decoded_token["exp"] >= time.time() else None + # Get the unverified headers to extract the Key ID (kid) + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + if not kid: + raise ValueError("Token header missing 'kid'") + + # Fetch the public key using the kid + key = get_key_by_kid(kid) + + # Construct the public key + public_key = construct_pem_key(key) + + # Decode and validate the token + decoded_token = jwt.decode( + token, + public_key, + algorithms=[JWT_ALGORITHM], + verify_iss=VERIFY_ISS, + ) + # Additional expiration check + if decoded_token["exp"] < time.time(): + raise ValueError("Token has expired") + + return decoded_token except Exception as e: - return {} + # Log the error or handle as needed + print(f"Token verification failed: {e}") + return None diff --git a/grisera/dataset/dataset_router.py b/grisera/dataset/dataset_router.py index aed82ec..a3021b8 100644 --- a/grisera/dataset/dataset_router.py +++ b/grisera/dataset/dataset_router.py @@ -10,7 +10,7 @@ from grisera.channel.channel_model import Types as channel_types from grisera.dataset.dataset_model import DatasetOut, DatasetsOut, DatasetIn from grisera.helpers.hateoas import get_links -from grisera.helpers.helpers import check_dataset_permission +from grisera.helpers.helpers import check_dataset_permission, get_permissions from grisera.life_activity.life_activity_model import LifeActivity as life_activity_types from grisera.measure_name.measure_name_model import MeasureName as measure_name_types, MeasureNameIn from grisera.measure.measure_model import Measure as measure_type, MeasureIn @@ -103,8 +103,12 @@ async def get_datasets(self, response: Response, token=Depends(JWTBearer())): Get all datasets """ dataset_ids = [] - for permission in token['permissions']: + user_id = token['sub'] + permissions = get_permissions(user_id) + print(permissions) + for permission in permissions: dataset_ids.append(str(permission['datasetId'])) + print(dataset_ids) get_response = self.dataset_service.get_datasets(dataset_ids) if get_response.errors is not None: response.status_code = 422 diff --git a/grisera/helpers/helpers.py b/grisera/helpers/helpers.py index 062cb03..73b2ab8 100644 --- a/grisera/helpers/helpers.py +++ b/grisera/helpers/helpers.py @@ -1,9 +1,12 @@ +import requests from fastapi import Request, Depends, HTTPException from typing import Union from grisera.auth.auth_bearer import JWTBearer from grisera.auth.auth_module import Roles +from grisera.auth.auth_config import PERMISSIONS_ENDPOINT, KEYCLOAK_SERVER, REALM, CLIENT_ID, CLIENT_SECRET + def create_stub_from_response(response, id_key='id', properties=None): if properties is None: @@ -21,10 +24,41 @@ def create_stub_from_response(response, id_key='id', properties=None): def check_dataset_permission(request: Request, dataset_id: Union[int, str], token=Depends(JWTBearer())): - for permission in token['permissions']: + user_id = token['sub'] + permissions = get_permissions(user_id) + for permission in permissions: if str(permission['datasetId']) == str(dataset_id): if (not request.method == "GET") and (str(permission['role']) == Roles.reader): raise HTTPException(status_code=403, detail="Invalid permission level to dataset") return dataset_id raise HTTPException(status_code=403, detail="Invalid authentication to dataset") + + +def get_permissions(user_id: Union[int, str]): + url = f"{KEYCLOAK_SERVER}/realms/{REALM}/protocol/openid-connect/token" + + payload = f'grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}' + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + response = requests.request("POST", url, headers=headers, data=payload) + try: + response.raise_for_status() + except requests.exceptions.HTTPError as http_err: + print(f"Token request failure: {response.status_code}, {response.json()}") + return response.json() + + access_token = response.json()['access_token'] + + headers = { + "Authorization": f"Bearer {access_token}" + } + try: + response = requests.get(f'{PERMISSIONS_ENDPOINT}/{user_id}', headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as http_err: + print(f"Request failure: {response.status_code}, {response.json()}") + return response.json() diff --git a/requirements.txt b/requirements.txt index 7d47e67..ae727e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,7 @@ requests~=2.28.2 fastapi-utils pydantic~=1.10.6 starlette~=0.26.1 -pyjwt +pyjwt~=2.10.1 + +cryptography~=44.0.0 +setuptools~=75.6.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 3bb25e2..9fc6d5a 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ from setuptools import setup, find_packages -VERSION = '0.0.38.30' +VERSION = '0.0.39.5' DESCRIPTION = 'Grisera-api package' LONG_DESCRIPTION = 'Graph Representation Integrating Signals for Emotion Recognition and Analysis (GRISERA) framework provides a persistent model for storing integrated signals and methods for its creation.' # Setting up setup( - name="grisera", + name="grisera2", version=VERSION, author="", author_email="", @@ -21,7 +21,9 @@ 'fastapi-utils', 'pydantic~=1.10.6', 'starlette~=0.26.1', - 'pyjwt' + 'pyjwt', + 'cryptography~=44.0.0', + 'setuptools~=75.6.0' ], classifiers=[ "Development Status :: 1 - Planning", From 87bfd958984b249bd47c9cadceb6f2d95fbd75ed Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Sun, 8 Jun 2025 10:36:42 +0200 Subject: [PATCH 2/9] refactor (#7) --- grisera/dataset/dataset_router.py | 2 -- grisera/helpers/helpers.py | 13 +++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/grisera/dataset/dataset_router.py b/grisera/dataset/dataset_router.py index a3021b8..4a74eb9 100644 --- a/grisera/dataset/dataset_router.py +++ b/grisera/dataset/dataset_router.py @@ -105,10 +105,8 @@ async def get_datasets(self, response: Response, token=Depends(JWTBearer())): dataset_ids = [] user_id = token['sub'] permissions = get_permissions(user_id) - print(permissions) for permission in permissions: dataset_ids.append(str(permission['datasetId'])) - print(dataset_ids) get_response = self.dataset_service.get_datasets(dataset_ids) if get_response.errors is not None: response.status_code = 422 diff --git a/grisera/helpers/helpers.py b/grisera/helpers/helpers.py index 73b2ab8..78b8cb9 100644 --- a/grisera/helpers/helpers.py +++ b/grisera/helpers/helpers.py @@ -36,6 +36,11 @@ def check_dataset_permission(request: Request, dataset_id: Union[int, str], toke def get_permissions(user_id: Union[int, str]): + access_token = _getAccessToken() + return _request_permissions(access_token, user_id) + + +def _getAccessToken(): url = f"{KEYCLOAK_SERVER}/realms/{REALM}/protocol/openid-connect/token" payload = f'grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}' @@ -44,14 +49,17 @@ def get_permissions(user_id: Union[int, str]): } response = requests.request("POST", url, headers=headers, data=payload) + try: response.raise_for_status() except requests.exceptions.HTTPError as http_err: print(f"Token request failure: {response.status_code}, {response.json()}") - return response.json() + raise http_err + + return response.json()['access_token'] - access_token = response.json()['access_token'] +def _request_permissions(access_token, user_id): headers = { "Authorization": f"Bearer {access_token}" } @@ -60,5 +68,6 @@ def get_permissions(user_id: Union[int, str]): response.raise_for_status() return response.json() except requests.exceptions.HTTPError as http_err: + response = http_err.response print(f"Request failure: {response.status_code}, {response.json()}") return response.json() From 8fff1ac5a161217e753c85f71fb4a622cbc1a03f Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Sun, 8 Jun 2025 11:24:44 +0200 Subject: [PATCH 3/9] Feature/refactors (#8) * refactor * optional ISSUER param --- grisera/auth/auth_config.py | 2 +- grisera/auth/auth_handler.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/grisera/auth/auth_config.py b/grisera/auth/auth_config.py index aef885f..fc41eaf 100644 --- a/grisera/auth/auth_config.py +++ b/grisera/auth/auth_config.py @@ -7,4 +7,4 @@ CLIENT_ID = os.environ.get("CLIENT_ID") or "grisera-api" CLIENT_SECRET = os.environ.get("CLIENT_SECRET") or "6UkCrp7UqFy78vh5TVhkaYP0OuVagNTd" PERMISSIONS_ENDPOINT = os.environ.get("PERMISSIONS_ENDPOINT") or "http://localhost:8085/api/permissions" -VERIFY_ISS = os.environ.get("VERIFY_ISS") or False +ISSUER = os.environ.get("ISSUER") diff --git a/grisera/auth/auth_handler.py b/grisera/auth/auth_handler.py index f2d65c9..0009656 100644 --- a/grisera/auth/auth_handler.py +++ b/grisera/auth/auth_handler.py @@ -4,7 +4,7 @@ import jwt -from grisera.auth.auth_config import JWT_ALGORITHM, JWKS_URL, VERIFY_ISS +from grisera.auth.auth_config import JWT_ALGORITHM, JWKS_URL, ISSUER import requests @@ -60,8 +60,10 @@ def decode_jwt(token: str) -> dict: token, public_key, algorithms=[JWT_ALGORITHM], - verify_iss=VERIFY_ISS, + verify_iss=ISSUER is not None, + issuer=ISSUER, ) + # Additional expiration check if decoded_token["exp"] < time.time(): raise ValueError("Token has expired") From fe23dae1d7b7be8337745f3c7bbe409cb6166707 Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Sun, 8 Jun 2025 11:31:02 +0200 Subject: [PATCH 4/9] Feature/refactors (#10) * refactor * optional ISSUER param * v bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9fc6d5a..e8f3452 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup, find_packages -VERSION = '0.0.39.5' +VERSION = '0.0.39.6' DESCRIPTION = 'Grisera-api package' LONG_DESCRIPTION = 'Graph Representation Integrating Signals for Emotion Recognition and Analysis (GRISERA) framework provides a persistent model for storing integrated signals and methods for its creation.' From fef06bb550e9a2a8df16fe5f8d8afa2e9b26a659 Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:05:30 +0200 Subject: [PATCH 5/9] removed comments (#18) --- grisera/auth/auth_handler.py | 6 ------ requirements.txt | 1 - 2 files changed, 7 deletions(-) diff --git a/grisera/auth/auth_handler.py b/grisera/auth/auth_handler.py index 0009656..1a486e0 100644 --- a/grisera/auth/auth_handler.py +++ b/grisera/auth/auth_handler.py @@ -43,19 +43,15 @@ def construct_pem_key(key): def decode_jwt(token: str) -> dict: try: - # Get the unverified headers to extract the Key ID (kid) unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token header missing 'kid'") - # Fetch the public key using the kid key = get_key_by_kid(kid) - # Construct the public key public_key = construct_pem_key(key) - # Decode and validate the token decoded_token = jwt.decode( token, public_key, @@ -64,12 +60,10 @@ def decode_jwt(token: str) -> dict: issuer=ISSUER, ) - # Additional expiration check if decoded_token["exp"] < time.time(): raise ValueError("Token has expired") return decoded_token except Exception as e: - # Log the error or handle as needed print(f"Token verification failed: {e}") return None diff --git a/requirements.txt b/requirements.txt index ae727e7..53d77e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,5 @@ fastapi-utils pydantic~=1.10.6 starlette~=0.26.1 pyjwt~=2.10.1 - cryptography~=44.0.0 setuptools~=75.6.0 \ No newline at end of file From bdc1f17225a92e186333b2aa80ccf8baa8e86d32 Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:26:00 +0200 Subject: [PATCH 6/9] Fixing tag collision --- .github/workflows/python-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index b42e3ca..21c9afb 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -25,6 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: + fetch-depth: 0 python-version: '3.x' - name: Install dependencies run: | From 914e7dbd5cb1214b133197dee9a5942b6818b87a Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:35:19 +0200 Subject: [PATCH 7/9] Refactor/2.1 (#20) From e10f9b7502bbace02eb02b19aaa5cdb35aa97afc Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:37:05 +0200 Subject: [PATCH 8/9] Refactor/2.1 (#21) fix tag collision * tag with v prefix fix tagging issue --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 21c9afb..2b9a664 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -39,7 +39,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.THIS_GITHUB_TOKEN }} DEFAULT_BRANCH: ${{ github.ref_name }} DEFAULT_BUMP: patch - WITH_V: false + WITH_V: true - name: Build and publish env: TWINE_USERNAME: __token__ From 40e212abbd7741ee37bf5a4f0123470bf82fbb11 Mon Sep 17 00:00:00 2001 From: Kamil <45385166+Kamczii@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:42:09 +0200 Subject: [PATCH 9/9] Refactor/2.1 (#22) * removed comments * fetch-depth: 0 fix tag collision * removed fetch-depth: 0 fix tag collision * tag with v prefix fix tagging issue * tag with v prefix fix tagging issue --- .github/workflows/python-publish.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 2b9a664..6cf0d7f 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: