core-framework 0.12.1__py3-none-any.whl → 0.12.2__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,316 @@
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
+ # Get database session
186
+ from core.database import get_read_session
187
+
188
+ async for db in get_read_session():
189
+ try:
190
+ # Convert user_id to correct type
191
+ user_id_converted = self._convert_user_id(user_id, User)
192
+
193
+ user = await User.objects.using(db).filter(id=user_id_converted).first()
194
+
195
+ if user is None:
196
+ return None
197
+
198
+ # Check if user is active
199
+ if hasattr(user, "is_active") and not user.is_active:
200
+ return None
201
+
202
+ return user
203
+ except Exception:
204
+ return None
205
+
206
+ return None
207
+
208
+ def _convert_user_id(self, user_id: str, User: type) -> Any:
209
+ """
210
+ Convert user_id string to the correct type based on model.
211
+
212
+ Handles INTEGER, UUID, and string IDs.
213
+ """
214
+ from uuid import UUID
215
+
216
+ # Try to detect PK type
217
+ try:
218
+ from core.auth.models import _get_pk_column_type
219
+ from sqlalchemy.dialects.postgresql import UUID as PG_UUID
220
+ from sqlalchemy import Integer, BigInteger
221
+
222
+ pk_type = _get_pk_column_type(User)
223
+
224
+ if pk_type == PG_UUID:
225
+ return UUID(user_id)
226
+ elif pk_type in (Integer, BigInteger):
227
+ return int(user_id)
228
+ except Exception:
229
+ pass
230
+
231
+ # Try UUID first, then int, then string
232
+ try:
233
+ return UUID(user_id)
234
+ except (ValueError, TypeError):
235
+ pass
236
+
237
+ try:
238
+ return int(user_id)
239
+ except (ValueError, TypeError):
240
+ pass
241
+
242
+ return user_id
243
+
244
+
245
+ class OptionalAuthenticationMiddleware(AuthenticationMiddleware):
246
+ """
247
+ Same as AuthenticationMiddleware but never raises errors.
248
+
249
+ Always proceeds with the request, setting user to None on any failure.
250
+ Useful for endpoints that work both with and without authentication.
251
+ """
252
+
253
+ async def dispatch(
254
+ self,
255
+ request: Request,
256
+ call_next: "Callable[[Request], Awaitable[Response]]",
257
+ ) -> Response:
258
+ """Process request, never failing on auth errors."""
259
+ request.state.user = None
260
+
261
+ if not self._should_skip(request.url.path):
262
+ try:
263
+ user = await self._authenticate(request)
264
+ request.state.user = user
265
+ except Exception:
266
+ pass # Silently ignore all errors
267
+
268
+ return await call_next(request)
269
+
270
+
271
+ # =============================================================================
272
+ # Auto-configuration helper
273
+ # =============================================================================
274
+
275
+ _middleware_registered = False
276
+
277
+
278
+ def ensure_auth_middleware(app: Any) -> None:
279
+ """
280
+ Ensure AuthenticationMiddleware is registered on the app.
281
+
282
+ Call this from configure_auth() when auto_middleware=True.
283
+
284
+ Args:
285
+ app: FastAPI or CoreApp instance
286
+ """
287
+ global _middleware_registered
288
+
289
+ if _middleware_registered:
290
+ return
291
+
292
+ # Try to add middleware
293
+ try:
294
+ if hasattr(app, "add_middleware"):
295
+ app.add_middleware(AuthenticationMiddleware)
296
+ _middleware_registered = True
297
+ except Exception:
298
+ pass
299
+
300
+
301
+ def reset_middleware_state() -> None:
302
+ """Reset middleware registration state (for testing)."""
303
+ global _middleware_registered
304
+ _middleware_registered = False
305
+
306
+
307
+ # =============================================================================
308
+ # Exports
309
+ # =============================================================================
310
+
311
+ __all__ = [
312
+ "AuthenticationMiddleware",
313
+ "OptionalAuthenticationMiddleware",
314
+ "ensure_auth_middleware",
315
+ "reset_middleware_state",
316
+ ]
core/auth/models.py CHANGED
@@ -47,11 +47,16 @@ if TYPE_CHECKING:
47
47
  # Cache de tabelas criadas para evitar duplicação
48
48
  _association_tables: dict[str, Table] = {}
49
49
 
50
+ # Cache de tipos de PK detectados
51
+ _pk_type_cache: dict[str, type] = {}
52
+
50
53
 
51
54
  def _get_pk_column_type(model_class: type) -> type:
