core-framework 0.12.1__py3-none-any.whl → 0.12.3__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.
@@ -0,0 +1,355 @@
1
+ """
2
+ Authentication Middleware for Core Framework.
3
+
4
+ Bug #8 Fix: Provides built-in middleware for populating request.state.user.
5
+
6
+ This middleware automatically authenticates requests using the configured
7
+ authentication backend and populates request.state.user.
8
+
9
+ Usage:
10
+ from core.auth.middleware import AuthenticationMiddleware
11
+
12
+ app = CoreApp(
13
+ middlewares=[(AuthenticationMiddleware, {})],
14
+ )
15
+
16
+ # Or configure via configure_auth()
17
+ from core.auth import configure_auth
18
+ configure_auth(
19
+ user_model=User,
20
+ auto_middleware=True, # Automatically adds middleware
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)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, TYPE_CHECKING
33
+
34
+ from starlette.middleware.base import BaseHTTPMiddleware
35
+ from starlette.requests import Request
36
+ from starlette.responses import Response
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Callable, Awaitable
40
+
41
+
42
+ class AuthenticationMiddleware(BaseHTTPMiddleware):
43
+ """
44
+ Middleware that authenticates requests and populates request.state.user.
45
+
46
+ This middleware:
47
+ 1. Initializes request.state.user to None
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
52
+
53
+ Example:
54
+ from core.auth.middleware import AuthenticationMiddleware
55
+
56
+ app = CoreApp(
57
+ middlewares=[(AuthenticationMiddleware, {})],
58
+ )
59
+
60
+ # In your view
61
+ @router.get("/me")
62
+ async def me(request: Request):
63
+ user = request.state.user # User or None
64
+ if user is None:
65
+ raise HTTPException(401, "Not authenticated")
66
+ return {"id": user.id, "email": user.email}
67
+
68
+ Configuration via kwargs:
69
+ - user_model: User model class (uses global config if None)
70
+ - header_name: Header to extract token from (default: "Authorization")
71
+ - scheme: Expected scheme (default: "Bearer")
72
+ - skip_paths: List of paths to skip authentication (e.g., ["/health"])
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ app: "Callable[[Request], Awaitable[Response]]",
78
+ user_model: type | None = None,
79
+ header_name: str = "Authorization",
80
+ scheme: str = "Bearer",
81
+ skip_paths: list[str] | None = None,
82
+ ) -> None:
83
+ super().__init__(app)
84
+ self._user_model = user_model
85
+ self.header_name = header_name
86
+ self.scheme = scheme
87
+ self.skip_paths = skip_paths or []
88
+
89
+ @property
90
+ def user_model(self) -> type | None:
91
+ """Get user model from instance or global config."""
92
+ if self._user_model is not None:
93
+ return self._user_model
94
+
95
+ try:
96
+ from core.auth.models import get_user_model
97
+ return get_user_model()
98
+ except Exception:
99
+ return None
100
+
101
+ async def dispatch(
102
+ self,
103
+ request: Request,
104
+ call_next: "Callable[[Request], Awaitable[Response]]",
105
+ ) -> Response:
106
+ """
107
+ Process request and authenticate user.
108
+
109
+ Always sets request.state.user (to User or None).
110
+ """
111
+ # Initialize user to None
112
+ request.state.user = None
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)
138
+
139
+ if not auth_header:
140
+ return None
141
+
142
+ parts = auth_header.split()
143
+
144
+ if len(parts) != 2:
145
+ return None
146
+
147
+ scheme, token = parts
148
+
149
+ if scheme.lower() != self.scheme.lower():
150
+ return None
151
+
152
+ return token
153
+
154
+ async def _authenticate(self, request: Request) -> Any | None:
155
+ """
156
+ Authenticate request and return user.
157
+
158
+ Returns:
159
+ User instance if authenticated, None otherwise
160
+ """
161
+ token = self._extract_token(request)
162
+
163
+ if not token:
164
+ return None
165
+
166
+ # Verify token
167
+ from core.auth.tokens import verify_token
168
+
169
+ payload = verify_token(token, token_type="access")
170
+
171
+ if payload is None:
172
+ return None
173
+
174
+ # Get user_id from token
175
+ user_id = payload.get("sub") or payload.get("user_id")
176
+
177
+ if not user_id:
178
+ return None
179
+
180
+ # Get user from database
181
+ User = self.user_model
182
+ if User is None:
183
+ return None
184
+
185
+ # Bug #3 & #4 Fix: Get database session correctly
186
+ # The middleware runs outside FastAPI DI context, so we need to
187
+ # handle database session creation carefully
188
+ db = await self._get_db_session()
189
+ if db is None:
190
+ return None
191
+
192
+ try:
193
+ # Convert user_id to correct type
194
+ user_id_converted = self._convert_user_id(user_id, User)
195
+
196
+ user = await User.objects.using(db).filter(id=user_id_converted).first()
197
+
198
+ if user is None:
199
+ return None
200
+
201
+ # Check if user is active
202
+ if hasattr(user, "is_active") and not user.is_active:
203
+ return None
204
+
205
+ return user
206
+ except Exception:
207
+ return None
208
+ finally:
209
+ await db.close()
210
+
211
+ async def _get_db_session(self) -> Any | None:
212
+ """
213
+ Get a database session for authentication.
214
+
215
+ Bug #3 & #4 Fix: Handles both initialized and uninitialized database states.
216
+ Creates session directly from engine if normal path fails.
217
+
218
+ Returns:
219
+ AsyncSession or None if database not available
220
+ """
221
+ # Try 1: Use the standard get_read_session (if database is initialized)
222
+ try:
223
+ from core.database import get_read_session, _read_session_factory
224
+
225
+ if _read_session_factory is not None:
226
+ return _read_session_factory()
227
+ except (RuntimeError, ImportError):
228
+ pass
229
+
230
+ # Try 2: Create session from settings (lazy initialization)
231
+ try:
232
+ from core.config import get_settings
233
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
234
+
235
+ settings = get_settings()
236
+ db_url = getattr(settings, 'database_read_url', None) or getattr(settings, 'database_url', None)
237
+
238
+ if db_url:
239
+ engine = create_async_engine(db_url, echo=False)
240
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
241
+ return session_factory()
242
+ except Exception:
243
+ pass
244
+
245
+ return None
246
+
247
+ def _convert_user_id(self, user_id: str, User: type) -> Any:
248
+ """
249
+ Convert user_id string to the correct type based on model.
250
+
251
+ Handles INTEGER, UUID, and string IDs.
252
+ """
253
+ from uuid import UUID
254
+
255
+ # Try to detect PK type
256
+ try:
257
+ from core.auth.models import _get_pk_column_type
258
+ from sqlalchemy.dialects.postgresql import UUID as PG_UUID
259
+ from sqlalchemy import Integer, BigInteger
260
+
261
+ pk_type = _get_pk_column_type(User)
262
+
263
+ if pk_type == PG_UUID:
264
+ return UUID(user_id)
265
+ elif pk_type in (Integer, BigInteger):
266
+ return int(user_id)
267
+ except Exception:
268
+ pass
269
+
270
+ # Try UUID first, then int, then string
271
+ try:
272
+ return UUID(user_id)
273
+ except (ValueError, TypeError):
274
+ pass
275
+
276
+ try:
277
+ return int(user_id)
278
+ except (ValueError, TypeError):
279
+ pass
280
+
281
+ return user_id
282
+
283
+
284
+ class OptionalAuthenticationMiddleware(AuthenticationMiddleware):
285
+ """
286
+ Same as AuthenticationMiddleware but never raises errors.
287
+
288
+ Always proceeds with the request, setting user to None on any failure.
289
+ Useful for endpoints that work both with and without authentication.
290
+ """
291
+
292
+ async def dispatch(
293
+ self,
294
+ request: Request,
295
+ call_next: "Callable[[Request], Awaitable[Response]]",
296
+ ) -> Response:
297
+ """Process request, never failing on auth errors."""
298
+ request.state.user = None
299
+
300
+ if not self._should_skip(request.url.path):
301
+ try:
302
+ user = await self._authenticate(request)
303
+ request.state.user = user
304
+ except Exception:
305
+ pass # Silently ignore all errors
306
+
307
+ return await call_next(request)
308
+
309
+
310
+ # =============================================================================
311
+ # Auto-configuration helper
312
+ # =============================================================================
313
+
314
+ _middleware_registered = False
315
+
316
+
317
+ def ensure_auth_middleware(app: Any) -> None:
318
+ """
319
+ Ensure AuthenticationMiddleware is registered on the app.
320
+
321
+ Call this from configure_auth() when auto_middleware=True.
322
+
323
+ Args:
324
+ app: FastAPI or CoreApp instance
325
+ """
326
+ global _middleware_registered
327
+
328
+ if _middleware_registered:
329
+ return
330
+
331
+ # Try to add middleware
332
+ try:
333
+ if hasattr(app, "add_middleware"):
334
+ app.add_middleware(AuthenticationMiddleware)
335
+ _middleware_registered = True
336
+ except Exception:
337
+ pass
338
+
339
+
340
+ def reset_middleware_state() -> None:
341
+ """Reset middleware registration state (for testing)."""
342
+ global _middleware_registered
343
+ _middleware_registered = False
344
+
345
+
346
+ # =============================================================================
347
+ # Exports
348
+ # =============================================================================
349
+
350
+ __all__ = [
351
+ "AuthenticationMiddleware",
352
+ "OptionalAuthenticationMiddleware",
353
+ "ensure_auth_middleware",
354
+ "reset_middleware_state",
355
+ ]
core/auth/models.py CHANGED
@@ -27,12 +27,14 @@ Uso:
27
27
  from __future__ import annotations
