core-framework 0.12.3__py3-none-any.whl → 0.12.5__py3-none-any.whl
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/__init__.py +1 -1
- core/auth/middleware.py +286 -168
- core/auth/models.py +49 -23
- core/auth/tokens.py +17 -3
- core/auth/views.py +27 -7
- {core_framework-0.12.3.dist-info → core_framework-0.12.5.dist-info}/METADATA +1 -1
- {core_framework-0.12.3.dist-info → core_framework-0.12.5.dist-info}/RECORD +9 -9
- {core_framework-0.12.3.dist-info → core_framework-0.12.5.dist-info}/WHEEL +0 -0
- {core_framework-0.12.3.dist-info → core_framework-0.12.5.dist-info}/entry_points.txt +0 -0
core/__init__.py
CHANGED
core/auth/middleware.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Authentication Middleware for Core Framework.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Uses Starlette's AuthenticationMiddleware pattern which correctly propagates
|
|
5
|
+
user to views via scope["user"] (accessed as request.user).
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
authentication backend and populates request.state.user.
|
|
7
|
+
IMPORTANT: Use request.user (not request.state.user) in your views!
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
from core.auth.middleware import AuthenticationMiddleware
|
|
@@ -13,78 +13,117 @@ Usage:
|
|
|
13
13
|
middlewares=[(AuthenticationMiddleware, {})],
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
The middleware will:
|
|
24
|
-
1. Extract Bearer token from Authorization header
|
|
25
|
-
2. Validate the token
|
|
26
|
-
3. Fetch the user from database
|
|
27
|
-
4. Set request.state.user to the authenticated user (or None)
|
|
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}
|
|
28
22
|
"""
|
|
29
23
|
|
|
30
24
|
from __future__ import annotations
|
|
31
25
|
|
|
26
|
+
import logging
|
|
32
27
|
from typing import Any, TYPE_CHECKING
|
|
28
|
+
from uuid import UUID
|
|
33
29
|
|
|
34
|
-
from starlette.
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
)
|
|
37
47
|
|
|
38
48
|
if TYPE_CHECKING:
|
|
39
|
-
from
|
|
49
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
50
|
+
|
|
51
|
+
# Logger for authentication - NEVER silent!
|
|
52
|
+
logger = logging.getLogger("core.auth")
|
|
40
53
|
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# Authenticated User Wrapper
|
|
57
|
+
# =============================================================================
|
|
58
|
+
|
|
59
|
+
class AuthenticatedUser(BaseUser):
|
|
43
60
|
"""
|
|
44
|
-
|
|
61
|
+
Wrapper for authenticated user that implements Starlette's BaseUser.
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
2. Extracts Bearer token from Authorization header
|
|
49
|
-
3. Validates the token using configured backend
|
|
50
|
-
4. Fetches user from database
|
|
51
|
-
5. Sets request.state.user to authenticated user
|
|
63
|
+
Provides access to the underlying database user model while implementing
|
|
64
|
+
the required Starlette interface.
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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!
|
|
67
111
|
|
|
68
|
-
Configuration
|
|
112
|
+
Configuration:
|
|
69
113
|
- user_model: User model class (uses global config if None)
|
|
70
114
|
- header_name: Header to extract token from (default: "Authorization")
|
|
71
115
|
- scheme: Expected scheme (default: "Bearer")
|
|
72
|
-
- skip_paths: List of paths to skip authentication (e.g., ["/health"])
|
|
73
116
|
"""
|
|
74
117
|
|
|
75
118
|
def __init__(
|
|
76
119
|
self,
|
|
77
|
-
app: "Callable[[Request], Awaitable[Response]]",
|
|
78
120
|
user_model: type | None = None,
|
|
79
121
|
header_name: str = "Authorization",
|
|
80
122
|
scheme: str = "Bearer",
|
|
81
|
-
skip_paths: list[str] | None = None,
|
|
82
123
|
) -> None:
|
|
83
|
-
super().__init__(app)
|
|
84
124
|
self._user_model = user_model
|
|
85
125
|
self.header_name = header_name
|
|
86
126
|
self.scheme = scheme
|
|
87
|
-
self.skip_paths = skip_paths or []
|
|
88
127
|
|
|
89
128
|
@property
|
|
90
129
|
def user_model(self) -> type | None:
|
|
@@ -95,139 +134,181 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
|
95
134
|
try:
|
|
96
135
|
from core.auth.models import get_user_model
|
|
97
136
|
return get_user_model()
|
|
98
|
-
except Exception:
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Failed to get user model: {e}")
|
|
99
139
|
return None
|
|
100
140
|
|
|
101
|
-
async def
|
|
102
|
-
self,
|
|
103
|
-
request: Request,
|
|
104
|
-
call_next: "Callable[[Request], Awaitable[Response]]",
|
|
105
|
-
) -> Response:
|
|
141
|
+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, BaseUser] | None:
|
|
106
142
|
"""
|
|
107
|
-
|
|
143
|
+
Authenticate the request.
|
|
108
144
|
|
|
109
|
-
|
|
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!
|
|
110
150
|
"""
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# Skip authentication for configured paths
|
|
115
|
-
if self._should_skip(request.url.path):
|
|
116
|
-
return await call_next(request)
|
|
117
|
-
|
|
118
|
-
# Try to authenticate
|
|
119
|
-
try:
|
|
120
|
-
user = await self._authenticate(request)
|
|
121
|
-
request.state.user = user
|
|
122
|
-
except Exception:
|
|
123
|
-
# Authentication failed, keep user as None
|
|
124
|
-
pass
|
|
125
|
-
|
|
126
|
-
return await call_next(request)
|
|
127
|
-
|
|
128
|
-
def _should_skip(self, path: str) -> bool:
|
|
129
|
-
"""Check if path should skip authentication."""
|
|
130
|
-
for skip_path in self.skip_paths:
|
|
131
|
-
if path.startswith(skip_path):
|
|
132
|
-
return True
|
|
133
|
-
return False
|
|
134
|
-
|
|
135
|
-
def _extract_token(self, request: Request) -> str | None:
|
|
136
|
-
"""Extract token from Authorization header."""
|
|
137
|
-
auth_header = request.headers.get(self.header_name)
|
|
151
|
+
# Extract token from header
|
|
152
|
+
auth_header = conn.headers.get(self.header_name)
|
|
138
153
|
|
|
139
154
|
if not auth_header:
|
|
155
|
+
logger.debug("No Authorization header present")
|
|
140
156
|
return None
|
|
141
157
|
|
|
158
|
+
# Parse header
|
|
142
159
|
parts = auth_header.split()
|
|
143
|
-
|
|
144
160
|
if len(parts) != 2:
|
|
161
|
+
logger.warning(f"Malformed Authorization header: expected 2 parts, got {len(parts)}")
|
|
145
162
|
return None
|
|
146
163
|
|
|
147
164
|
scheme, token = parts
|
|
148
|
-
|
|
149
165
|
if scheme.lower() != self.scheme.lower():
|
|
166
|
+
logger.warning(f"Unexpected auth scheme: expected '{self.scheme}', got '{scheme}'")
|
|
150
167
|
return None
|
|
151
168
|
|
|
152
|
-
|
|
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!
|
|
153
202
|
|
|
154
|
-
async def
|
|
203
|
+
async def _verify_and_get_user(self, token: str) -> Any | None:
|
|
155
204
|
"""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
214
|
"""
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if not token:
|
|
164
|
-
return None
|
|
215
|
+
from core.auth.tokens import verify_token, decode_token
|
|
216
|
+
from core.auth.base import TokenError
|
|
165
217
|
|
|
166
218
|
# Verify token
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
219
|
+
try:
|
|
220
|
+
payload = verify_token(token, token_type="access")
|
|
221
|
+
except TokenError as e:
|
|
222
|
+
raise InvalidToken(f"Token verification failed: {e}")
|
|
170
223
|
|
|
171
224
|
if payload is None:
|
|
172
|
-
|
|
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}")
|
|
173
237
|
|
|
174
238
|
# Get user_id from token
|
|
175
239
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
176
|
-
|
|
177
240
|
if not user_id:
|
|
178
|
-
|
|
241
|
+
raise InvalidToken("Token missing 'sub' or 'user_id' claim")
|
|
179
242
|
|
|
180
|
-
|
|
243
|
+
logger.debug(f"User ID from token: {user_id}")
|
|
244
|
+
|
|
245
|
+
# Get user model
|
|
181
246
|
User = self.user_model
|
|
182
247
|
if User is None:
|
|
183
|
-
|
|
248
|
+
raise ConfigurationError(
|
|
249
|
+
"No user model configured. "
|
|
250
|
+
"Set user_model in AuthenticationMiddleware or call configure_auth(user_model=...)"
|
|
251
|
+
)
|
|
184
252
|
|
|
185
|
-
#
|
|
186
|
-
# The middleware runs outside FastAPI DI context, so we need to
|
|
187
|
-
# handle database session creation carefully
|
|
253
|
+
# Get database session
|
|
188
254
|
db = await self._get_db_session()
|
|
189
255
|
if db is None:
|
|
190
|
-
|
|
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
|
+
)
|
|
191
260
|
|
|
192
261
|
try:
|
|
193
262
|
# Convert user_id to correct type
|
|
194
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__})")
|
|
195
265
|
|
|
266
|
+
# Fetch user
|
|
196
267
|
user = await User.objects.using(db).filter(id=user_id_converted).first()
|
|
197
268
|
|
|
198
269
|
if user is None:
|
|
199
|
-
|
|
270
|
+
raise UserNotFound(f"User with id={user_id} not found")
|
|
200
271
|
|
|
201
272
|
# Check if user is active
|
|
202
273
|
if hasattr(user, "is_active") and not user.is_active:
|
|
203
|
-
|
|
274
|
+
raise UserInactive(f"User {user_id} is inactive")
|
|
204
275
|
|
|
276
|
+
logger.debug(f"User found: {getattr(user, 'email', user)}")
|
|
205
277
|
return user
|
|
206
|
-
|
|
207
|
-
|
|
278
|
+
|
|
279
|
+
except (UserNotFound, UserInactive):
|
|
280
|
+
raise # Re-raise our exceptions
|
|
281
|
+
except Exception as e:
|
|
282
|
+
raise DatabaseException(f"Database query failed: {e}")
|
|
208
283
|
finally:
|
|
209
284
|
await db.close()
|
|
210
285
|
|
|
211
|
-
async def _get_db_session(self) ->
|
|
286
|
+
async def _get_db_session(self) -> "AsyncSession | None":
|
|
212
287
|
"""
|
|
213
288
|
Get a database session for authentication.
|
|
214
289
|
|
|
215
|
-
|
|
216
|
-
Creates session directly from engine if normal path fails.
|
|
290
|
+
Tries multiple strategies and logs each attempt.
|
|
217
291
|
|
|
218
292
|
Returns:
|
|
219
|
-
AsyncSession or None if
|
|
293
|
+
AsyncSession or None if all strategies fail
|
|
220
294
|
"""
|
|
221
|
-
|
|
295
|
+
errors: list[str] = []
|
|
296
|
+
|
|
297
|
+
# Strategy 1: Use initialized session factory
|
|
222
298
|
try:
|
|
223
|
-
from core.database import
|
|
299
|
+
from core.database import _read_session_factory
|
|
224
300
|
|
|
225
301
|
if _read_session_factory is not None:
|
|
302
|
+
logger.debug("Using initialized session factory")
|
|
226
303
|
return _read_session_factory()
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
231
312
|
try:
|
|
232
313
|
from core.config import get_settings
|
|
233
314
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
@@ -236,12 +317,22 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
|
236
317
|
db_url = getattr(settings, 'database_read_url', None) or getattr(settings, 'database_url', None)
|
|
237
318
|
|
|
238
319
|
if db_url:
|
|
320
|
+
logger.debug(f"Creating session from settings: {db_url[:30]}...")
|
|
239
321
|
engine = create_async_engine(db_url, echo=False)
|
|
240
322
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
241
323
|
return session_factory()
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
)
|
|
245
336
|
return None
|
|
246
337
|
|
|
247
338
|
def _convert_user_id(self, user_id: str, User: type) -> Any:
|
|
@@ -250,9 +341,7 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
|
250
341
|
|
|
251
342
|
Handles INTEGER, UUID, and string IDs.
|
|
252
343
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# Try to detect PK type
|
|
344
|
+
# Try to detect PK type from model
|
|
256
345
|
try:
|
|
257
346
|
from core.auth.models import _get_pk_column_type
|
|
258
347
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
@@ -261,86 +350,113 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
|
261
350
|
pk_type = _get_pk_column_type(User)
|
|
262
351
|
|
|
263
352
|
if pk_type == PG_UUID:
|
|
353
|
+
logger.debug(f"Converting user_id to UUID: {user_id}")
|
|
264
354
|
return UUID(user_id)
|
|
265
355
|
elif pk_type in (Integer, BigInteger):
|
|
356
|
+
logger.debug(f"Converting user_id to int: {user_id}")
|
|
266
357
|
return int(user_id)
|
|
267
|
-
except Exception:
|
|
268
|
-
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.debug(f"Could not detect PK type, trying heuristics: {e}")
|
|
269
360
|
|
|
270
|
-
# Try UUID first
|
|
361
|
+
# Fallback: Try UUID first (common in modern systems)
|
|
271
362
|
try:
|
|
272
363
|
return UUID(user_id)
|
|
273
364
|
except (ValueError, TypeError):
|
|
274
365
|
pass
|
|
275
366
|
|
|
367
|
+
# Try int
|
|
276
368
|
try:
|
|
277
369
|
return int(user_id)
|
|
278
370
|
except (ValueError, TypeError):
|
|
279
371
|
pass
|
|
280
372
|
|
|
373
|
+
# Return as string
|
|
374
|
+
logger.debug(f"Using user_id as string: {user_id}")
|
|
281
375
|
return user_id
|
|
282
376
|
|
|
283
377
|
|
|
284
|
-
|
|
378
|
+
# =============================================================================
|
|
379
|
+
# Middleware Factory
|
|
380
|
+
# =============================================================================
|
|
381
|
+
|
|
382
|
+
class AuthenticationMiddleware(StarletteAuthMiddleware):
|
|
285
383
|
"""
|
|
286
|
-
|
|
384
|
+
Authentication Middleware using Starlette's proper pattern.
|
|
287
385
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
"""
|
|
386
|
+
This middleware correctly propagates user to views via request.user
|
|
387
|
+
(not request.state.user which doesn't work with BaseHTTPMiddleware).
|
|
291
388
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
) -> Response:
|
|
297
|
-
"""Process request, never failing on auth errors."""
|
|
298
|
-
request.state.user = None
|
|
389
|
+
Usage:
|
|
390
|
+
app = CoreApp(
|
|
391
|
+
middlewares=[(AuthenticationMiddleware, {})],
|
|
392
|
+
)
|
|
299
393
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
user = await self._authenticate(request)
|
|
303
|
-
request.state.user = user
|
|
304
|
-
except Exception:
|
|
305
|
-
pass # Silently ignore all errors
|
|
394
|
+
# Or with shortcut:
|
|
395
|
+
app = CoreApp(middleware=["auth"])
|
|
306
396
|
|
|
307
|
-
|
|
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")
|
|
308
422
|
|
|
309
423
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
313
432
|
|
|
314
|
-
_middleware_registered = False
|
|
315
433
|
|
|
434
|
+
# =============================================================================
|
|
435
|
+
# Legacy Compatibility
|
|
436
|
+
# =============================================================================
|
|
316
437
|
|
|
317
438
|
def ensure_auth_middleware(app: Any) -> None:
|
|
318
439
|
"""
|
|
319
440
|
Ensure AuthenticationMiddleware is registered on the app.
|
|
320
441
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
Args:
|
|
324
|
-
app: FastAPI or CoreApp instance
|
|
442
|
+
DEPRECATED: Use middleware=["auth"] instead.
|
|
325
443
|
"""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
# Try to add middleware
|
|
444
|
+
logger.warning(
|
|
445
|
+
"ensure_auth_middleware is deprecated. "
|
|
446
|
+
"Use middleware=['auth'] or add AuthenticationMiddleware directly."
|
|
447
|
+
)
|
|
332
448
|
try:
|
|
333
449
|
if hasattr(app, "add_middleware"):
|
|
334
450
|
app.add_middleware(AuthenticationMiddleware)
|
|
335
|
-
|
|
336
|
-
except Exception:
|
|
337
|
-
|
|
451
|
+
logger.info("AuthenticationMiddleware added to app")
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.error(f"Failed to add AuthenticationMiddleware: {e}")
|
|
454
|
+
raise
|
|
338
455
|
|
|
339
456
|
|
|
340
457
|
def reset_middleware_state() -> None:
|
|
341
|
-
"""Reset middleware
|
|
342
|
-
|
|
343
|
-
_middleware_registered = False
|
|
458
|
+
"""Reset middleware state (for testing)."""
|
|
459
|
+
pass # No longer needed with new implementation
|
|
344
460
|
|
|
345
461
|
|
|
346
462
|
# =============================================================================
|
|
@@ -350,6 +466,8 @@ def reset_middleware_state() -> None:
|
|
|
350
466
|
__all__ = [
|
|
351
467
|
"AuthenticationMiddleware",
|
|
352
468
|
"OptionalAuthenticationMiddleware",
|
|
469
|
+
"JWTAuthBackend",
|
|
470
|
+
"AuthenticatedUser",
|
|
353
471
|
"ensure_auth_middleware",
|
|
354
472
|
"reset_middleware_state",
|
|
355
473
|
]
|
core/auth/models.py
CHANGED
|
@@ -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
|
core/auth/tokens.py
CHANGED
|
@@ -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:
|
core/auth/views.py
CHANGED
|
@@ -360,15 +360,27 @@ class AuthViewSet(ViewSet):
|
|
|
360
360
|
) -> dict:
|
|
361
361
|
"""
|
|
362
362
|
Get current authenticated user.
|
|
363
|
+
|
|
364
|
+
Uses request.user (Starlette pattern) with fallback to request.state.user
|
|
365
|
+
for backward compatibility.
|
|
363
366
|
"""
|
|
367
|
+
# Try request.user first (Starlette AuthenticationMiddleware pattern)
|
|
368
|
+
user = getattr(request, "user", None)
|
|
369
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
370
|
+
# user is an AuthenticatedUser wrapper - get underlying model
|
|
371
|
+
if hasattr(user, "_user"):
|
|
372
|
+
user = user._user
|
|
373
|
+
return self.user_output_schema.model_validate(user).model_dump()
|
|
374
|
+
|
|
375
|
+
# Fallback to request.state.user (legacy pattern)
|
|
364
376
|
user = getattr(request.state, "user", None)
|
|
365
|
-
if user is None:
|
|
366
|
-
|
|
367
|
-
status_code=401,
|
|
368
|
-
detail="Not authenticated"
|
|
369
|
-
)
|
|
377
|
+
if user is not None:
|
|
378
|
+
return self.user_output_schema.model_validate(user).model_dump()
|
|
370
379
|
|
|
371
|
-
|
|
380
|
+
raise HTTPException(
|
|
381
|
+
status_code=401,
|
|
382
|
+
detail="Not authenticated"
|
|
383
|
+
)
|
|
372
384
|
|
|
373
385
|
@action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
|
|
374
386
|
async def change_password(
|
|
@@ -381,7 +393,15 @@ class AuthViewSet(ViewSet):
|
|
|
381
393
|
"""
|
|
382
394
|
Change password for current user.
|
|
383
395
|
"""
|
|
384
|
-
|
|
396
|
+
# Try request.user first (Starlette pattern)
|
|
397
|
+
user = getattr(request, "user", None)
|
|
398
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
399
|
+
if hasattr(user, "_user"):
|
|
400
|
+
user = user._user
|
|
401
|
+
else:
|
|
402
|
+
# Fallback to request.state.user
|
|
403
|
+
user = getattr(request.state, "user", None)
|
|
404
|
+
|
|
385
405
|
if user is None:
|
|
386
406
|
raise HTTPException(
|
|
387
407
|
status_code=401,
|
|
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
core/__init__.py,sha256=
|
|
1
|
+
core/__init__.py,sha256=pc5DiPlXIs8HLdJ6-rz30XpNpVTCGRhDxLw8VG65iDg,12058
|
|
2
2
|
core/app.py,sha256=sCA3mJI696i7MIjrPxfOr5zEYt0njarQfHHy3EAajk4,21071
|
|
3
3
|
core/choices.py,sha256=rhcL3p2dB7RK99zIilpmoTFVcibQEIaRpz0CY0kImCE,10502
|
|
4
4
|
core/config.py,sha256=2-MVF9nLoYmxpYYH_Gzn4-Sa3MU87YZskRPtlNyhg6Q,14049
|
|
@@ -22,12 +22,12 @@ core/auth/backends.py,sha256=R-siIE8TrNqDHkCx42zXN1WVvvuWOun1nj8D5elrC9g,10425
|
|
|
22
22
|
core/auth/base.py,sha256=Q7vXgwTmgdmyW7G8eJmDket2bKB_8YFnraZ_kK9_gTs,21425
|
|
23
23
|
core/auth/decorators.py,sha256=tmC7prKUvHuzQ3J872nM6r83DR9d82dCLXKLvUB1Os8,12288
|
|
24
24
|
core/auth/hashers.py,sha256=0gIf67TU0k5H744FADpyh9_ugxA7m3mhYPZxLh_lEtc,12808
|
|
25
|
-
core/auth/middleware.py,sha256=
|
|
26
|
-
core/auth/models.py,sha256=
|
|
25
|
+
core/auth/middleware.py,sha256=3Wddxi2MKHrHguKfdW9LRKhHaHcmQ807edVcsFLqhVc,16645
|
|
26
|
+
core/auth/models.py,sha256=aEE7deQKPS1aH0Btzzh3Z1Bwuqy8zvLZwu4JFEmiUNk,34058
|
|
27
27
|
core/auth/permissions.py,sha256=v3ykAgNpq5wJ0NkuC_FuveMctOkDfM9Xp11XEnUAuBg,12461
|
|
28
28
|
core/auth/schemas.py,sha256=L0W96dOD348rJDGeu1K5Rz3aJj-GdwMr2vbwwsYfo2g,3469
|
|
29
|
-
core/auth/tokens.py,sha256=
|
|
30
|
-
core/auth/views.py,sha256=
|
|
29
|
+
core/auth/tokens.py,sha256=jOF40D5O8WRG8klRwMBuSG-jOhdsp1irXn2aZ2puNSg,9149
|
|
30
|
+
core/auth/views.py,sha256=3gMaq8pzWoWr29ExJk21JgGay2fE3Fq3Tz99Wb_ftvE,14203
|
|
31
31
|
core/cli/__init__.py,sha256=obodnvfe8DUziqpk-IAaHTEOb1KSfYQeuBZEAofut4o,449
|
|
32
32
|
core/cli/main.py,sha256=daWz8tuMkMYrkNBfueDH5OghncdqLs3k7BUMmvDsSvk,119635
|
|
33
33
|
core/deployment/__init__.py,sha256=RNcBRO9oB3WRnhtTTwM6wzVEcUKpKF4XfRkGSbbykIc,794
|
|
@@ -78,7 +78,7 @@ example/auth.py,sha256=zBpLutb8lVKnGfQqQ2wnyygsSutHYZzeJBuhnFhxBaQ,4971
|
|
|
78
78
|
example/models.py,sha256=xKdx0kJ9n0tZ7sCce3KhV3BTvKvsh6m7G69eFm3ukf0,4549
|
|
79
79
|
example/schemas.py,sha256=wJ9QofnuHp4PjtM_IuMMBLVFVDJ4YlwcF6uQm1ooKiY,6139
|
|
80
80
|
example/views.py,sha256=GQwgQcW6yoeUIDbF7-lsaZV7cs8G1S1vGVtiwVpZIQE,14338
|
|
81
|
-
core_framework-0.12.
|
|
82
|
-
core_framework-0.12.
|
|
83
|
-
core_framework-0.12.
|
|
84
|
-
core_framework-0.12.
|
|
81
|
+
core_framework-0.12.5.dist-info/METADATA,sha256=INxj7mhfca6AsK23r_uZ4pMozXdOumZN9EM9BUV2IcI,12791
|
|
82
|
+
core_framework-0.12.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
83
|
+
core_framework-0.12.5.dist-info/entry_points.txt,sha256=lQ65IAOpieqU1VcHCUReeyandpyy8IKGix6IkJW_4Is,39
|
|
84
|
+
core_framework-0.12.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|