oxutils 0.1.6__tar.gz → 0.1.8__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.
- {oxutils-0.1.6 → oxutils-0.1.8}/PKG-INFO +13 -1
- {oxutils-0.1.6 → oxutils-0.1.8}/pyproject.toml +18 -1
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/__init__.py +1 -1
- oxutils-0.1.8/src/oxutils/constants.py +4 -0
- oxutils-0.1.8/src/oxutils/jwt/models.py +68 -0
- oxutils-0.1.8/src/oxutils/jwt/tokens.py +69 -0
- oxutils-0.1.8/src/oxutils/jwt/utils.py +45 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/logger/settings.py +2 -2
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/apps.py +4 -1
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/caches.py +8 -5
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/middleware.py +45 -7
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/models.py +35 -9
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/permissions.py +22 -30
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/schemas.py +3 -2
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/utils.py +22 -1
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/settings.py +9 -2
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/apps.py +1 -1
- oxutils-0.1.8/src/oxutils/users/migrations/0001_initial.py +47 -0
- oxutils-0.1.8/src/oxutils/utils.py +25 -0
- oxutils-0.1.6/src/oxutils/constants.py +0 -2
- oxutils-0.1.6/src/oxutils/jwt/client.py +0 -123
- oxutils-0.1.6/src/oxutils/jwt/constants.py +0 -1
- {oxutils-0.1.6 → oxutils-0.1.8}/README.md +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/apps.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/apps.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/export.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/masks.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/migrations/0001_initial.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/migrations/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/models.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/settings.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/audit/utils.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/celery/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/celery/base.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/celery/settings.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/conf.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/context/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/context/site_name_processor.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/admin.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/apps.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/controllers.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/enums.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/migrations/0001_initial.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/migrations/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/models.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/schemas.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/tests.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/currency/utils.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/enums/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/enums/audit.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/enums/invoices.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/exceptions.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/functions.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/jwt/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/jwt/auth.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/locale/fr/LC_MESSAGES/django.po +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/logger/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/logger/receivers.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/mixins/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/mixins/base.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/mixins/schemas.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/mixins/services.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/models/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/models/base.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/models/billing.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/models/invoice.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/admin.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/cacheops.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/controllers.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/enums.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/management/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/management/commands/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/management/commands/init_oxiliere_system.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/migrations/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/settings.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/tests.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/pdf/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/pdf/printer.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/pdf/utils.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/pdf/views.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/py.typed +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/s3/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/s3/settings.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/s3/storages.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/types.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/admin.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/migrations/__init__.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/models.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/tests.py +0 -0
- {oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/users/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oxutils
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Production-ready utilities for Django applications in the Oxiliere ecosystem
|
|
5
5
|
Keywords: django,utilities,jwt,s3,audit,logging,celery,structlog
|
|
6
6
|
Author: Edimedia Mutoke
|
|
@@ -30,9 +30,19 @@ Requires-Dist: jwcrypto>=1.5.6
|
|
|
30
30
|
Requires-Dist: pydantic-settings>=2.12.0
|
|
31
31
|
Requires-Dist: pyjwt>=2.10.1
|
|
32
32
|
Requires-Dist: requests>=2.32.5
|
|
33
|
+
Requires-Dist: bcc-rates>=1.1.0 ; extra == 'all'
|
|
34
|
+
Requires-Dist: django-cacheops>=7.2 ; extra == 'all'
|
|
35
|
+
Requires-Dist: django-tenants>=3.9.0 ; extra == 'all'
|
|
36
|
+
Requires-Dist: django-safedelete>=1.4.1 ; extra == 'all'
|
|
37
|
+
Requires-Dist: django-auditlog>=3.4.1 ; extra == 'all'
|
|
38
|
+
Requires-Dist: django-ninja-jwt>=5.4.2 ; extra == 'all'
|
|
39
|
+
Requires-Dist: weasyprint>=67.0 ; extra == 'all'
|
|
33
40
|
Requires-Dist: bcc-rates>=1.1.0 ; extra == 'currency'
|
|
41
|
+
Requires-Dist: django-ninja-jwt>=5.4.2 ; extra == 'jwt'
|
|
34
42
|
Requires-Dist: django-cacheops>=7.2 ; extra == 'oxiliere'
|
|
35
43
|
Requires-Dist: django-tenants>=3.9.0 ; extra == 'oxiliere'
|
|
44
|
+
Requires-Dist: django-safedelete>=1.4.1 ; extra == 'oxiliere'
|
|
45
|
+
Requires-Dist: django-auditlog>=3.4.1 ; extra == 'oxiliere'
|
|
36
46
|
Requires-Dist: weasyprint>=67.0 ; extra == 'pdf'
|
|
37
47
|
Requires-Python: >=3.12
|
|
38
48
|
Project-URL: Changelog, https://github.com/oxiliere/oxutils/blob/main/CHANGELOG.md
|
|
@@ -40,7 +50,9 @@ Project-URL: Documentation, https://github.com/oxiliere/oxutils/tree/main/docs
|
|
|
40
50
|
Project-URL: Homepage, https://github.com/oxiliere/oxutils
|
|
41
51
|
Project-URL: Issues, https://github.com/oxiliere/oxutils/issues
|
|
42
52
|
Project-URL: Repository, https://github.com/oxiliere/oxutils
|
|
53
|
+
Provides-Extra: all
|
|
43
54
|
Provides-Extra: currency
|
|
55
|
+
Provides-Extra: jwt
|
|
44
56
|
Provides-Extra: oxiliere
|
|
45
57
|
Provides-Extra: pdf
|
|
46
58
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oxutils"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.8"
|
|
4
4
|
description = "Production-ready utilities for Django applications in the Oxiliere ecosystem"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -65,11 +65,27 @@ currency = [
|
|
|
65
65
|
oxiliere = [
|
|
66
66
|
"django-cacheops>=7.2",
|
|
67
67
|
"django-tenants>=3.9.0",
|
|
68
|
+
"django-safedelete>=1.4.1",
|
|
69
|
+
"django-auditlog>=3.4.1",
|
|
68
70
|
]
|
|
69
71
|
pdf = [
|
|
70
72
|
"weasyprint>=67.0",
|
|
71
73
|
]
|
|
72
74
|
|
|
75
|
+
jwt = [
|
|
76
|
+
"django-ninja-jwt>=5.4.2",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
all = [
|
|
80
|
+
"bcc-rates>=1.1.0",
|
|
81
|
+
"django-cacheops>=7.2",
|
|
82
|
+
"django-tenants>=3.9.0",
|
|
83
|
+
"django-safedelete>=1.4.1",
|
|
84
|
+
"django-auditlog>=3.4.1",
|
|
85
|
+
"django-ninja-jwt>=5.4.2",
|
|
86
|
+
"weasyprint>=67.0",
|
|
87
|
+
]
|
|
88
|
+
|
|
73
89
|
[dependency-groups]
|
|
74
90
|
dev = [
|
|
75
91
|
"pytest>=8.0.0",
|
|
@@ -79,6 +95,7 @@ dev = [
|
|
|
79
95
|
"coverage>=7.4.0",
|
|
80
96
|
"pillow>=12.0.0",
|
|
81
97
|
"ruff>=0.8.0",
|
|
98
|
+
"psycopg[binary]>=3.3.2",
|
|
82
99
|
]
|
|
83
100
|
|
|
84
101
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from django.utils.functional import cached_property
|
|
3
|
+
from ninja_jwt.models import TokenUser as DefaultTonkenUser
|
|
4
|
+
from ninja_jwt.settings import api_settings
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from .tokens import OrganizationAccessToken
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TokenTenant:
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
schema_name: str,
|
|
19
|
+
tenant_id: int,
|
|
20
|
+
oxi_id: str,
|
|
21
|
+
subscription_plan: str,
|
|
22
|
+
subscription_status: str,
|
|
23
|
+
status: str,
|
|
24
|
+
):
|
|
25
|
+
self.schema_name = schema_name
|
|
26
|
+
self.id = tenant_id
|
|
27
|
+
self.oxi_id = oxi_id
|
|
28
|
+
self.subscription_plan = subscription_plan
|
|
29
|
+
self.subscription_status = subscription_status
|
|
30
|
+
self.status = status
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
return f"{self.schema_name} - {self.oxi_id}"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def pk(self):
|
|
37
|
+
return self.id
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def for_token(cls, token):
|
|
41
|
+
try:
|
|
42
|
+
token_obj = OrganizationAccessToken(token=token)
|
|
43
|
+
tenant = cls(
|
|
44
|
+
schema_name=token_obj['schema_name'],
|
|
45
|
+
tenant_id=token_obj['tenant_id'],
|
|
46
|
+
oxi_id=token_obj['oxi_id'],
|
|
47
|
+
subscription_plan=token_obj['subscription_plan'],
|
|
48
|
+
subscription_status=token_obj['subscription_status'],
|
|
49
|
+
status=token_obj['status'],
|
|
50
|
+
)
|
|
51
|
+
return tenant
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception('Failed to create TokenTenant from token', token=token)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TokenUser(DefaultTonkenUser):
|
|
58
|
+
@cached_property
|
|
59
|
+
def id(self):
|
|
60
|
+
return UUID(self.token[api_settings.USER_ID_CLAIM])
|
|
61
|
+
|
|
62
|
+
@cached_property
|
|
63
|
+
def token_created_at(self):
|
|
64
|
+
return self.token.get('cat', None)
|
|
65
|
+
|
|
66
|
+
@cached_property
|
|
67
|
+
def token_session(self):
|
|
68
|
+
return self.token.get('session', None)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from ninja_jwt.tokens import Token, AccessToken
|
|
3
|
+
from ninja_jwt.backends import TokenBackend
|
|
4
|
+
from ninja_jwt.settings import api_settings
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from oxutils.settings import oxi_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'AccessToken',
|
|
13
|
+
'OrganizationAccessToken',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
token_backend = TokenBackend(
|
|
19
|
+
algorithm='HS256',
|
|
20
|
+
signing_key=settings.SECRET_KEY,
|
|
21
|
+
verifying_key="",
|
|
22
|
+
audience=None,
|
|
23
|
+
issuer=None,
|
|
24
|
+
jwk_url=None,
|
|
25
|
+
leeway=api_settings.LEEWAY,
|
|
26
|
+
json_encoder=api_settings.JSON_ENCODER,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OxilierServiceToken(Token):
|
|
32
|
+
token_type = oxi_settings.jwt_service_token_key
|
|
33
|
+
lifetime = timedelta(minutes=oxi_settings.jwt_service_token_lifetime)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_service(cls, payload: dict = {}) -> Token:
|
|
38
|
+
token = cls()
|
|
39
|
+
|
|
40
|
+
for key, value in payload.items():
|
|
41
|
+
token[key] = value
|
|
42
|
+
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrganizationAccessToken(Token):
|
|
47
|
+
token_type = oxi_settings.jwt_org_access_token_key
|
|
48
|
+
lifetime = timedelta(minutes=oxi_settings.jwt_org_access_token_lifetime)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def for_tenant(cls, tenant) -> Token:
|
|
52
|
+
token = cls()
|
|
53
|
+
token.payload['tenant_id'] = str(tenant.id)
|
|
54
|
+
token.payload['oxi_id'] = str(tenant.oxi_id)
|
|
55
|
+
token.payload['schema_name'] = str(tenant.schema_name)
|
|
56
|
+
token.payload['subscription_plan'] = str(tenant.subscription_plan)
|
|
57
|
+
token.payload['subscription_status'] = str(tenant.subscription_status)
|
|
58
|
+
token.payload['subscription_end_date'] = str(tenant.subscription_end_date)
|
|
59
|
+
token.payload['status'] = str(tenant.status)
|
|
60
|
+
|
|
61
|
+
return token
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def token_backend(self):
|
|
65
|
+
return token_backend
|
|
66
|
+
|
|
67
|
+
def get_token_backend(self):
|
|
68
|
+
# Backward compatibility.
|
|
69
|
+
return self.token_backend
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import structlog
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
|
|
6
|
+
from ninja_jwt.exceptions import InvalidToken
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = structlog.getLogger("django")
|
|
11
|
+
User = get_user_model()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_user(f):
|
|
15
|
+
"""
|
|
16
|
+
Decorator that loads the complete user object from the database for stateless JWT authentication.
|
|
17
|
+
This is necessary because JWT tokens only contain the user ID, and the full user object
|
|
18
|
+
might be needed in the view methods.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
|
|
22
|
+
.. code-block:: python
|
|
23
|
+
|
|
24
|
+
@load_user
|
|
25
|
+
def my_view_method(self, *args, **kwargs):
|
|
26
|
+
# self.request.user will be the complete user object
|
|
27
|
+
pass
|
|
28
|
+
"""
|
|
29
|
+
@wraps(f)
|
|
30
|
+
def wrapper(self, *args, **kwargs):
|
|
31
|
+
populate_user(self.context.request)
|
|
32
|
+
res = f(self, *args, **kwargs)
|
|
33
|
+
return res
|
|
34
|
+
return wrapper
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def populate_user(request: HttpRequest):
|
|
38
|
+
if isinstance(request.user, User):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
request.user = User.objects.get(oxi_id=request.user.id)
|
|
43
|
+
except User.DoesNotExist as exc:
|
|
44
|
+
logger.exception('user_not_found', oxi_id=request.user.id, message=str(exc))
|
|
45
|
+
raise InvalidToken()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import structlog
|
|
2
|
-
|
|
2
|
+
from oxutils.settings import oxi_settings
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
|
|
@@ -29,7 +29,7 @@ LOGGING = {
|
|
|
29
29
|
},
|
|
30
30
|
"json_file": {
|
|
31
31
|
"class": "logging.handlers.WatchedFileHandler",
|
|
32
|
-
"filename":
|
|
32
|
+
"filename": oxi_settings.log_file_path,
|
|
33
33
|
"formatter": "json_formatter",
|
|
34
34
|
},
|
|
35
35
|
},
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
from django.conf import settings
|
|
1
2
|
from cacheops import cached_as, cached
|
|
2
|
-
from oxutils.oxiliere.
|
|
3
|
-
|
|
3
|
+
from oxutils.oxiliere.utils import get_tenant_model, get_tenant_user_model
|
|
4
|
+
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
TenantModel = get_tenant_model()
|
|
9
|
+
TenantUserModel = get_tenant_user_model()
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
@cached_as(TenantModel, timeout=60*15)
|
|
@@ -12,13 +15,13 @@ def get_tenant_by_oxi_id(oxi_id: str):
|
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
@cached_as(TenantModel, timeout=60*15)
|
|
15
|
-
def get_tenant_by_schema_name(schema_name: str)
|
|
18
|
+
def get_tenant_by_schema_name(schema_name: str):
|
|
16
19
|
return TenantModel.objects.get(schema_name=schema_name)
|
|
17
20
|
|
|
18
21
|
|
|
19
|
-
@cached_as(
|
|
22
|
+
@cached_as(TenantUserModel, timeout=60*15)
|
|
20
23
|
def get_tenant_user(oxi_org_id: str, oxi_user_id: str):
|
|
21
|
-
return
|
|
24
|
+
return TenantUserModel.objects.get(
|
|
22
25
|
tenant__oxi_id=oxi_org_id,
|
|
23
26
|
user__oxi_id=oxi_user_id
|
|
24
27
|
)
|
|
@@ -9,7 +9,14 @@ from django_tenants.utils import (
|
|
|
9
9
|
get_public_schema_name,
|
|
10
10
|
get_public_schema_urlconf
|
|
11
11
|
)
|
|
12
|
-
from oxutils.
|
|
12
|
+
from oxutils.settings import oxi_settings
|
|
13
|
+
from oxutils.constants import (
|
|
14
|
+
ORGANIZATION_HEADER_KEY,
|
|
15
|
+
ORGANIZATION_TOKEN_COOKIE_KEY
|
|
16
|
+
)
|
|
17
|
+
from oxutils.jwt.models import TokenTenant
|
|
18
|
+
from oxutils.jwt.tokens import OrganizationAccessToken
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
class TenantMainMiddleware(MiddlewareMixin):
|
|
15
22
|
TENANT_NOT_FOUND_EXCEPTION = Http404
|
|
@@ -42,17 +49,48 @@ class TenantMainMiddleware(MiddlewareMixin):
|
|
|
42
49
|
from django.http import HttpResponseBadRequest
|
|
43
50
|
return HttpResponseBadRequest('Missing X-Organization-ID header')
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
# Try to get tenant from cookie token first
|
|
53
|
+
tenant_token = request.COOKIES.get(ORGANIZATION_TOKEN_COOKIE_KEY)
|
|
54
|
+
tenant = None
|
|
55
|
+
request._should_set_tenant_cookie = False
|
|
56
|
+
|
|
57
|
+
if tenant_token:
|
|
58
|
+
tenant = TokenTenant.for_token(tenant_token)
|
|
59
|
+
# Verify the token's oxi_id matches the request
|
|
60
|
+
if tenant and tenant.oxi_id != oxi_id:
|
|
61
|
+
tenant = None
|
|
62
|
+
|
|
63
|
+
# If no valid token, fetch from database
|
|
64
|
+
if not tenant:
|
|
65
|
+
tenant_model = connection.tenant_model
|
|
66
|
+
try:
|
|
67
|
+
tenant = self.get_tenant(tenant_model, oxi_id)
|
|
68
|
+
# Mark that we need to set the cookie in the response
|
|
69
|
+
request._should_set_tenant_cookie = True
|
|
70
|
+
except tenant_model.DoesNotExist:
|
|
71
|
+
default_tenant = self.no_tenant_found(request, oxi_id)
|
|
72
|
+
return default_tenant
|
|
51
73
|
|
|
52
74
|
request.tenant = tenant
|
|
53
75
|
connection.set_tenant(request.tenant)
|
|
54
76
|
self.setup_url_routing(request)
|
|
55
77
|
|
|
78
|
+
def process_response(self, request, response):
|
|
79
|
+
"""Set the tenant token cookie if needed."""
|
|
80
|
+
if hasattr(request, '_should_set_tenant_cookie') and request._should_set_tenant_cookie:
|
|
81
|
+
if hasattr(request, 'tenant') and not isinstance(request.tenant, TokenTenant):
|
|
82
|
+
# Generate token from DB tenant
|
|
83
|
+
token = OrganizationAccessToken.for_tenant(request.tenant)
|
|
84
|
+
response.set_cookie(
|
|
85
|
+
key=ORGANIZATION_TOKEN_COOKIE_KEY,
|
|
86
|
+
value=str(token),
|
|
87
|
+
max_age=60 * oxi_settings.jwt_org_access_token_lifetime,
|
|
88
|
+
httponly=True,
|
|
89
|
+
secure=getattr(settings, 'SESSION_COOKIE_SECURE', False),
|
|
90
|
+
samesite='Lax',
|
|
91
|
+
)
|
|
92
|
+
return response
|
|
93
|
+
|
|
56
94
|
def no_tenant_found(self, request, oxi_id):
|
|
57
95
|
""" What should happen if no tenant is found.
|
|
58
96
|
This makes it easier if you want to override the default behavior """
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
from django.db import models
|
|
2
2
|
from django.conf import settings
|
|
3
|
-
from
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
from django_tenants.models import TenantMixin
|
|
4
5
|
from oxutils.models import (
|
|
5
6
|
TimestampMixin,
|
|
6
7
|
BaseModelMixin,
|
|
8
|
+
UUIDPrimaryKeyMixin,
|
|
7
9
|
)
|
|
8
10
|
from oxutils.oxiliere.enums import TenantStatus
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
tenant_model = getattr(settings, 'TENANT_MODEL', 'oxiliere.Tenant')
|
|
16
|
+
tenant_user_model = getattr(settings, 'TENANT_USER_MODEL', 'oxiliere.TenantUser')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseTenant(TenantMixin, UUIDPrimaryKeyMixin, TimestampMixin):
|
|
14
21
|
name = models.CharField(max_length=100)
|
|
15
|
-
oxi_id = models.
|
|
22
|
+
oxi_id = models.CharField(unique=True)
|
|
16
23
|
subscription_plan = models.CharField(max_length=255, null=True, blank=True)
|
|
17
24
|
subscription_status = models.CharField(max_length=255, null=True, blank=True)
|
|
18
25
|
subscription_end_date = models.DateTimeField(null=True, blank=True)
|
|
@@ -25,15 +32,18 @@ class Tenant(TenantMixin, TimestampMixin):
|
|
|
25
32
|
# default true, schema will be automatically created and synced when it is saved
|
|
26
33
|
auto_create_schema = True
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
class Meta:
|
|
36
|
+
abstract = True
|
|
37
|
+
verbose_name = _('Tenant')
|
|
38
|
+
verbose_name_plural = _('Tenants')
|
|
39
|
+
indexes = [
|
|
40
|
+
models.Index(fields=['oxi_id'])
|
|
41
|
+
]
|
|
32
42
|
|
|
33
43
|
|
|
34
|
-
class
|
|
44
|
+
class BaseTenantUser(BaseModelMixin):
|
|
35
45
|
tenant = models.ForeignKey(
|
|
36
|
-
|
|
46
|
+
tenant_model, on_delete=models.CASCADE
|
|
37
47
|
)
|
|
38
48
|
user = models.ForeignKey(
|
|
39
49
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
|
|
@@ -47,9 +57,25 @@ class TenantUser(BaseModelMixin):
|
|
|
47
57
|
)
|
|
48
58
|
|
|
49
59
|
class Meta:
|
|
60
|
+
abstract = True
|
|
61
|
+
verbose_name = 'Tenant User'
|
|
62
|
+
verbose_name_plural = 'Tenant Users'
|
|
50
63
|
constraints = [
|
|
51
64
|
models.UniqueConstraint(
|
|
52
65
|
fields=['tenant', 'user'],
|
|
53
66
|
name='unique_tenant_user'
|
|
54
67
|
)
|
|
55
68
|
]
|
|
69
|
+
indexes = [
|
|
70
|
+
models.Index(fields=['tenant', 'user'])
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Tenant(BaseTenant):
|
|
75
|
+
class Meta(BaseTenant.Meta):
|
|
76
|
+
abstract = not tenant_model == 'oxiliere.Tenant'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TenantUser(BaseTenantUser):
|
|
80
|
+
class Meta(BaseTenantUser.Meta):
|
|
81
|
+
abstract = not tenant_user_model == 'oxiliere.TenantUser'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
from
|
|
2
|
-
from django.conf import settings
|
|
1
|
+
from ninja_extra.permissions import BasePermission
|
|
3
2
|
from oxutils.oxiliere.models import TenantUser
|
|
3
|
+
from oxutils.constants import OXILIERE_SERVICE_TOKEN
|
|
4
|
+
from oxutils.jwt.tokens import OxilierServiceToken
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class TenantPermission(BasePermission):
|
|
@@ -8,7 +9,7 @@ class TenantPermission(BasePermission):
|
|
|
8
9
|
Vérifie que l'utilisateur a accès au tenant actuel.
|
|
9
10
|
L'utilisateur doit être authentifié et avoir un lien avec le tenant.
|
|
10
11
|
"""
|
|
11
|
-
def has_permission(self, request,
|
|
12
|
+
def has_permission(self, request, **kwargs):
|
|
12
13
|
if not request.user or not request.user.is_authenticated:
|
|
13
14
|
return False
|
|
14
15
|
|
|
@@ -17,8 +18,8 @@ class TenantPermission(BasePermission):
|
|
|
17
18
|
|
|
18
19
|
# Vérifier que l'utilisateur a accès à ce tenant
|
|
19
20
|
return TenantUser.objects.filter(
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
tenant__pk=request.tenant.pk,
|
|
22
|
+
user__pk=request.user.pk
|
|
22
23
|
).exists()
|
|
23
24
|
|
|
24
25
|
|
|
@@ -26,17 +27,16 @@ class TenantOwnerPermission(BasePermission):
|
|
|
26
27
|
"""
|
|
27
28
|
Vérifie que l'utilisateur est propriétaire (owner) du tenant actuel.
|
|
28
29
|
"""
|
|
29
|
-
def has_permission(self, request,
|
|
30
|
+
def has_permission(self, request, **kwargs):
|
|
30
31
|
if not request.user or not request.user.is_authenticated:
|
|
31
32
|
return False
|
|
32
33
|
|
|
33
34
|
if not hasattr(request, 'tenant'):
|
|
34
35
|
return False
|
|
35
36
|
|
|
36
|
-
# Vérifier que l'utilisateur est owner du tenant
|
|
37
37
|
return TenantUser.objects.filter(
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
tenant__pk=request.tenant.pk,
|
|
39
|
+
user__pk=request.user.pk,
|
|
40
40
|
is_owner=True
|
|
41
41
|
).exists()
|
|
42
42
|
|
|
@@ -45,22 +45,17 @@ class TenantAdminPermission(BasePermission):
|
|
|
45
45
|
"""
|
|
46
46
|
Vérifie que l'utilisateur est admin ou owner du tenant actuel.
|
|
47
47
|
"""
|
|
48
|
-
def has_permission(self, request,
|
|
48
|
+
def has_permission(self, request, **kwargs):
|
|
49
49
|
if not request.user or not request.user.is_authenticated:
|
|
50
50
|
return False
|
|
51
51
|
|
|
52
52
|
if not hasattr(request, 'tenant'):
|
|
53
53
|
return False
|
|
54
54
|
|
|
55
|
-
# Vérifier que l'utilisateur est admin ou owner du tenant
|
|
56
55
|
return TenantUser.objects.filter(
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
tenant__pk=request.tenant.pk,
|
|
57
|
+
user__pk=request.user.pk,
|
|
59
58
|
is_admin=True
|
|
60
|
-
).exists() or TenantUser.objects.filter(
|
|
61
|
-
tenant=request.tenant,
|
|
62
|
-
user=request.user,
|
|
63
|
-
is_owner=True
|
|
64
59
|
).exists()
|
|
65
60
|
|
|
66
61
|
|
|
@@ -69,7 +64,7 @@ class TenantUserPermission(BasePermission):
|
|
|
69
64
|
Vérifie que l'utilisateur est un membre du tenant actuel.
|
|
70
65
|
Alias de TenantPermission pour plus de clarté sémantique.
|
|
71
66
|
"""
|
|
72
|
-
def has_permission(self, request,
|
|
67
|
+
def has_permission(self, request, **kwargs):
|
|
73
68
|
if not request.user or not request.user.is_authenticated:
|
|
74
69
|
return False
|
|
75
70
|
|
|
@@ -77,8 +72,8 @@ class TenantUserPermission(BasePermission):
|
|
|
77
72
|
return False
|
|
78
73
|
|
|
79
74
|
return TenantUser.objects.filter(
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
tenant__pk=request.tenant.pk,
|
|
76
|
+
user__pk=request.user.pk
|
|
82
77
|
).exists()
|
|
83
78
|
|
|
84
79
|
|
|
@@ -87,18 +82,15 @@ class OxiliereServicePermission(BasePermission):
|
|
|
87
82
|
Vérifie que la requête provient d'un service interne Oxiliere.
|
|
88
83
|
Utilise un token de service ou une clé API spéciale.
|
|
89
84
|
"""
|
|
90
|
-
def has_permission(self, request,
|
|
91
|
-
|
|
92
|
-
service_token = request.headers.get(
|
|
85
|
+
def has_permission(self, request, **kwargs):
|
|
86
|
+
custom = 'HTTP_' + OXILIERE_SERVICE_TOKEN.upper().replace('-', '_')
|
|
87
|
+
service_token = request.headers.get(OXILIERE_SERVICE_TOKEN) or request.META.get(custom)
|
|
93
88
|
|
|
94
89
|
if not service_token:
|
|
95
90
|
return False
|
|
96
91
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
try:
|
|
93
|
+
OxilierServiceToken(token=service_token)
|
|
94
|
+
return True
|
|
95
|
+
except Exception:
|
|
101
96
|
return False
|
|
102
|
-
|
|
103
|
-
return service_token == expected_token
|
|
104
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
|
+
from uuid import UUID
|
|
2
3
|
from ninja import Schema
|
|
3
4
|
from django.db import transaction
|
|
4
5
|
from django.contrib.auth import get_user_model
|
|
@@ -20,7 +21,7 @@ class TenantSchema(Schema):
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class TenantOwnerSchema(Schema):
|
|
23
|
-
oxi_id:
|
|
24
|
+
oxi_id: UUID
|
|
24
25
|
email: str
|
|
25
26
|
|
|
26
27
|
|
|
@@ -38,7 +39,7 @@ class CreateTenantSchema(Schema):
|
|
|
38
39
|
logger.info("tenant_exists", oxi_id=self.tenant.oxi_id)
|
|
39
40
|
raise ValueError("Tenant with oxi_id {} already exists".format(self.tenant.oxi_id))
|
|
40
41
|
|
|
41
|
-
user = UserModel.objects.get_or_create(
|
|
42
|
+
user, _ = UserModel.objects.get_or_create(
|
|
42
43
|
oxi_id=self.owner.oxi_id,
|
|
43
44
|
defaults={
|
|
44
45
|
'email': self.owner.email,
|
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
from typing import Any
|
|
2
|
+
from django.apps import apps
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_model(setting: str) -> Any:
|
|
9
|
+
try:
|
|
10
|
+
value = getattr(settings, setting)
|
|
11
|
+
except AttributeError:
|
|
12
|
+
raise ValueError(f"Model `{setting}` is not a valid Tenant model.")
|
|
13
|
+
|
|
14
|
+
return apps.get_model(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_tenant_model() -> Any:
|
|
18
|
+
return get_model('TENANT_MODEL')
|
|
19
|
+
|
|
20
|
+
def get_tenant_user_model() -> Any:
|
|
21
|
+
return get_model('TENANT_USER_MODEL')
|
|
22
|
+
|
|
2
23
|
|
|
3
24
|
def oxid_to_schema_name(oxid: str) -> str:
|
|
4
25
|
"""
|
|
@@ -28,14 +28,21 @@ class OxUtilsSettings(BaseSettings):
|
|
|
28
28
|
jwt_signing_key: Optional[str] = None
|
|
29
29
|
jwt_verifying_key: Optional[str] = None
|
|
30
30
|
jwt_jwks_url: Optional[str] = None
|
|
31
|
-
jwt_access_token_key: str = Field('
|
|
32
|
-
jwt_org_access_token_key: str = Field('
|
|
31
|
+
jwt_access_token_key: str = Field('access')
|
|
32
|
+
jwt_org_access_token_key: str = Field('org_access')
|
|
33
|
+
jwt_service_token_key: str = Field('service')
|
|
34
|
+
jwt_algorithm: Optional[str] = Field('RS256')
|
|
35
|
+
jwt_access_token_lifetime: int = Field(15) # minutes
|
|
36
|
+
jwt_service_token_lifetime: int = Field(3) # minutes
|
|
37
|
+
jwt_org_access_token_lifetime: int = Field(60) # minutes
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
# AuditLog
|
|
36
41
|
log_access: bool = Field(False)
|
|
37
42
|
retention_delay: int = Field(7) # one week
|
|
38
43
|
|
|
44
|
+
# logger
|
|
45
|
+
log_file_path: Optional[str] = Field('logs/oxiliere.log')
|
|
39
46
|
|
|
40
47
|
# Static S3
|
|
41
48
|
use_static_s3: bool = Field(False)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Generated by Django 5.2.9 on 2025-12-23 10:52
|
|
2
|
+
|
|
3
|
+
import django.utils.timezone
|
|
4
|
+
import uuid
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='User',
|
|
19
|
+
fields=[
|
|
20
|
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
21
|
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
22
|
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
23
|
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
24
|
+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
25
|
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
26
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this record', primary_key=True, serialize=False)),
|
|
27
|
+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
|
|
28
|
+
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
29
|
+
('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
|
|
30
|
+
('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
|
|
31
|
+
('oxi_id', models.UUIDField(unique=True)),
|
|
32
|
+
('email', models.EmailField(max_length=254, unique=True)),
|
|
33
|
+
('is_active', models.BooleanField(default=True)),
|
|
34
|
+
('subscription_plan', models.CharField(blank=True, max_length=255, null=True)),
|
|
35
|
+
('subscription_status', models.CharField(blank=True, max_length=255, null=True)),
|
|
36
|
+
('subscription_end_date', models.DateTimeField(blank=True, null=True)),
|
|
37
|
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
38
|
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
39
|
+
],
|
|
40
|
+
options={
|
|
41
|
+
'verbose_name': 'utilisateur',
|
|
42
|
+
'verbose_name_plural': 'utilisateurs',
|
|
43
|
+
'ordering': ['-created_at'],
|
|
44
|
+
'indexes': [models.Index(fields=['oxi_id'], name='users_user_oxi_id_389c72_idx'), models.Index(fields=['email'], name='users_user_email_6f2530_idx')],
|
|
45
|
+
},
|
|
46
|
+
),
|
|
47
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.http import HttpRequest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_client_ip(request: HttpRequest) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Extract client IP address from request metadata.
|
|
7
|
+
|
|
8
|
+
Priority:
|
|
9
|
+
|
|
10
|
+
1. X-Forwarded-For header (first entry if multiple)
|
|
11
|
+
2. REMOTE_ADDR meta value
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
request (HttpRequest): Django request object
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
str: Client IP address or None if not found
|
|
18
|
+
"""
|
|
19
|
+
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
20
|
+
if x_forwarded_for:
|
|
21
|
+
ip = x_forwarded_for.split(',')[0]
|
|
22
|
+
else:
|
|
23
|
+
ip = request.META.get('REMOTE_ADDR')
|
|
24
|
+
return ip
|
|
25
|
+
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import jwt
|
|
2
|
-
import requests
|
|
3
|
-
from typing import Dict, Any, Optional
|
|
4
|
-
from datetime import datetime, timedelta
|
|
5
|
-
from django.core.exceptions import ImproperlyConfigured
|
|
6
|
-
from oxutils.settings import oxi_settings
|
|
7
|
-
from .constants import JWT_ALGORITHM
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
_jwks_cache: Optional[Dict[str, Any]] = None
|
|
11
|
-
_jwks_cache_time: Optional[datetime] = None
|
|
12
|
-
_jwks_cache_ttl = timedelta(hours=1)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def get_jwks_url() -> str:
|
|
16
|
-
"""
|
|
17
|
-
Get JWKS URL from settings.
|
|
18
|
-
|
|
19
|
-
Returns:
|
|
20
|
-
The configured JWKS URL.
|
|
21
|
-
|
|
22
|
-
Raises:
|
|
23
|
-
ImproperlyConfigured: If jwt_jwks_url is not configured.
|
|
24
|
-
"""
|
|
25
|
-
if not oxi_settings.jwt_jwks_url:
|
|
26
|
-
raise ImproperlyConfigured(
|
|
27
|
-
"JWT JWKS URL is not configured. Set OXI_JWT_JWKS_URL environment variable."
|
|
28
|
-
)
|
|
29
|
-
return oxi_settings.jwt_jwks_url
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def fetch_jwks(force_refresh: bool = False) -> Dict[str, Any]:
|
|
33
|
-
"""
|
|
34
|
-
Fetch JWKS from the authentication server with caching.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
force_refresh: Force refresh the cache even if not expired.
|
|
38
|
-
|
|
39
|
-
Returns:
|
|
40
|
-
Dict containing the JWKS.
|
|
41
|
-
|
|
42
|
-
Raises:
|
|
43
|
-
ImproperlyConfigured: If JWKS cannot be fetched.
|
|
44
|
-
"""
|
|
45
|
-
global _jwks_cache, _jwks_cache_time
|
|
46
|
-
|
|
47
|
-
now = datetime.now()
|
|
48
|
-
|
|
49
|
-
# Return cached JWKS if valid
|
|
50
|
-
if not force_refresh and _jwks_cache is not None and _jwks_cache_time is not None:
|
|
51
|
-
if now - _jwks_cache_time < _jwks_cache_ttl:
|
|
52
|
-
return _jwks_cache
|
|
53
|
-
|
|
54
|
-
# Fetch fresh JWKS
|
|
55
|
-
jwks_url = get_jwks_url()
|
|
56
|
-
try:
|
|
57
|
-
response = requests.get(jwks_url, timeout=10)
|
|
58
|
-
response.raise_for_status()
|
|
59
|
-
_jwks_cache = response.json()
|
|
60
|
-
_jwks_cache_time = now
|
|
61
|
-
return _jwks_cache
|
|
62
|
-
except requests.RequestException as e:
|
|
63
|
-
raise ImproperlyConfigured(
|
|
64
|
-
f"Failed to fetch JWKS from {jwks_url}: {str(e)}"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def get_key(kid: str):
|
|
69
|
-
"""
|
|
70
|
-
Get the public key for a given Key ID (kid).
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
kid: The Key ID from the JWT header.
|
|
74
|
-
|
|
75
|
-
Returns:
|
|
76
|
-
RSA public key for verification.
|
|
77
|
-
|
|
78
|
-
Raises:
|
|
79
|
-
ValueError: If the kid is not found in JWKS.
|
|
80
|
-
"""
|
|
81
|
-
jwks = fetch_jwks()
|
|
82
|
-
|
|
83
|
-
for key in jwks.get("keys", []):
|
|
84
|
-
if key.get("kid") == kid:
|
|
85
|
-
return jwt.algorithms.RSAAlgorithm.from_jwk(key)
|
|
86
|
-
|
|
87
|
-
raise ValueError(f"Unknown Key ID (kid): {kid}")
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def verify_token(token: str) -> Dict[str, Any]:
|
|
91
|
-
"""
|
|
92
|
-
Verify and decode a JWT token.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
token: The JWT token string to verify.
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
Dict containing the decoded token payload.
|
|
99
|
-
|
|
100
|
-
Raises:
|
|
101
|
-
jwt.InvalidTokenError: If token is invalid or expired.
|
|
102
|
-
ValueError: If kid is not found.
|
|
103
|
-
"""
|
|
104
|
-
try:
|
|
105
|
-
headers = jwt.get_unverified_header(token)
|
|
106
|
-
kid = headers.get("kid")
|
|
107
|
-
|
|
108
|
-
if not kid:
|
|
109
|
-
raise ValueError("Token header missing 'kid' field")
|
|
110
|
-
|
|
111
|
-
key = get_key(kid)
|
|
112
|
-
return jwt.decode(token, key=key, algorithms=JWT_ALGORITHM)
|
|
113
|
-
except jwt.InvalidTokenError:
|
|
114
|
-
raise
|
|
115
|
-
except Exception as e:
|
|
116
|
-
raise jwt.InvalidTokenError(f"Token verification failed: {str(e)}")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def clear_jwks_cache() -> None:
|
|
120
|
-
"""Clear the cached JWKS. Useful for testing or key rotation."""
|
|
121
|
-
global _jwks_cache, _jwks_cache_time
|
|
122
|
-
_jwks_cache = None
|
|
123
|
-
_jwks_cache_time = None
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
JWT_ALGORITHM = ["RS256"]
|
|
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
|
|
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
|
|
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
|
{oxutils-0.1.6 → oxutils-0.1.8}/src/oxutils/oxiliere/management/commands/init_oxiliere_system.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
|