28
28
 
29
29
  from typing import Any, ClassVar, TYPE_CHECKING
30
+ from uuid import UUID
30
31
 
31
32
  from sqlalchemy import Table, Column, Integer, ForeignKey, inspect
32
33
  from sqlalchemy.orm import Mapped, relationship, declared_attr
33
34
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID
34
35
 
35
36
  from core.models import Model, Field
37
+ from core.fields import AdvancedField
36
38
  from core.auth.base import get_password_hasher, get_auth_config
37
39
  from core.datetime import timezone, DateTime
38
40
 
@@ -47,11 +49,16 @@ if TYPE_CHECKING:
47
49
  # Cache de tabelas criadas para evitar duplicação
48
50
  _association_tables: dict[str, Table] = {}
49
51
 
52
+ # Cache de tipos de PK detectados
53
+ _pk_type_cache: dict[str, type] = {}
54
+
50
55
 
51
56
  def _get_pk_column_type(model_class: type) -> type:
52
57
  """
53
58
  Detecta o tipo da coluna PK de um modelo.
54
59
 
60
+ Bug #3 Fix: Detecção robusta do tipo de PK para FKs.
61
+
55
62
  Suporta:
56
63
  - Integer (int)
57
64
  - BigInteger (int com bigint=True)
@@ -63,42 +70,83 @@ def _get_pk_column_type(model_class: type) -> type:
63
70
  """