52
55
  """
53
56
  Detecta o tipo da coluna PK de um modelo.
54
57
 
58
+ Bug #3 Fix: Detecção robusta do tipo de PK para FKs.
59
+
55
60
  Suporta:
56
61
  - Integer (int)
57
62
  - BigInteger (int com bigint=True)
@@ -63,42 +68,84 @@ def _get_pk_column_type(model_class: type) -> type:
63
68
  """
64
69
  from sqlalchemy import Integer, BigInteger, String
65
70
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID
71
+ from sqlalchemy import Uuid
66
72
  import uuid
67
73
 
68
- # Tenta obter do __annotations__ ou columns
74
+ # Verifica cache primeiro
75
+ cache_key = f"{model_class.__module__}.{model_class.__name__}"
76
+ if cache_key in _pk_type_cache:
77
+ return _pk_type_cache[cache_key]
78
+
79
+ detected_type = Integer # Default
80
+
81
+ # Método 1: Tenta obter da tabela já mapeada
69
82
  if hasattr(model_class, "__table__"):
70
- # Modelo já mapeado - pega direto da tabela
71
83
  pk_columns = [c for c in model_class.__table__.columns if c.primary_key]
72
84
  if pk_columns:
73
- return type(pk_columns[0].type)
85
+ pk_col_type = pk_columns[0].type
86
+ # Verifica se é UUID
87
+ if isinstance(pk_col_type, (PG_UUID, Uuid)):
88
+ detected_type = PG_UUID
89
+ elif isinstance(pk_col_type, BigInteger):
90
+ detected_type = BigInteger
91
+ elif isinstance(pk_col_type, String):
92
+ detected_type = String
93
+ elif isinstance(pk_col_type, Integer):
94
+ detected_type = Integer
95
+ else:
96
+ # Tenta pelo nome do tipo
97
+ type_name = type(pk_col_type).__name__.upper()
98
+ if "UUID" in type_name:
99
+ detected_type = PG_UUID
100
+
101
+ _pk_type_cache[cache_key] = detected_type
102
+ return detected_type
74
103
 
75
- # Tenta via annotations
104
+ # Método 2: Tenta via annotations
76
105
  annotations = getattr(model_class, "__annotations__", {})
77
106
 
78
107
  if "id" in annotations:
79
108
  ann = annotations["id"]
80
109
  ann_str = str(ann)
81
110
 
82
- # Detecta UUID
83
- if "UUID" in ann_str or "uuid" in ann_str:
84
- return PG_UUID
85
-
111
+ # Detecta UUID (vários formatos)
112
+ if "UUID" in ann_str or "uuid" in ann_str or "Uuid" in ann_str:
113
+ detected_type = PG_UUID
86
114
  # Detecta int
87
- if "int" in ann_str.lower():
88
- return Integer
89
-
115
+ elif "int" in ann_str.lower() and "uuid" not in ann_str.lower():
116
+ detected_type = Integer
90
117
  # Detecta str
91
- if "str" in ann_str.lower():
92
- return String
93
-
94
- # Default: Integer
95
- return Integer
118
+ elif "str" in ann_str.lower() and "uuid" not in ann_str.lower():
119
+ detected_type = String
120
+
121
+ # Método 3: Verifica campos na classe (declared_attr ou column_property)
122
+ for attr_name in dir(model_class):
123
+ if attr_name == "id":
124
+ attr = getattr(model_class, attr_name, None)
125
+ if attr is not None:
126
+ # Pode ser um InstrumentedAttribute
127
+ if hasattr(attr, "type"):
128
+ attr_type = attr.type
129
+ if isinstance(attr_type, (PG_UUID, Uuid)):
130
+ detected_type = PG_UUID
131
+ break
132
+ # Pode ser um mapped_column
133
+ if hasattr(attr, "property") and hasattr(attr.property, "columns"):
134
+ for col in attr.property.columns:
135
+ if isinstance(col.type, (PG_UUID, Uuid)):
136
+ detected_type = PG_UUID
137
+ break
138
+
139
+ _pk_type_cache[cache_key] = detected_type
140
+ return detected_type
96
141
 
97
142
 
98
143
  def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
99
144
  """
100
145
  Cria coluna FK para user_id detectando automaticamente o tipo.
101
146
 
147
+ Bug #3 Fix: Suporte correto a UUID em FKs.
148
+
102
149
  Args:
103
150
  user_tablename: Nome da tabela do usuário
104
151
  user_model: Classe do modelo (opcional, para detecção de tipo)
@@ -108,9 +155,10 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
108
155
  """
109
156
  from sqlalchemy import Integer, BigInteger, String
110
157
  from sqlalchemy.dialects.postgresql import UUID as PG_UUID
158
+ from sqlalchemy import Uuid
111
159
 
112
160
  # Tenta detectar o tipo do modelo
113
- col_type = Integer # Default
161
+ col_type: Any = Integer # Default
114
162
 
