cardo-python-utils 0.5.dev9__tar.gz → 0.5.dev11__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cardo_python_utils-0.5.dev9/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev11}/PKG-INFO +1 -1
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11/cardo_python_utils.egg-info}/PKG-INFO +1 -1
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/cardo_python_utils.egg-info/SOURCES.txt +1 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/pyproject.toml +1 -1
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/api/drf.py +3 -49
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/api/ninja.py +3 -46
- cardo_python_utils-0.5.dev11/python_utils/django/keycloak/api/utils.py +77 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/service.py +53 -23
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/LICENSE +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/MANIFEST.in +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/README.rst +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/cardo_python_utils.egg-info/requires.txt +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/cardo_python_utils.egg-info/top_level.txt +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/__init__.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/choices.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/data_structures.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/db.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/__init__.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/admin/__init__.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/admin/auth.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/admin/user_group.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/admin/user_groups_changelist.html +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/api/__init__.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/models/__init__.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/models/user_group.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/utils.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/esma_choices.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/exceptions.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/imports.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/math.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/text.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/time.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/types_hinting.py +0 -0
- {cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/setup.cfg +0 -0
{cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/cardo_python_utils.egg-info/SOURCES.txt
RENAMED
|
@@ -28,5 +28,6 @@ python_utils/django/keycloak/admin/user_groups_changelist.html
|
|
|
28
28
|
python_utils/django/keycloak/api/__init__.py
|
|
29
29
|
python_utils/django/keycloak/api/drf.py
|
|
30
30
|
python_utils/django/keycloak/api/ninja.py
|
|
31
|
+
python_utils/django/keycloak/api/utils.py
|
|
31
32
|
python_utils/django/keycloak/models/__init__.py
|
|
32
33
|
python_utils/django/keycloak/models/user_group.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cardo-python-utils"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.dev11"
|
|
8
8
|
description = "Python library enhanced with a wide range of functions for different scenarios."
|
|
9
9
|
readme = "README.rst"
|
|
10
10
|
requires-python = ">=3.8"
|
{cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/api/drf.py
RENAMED
|
@@ -1,31 +1,19 @@
|
|
|
1
|
-
import jwt
|
|
2
|
-
|
|
3
1
|
from django.conf import settings
|
|
4
|
-
from django.contrib.auth import get_user_model
|
|
5
|
-
from jwt import PyJWKClient
|
|
6
2
|
from jwt.exceptions import InvalidTokenError
|
|
7
3
|
|
|
8
4
|
from rest_framework import authentication
|
|
9
5
|
from rest_framework.exceptions import AuthenticationFailed
|
|
10
6
|
from rest_framework.permissions import BasePermission
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
jwks_client = PyJWKClient(getattr(settings, "JWKS_URL", ""))
|
|
8
|
+
from .utils import create_or_update_user, decode_jwt
|
|
14
9
|
|
|
15
10
|
|
|
16
11
|
class AuthenticationBackend(authentication.TokenAuthentication):
|
|
17
12
|
keyword = "Bearer"
|
|
18
13
|
|
|
19
14
|
def authenticate_credentials(self, token: str):
|
|
20
|
-
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
21
|
-
|
|
22
15
|
try:
|
|
23
|
-
payload =
|
|
24
|
-
token,
|
|
25
|
-
signing_key.key,
|
|
26
|
-
algorithms=["RS256"],
|
|
27
|
-
audience=getattr(settings, "JWT_AUDIENCE", None),
|
|
28
|
-
)
|
|
16
|
+
payload = decode_jwt(token)
|
|
29
17
|
except InvalidTokenError as e:
|
|
30
18
|
raise AuthenticationFailed(f"Invalid token: {str(e)}") from e
|
|
31
19
|
|
|
@@ -36,43 +24,9 @@ class AuthenticationBackend(authentication.TokenAuthentication):
|
|
|
36
24
|
"Invalid token: preferred_username not present."
|
|
37
25
|
) from e
|
|
38
26
|
|
|
39
|
-
user =
|
|
27
|
+
user = create_or_update_user(username, payload)
|
|
40
28
|
return user, payload
|
|
41
29
|
|
|
42
|
-
def _get_user(self, username: str, payload: dict):
|
|
43
|
-
"""
|
|
44
|
-
Get or create a user based on the JWT payload.
|
|
45
|
-
If the user exists, update their details.
|
|
46
|
-
"""
|
|
47
|
-
user_model = get_user_model()
|
|
48
|
-
user_data = {
|
|
49
|
-
"first_name": payload.get("given_name") or "",
|
|
50
|
-
"last_name": payload.get("family_name") or "",
|
|
51
|
-
"email": payload.get("email") or "",
|
|
52
|
-
"is_staff": payload.get("is_staff", False),
|
|
53
|
-
}
|
|
54
|
-
if hasattr(user_model, "is_demo"):
|
|
55
|
-
user_data["is_demo"] = payload.get("is_demo", False)
|
|
56
|
-
|
|
57
|
-
user = user_model.objects.filter(username=username).first()
|
|
58
|
-
if user:
|
|
59
|
-
update_needed = False
|
|
60
|
-
|
|
61
|
-
for field, value in user_data.items():
|
|
62
|
-
if getattr(user, field) != value:
|
|
63
|
-
setattr(user, field, value)
|
|
64
|
-
update_needed = True
|
|
65
|
-
|
|
66
|
-
if update_needed:
|
|
67
|
-
user.save(update_fields=list(user_data.keys()))
|
|
68
|
-
|
|
69
|
-
return user
|
|
70
|
-
else:
|
|
71
|
-
return user_model.objects.create(
|
|
72
|
-
username=username,
|
|
73
|
-
**user_data,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
30
|
|
|
77
31
|
class HasScope(BasePermission):
|
|
78
32
|
"""
|
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
from typing import Literal
|
|
2
2
|
|
|
3
|
-
from jwt import PyJWKClient, decode as jwt_decode
|
|
4
3
|
from jwt.exceptions import InvalidTokenError
|
|
5
4
|
|
|
6
5
|
from django.conf import settings
|
|
7
|
-
from django.contrib.auth import get_user_model
|
|
8
6
|
from ninja.security import HttpBearer
|
|
9
7
|
from ninja.errors import AuthenticationError, HttpError
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
from .utils import create_or_update_user, decode_jwt
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
class AuthBearer(HttpBearer):
|
|
15
13
|
def authenticate(self, request, token):
|
|
16
|
-
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
17
|
-
|
|
18
14
|
try:
|
|
19
|
-
payload =
|
|
20
|
-
token,
|
|
21
|
-
signing_key.key,
|
|
22
|
-
algorithms=["RS256"],
|
|
23
|
-
audience=getattr(settings, "JWT_AUDIENCE", None),
|
|
24
|
-
)
|
|
15
|
+
payload = decode_jwt(token)
|
|
25
16
|
except InvalidTokenError as e:
|
|
26
17
|
raise AuthenticationError(f"Invalid token: {str(e)}") from e
|
|
27
18
|
|
|
@@ -32,46 +23,12 @@ class AuthBearer(HttpBearer):
|
|
|
32
23
|
"Invalid token: preferred_username not present."
|
|
33
24
|
) from e
|
|
34
25
|
|
|
35
|
-
user =
|
|
26
|
+
user = create_or_update_user(username, payload)
|
|
36
27
|
|
|
37
28
|
self._verify_scopes(request, payload)
|
|
38
29
|
|
|
39
30
|
return user
|
|
40
31
|
|
|
41
|
-
def _get_user(self, username: str, payload: dict):
|
|
42
|
-
"""
|
|
43
|
-
Get or create a user based on the JWT payload.
|
|
44
|
-
If the user exists, update their details.
|
|
45
|
-
"""
|
|
46
|
-
user_model = get_user_model()
|
|
47
|
-
user_data = {
|
|
48
|
-
"first_name": payload.get("given_name") or "",
|
|
49
|
-
"last_name": payload.get("family_name") or "",
|
|
50
|
-
"email": payload.get("email") or "",
|
|
51
|
-
"is_staff": payload.get("is_staff", False),
|
|
52
|
-
}
|
|
53
|
-
if hasattr(user_model, "is_demo"):
|
|
54
|
-
user_data["is_demo"] = payload.get("is_demo", False)
|
|
55
|
-
|
|
56
|
-
user = user_model.objects.filter(username=username).first()
|
|
57
|
-
if user:
|
|
58
|
-
update_needed = False
|
|
59
|
-
|
|
60
|
-
for field, value in user_data.items():
|
|
61
|
-
if getattr(user, field) != value:
|
|
62
|
-
setattr(user, field, value)
|
|
63
|
-
update_needed = True
|
|
64
|
-
|
|
65
|
-
if update_needed:
|
|
66
|
-
user.save(update_fields=list(user_data.keys()))
|
|
67
|
-
|
|
68
|
-
return user
|
|
69
|
-
else:
|
|
70
|
-
return user_model.objects.create(
|
|
71
|
-
username=username,
|
|
72
|
-
**user_data,
|
|
73
|
-
)
|
|
74
|
-
|
|
75
32
|
def _verify_scopes(self, request, token_payload):
|
|
76
33
|
allowed_scopes = self._get_view_allowed_scopes(request)
|
|
77
34
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.contrib.auth import get_user_model
|
|
5
|
+
from jwt import decode, PyJWKClient
|
|
6
|
+
|
|
7
|
+
jwks_client = PyJWKClient(getattr(settings, "JWKS_URL", ""))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenPayload(TypedDict, total=False):
|
|
11
|
+
exp: int
|
|
12
|
+
iat: int
|
|
13
|
+
jti: str
|
|
14
|
+
iss: str
|
|
15
|
+
aud: str | list[str]
|
|
16
|
+
typ: str
|
|
17
|
+
azp: str
|
|
18
|
+
sid: str
|
|
19
|
+
scope: str
|
|
20
|
+
preferred_username: str
|
|
21
|
+
given_name: str
|
|
22
|
+
family_name: str
|
|
23
|
+
email: str
|
|
24
|
+
is_staff: bool
|
|
25
|
+
is_demo: bool
|
|
26
|
+
groups: list[str] # Full path of the user group, e.g. "/group1/subgroup1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decode_jwt(token: str) -> TokenPayload:
|
|
30
|
+
"""
|
|
31
|
+
Decode a JWT token using the public certificate of the Auth Server.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
jwt.exceptions.InvalidTokenError: If the token is invalid or cannot be decoded.
|
|
35
|
+
"""
|
|
36
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
37
|
+
|
|
38
|
+
return decode(
|
|
39
|
+
token,
|
|
40
|
+
signing_key.key,
|
|
41
|
+
algorithms=["RS256"],
|
|
42
|
+
audience=getattr(settings, "JWT_AUDIENCE", None),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_or_update_user(username: str, payload: TokenPayload):
|
|
47
|
+
"""
|
|
48
|
+
Create or update a user based on the JWT payload.
|
|
49
|
+
"""
|
|
50
|
+
user_model = get_user_model()
|
|
51
|
+
user_data = {
|
|
52
|
+
"first_name": payload.get("given_name") or "",
|
|
53
|
+
"last_name": payload.get("family_name") or "",
|
|
54
|
+
"email": payload.get("email") or "",
|
|
55
|
+
"is_staff": payload.get("is_staff", False),
|
|
56
|
+
}
|
|
57
|
+
if hasattr(user_model, "is_demo"):
|
|
58
|
+
user_data["is_demo"] = payload.get("is_demo", False)
|
|
59
|
+
|
|
60
|
+
user = user_model.objects.filter(username=username).first()
|
|
61
|
+
if user:
|
|
62
|
+
update_needed = False
|
|
63
|
+
|
|
64
|
+
for field, value in user_data.items():
|
|
65
|
+
if getattr(user, field) != value:
|
|
66
|
+
setattr(user, field, value)
|
|
67
|
+
update_needed = True
|
|
68
|
+
|
|
69
|
+
if update_needed:
|
|
70
|
+
user.save(update_fields=list(user_data.keys()))
|
|
71
|
+
|
|
72
|
+
return user
|
|
73
|
+
else:
|
|
74
|
+
return user_model.objects.create(
|
|
75
|
+
username=username,
|
|
76
|
+
**user_data,
|
|
77
|
+
)
|
{cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/django/keycloak/service.py
RENAMED
|
@@ -4,10 +4,32 @@ from keycloak import KeycloakAdmin
|
|
|
4
4
|
from keycloak import KeycloakOpenIDConnection
|
|
5
5
|
from keycloak.exceptions import KeycloakGetError
|
|
6
6
|
|
|
7
|
+
def _get_user_group_model():
|
|
8
|
+
"""
|
|
9
|
+
Dynamically get the UserGroup model.
|
|
10
|
+
|
|
11
|
+
Attempts to use the model specified in settings.KEYCLOAK_USER_GROUP_MODEL.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The UserGroup model class.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
LookupError: If the model cannot be found.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
model_string = getattr(settings, "KEYCLOAK_USER_GROUP_MODEL")
|
|
21
|
+
except AttributeError:
|
|
22
|
+
raise LookupError(
|
|
23
|
+
"Please set KEYCLOAK_USER_GROUP_MODEL in your Django settings "
|
|
24
|
+
"(e.g., 'myapp.UserGroup')."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return apps.get_model(model_string)
|
|
28
|
+
|
|
7
29
|
|
|
8
30
|
class KeycloakService:
|
|
9
31
|
def __init__(self):
|
|
10
|
-
self._user_group_model =
|
|
32
|
+
self._user_group_model = _get_user_group_model()
|
|
11
33
|
self._keycloak_admin = self._get_keycloak_admin()
|
|
12
34
|
|
|
13
35
|
def sync_user_groups(self, raise_exceptions: bool = False):
|
|
@@ -53,28 +75,6 @@ class KeycloakService:
|
|
|
53
75
|
)
|
|
54
76
|
return KeycloakAdmin(connection=keycloak_connection)
|
|
55
77
|
|
|
56
|
-
def _get_user_group_model(self):
|
|
57
|
-
"""
|
|
58
|
-
Dynamically get the UserGroup model.
|
|
59
|
-
|
|
60
|
-
Attempts to use the model specified in settings.KEYCLOAK_USER_GROUP_MODEL.
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
The UserGroup model class.
|
|
64
|
-
|
|
65
|
-
Raises:
|
|
66
|
-
LookupError: If the model cannot be found.
|
|
67
|
-
"""
|
|
68
|
-
try:
|
|
69
|
-
model_string = getattr(settings, "KEYCLOAK_USER_GROUP_MODEL")
|
|
70
|
-
except AttributeError:
|
|
71
|
-
raise LookupError(
|
|
72
|
-
"Please set KEYCLOAK_USER_GROUP_MODEL in your Django settings "
|
|
73
|
-
"(e.g., 'myapp.UserGroup')."
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
return apps.get_model(model_string)
|
|
77
|
-
|
|
78
78
|
def _process_group_recursively(
|
|
79
79
|
self, group, existing_groups_by_id, reported_group_ids
|
|
80
80
|
):
|
|
@@ -98,3 +98,33 @@ class KeycloakService:
|
|
|
98
98
|
self._process_group_recursively(
|
|
99
99
|
subgroup, existing_groups_by_id, reported_group_ids
|
|
100
100
|
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AuthServiceBase:
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _get_all_level_paths(path: str) -> list[str]:
|
|
106
|
+
"""
|
|
107
|
+
Given a group path, return all level paths up to the root.
|
|
108
|
+
E.g., for "/a/b/c", return ["/a/b/c", "/a/b", "/a"]
|
|
109
|
+
"""
|
|
110
|
+
paths = []
|
|
111
|
+
while "/" in path:
|
|
112
|
+
paths.append(path)
|
|
113
|
+
path = "/".join(path.split("/")[:-1])
|
|
114
|
+
return paths
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def _get_user_groups_from_paths(cls, group_paths: list[str]):
|
|
118
|
+
all_group_paths = set()
|
|
119
|
+
for path in group_paths:
|
|
120
|
+
all_group_paths.update(cls._get_all_level_paths(path))
|
|
121
|
+
|
|
122
|
+
UserGroup = _get_user_group_model()
|
|
123
|
+
user_groups = UserGroup.objects.filter(path__in=all_group_paths)
|
|
124
|
+
|
|
125
|
+
# If a group is missing/has been renamed in Keycloak, sync the groups
|
|
126
|
+
if user_groups.count() != len(all_group_paths):
|
|
127
|
+
KeycloakService().sync_user_groups()
|
|
128
|
+
user_groups = UserGroup.objects.filter(path__in=all_group_paths)
|
|
129
|
+
|
|
130
|
+
return user_groups
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cardo_python_utils-0.5.dev9 → cardo_python_utils-0.5.dev11}/python_utils/data_structures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|