64
71
  from sqlalchemy import Integer, BigInteger, String
65
72
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID
66
- import uuid
73
+ from sqlalchemy import Uuid
74
+
75
+ # Verifica cache primeiro
76
+ cache_key = f"{model_class.__module__}.{model_class.__name__}"
77
+ if cache_key in _pk_type_cache:
78
+ return _pk_type_cache[cache_key]
67
79
 
68
- # Tenta obter do __annotations__ ou columns
80
+ detected_type = Integer # Default
81
+
82
+ # Método 1: Tenta obter da tabela já mapeada
69
83
  if hasattr(model_class, "__table__"):
70
- # Modelo já mapeado - pega direto da tabela
71
84
  pk_columns = [c for c in model_class.__table__.columns if c.primary_key]
72
85
  if pk_columns:
73
- return type(pk_columns[0].type)
86
+ pk_col_type = pk_columns[0].type
87
+ # Verifica se é UUID
88
+ if isinstance(pk_col_type, (PG_UUID, Uuid)):
89
+ detected_type = PG_UUID
90
+ elif isinstance(pk_col_type, BigInteger):
91
+ detected_type = BigInteger
92
+ elif isinstance(pk_col_type, String):
93
+ detected_type = String
94
+ elif isinstance(pk_col_type, Integer):
95
+ detected_type = Integer
96
+ else:
97
+ # Tenta pelo nome do tipo
98
+ type_name = type(pk_col_type).__name__.upper()
99
+ if "UUID" in type_name:
100
+ detected_type = PG_UUID
101
+
102
+ _pk_type_cache[cache_key] = detected_type
103
+ return detected_type
74
104
 