115
163
  if user_model is not None:
116
164
  detected = _get_pk_column_type(user_model)
@@ -122,6 +170,16 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
122
170
  col_type = String(36) # UUID como string
123
171
  else:
124
172
  col_type = Integer
173
+ else:
174
+ # Sem modelo, tenta inferir da configuração global
175
+ try:
176
+ config = get_auth_config()
177
+ if config.user_model is not None:
178
+ detected = _get_pk_column_type(config.user_model)
179
+ if detected == PG_UUID:
180
+ col_type = PG_UUID(as_uuid=True)
181
+ except Exception:
182
+ pass
125
183
 
126
184
  return Column(
127
185
  "user_id",
@@ -131,6 +189,17 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
131
189
  )
132
190
 
133
191
 
192
+ def clear_association_table_cache() -> None:
193
+ """
194
+ Limpa o cache de tabelas de associação.
195
+
196
+ Útil para testes ou quando o modelo de usuário muda.
197
+ """
198
+ global _association_tables, _pk_type_cache
199
+ _association_tables.clear()
200
+ _pk_type_cache.clear()
201
+
202
+
134
203
  def get_user_groups_table(user_tablename: str = "auth_users", user_model: type | None = None) -> Table:
135
204
  """
136
205
  Obtém ou cria tabela de associação user <-> groups.
@@ -433,17 +502,29 @@ class AbstractUser(Model):
433
502
  """
434
503
  Modelo abstrato base para usuários.
435
504
 
505
+ Bug #4 Fix: Suporta tanto INTEGER quanto UUID como PK.
506
+
436
507
  Herde desta classe para criar seu modelo de usuário customizado:
437
508
 
509
+ # Com INTEGER (padrão)
438
510
  class User(AbstractUser, PermissionsMixin):
439
511
  __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)
512
+
513
+ # Com UUID - sobrescreva o campo id
514
+ from core.fields import AdvancedField
515
+ from uuid import UUID
516
+
517
+ class User(AbstractUser, PermissionsMixin):
518
+ __tablename__ = "users"
519
+ id: Mapped[UUID] = AdvancedField.uuid_pk() # Override para UUID
520
+
521
+ Ou use AbstractUUIDUser para UUID por padrão:
522
+
523
+ class User(AbstractUUIDUser, PermissionsMixin):
524
+ __tablename__ = "users"
444
525
 
445
526
  Campos incluídos:
446
- - id: Chave primária
527
+ - id: Chave primária (INTEGER por padrão, pode ser UUID)
447
528
  - email: Email único (usado para login)
448
529
  - password_hash: Hash da senha
449
530
  - is_active: Se o usuário está ativo
@@ -455,7 +536,7 @@ class AbstractUser(Model):
455
536
 
456
537
  __abstract__ = True
457
538
 
458
- # Campos de autenticação
539
+ # Campos de autenticação - id pode ser sobrescrito por subclasses
459
540
  id: Mapped[int] = Field.pk()
460
541
  email: Mapped[str] = Field.string(max_length=255, unique=True, index=True)
461
542
  password_hash: Mapped[str] = Field.string(max_length=255)
@@ -682,6 +763,41 @@ class AbstractUser(Model):
682
763
  return await cls.objects.using(db).filter(email=email.lower()).first()
683
764
 
684
765
 
766
+ # =============================================================================
767
+ # AbstractUUIDUser Model (Bug #4 Fix)
768
+ # =============================================================================
769
+
770
+ class AbstractUUIDUser(AbstractUser):
771
+ """
772
+ Modelo abstrato base para usuários com UUID como PK.
773
+
774
+ Bug #4 Fix: Fornece uma versão do AbstractUser que usa UUID por padrão.
775
+
776
+ Ideal para:
777
+ - Sistemas distribuídos
778
+ - APIs públicas (UUIDs são mais seguros que IDs sequenciais)
779
+ - Microservices
780
+
781
+ Exemplo:
782
+ from core.auth import AbstractUUIDUser, PermissionsMixin
783
+
784
+ class User(AbstractUUIDUser, PermissionsMixin):
785
+ __tablename__ = "users"
786
+
787
+ # Campos adicionais
788
+ name: Mapped[str] = Field.string(max_length=100)
789
+ """
790
+
791
+ __abstract__ = True
792
+
793
+ # Importa aqui para evitar circular import
794
+ from core.fields import AdvancedField
795
+ from uuid import UUID as UUIDType
796
+
797
+ # Override: usa UUID como PK
798
+ id: Mapped[UUIDType] = AdvancedField.uuid_pk()
799
+
800
+
685
801
  # =============================================================================
686
802
  # PermissionsMixin
687
803
  # =============================================================================
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