core-framework 0.12.3__tar.gz → 0.12.5__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.
- {core_framework-0.12.3 → core_framework-0.12.5}/PKG-INFO +1 -1
- {core_framework-0.12.3 → core_framework-0.12.5}/core/__init__.py +1 -1
- core_framework-0.12.5/core/auth/middleware.py +473 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/models.py +49 -23
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/tokens.py +17 -3
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/views.py +27 -7
- {core_framework-0.12.3 → core_framework-0.12.5}/pyproject.toml +1 -1
- core_framework-0.12.3/core/auth/middleware.py +0 -355
- {core_framework-0.12.3 → core_framework-0.12.5}/.gitignore +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/README.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/app.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/backends.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/base.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/decorators.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/hashers.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/permissions.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/auth/schemas.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/choices.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/cli/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/cli/main.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/config.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/database.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/datetime.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/dependencies.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/deployment/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/deployment/docker.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/deployment/kubernetes.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/deployment/pm2.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/exceptions.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/fields.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/avro.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/base.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/config.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/confluent/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/confluent/consumer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/confluent/producer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/decorators.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/kafka/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/kafka/admin.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/kafka/broker.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/kafka/consumer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/kafka/producer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/rabbitmq/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/rabbitmq/broker.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/rabbitmq/consumer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/rabbitmq/producer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/redis/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/redis/broker.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/redis/consumer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/redis/producer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/registry.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/topics.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/messaging/workers.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/middleware.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/analyzer.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/cli.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/engine.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/migration.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/operations.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/migrations/state.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/models.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/permissions.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/querysets.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/relations.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/routing.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/serializers.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/base.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/config.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/decorators.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/registry.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/scheduler.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tasks/worker.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/tenancy.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/validators.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/core/views.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/01-quickstart.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/02-viewsets.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/03-authentication.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/04-messaging.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/05-multi-service.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/06-tasks.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/07-deployment.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/08-complete-example.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/09-settings.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/10-migrations.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/11-permissions.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/12-auth-backends.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/13-validators.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/14-querysets.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/15-routing.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/16-serializers.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/17-datetime.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/18-dependencies.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/19-views.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/20-fields.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/21-tenancy.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/22-replicas.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/23-soft-delete.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/24-relations.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/25-exceptions.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/26-choices.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/27-workers.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/28-avro.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/29-topics.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/30-changelog-0.12.2.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/31-middleware.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/32-migration-guide-0.12.2.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/33-changelog-0.12.3.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/99-faq-troubleshooting.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/GUIDE.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/docs/README.md +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/app.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/auth.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/models.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/schemas.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/example/views.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/libs/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/main.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/tests/__init__.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/tests/conftest.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/tests/test_models.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/tests/test_querysets.py +0 -0
- {core_framework-0.12.3 → core_framework-0.12.5}/tests/test_serializers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: core-framework
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.5
|
|
4
4
|
Summary: Core Framework - Django-inspired, FastAPI-powered. Alta performance, baixo acoplamento, produtividade extrema.
|
|
5
5
|
Project-URL: Homepage, https://github.com/SorPuti/core-framework
|
|
6
6
|
Project-URL: Documentation, https://github.com/SorPuti/core-framework#readme
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication Middleware for Core Framework.
|
|
3
|
+
|
|
4
|
+
Uses Starlette's AuthenticationMiddleware pattern which correctly propagates
|
|
5
|
+
user to views via scope["user"] (accessed as request.user).
|
|
6
|
+
|
|
7
|
+
IMPORTANT: Use request.user (not request.state.user) in your views!
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from core.auth.middleware import AuthenticationMiddleware
|
|
11
|
+
|
|
12
|
+
app = CoreApp(
|
|
13
|
+
middlewares=[(AuthenticationMiddleware, {})],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# In your view - use request.user
|
|
17
|
+
@router.get("/me")
|
|
18
|
+
async def me(request: Request):
|
|
19
|
+
if not request.user.is_authenticated:
|
|
20
|
+
raise HTTPException(401, "Not authenticated")
|
|
21
|
+
return {"id": request.user.id, "email": request.user.email}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Any, TYPE_CHECKING
|
|
28
|
+
from uuid import UUID
|
|
29
|
+
|
|
30
|
+
from starlette.authentication import (
|
|
31
|
+
AuthenticationBackend,
|
|
32
|
+
AuthCredentials,
|
|
33
|
+
BaseUser,
|
|
34
|
+
UnauthenticatedUser,
|
|
35
|
+
)
|
|
36
|
+
from starlette.middleware.authentication import AuthenticationMiddleware as StarletteAuthMiddleware
|
|
37
|
+
from starlette.requests import HTTPConnection
|
|
38
|
+
|
|
39
|
+
from core.exceptions import (
|
|
40
|
+
InvalidToken,
|
|
41
|
+
TokenExpired,
|
|
42
|
+
UserNotFound,
|
|
43
|
+
UserInactive,
|
|
44
|
+
DatabaseException,
|
|
45
|
+
ConfigurationError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
50
|
+
|
|
51
|
+
# Logger for authentication - NEVER silent!
|
|
52
|
+
logger = logging.getLogger("core.auth")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# Authenticated User Wrapper
|
|
57
|
+
# =============================================================================
|
|
58
|
+
|
|
59
|
+
class AuthenticatedUser(BaseUser):
|
|
60
|
+
"""
|
|
61
|
+
Wrapper for authenticated user that implements Starlette's BaseUser.
|
|
62
|
+
|
|
63
|
+
Provides access to the underlying database user model while implementing
|
|
64
|
+
the required Starlette interface.
|
|
65
|
+
|
|
66
|
+
Usage in views:
|
|
67
|
+
user = request.user
|
|
68
|
+
if user.is_authenticated:
|
|
69
|
+
print(user.email) # Access model attributes
|
|
70
|
+
print(user.id) # Access model attributes
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, user: Any) -> None:
|
|
74
|
+
self._user = user
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_authenticated(self) -> bool:
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def display_name(self) -> str:
|
|
82
|
+
return getattr(self._user, "email", str(self._user))
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def identity(self) -> str:
|
|
86
|
+
return str(getattr(self._user, "id", ""))
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name: str) -> Any:
|
|
89
|
+
"""Proxy attribute access to underlying user model."""
|
|
90
|
+
return getattr(self._user, name)
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return f"<AuthenticatedUser {self.display_name}>"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# =============================================================================
|
|
97
|
+
# Authentication Backend
|
|
98
|
+
# =============================================================================
|
|
99
|
+
|
|
100
|
+
class JWTAuthBackend(AuthenticationBackend):
|
|
101
|
+
"""
|
|
102
|
+
JWT Authentication Backend for Starlette.
|
|
103
|
+
|
|
104
|
+
This backend:
|
|
105
|
+
1. Extracts Bearer token from Authorization header
|
|
106
|
+
2. Verifies the token using core.auth.tokens
|
|
107
|
+
3. Fetches user from database
|
|
108
|
+
4. Returns AuthCredentials and AuthenticatedUser
|
|
109
|
+
|
|
110
|
+
IMPORTANT: All errors are logged, NEVER silenced!
|
|
111
|
+
|
|
112
|
+
Configuration:
|
|
113
|
+
- user_model: User model class (uses global config if None)
|
|
114
|
+
- header_name: Header to extract token from (default: "Authorization")
|
|
115
|
+
- scheme: Expected scheme (default: "Bearer")
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
user_model: type | None = None,
|
|
121
|
+
header_name: str = "Authorization",
|
|
122
|
+
scheme: str = "Bearer",
|
|
123
|
+
) -> None:
|
|
124
|
+
self._user_model = user_model
|
|
125
|
+
self.header_name = header_name
|
|
126
|
+
self.scheme = scheme
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def user_model(self) -> type | None:
|
|
130
|
+
"""Get user model from instance or global config."""
|
|
131
|
+
if self._user_model is not None:
|
|
132
|
+
return self._user_model
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
from core.auth.models import get_user_model
|
|
136
|
+
return get_user_model()
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Failed to get user model: {e}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, BaseUser] | None:
|
|
142
|
+
"""
|
|
143
|
+
Authenticate the request.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Tuple of (AuthCredentials, AuthenticatedUser) if authenticated
|
|
147
|
+
None if no credentials provided
|
|
148
|
+
|
|
149
|
+
Note: Returns None for missing credentials, but LOGS all errors!
|
|
150
|
+
"""
|
|
151
|
+
# Extract token from header
|
|
152
|
+
auth_header = conn.headers.get(self.header_name)
|
|
153
|
+
|
|
154
|
+
if not auth_header:
|
|
155
|
+
logger.debug("No Authorization header present")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Parse header
|
|
159
|
+
parts = auth_header.split()
|
|
160
|
+
if len(parts) != 2:
|
|
161
|
+
logger.warning(f"Malformed Authorization header: expected 2 parts, got {len(parts)}")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
scheme, token = parts
|
|
165
|
+
if scheme.lower() != self.scheme.lower():
|
|
166
|
+
logger.warning(f"Unexpected auth scheme: expected '{self.scheme}', got '{scheme}'")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
logger.debug(f"Token extracted: {token[:20]}...")
|
|
170
|
+
|
|
171
|
+
# Verify token
|
|
172
|
+
try:
|
|
173
|
+
user = await self._verify_and_get_user(token)
|
|
174
|
+
if user is None:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
logger.info(f"User authenticated: {getattr(user, 'email', user)}")
|
|
178
|
+
return AuthCredentials(["authenticated"]), AuthenticatedUser(user)
|
|
179
|
+
|
|
180
|
+
except InvalidToken as e:
|
|
181
|
+
logger.warning(f"Invalid token: {e.message}")
|
|
182
|
+
return None
|
|
183
|
+
except TokenExpired as e:
|
|
184
|
+
logger.warning(f"Token expired: {e.message}")
|
|
185
|
+
return None
|
|
186
|
+
except UserNotFound as e:
|
|
187
|
+
logger.warning(f"User not found: {e.message}")
|
|
188
|
+
return None
|
|
189
|
+
except UserInactive as e:
|
|
190
|
+
logger.warning(f"User inactive: {e.message}")
|
|
191
|
+
return None
|
|
192
|
+
except DatabaseException as e:
|
|
193
|
+
logger.error(f"Database error during authentication: {e.message}", exc_info=True)
|
|
194
|
+
raise # Re-raise database errors - these are critical!
|
|
195
|
+
except ConfigurationError as e:
|
|
196
|
+
logger.error(f"Configuration error: {e.message}", exc_info=True)
|
|
197
|
+
raise # Re-raise configuration errors - these need to be fixed!
|
|
198
|
+
except Exception as e:
|
|
199
|
+
# Log unexpected errors with full stack trace
|
|
200
|
+
logger.exception(f"Unexpected error during authentication: {e}")
|
|
201
|
+
raise # NEVER silence unexpected errors!
|
|
202
|
+
|
|
203
|
+
async def _verify_and_get_user(self, token: str) -> Any | None:
|
|
204
|
+
"""
|
|
205
|
+
Verify token and fetch user from database.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
InvalidToken: If token is invalid or malformed
|
|
209
|
+
TokenExpired: If token has expired
|
|
210
|
+
UserNotFound: If user doesn't exist
|
|
211
|
+
UserInactive: If user is inactive
|
|
212
|
+
DatabaseException: If database query fails
|
|
213
|
+
ConfigurationError: If auth is not properly configured
|
|
214
|
+
"""
|
|
215
|
+
from core.auth.tokens import verify_token, decode_token
|
|
216
|
+
from core.auth.base import TokenError
|
|
217
|
+
|
|
218
|
+
# Verify token
|
|
219
|
+
try:
|
|
220
|
+
payload = verify_token(token, token_type="access")
|
|
221
|
+
except TokenError as e:
|
|
222
|
+
raise InvalidToken(f"Token verification failed: {e}")
|
|
223
|
+
|
|
224
|
+
if payload is None:
|
|
225
|
+
# verify_token returns None for various reasons - let's be more specific
|
|
226
|
+
try:
|
|
227
|
+
# Try to decode to get more info
|
|
228
|
+
raw_payload = decode_token(token)
|
|
229
|
+
token_type = raw_payload.get("type")
|
|
230
|
+
if token_type != "access":
|
|
231
|
+
raise InvalidToken(f"Token type mismatch: expected 'access', got '{token_type}'")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise InvalidToken(f"Token decode failed: {e}")
|
|
234
|
+
raise InvalidToken("Token verification returned None")
|
|
235
|
+
|
|
236
|
+
logger.debug(f"Token payload: {payload}")
|
|
237
|
+
|
|
238
|
+
# Get user_id from token
|
|
239
|
+
user_id = payload.get("sub") or payload.get("user_id")
|
|
240
|
+
if not user_id:
|
|
241
|
+
raise InvalidToken("Token missing 'sub' or 'user_id' claim")
|
|
242
|
+
|
|
243
|
+
logger.debug(f"User ID from token: {user_id}")
|
|
244
|
+
|
|
245
|
+
# Get user model
|
|
246
|
+
User = self.user_model
|
|
247
|
+
if User is None:
|
|
248
|
+
raise ConfigurationError(
|
|
249
|
+
"No user model configured. "
|
|
250
|
+
"Set user_model in AuthenticationMiddleware or call configure_auth(user_model=...)"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Get database session
|
|
254
|
+
db = await self._get_db_session()
|
|
255
|
+
if db is None:
|
|
256
|
+
raise DatabaseException(
|
|
257
|
+
"Could not obtain database session. "
|
|
258
|
+
"Ensure database is initialized with init_replicas() or database_url is set in settings."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# Convert user_id to correct type
|
|
263
|
+
user_id_converted = self._convert_user_id(user_id, User)
|
|
264
|
+
logger.debug(f"User ID converted: {user_id_converted} (type: {type(user_id_converted).__name__})")
|
|
265
|
+
|
|
266
|
+
# Fetch user
|
|
267
|
+
user = await User.objects.using(db).filter(id=user_id_converted).first()
|
|
268
|
+
|
|
269
|
+
if user is None:
|
|
270
|
+
raise UserNotFound(f"User with id={user_id} not found")
|
|
271
|
+
|
|
272
|
+
# Check if user is active
|
|
273
|
+
if hasattr(user, "is_active") and not user.is_active:
|
|
274
|
+
raise UserInactive(f"User {user_id} is inactive")
|
|
275
|
+
|
|
276
|
+
logger.debug(f"User found: {getattr(user, 'email', user)}")
|
|
277
|
+
return user
|
|
278
|
+
|
|
279
|
+
except (UserNotFound, UserInactive):
|
|
280
|
+
raise # Re-raise our exceptions
|
|
281
|
+
except Exception as e:
|
|
282
|
+
raise DatabaseException(f"Database query failed: {e}")
|
|
283
|
+
finally:
|
|
284
|
+
await db.close()
|
|
285
|
+
|
|
286
|
+
async def _get_db_session(self) -> "AsyncSession | None":
|
|
287
|
+
"""
|
|
288
|
+
Get a database session for authentication.
|
|
289
|
+
|
|
290
|
+
Tries multiple strategies and logs each attempt.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
AsyncSession or None if all strategies fail
|
|
294
|
+
"""
|
|
295
|
+
errors: list[str] = []
|
|
296
|
+
|
|
297
|
+
# Strategy 1: Use initialized session factory
|
|
298
|
+
try:
|
|
299
|
+
from core.database import _read_session_factory
|
|
300
|
+
|
|
301
|
+
if _read_session_factory is not None:
|
|
302
|
+
logger.debug("Using initialized session factory")
|
|
303
|
+
return _read_session_factory()
|
|
304
|
+
else:
|
|
305
|
+
errors.append("Session factory not initialized (init_replicas not called)")
|
|
306
|
+
except ImportError as e:
|
|
307
|
+
errors.append(f"Could not import database module: {e}")
|
|
308
|
+
except Exception as e:
|
|
309
|
+
errors.append(f"Session factory error: {e}")
|
|
310
|
+
|
|
311
|
+
# Strategy 2: Create session from settings
|
|
312
|
+
try:
|
|
313
|
+
from core.config import get_settings
|
|
314
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
315
|
+
|
|
316
|
+
settings = get_settings()
|
|
317
|
+
db_url = getattr(settings, 'database_read_url', None) or getattr(settings, 'database_url', None)
|
|
318
|
+
|
|
319
|
+
if db_url:
|
|
320
|
+
logger.debug(f"Creating session from settings: {db_url[:30]}...")
|
|
321
|
+
engine = create_async_engine(db_url, echo=False)
|
|
322
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
323
|
+
return session_factory()
|
|
324
|
+
else:
|
|
325
|
+
errors.append("No database_url in settings")
|
|
326
|
+
except ImportError as e:
|
|
327
|
+
errors.append(f"Could not import config module: {e}")
|
|
328
|
+
except Exception as e:
|
|
329
|
+
errors.append(f"Settings engine error: {e}")
|
|
330
|
+
|
|
331
|
+
# All strategies failed - log detailed error
|
|
332
|
+
logger.error(
|
|
333
|
+
f"Could not obtain database session. Attempted strategies:\n" +
|
|
334
|
+
"\n".join(f" - {err}" for err in errors)
|
|
335
|
+
)
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def _convert_user_id(self, user_id: str, User: type) -> Any:
|
|
339
|
+
"""
|
|
340
|
+
Convert user_id string to the correct type based on model.
|
|
341
|
+
|
|
342
|
+
Handles INTEGER, UUID, and string IDs.
|
|
343
|
+
"""
|
|
344
|
+
# Try to detect PK type from model
|
|
345
|
+
try:
|
|
346
|
+
from core.auth.models import _get_pk_column_type
|
|
347
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
348
|
+
from sqlalchemy import Integer, BigInteger
|
|
349
|
+
|
|
350
|
+
pk_type = _get_pk_column_type(User)
|
|
351
|
+
|
|
352
|
+
if pk_type == PG_UUID:
|
|
353
|
+
logger.debug(f"Converting user_id to UUID: {user_id}")
|
|
354
|
+
return UUID(user_id)
|
|
355
|
+
elif pk_type in (Integer, BigInteger):
|
|
356
|
+
logger.debug(f"Converting user_id to int: {user_id}")
|
|
357
|
+
return int(user_id)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.debug(f"Could not detect PK type, trying heuristics: {e}")
|
|
360
|
+
|
|
361
|
+
# Fallback: Try UUID first (common in modern systems)
|
|
362
|
+
try:
|
|
363
|
+
return UUID(user_id)
|
|
364
|
+
except (ValueError, TypeError):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
# Try int
|
|
368
|
+
try:
|
|
369
|
+
return int(user_id)
|
|
370
|
+
except (ValueError, TypeError):
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Return as string
|
|
374
|
+
logger.debug(f"Using user_id as string: {user_id}")
|
|
375
|
+
return user_id
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# =============================================================================
|
|
379
|
+
# Middleware Factory
|
|
380
|
+
# =============================================================================
|
|
381
|
+
|
|
382
|
+
class AuthenticationMiddleware(StarletteAuthMiddleware):
|
|
383
|
+
"""
|
|
384
|
+
Authentication Middleware using Starlette's proper pattern.
|
|
385
|
+
|
|
386
|
+
This middleware correctly propagates user to views via request.user
|
|
387
|
+
(not request.state.user which doesn't work with BaseHTTPMiddleware).
|
|
388
|
+
|
|
389
|
+
Usage:
|
|
390
|
+
app = CoreApp(
|
|
391
|
+
middlewares=[(AuthenticationMiddleware, {})],
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Or with shortcut:
|
|
395
|
+
app = CoreApp(middleware=["auth"])
|
|
396
|
+
|
|
397
|
+
# In views, use request.user:
|
|
398
|
+
@router.get("/me")
|
|
399
|
+
async def me(request: Request):
|
|
400
|
+
if not request.user.is_authenticated:
|
|
401
|
+
raise HTTPException(401, "Not authenticated")
|
|
402
|
+
return {"email": request.user.email}
|
|
403
|
+
|
|
404
|
+
Note: Also sets request.state.user for backward compatibility.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
app: Any,
|
|
410
|
+
user_model: type | None = None,
|
|
411
|
+
header_name: str = "Authorization",
|
|
412
|
+
scheme: str = "Bearer",
|
|
413
|
+
on_error: Any = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
backend = JWTAuthBackend(
|
|
416
|
+
user_model=user_model,
|
|
417
|
+
header_name=header_name,
|
|
418
|
+
scheme=scheme,
|
|
419
|
+
)
|
|
420
|
+
super().__init__(app, backend=backend, on_error=on_error)
|
|
421
|
+
logger.info("AuthenticationMiddleware initialized")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class OptionalAuthenticationMiddleware(AuthenticationMiddleware):
|
|
425
|
+
"""
|
|
426
|
+
Same as AuthenticationMiddleware but doesn't require authentication.
|
|
427
|
+
|
|
428
|
+
Useful for endpoints that work both with and without authentication.
|
|
429
|
+
User will be UnauthenticatedUser if no valid token provided.
|
|
430
|
+
"""
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# =============================================================================
|
|
435
|
+
# Legacy Compatibility
|
|
436
|
+
# =============================================================================
|
|
437
|
+
|
|
438
|
+
def ensure_auth_middleware(app: Any) -> None:
|
|
439
|
+
"""
|
|
440
|
+
Ensure AuthenticationMiddleware is registered on the app.
|
|
441
|
+
|
|
442
|
+
DEPRECATED: Use middleware=["auth"] instead.
|
|
443
|
+
"""
|
|
444
|
+
logger.warning(
|
|
445
|
+
"ensure_auth_middleware is deprecated. "
|
|
446
|
+
"Use middleware=['auth'] or add AuthenticationMiddleware directly."
|
|
447
|
+
)
|
|
448
|
+
try:
|
|
449
|
+
if hasattr(app, "add_middleware"):
|
|
450
|
+
app.add_middleware(AuthenticationMiddleware)
|
|
451
|
+
logger.info("AuthenticationMiddleware added to app")
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.error(f"Failed to add AuthenticationMiddleware: {e}")
|
|
454
|
+
raise
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def reset_middleware_state() -> None:
|
|
458
|
+
"""Reset middleware state (for testing)."""
|
|
459
|
+
pass # No longer needed with new implementation
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# =============================================================================
|
|
463
|
+
# Exports
|
|
464
|
+
# =============================================================================
|
|
465
|
+
|
|
466
|
+
__all__ = [
|
|
467
|
+
"AuthenticationMiddleware",
|
|
468
|
+
"OptionalAuthenticationMiddleware",
|
|
469
|
+
"JWTAuthBackend",
|
|
470
|
+
"AuthenticatedUser",
|
|
471
|
+
"ensure_auth_middleware",
|
|
472
|
+
"reset_middleware_state",
|
|
473
|
+
]
|
|
@@ -58,6 +58,7 @@ def _get_pk_column_type(model_class: type) -> type:
|
|
|
58
58
|
Detecta o tipo da coluna PK de um modelo.
|
|
59
59
|
|
|
60
60
|
Bug #3 Fix: Detecção robusta do tipo de PK para FKs.
|
|
61
|
+
Verifica toda a cadeia de herança (MRO) para detectar corretamente.
|
|
61
62
|
|
|
62
63
|
Suporta:
|
|
63
64
|
- Integer (int)
|
|
@@ -71,6 +72,7 @@ def _get_pk_column_type(model_class: type) -> type:
|
|
|
71
72
|
from sqlalchemy import Integer, BigInteger, String
|
|
72
73
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
73
74
|
from sqlalchemy import Uuid
|
|
75
|
+
import uuid as uuid_module
|
|
74
76
|
|
|
75
77
|
# Verifica cache primeiro
|
|
76
78
|
cache_key = f"{model_class.__module__}.{model_class.__name__}"
|
|
@@ -79,7 +81,30 @@ def _get_pk_column_type(model_class: type) -> type:
|
|
|
79
81
|
|
|
80
82
|
detected_type = Integer # Default
|
|
81
83
|
|
|
82
|
-
# Método
|
|
84
|
+
# Método 0 (MAIS IMPORTANTE): Verifica herança de AbstractUUIDUser
|
|
85
|
+
# Isso é verificado PRIMEIRO porque funciona mesmo durante o mapeamento
|
|
86
|
+
for base in model_class.__mro__:
|
|
87
|
+
base_name = base.__name__
|
|
88
|
+
# Verifica se herda de AbstractUUIDUser ou qualquer classe com UUID no nome
|
|
89
|
+
if base_name == "AbstractUUIDUser":
|
|
90
|
+
detected_type = PG_UUID
|
|
91
|
+
_pk_type_cache[cache_key] = detected_type
|
|
92
|
+
return detected_type
|
|
93
|
+
|
|
94
|
+
# Método 1: Verifica annotations em TODA a cadeia de herança (MRO)
|
|
95
|
+
for base in model_class.__mro__:
|
|
96
|
+
annotations = getattr(base, "__annotations__", {})
|
|
97
|
+
if "id" in annotations:
|
|
98
|
+
ann = annotations["id"]
|
|
99
|
+
ann_str = str(ann)
|
|
100
|
+
|
|
101
|
+
# Detecta UUID (vários formatos)
|
|
102
|
+
if "UUID" in ann_str or "uuid" in ann_str or "Uuid" in ann_str:
|
|
103
|
+
detected_type = PG_UUID
|
|
104
|
+
_pk_type_cache[cache_key] = detected_type
|
|
105
|
+
return detected_type
|
|
106
|
+
|
|
107
|
+
# Método 2: Tenta obter da tabela já mapeada (se existir)
|
|
83
108
|
if hasattr(model_class, "__table__"):
|
|
84
109
|
pk_columns = [c for c in model_class.__table__.columns if c.primary_key]
|
|
85
110
|
if pk_columns:
|
|
@@ -102,40 +127,41 @@ def _get_pk_column_type(model_class: type) -> type:
|
|
|
102
127
|
_pk_type_cache[cache_key] = detected_type
|
|
103
128
|
return detected_type
|
|
104
129
|
|
|
105
|
-
# Método
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
ann = annotations["id"]
|
|
110
|
-
ann_str = str(ann)
|
|
111
|
-
|
|
112
|
-
# Detecta UUID (vários formatos)
|
|
113
|
-
if "UUID" in ann_str or "uuid" in ann_str or "Uuid" in ann_str:
|
|
114
|
-
detected_type = PG_UUID
|
|
115
|
-
# Detecta int
|
|
116
|
-
elif "int" in ann_str.lower() and "uuid" not in ann_str.lower():
|
|
117
|
-
detected_type = Integer
|
|
118
|
-
# Detecta str
|
|
119
|
-
elif "str" in ann_str.lower() and "uuid" not in ann_str.lower():
|
|
120
|
-
detected_type = String
|
|
121
|
-
|
|
122
|
-
# Método 3: Verifica campos na classe (declared_attr ou column_property)
|
|
123
|
-
for attr_name in dir(model_class):
|
|
124
|
-
if attr_name == "id":
|
|
125
|
-
attr = getattr(model_class, attr_name, None)
|
|
130
|
+
# Método 3: Verifica atributo 'id' diretamente na classe e bases
|
|
131
|
+
for base in model_class.__mro__:
|
|
132
|
+
if hasattr(base, "id"):
|
|
133
|
+
attr = getattr(base, "id", None)
|
|
126
134
|
if attr is not None:
|
|
127
|
-
# Pode ser um InstrumentedAttribute
|
|
135
|
+
# Pode ser um InstrumentedAttribute ou MappedColumn
|
|
128
136
|
if hasattr(attr, "type"):
|
|
129
137
|
attr_type = attr.type
|
|
130
138
|
if isinstance(attr_type, (PG_UUID, Uuid)):
|
|
131
139
|
detected_type = PG_UUID
|
|
132
140
|
break
|
|
141
|
+
type_name = type(attr_type).__name__.upper()
|
|
142
|
+
if "UUID" in type_name:
|
|
143
|
+
detected_type = PG_UUID
|
|
144
|
+
break
|
|
133
145
|
# Pode ser um mapped_column
|
|
134
146
|
if hasattr(attr, "property") and hasattr(attr.property, "columns"):
|
|
135
147
|
for col in attr.property.columns:
|
|
136
148
|
if isinstance(col.type, (PG_UUID, Uuid)):
|
|
137
149
|
detected_type = PG_UUID
|
|
138
150
|
break
|
|
151
|
+
type_name = type(col.type).__name__.upper()
|
|
152
|
+
if "UUID" in type_name:
|
|
153
|
+
detected_type = PG_UUID
|
|
154
|
+
break
|
|
155
|
+
# Verifica se é um MappedColumn com tipo definido
|
|
156
|
+
if hasattr(attr, "column") and hasattr(attr.column, "type"):
|
|
157
|
+
col_type = attr.column.type
|
|
158
|
+
if isinstance(col_type, (PG_UUID, Uuid)):
|
|
159
|
+
detected_type = PG_UUID
|
|
160
|
+
break
|
|
161
|
+
type_name = type(col_type).__name__.upper()
|
|
162
|
+
if "UUID" in type_name:
|
|
163
|
+
detected_type = PG_UUID
|
|
164
|
+
break
|
|
139
165
|
|
|
140
166
|
_pk_type_cache[cache_key] = detected_type
|
|
141
167
|
return detected_type
|
|
@@ -18,6 +18,7 @@ Uso:
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import logging
|
|
21
22
|
from datetime import timedelta
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
@@ -29,6 +30,9 @@ from core.auth.base import (
|
|
|
29
30
|
)
|
|
30
31
|
from core.datetime import timezone
|
|
31
32
|
|
|
33
|
+
# Logger for token operations
|
|
34
|
+
logger = logging.getLogger("core.auth.tokens")
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
class JWTBackend(TokenBackend):
|
|
34
38
|
"""
|
|
@@ -123,10 +127,14 @@ class JWTBackend(TokenBackend):
|
|
|
123
127
|
import jwt
|
|
124
128
|
|
|
125
129
|
try:
|
|
126
|
-
|
|
130
|
+
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
|
131
|
+
logger.debug(f"Token decoded: sub={payload.get('sub')}, type={payload.get('type')}")
|
|
132
|
+
return payload
|
|
127
133
|
except jwt.ExpiredSignatureError:
|
|
134
|
+
logger.debug("Token decode failed: token expired")
|
|
128
135
|
raise TokenError("Token expired")
|
|
129
136
|
except jwt.InvalidTokenError as e:
|
|
137
|
+
logger.debug(f"Token decode failed: {e}")
|
|
130
138
|
raise TokenError(f"Invalid token: {e}")
|
|
131
139
|
|
|
132
140
|
def verify_token(
|
|
@@ -147,11 +155,17 @@ class JWTBackend(TokenBackend):
|
|
|
147
155
|
try:
|
|
148
156
|
payload = self.decode_token(token)
|
|
149
157
|
|
|
150
|
-
|
|
158
|
+
actual_type = payload.get("type")
|
|
159
|
+
if actual_type != token_type:
|
|
160
|
+
logger.debug(
|
|
161
|
+
f"Token type mismatch: expected '{token_type}', got '{actual_type}'"
|
|
162
|
+
)
|
|
151
163
|
return None
|
|
152
164
|
|
|
165
|
+
logger.debug(f"Token verified successfully: sub={payload.get('sub')}")
|
|
153
166
|
return payload
|
|
154
|
-
except TokenError:
|
|
167
|
+
except TokenError as e:
|
|
168
|
+
logger.debug(f"Token verification failed: {e}")
|
|
155
169
|
return None
|
|
156
170
|
|
|
157
171
|
def refresh_token(self, refresh_token: str) -> tuple[str, str] | None:
|