75
- # Tenta via annotations
105
+ # Método 2: Tenta via annotations
76
106
  annotations = getattr(model_class, "__annotations__", {})
77
107
 
78
108
  if "id" in annotations:
79
109
  ann = annotations["id"]
80
110
  ann_str = str(ann)
81
111
 
82
- # Detecta UUID
83
- if "UUID" in ann_str or "uuid" in ann_str:
84
- return PG_UUID
85
-
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
86
115
  # Detecta int
87
- if "int" in ann_str.lower():
88
- return Integer
89
-
116
+ elif "int" in ann_str.lower() and "uuid" not in ann_str.lower():
117
+ detected_type = Integer
90
118
  # Detecta str
91
- if "str" in ann_str.lower():
92
- return String
93
-
94
- # Default: Integer
95
- return Integer
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)
126
+ if attr is not None:
127
+ # Pode ser um InstrumentedAttribute
128
+ if hasattr(attr, "type"):
129
+ attr_type = attr.type
130
+ if isinstance(attr_type, (PG_UUID, Uuid)):
131
+ detected_type = PG_UUID
132
+ break
133
+ # Pode ser um mapped_column
134
+ if hasattr(attr, "property") and hasattr(attr.property, "columns"):
135
+ for col in attr.property.columns:
136
+ if isinstance(col.type, (PG_UUID, Uuid)):
137
+ detected_type = PG_UUID
138
+ break
139
+
140
+ _pk_type_cache[cache_key] = detected_type
141
+ return detected_type
96
142
 
97
143
 
98
144
  def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
99
145
  """
100
146
  Cria coluna FK para user_id detectando automaticamente o tipo.
101
147
 
148
+ Bug #3 Fix: Suporte correto a UUID em FKs.
149
+
102
150
  Args:
103
151
  user_tablename: Nome da tabela do usuário
104
152
  user_model: Classe do modelo (opcional, para detecção de tipo)
@@ -108,9 +156,10 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
108
156
  """
109
157
  from sqlalchemy import Integer, BigInteger, String
110
158
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID
159
+ from sqlalchemy import Uuid
111
160
 
112
161
  # Tenta detectar o tipo do modelo
113
- col_type = Integer # Default
162
+ col_type: Any = Integer # Default
114
163
 
115
164
  if user_model is not None:
116
165
  detected = _get_pk_column_type(user_model)
@@ -122,6 +171,16 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
122
171
  col_type = String(36) # UUID como string
123
172
  else:
124
173
  col_type = Integer
174
+ else:
175
+ # Sem modelo, tenta inferir da configuração global
176
+ try:
177
+ config = get_auth_config()
178
+ if config.user_model is not None:
179
+ detected = _get_pk_column_type(config.user_model)
180
+ if detected == PG_UUID:
181
+ col_type = PG_UUID(as_uuid=True)
182
+ except Exception:
183
+ pass
125
184
 
126
185
  return Column(
127
186
  "user_id",
@@ -131,6 +190,17 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
131
190
  )
132
191
 
133
192
 
193
+ def clear_association_table_cache() -> None:
194
+ """
195
+ Limpa o cache de tabelas de associação.
196
+
197
+ Útil para testes ou quando o modelo de usuário muda.
198
+ """
199
+ global _association_tables, _pk_type_cache
200
+ _association_tables.clear()
201
+ _pk_type_cache.clear()
202
+
203
+
134
204
  def get_user_groups_table(user_tablename: str = "auth_users", user_model: type | None = None) -> Table:
135
205
  """
136
206
  Obtém ou cria tabela de associação user <-> groups.
@@ -433,17 +503,29 @@ class AbstractUser(Model):
433
503
  """
434
504
  Modelo abstrato base para usuários.
435
505
 
506
+ Bug #4 Fix: Suporta tanto INTEGER quanto UUID como PK.
507
+
436
508
  Herde desta classe para criar seu modelo de usuário customizado:
437
509
 
510
+ # Com INTEGER (padrão)
438
511
  class User(AbstractUser, PermissionsMixin):
439
512
  __tablename__ = "users"
440
-
441
- # Campos adicionais
442
- phone: Mapped[str | None] = Field.string(max_length=20, nullable=True)
443
- avatar_url: Mapped[str | None] = Field.string(max_length=500, nullable=True)
513
+
514
+ # Com UUID - sobrescreva o campo id
515
+ from core.fields import AdvancedField
516
+ from uuid import UUID
517
+
518
+ class User(AbstractUser, PermissionsMixin):
519
+ __tablename__ = "users"
520
+ id: Mapped[UUID] = AdvancedField.uuid_pk() # Override para UUID
521
+
522
+ Ou use AbstractUUIDUser para UUID por padrão:
523
+
524
+ class User(AbstractUUIDUser, PermissionsMixin):
525
+ __tablename__ = "users"
444
526
 
445
527
  Campos incluídos:
446
- - id: Chave primária
528
+ - id: Chave primária (INTEGER por padrão, pode ser UUID)
447
529
  - email: Email único (usado para login)
448
530
  - password_hash: Hash da senha
449
531
  - is_active: Se o usuário está ativo
@@ -455,7 +537,7 @@ class AbstractUser(Model):
455
537
 
456
538
  __abstract__ = True
457
539
 
458
- # Campos de autenticação
540
+ # Campos de autenticação - id pode ser sobrescrito por subclasses
459
541
  id: Mapped[int] = Field.pk()
460
542
  email: Mapped[str] = Field.string(max_length=255, unique=True, index=True)
461
543
  password_hash: Mapped[str] = Field.string(max_length=255)
@@ -682,6 +764,38 @@ class AbstractUser(Model):
682
764
  return await cls.objects.using(db).filter(email=email.lower()).first()
683
765
 
684
766
 
767
+ # =============================================================================
768
+ # AbstractUUIDUser Model (Bug #4 Fix)
769
+ # =============================================================================
770
+
771
+ class AbstractUUIDUser(AbstractUser):
772
+ """
773
+ Modelo abstrato base para usuários com UUID como PK.
774
+
775
+ Bug #4 Fix: Fornece uma versão do AbstractUser que usa UUID por padrão.
776
+
777
+ Ideal para:
778
+ - Sistemas distribuídos
779
+ - APIs públicas (UUIDs são mais seguros que IDs sequenciais)
780
+ - Microservices
781
+
782
+ Exemplo:
783
+ from core.auth import AbstractUUIDUser, PermissionsMixin
784
+
785
+ class User(AbstractUUIDUser, PermissionsMixin):
786
+ __tablename__ = "users"
787
+
788
+ # Campos adicionais
789
+ name: Mapped[str] = Field.string(max_length=100)
790
+ """
791
+
792
+ __abstract__ = True
793
+
794
+ # Override: usa UUID como PK
795
+ # UUID importado no topo do módulo para resolução correta de tipos
796
+ id: Mapped[UUID] = AdvancedField.uuid_pk()
797
+
798
+
685
799
  # =============================================================================
686
800
  # PermissionsMixin
687
801
  # =============================================================================
core/auth/schemas.py CHANGED
@@ -100,12 +100,16 @@ class BaseUserOutput(OutputSchema):
100
100
  """
101
101
  Base user output schema.
102
102
 
103
+ Bug #4 Fix: Now supports both int and UUID ids.
104
+
103
105
  Extend to add custom fields:
104
106
  class UserOutput(BaseUserOutput):
105
107
  phone: str | None = None
106
108
  avatar_url: str | None = None
109
+
110
+ For UUID users, the id will be automatically serialized to string.
107
111
  """
108
- id: int
112
+ id: int | str # Supports both INTEGER and UUID (serialized as string)
109
113
  email: str
110
114
  is_active: bool = True
111
115
  is_staff: bool = False