fastapi-djauth 1.4.0__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.

Potentially problematic release.


This version of fastapi-djauth might be problematic. Click here for more details.

Files changed (62) hide show
  1. djauth/__init__.py +82 -0
  2. djauth/__main__.py +98 -0
  3. djauth/api.py +890 -0
  4. djauth/backends.py +130 -0
  5. djauth/core.py +1280 -0
  6. djauth/database.py +148 -0
  7. djauth/decorators.py +119 -0
  8. djauth/errors.py +107 -0
  9. djauth/fields.py +157 -0
  10. djauth/hashers.py +54 -0
  11. djauth/models.py +200 -0
  12. djauth/plantilla/.dockerignore.tpl +8 -0
  13. djauth/plantilla/.env.example.tpl +15 -0
  14. djauth/plantilla/.github/workflows/ci.yml.tpl +22 -0
  15. djauth/plantilla/.gitignore.tpl +12 -0
  16. djauth/plantilla/Dockerfile.tpl +26 -0
  17. djauth/plantilla/README.md.tpl +83 -0
  18. djauth/plantilla/alembic.ini.tpl +39 -0
  19. djauth/plantilla/migrations/env.py.tpl +52 -0
  20. djauth/plantilla/migrations/script.py.mako.tpl +24 -0
  21. djauth/plantilla/migrations/versions/0001_productos.py.tpl +35 -0
  22. djauth/plantilla/pyproject.toml.tpl +46 -0
  23. djauth/plantilla/src/__paquete__/__init__.py.tpl +3 -0
  24. djauth/plantilla/src/__paquete__/api/__init__.py.tpl +0 -0
  25. djauth/plantilla/src/__paquete__/api/deps.py.tpl +12 -0
  26. djauth/plantilla/src/__paquete__/api/router.py.tpl +8 -0
  27. djauth/plantilla/src/__paquete__/api/v1/__init__.py.tpl +0 -0
  28. djauth/plantilla/src/__paquete__/api/v1/productos.py.tpl +74 -0
  29. djauth/plantilla/src/__paquete__/core/__init__.py.tpl +0 -0
  30. djauth/plantilla/src/__paquete__/core/config.py.tpl +41 -0
  31. djauth/plantilla/src/__paquete__/core/security.py.tpl +50 -0
  32. djauth/plantilla/src/__paquete__/db.py.tpl +41 -0
  33. djauth/plantilla/src/__paquete__/main.py.tpl +52 -0
  34. djauth/plantilla/src/__paquete__/models/__init__.py.tpl +5 -0
  35. djauth/plantilla/src/__paquete__/models/producto.py.tpl +24 -0
  36. djauth/plantilla/src/__paquete__/schemas/__init__.py.tpl +0 -0
  37. djauth/plantilla/src/__paquete__/schemas/producto.py.tpl +36 -0
  38. djauth/plantilla/src/__paquete__/services/__init__.py.tpl +0 -0
  39. djauth/plantilla/src/__paquete__/services/productos.py.tpl +46 -0
  40. djauth/plantilla/tests/__init__.py.tpl +0 -0
  41. djauth/plantilla/tests/conftest.py.tpl +35 -0
  42. djauth/plantilla/tests/test_app.py.tpl +42 -0
  43. djauth/py.typed +0 -0
  44. djauth/routers.py +404 -0
  45. djauth/settings.py +127 -0
  46. djauth/startproject.py +271 -0
  47. djauth/static/account.html +51 -0
  48. djauth/static/account.js +34 -0
  49. djauth/static/admin.html +167 -0
  50. djauth/static/admin.js +346 -0
  51. djauth/static/common.js +99 -0
  52. djauth/static/djauth.css +99 -0
  53. djauth/static/login.html +26 -0
  54. djauth/static/login.js +38 -0
  55. djauth/static/reset.html +27 -0
  56. djauth/static/reset.js +30 -0
  57. djauth/validators.py +139 -0
  58. fastapi_djauth-1.4.0.dist-info/METADATA +133 -0
  59. fastapi_djauth-1.4.0.dist-info/RECORD +62 -0
  60. fastapi_djauth-1.4.0.dist-info/WHEEL +4 -0
  61. fastapi_djauth-1.4.0.dist-info/entry_points.txt +2 -0
  62. fastapi_djauth-1.4.0.dist-info/licenses/LICENSE +20 -0
djauth/api.py ADDED
@@ -0,0 +1,890 @@
1
+ """Integración con FastAPI: rutas, dependencias, middleware de protección y páginas.
2
+
3
+ kit = AuthKit(AuthSettings(database_url="sqlite:///auth.sqlite3")).init()
4
+ kit.install(app) # rutas /auth/… y páginas de login y administración
5
+ kit.install(app, protect=True, rules=[…]) # además exige sesión o token en toda la aplicación
6
+
7
+ @app.get("/pedidos")
8
+ def pedidos(user: AuthUser = Depends(require_perm("ventas.view_pedido"))): ...
9
+ """
10
+
11
+ # Sin `from __future__ import annotations`: FastAPI evalúa las anotaciones de las rutas, y estas nombran
12
+ # dependencias locales de `build_router` (`Me`, `staff(...)`) que no se pueden resolver como texto.
13
+ import hmac
14
+ import json
15
+ import logging
16
+ import re
17
+ from collections.abc import Awaitable, Callable, Coroutine, Iterable, MutableMapping
18
+ from dataclasses import dataclass
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Annotated, Any, Literal, cast
22
+ from urllib.parse import parse_qs, quote, urlsplit
23
+
24
+ from fastapi import APIRouter, Depends, Query, Request, Response
25
+ from fastapi.exceptions import RequestValidationError
26
+ from fastapi.responses import FileResponse, JSONResponse
27
+ from fastapi.routing import APIRoute
28
+ from fastapi.security import OAuth2PasswordBearer
29
+ from fastapi.staticfiles import StaticFiles
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+ from starlette.concurrency import run_in_threadpool
32
+ from starlette.requests import HTTPConnection
33
+
34
+ from djauth.core import HISTORY_ACTIONS, SERVICE_USER, AuthKit, AuthUser, request_ip
35
+ from djauth.errors import (
36
+ AuthError,
37
+ NotAuthenticatedError,
38
+ PermissionDeniedError,
39
+ TooManyAttemptsError,
40
+ ValidationError,
41
+ )
42
+ from djauth.validators import password_help_texts
43
+
44
+ logger = logging.getLogger("djauth")
45
+ STATIC_DIR = Path(__file__).parent / "static"
46
+ UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
47
+
48
+ # Cabeceras de las páginas del módulo: no se pueden incrustar en otro sitio, no cargan nada externo
49
+ # y no filtran la URL (el enlace de restablecimiento lleva un token).
50
+ PAGE_HEADERS = {
51
+ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
52
+ "X-Frame-Options": "DENY",
53
+ "X-Content-Type-Options": "nosniff",
54
+ "Referrer-Policy": "no-referrer",
55
+ "Cache-Control": "no-store",
56
+ }
57
+ API_HEADERS = {"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}
58
+
59
+ # Solo documenta el esquema en /docs (botón "Authorize"); la validación real la hace `identify`.
60
+ _oauth2_docs = OAuth2PasswordBearer(tokenUrl="/auth/api/token", auto_error=False)
61
+
62
+
63
+ # ====================================================================== errores
64
+ def csrf_error() -> PermissionDeniedError:
65
+ return PermissionDeniedError(
66
+ "Petición rechazada: llegó desde otro sitio web con su sesión abierta (protección CSRF).",
67
+ code="csrf_failed",
68
+ hint=(
69
+ "Si es su propio frontend en otro dominio, agréguelo a `trusted_origins`. "
70
+ "Los clientes de API deben usar un token Bearer."
71
+ ),
72
+ )
73
+
74
+
75
+ def missing_permission_error(perms: Iterable[str]) -> PermissionDeniedError:
76
+ perms = list(perms)
77
+ return PermissionDeniedError(
78
+ f"No tiene permiso para esta acción. Requiere: {', '.join(perms)}.",
79
+ code="missing_permission",
80
+ hint="Pida a un administrador ese permiso o un rol (grupo) que lo incluya.",
81
+ )
82
+
83
+
84
+ def error_response(exc: AuthError) -> JSONResponse:
85
+ headers = dict(API_HEADERS)
86
+ if exc.status == 401:
87
+ headers["WWW-Authenticate"] = "Bearer"
88
+ if isinstance(exc, TooManyAttemptsError):
89
+ headers["Retry-After"] = str(exc.retry_after)
90
+ return JSONResponse(exc.to_dict(), exc.status, headers=headers)
91
+
92
+
93
+ async def auth_error_handler(_request: Request, exc: Exception) -> JSONResponse:
94
+ """Manejador global: cualquier `AuthError` (también en las rutas de la aplicación) responde con
95
+ `{"detail", "code", "hint", "errors"}`."""
96
+ if not isinstance(exc, AuthError): # solo se registra para AuthError; por si acaso, no se oculta
97
+ raise exc
98
+ return error_response(exc)
99
+
100
+
101
+ _PYDANTIC_MESSAGES = {
102
+ "missing": "Este campo es obligatorio.",
103
+ "string_too_short": "Debe tener al menos {min_length} caracteres.",
104
+ "too_short": "Debe tener al menos {min_length} elementos.",
105
+ "string_too_long": "Máximo {max_length} caracteres.",
106
+ "too_long": "Máximo {max_length} elementos.",
107
+ "string_type": "Debe ser un texto.",
108
+ "int_parsing": "Debe ser un número entero.",
109
+ "int_type": "Debe ser un número entero.",
110
+ "int_from_float": "Debe ser un número entero.",
111
+ "float_parsing": "Debe ser un número.",
112
+ "float_type": "Debe ser un número.",
113
+ "bool_parsing": "Debe ser verdadero o falso.",
114
+ "bool_type": "Debe ser verdadero o falso.",
115
+ "list_type": "Debe ser una lista.",
116
+ "dict_type": "Debe ser un objeto JSON.",
117
+ "model_attributes_type": "Debe ser un objeto JSON.",
118
+ "json_invalid": "El cuerpo de la petición no es un JSON válido.",
119
+ "greater_than_equal": "Debe ser mayor o igual que {ge}.",
120
+ "less_than_equal": "Debe ser menor o igual que {le}.",
121
+ "greater_than": "Debe ser mayor que {gt}.",
122
+ "less_than": "Debe ser menor que {lt}.",
123
+ }
124
+
125
+
126
+ def translate_validation(exc: RequestValidationError) -> ValidationError:
127
+ """Los errores de formato de FastAPI (pydantic), en español y agrupados por campo."""
128
+ errors: dict[str, list[str]] = {}
129
+ for err in exc.errors():
130
+ loc = [str(part) for part in err.get("loc", ()) if part not in ("body", "query", "path", "header")]
131
+ field = ".".join(loc) or "__all__"
132
+ template = _PYDANTIC_MESSAGES.get(err.get("type", ""))
133
+ try:
134
+ message = template.format(**(err.get("ctx") or {})) if template else err.get("msg", "Valor no válido.")
135
+ except (KeyError, IndexError):
136
+ message = err.get("msg", "Valor no válido.")
137
+ errors.setdefault(field, []).append(message)
138
+ error = ValidationError(errors, hint="Revise el formato de los campos marcados.", code="invalid_format")
139
+ error.status = 422 # datos mal formados (tipo, longitud, faltantes); 400 queda para reglas de negocio
140
+ return error
141
+
142
+
143
+ # ====================================================================== identificación
144
+ def _kit(conn: HTTPConnection) -> AuthKit:
145
+ kit = getattr(conn.app.state, "djauth", None)
146
+ if kit is None:
147
+ raise RuntimeError("AuthKit no está instalado en esta aplicación: llame a kit.install(app)")
148
+ return kit
149
+
150
+
151
+ def _equal(a: str | None, b: str | None) -> bool:
152
+ if not a or not b:
153
+ return False
154
+ return hmac.compare_digest(a.encode(), b.encode())
155
+
156
+
157
+ def identify(
158
+ kit: AuthKit, headers: Any, query_string: bytes = b"", *, websocket: bool = False, api_key: str | None = None
159
+ ) -> tuple[AuthUser | None, str | None]:
160
+ """Quién hace la petición y por qué vía: ("session" | "bearer" | "api_key").
161
+
162
+ Orden: `Authorization: Bearer`, `X-API-Key`, cookie de sesión. En un WebSocket (el navegador no
163
+ puede enviar cabeceras) se acepta además `?access_token=` o `?api_key=`.
164
+ """
165
+ s = kit.settings
166
+ auth = headers.get("authorization") or ""
167
+ scheme, _, credential = auth.partition(" ")
168
+ credential = credential.strip()
169
+ if scheme.lower() == "bearer" and credential:
170
+ if _equal(credential, api_key):
171
+ return SERVICE_USER, "api_key"
172
+ if s.enable_bearer and (user := kit.user_from_token(credential, "bearer")):
173
+ return user, "bearer"
174
+ return None, None # una credencial explícita e inválida no cae a la cookie
175
+ if _equal(headers.get("x-api-key"), api_key):
176
+ return SERVICE_USER, "api_key"
177
+ if s.enable_sessions:
178
+ for part in (headers.get("cookie") or "").split(";"):
179
+ name, _, value = part.strip().partition("=")
180
+ if name == s.session_cookie_name and (user := kit.user_from_token(value, "session")):
181
+ return user, "session"
182
+ if websocket and query_string:
183
+ query = parse_qs(query_string.decode("latin-1"))
184
+ token = (query.get("access_token") or [""])[0]
185
+ if s.enable_bearer and token and (user := kit.user_from_token(token, "bearer")):
186
+ return user, "bearer"
187
+ if _equal((query.get("api_key") or [""])[0], api_key):
188
+ return SERVICE_USER, "api_key"
189
+ return None, None
190
+
191
+
192
+ def same_origin(headers: Any, trusted: Iterable[str] = (), *, strict: bool = False) -> bool:
193
+ """Defensa CSRF para la autenticación por cookie: una petición que modifica datos debe venir de
194
+ este mismo sitio. Los navegadores envían `Origin` (o `Sec-Fetch-Site`) en esas peticiones."""
195
+ origin = headers.get("origin")
196
+ if origin:
197
+ if origin in trusted:
198
+ return True
199
+ return urlsplit(origin).netloc == (headers.get("host") or "")
200
+ if strict:
201
+ # Los navegadores siempre envían Origin al abrir un WebSocket; sin él no es un navegador y la
202
+ # cookie no pudo llegar por un ataque entre sitios, pero en modo estricto se exige igual.
203
+ return False
204
+ site = headers.get("sec-fetch-site")
205
+ return site in (None, "same-origin", "none")
206
+
207
+
208
+ def _user_of(conn: HTTPConnection) -> tuple[AuthUser | None, str | None]:
209
+ state = conn.scope.get("state") or {}
210
+ if "user" in state: # ya lo resolvió el middleware
211
+ return state["user"], state.get("auth_via")
212
+ kit = _kit(conn)
213
+ return identify(kit, conn.headers, conn.scope.get("query_string", b""), websocket=conn.scope["type"] == "websocket")
214
+
215
+
216
+ # ====================================================================== dependencias públicas
217
+ def optional_user(conn: HTTPConnection) -> AuthUser | None:
218
+ """El usuario si hay sesión o token válido; si no, None (para rutas públicas que se adaptan)."""
219
+ return _user_of(conn)[0]
220
+
221
+
222
+ def current_user(request: Request, _docs: Annotated[str | None, Depends(_oauth2_docs)] = None) -> AuthUser:
223
+ """Equivale a `@login_required`: 401 si no hay sesión ni token válido."""
224
+ user, via = _user_of(request)
225
+ if user is None:
226
+ raise NotAuthenticatedError()
227
+ if (
228
+ via == "session"
229
+ and request.method in UNSAFE_METHODS
230
+ and not same_origin(request.headers, _kit(request).settings.trusted_origins)
231
+ ):
232
+ raise csrf_error()
233
+ return user
234
+
235
+
236
+ def require_perm(*perms: str) -> Callable[..., AuthUser]:
237
+ """Equivale a `@permission_required`: 403 si falta alguno de los permisos (`app.codename`)."""
238
+
239
+ def dependency(user: Annotated[AuthUser, Depends(current_user)]) -> AuthUser:
240
+ missing = [p for p in perms if not user.has_perm(p)]
241
+ if missing:
242
+ raise missing_permission_error(missing)
243
+ return user
244
+
245
+ return dependency
246
+
247
+
248
+ def require_staff(user: Annotated[AuthUser, Depends(current_user)]) -> AuthUser:
249
+ """Equivale a `@staff_member_required`."""
250
+ if not (user.is_staff or user.is_superuser):
251
+ raise PermissionDeniedError(
252
+ "Solo el personal de administración puede entrar aquí.",
253
+ code="staff_required",
254
+ hint='Un superusuario debe marcar en su cuenta "Acceso a la administración".',
255
+ )
256
+ return user
257
+
258
+
259
+ def require_superuser(user: Annotated[AuthUser, Depends(current_user)]) -> AuthUser:
260
+ if not user.is_superuser:
261
+ raise PermissionDeniedError("Solo un superusuario puede hacer esto.", code="superuser_required")
262
+ return user
263
+
264
+
265
+ def ws_user(websocket: HTTPConnection) -> AuthUser | None:
266
+ """Usuario de un WebSocket (cookie de sesión, o `?access_token=`). None si no está autenticado.
267
+
268
+ Con la cookie, el WebSocket debe abrirse desde este mismo sitio: los navegadores envían la cookie
269
+ aunque otro sitio abra la conexión (secuestro de WebSocket entre sitios, CSWSH).
270
+ """
271
+ user, via = _user_of(websocket)
272
+ if via == "session" and not same_origin(websocket.headers, _kit(websocket).settings.trusted_origins, strict=True):
273
+ return None
274
+ return user
275
+
276
+
277
+ # ====================================================================== middleware de protección
278
+ @dataclass(frozen=True)
279
+ class Rule:
280
+ """Permiso que exige una ruta. `pattern` es una expresión regular sobre el path; `methods` limita
281
+ los métodos HTTP (None = todos; un WebSocket cuenta como "WEBSOCKET"). Gana la primera que coincide."""
282
+
283
+ pattern: str
284
+ perm: str | None
285
+ methods: frozenset[str] | None = None
286
+
287
+ def matches(self, method: str, path: str) -> bool:
288
+ return (self.methods is None or method in self.methods) and re.match(self.pattern, path) is not None
289
+
290
+
291
+ def rule(pattern: str, perm: str | None, methods: Iterable[str] | None = None) -> Rule:
292
+ return Rule(pattern, perm, frozenset(m.upper() for m in methods) if methods else None)
293
+
294
+
295
+ Scope = MutableMapping[str, Any]
296
+ Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
297
+ Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
298
+
299
+
300
+ class _Headers:
301
+ """Acceso por nombre a las cabeceras de un scope ASGI."""
302
+
303
+ def __init__(self, scope: Scope) -> None:
304
+ self._h = {k.decode("latin-1"): v.decode("latin-1") for k, v in scope.get("headers") or ()}
305
+
306
+ def get(self, name: str, default: str | None = None) -> str | None:
307
+ return self._h.get(name, default)
308
+
309
+
310
+ class AuthMiddleware:
311
+ """Exige autenticación en toda la aplicación (HTTP y WebSocket) y aplica las reglas de permiso.
312
+
313
+ Sin credenciales: una navegación del navegador se redirige al inicio de sesión; cualquier otra
314
+ petición recibe 401. Con credenciales pero sin el permiso de la regla: 403.
315
+ """
316
+
317
+ def __init__(
318
+ self,
319
+ app: Callable[[Scope, Receive, Send], Awaitable[None]],
320
+ kit: AuthKit,
321
+ *,
322
+ login_url: str = "/auth/login",
323
+ exempt: Iterable[str] = (),
324
+ exempt_prefixes: Iterable[str] = (),
325
+ rules: Iterable[Rule] = (),
326
+ api_key: str | None = None,
327
+ ) -> None:
328
+ self.app = app
329
+ self.kit = kit
330
+ self.login_url = login_url
331
+ self.exempt = frozenset(exempt)
332
+ self.exempt_prefixes = tuple(exempt_prefixes)
333
+ self.rules = tuple(rules)
334
+ self.api_key = api_key
335
+
336
+ async def _reject(
337
+ self, scope: Scope, receive: Receive, send: Send, error: AuthError | None, location: str | None = None
338
+ ) -> None:
339
+ if scope["type"] == "websocket":
340
+ await receive()
341
+ reason = f"{error.code}: {error.message}" if error else "not_authenticated"
342
+ await send({"type": "websocket.close", "code": 1008, "reason": reason.encode()[:120].decode(errors="ignore")})
343
+ return
344
+ status = 303 if location else (error.status if error else 401)
345
+ body = json.dumps(error.to_dict(), ensure_ascii=False).encode() if error else b""
346
+ headers = [(b"content-type", b"application/json; charset=utf-8"), (b"cache-control", b"no-store")]
347
+ if location:
348
+ headers = [(b"location", location.encode("latin-1")), (b"cache-control", b"no-store")]
349
+ elif status == 401:
350
+ headers.append((b"www-authenticate", b"Bearer"))
351
+ headers.append((b"content-length", str(len(body)).encode()))
352
+ await send({"type": "http.response.start", "status": status, "headers": headers})
353
+ await send({"type": "http.response.body", "body": body})
354
+
355
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
356
+ if scope["type"] not in ("http", "websocket"):
357
+ await self.app(scope, receive, send)
358
+ return
359
+ path = scope.get("path", "")
360
+ method = scope.get("method", "WEBSOCKET")
361
+ if path in self.exempt or path.startswith(self.exempt_prefixes) or method == "OPTIONS":
362
+ await self.app(scope, receive, send)
363
+ return
364
+ headers = _Headers(scope)
365
+ # Consulta a la base (con caché): fuera del bucle de eventos.
366
+ user, via = await run_in_threadpool(
367
+ identify,
368
+ self.kit,
369
+ headers,
370
+ scope.get("query_string", b""),
371
+ websocket=scope["type"] == "websocket",
372
+ api_key=self.api_key,
373
+ )
374
+ if user is None:
375
+ wants_page = method == "GET" and "text/html" in (headers.get("accept") or "")
376
+ if wants_page and self.kit.settings.enable_sessions:
377
+ query = scope.get("query_string", b"").decode("latin-1")
378
+ target = quote(path + (f"?{query}" if query else ""), safe="")
379
+ await self._reject(scope, receive, send, None, f"{self.login_url}?next={target}")
380
+ else:
381
+ await self._reject(scope, receive, send, NotAuthenticatedError())
382
+ return
383
+ # Con cookie, lo que modifica datos y los WebSocket deben venir de este sitio (CSRF y CSWSH).
384
+ cross_site_risk = method in UNSAFE_METHODS or scope["type"] == "websocket"
385
+ strict = scope["type"] == "websocket"
386
+ if via == "session" and cross_site_risk and not same_origin(headers, self.kit.settings.trusted_origins, strict=strict):
387
+ await self._reject(scope, receive, send, csrf_error())
388
+ return
389
+ for r in self.rules:
390
+ if r.matches(method, path):
391
+ if r.perm and not user.has_perm(r.perm):
392
+ logger.warning("403 %s %s: %s no tiene %s", method, path, user.username, r.perm)
393
+ await self._reject(scope, receive, send, missing_permission_error([r.perm]))
394
+ return
395
+ break
396
+ scope.setdefault("state", {})
397
+ scope["state"]["user"] = user
398
+ scope["state"]["auth_via"] = via
399
+ await self.app(scope, receive, send)
400
+
401
+
402
+ # ====================================================================== esquemas
403
+ Password = Annotated[str, Field(min_length=1, max_length=4096)]
404
+
405
+
406
+ class LoginIn(BaseModel):
407
+ """`username` lleva el usuario o el correo según `username_field`; también se acepta `email`."""
408
+
409
+ username: str | None = Field(default=None, max_length=254)
410
+ email: str | None = Field(default=None, max_length=254)
411
+ password: Password
412
+
413
+ @property
414
+ def login(self) -> str:
415
+ value = (self.username or self.email or "").strip()
416
+ if not value:
417
+ raise ValidationError({"username": ["Este campo es obligatorio."]}, code="invalid_format")
418
+ return value
419
+
420
+
421
+ class PasswordChangeIn(BaseModel):
422
+ old_password: Password
423
+ new_password: Password
424
+
425
+
426
+ class ResetConfirmIn(BaseModel):
427
+ uid: int
428
+ token: str = Field(max_length=200)
429
+ new_password: Password
430
+
431
+
432
+ class ProfileIn(BaseModel):
433
+ email: str | None = Field(default=None, max_length=254)
434
+ first_name: str | None = Field(default=None, max_length=150)
435
+ last_name: str | None = Field(default=None, max_length=150)
436
+
437
+
438
+ class UserCreateIn(BaseModel):
439
+ # Las columnas propias del usuario (user_mixin) llegan como claves de primer nivel.
440
+ model_config = ConfigDict(extra="allow")
441
+
442
+ username: str = Field(default="", max_length=150, description="No se usa si username_field es 'email'")
443
+ password: Password | None = Field(default=None, description="Sin contraseña, el usuario no puede entrar hasta asignarle una")
444
+ email: str = Field(default="", max_length=254)
445
+ first_name: str = Field(default="", max_length=150)
446
+ last_name: str = Field(default="", max_length=150)
447
+ is_active: bool = True
448
+ is_staff: bool = False
449
+ is_superuser: bool = False
450
+ groups: list[int] = Field(default_factory=list)
451
+ permissions: list[int] = Field(default_factory=list)
452
+ extra: dict[str, Any] | None = None
453
+
454
+
455
+ class UserUpdateIn(BaseModel):
456
+ model_config = ConfigDict(extra="allow")
457
+
458
+ username: str | None = Field(default=None, max_length=150)
459
+ email: str | None = Field(default=None, max_length=254)
460
+ first_name: str | None = Field(default=None, max_length=150)
461
+ last_name: str | None = Field(default=None, max_length=150)
462
+ is_active: bool | None = None
463
+ is_staff: bool | None = None
464
+ is_superuser: bool | None = None
465
+ groups: list[int] | None = None
466
+ permissions: list[int] | None = None
467
+ extra: dict[str, Any] | None = None
468
+
469
+
470
+ class SetPasswordIn(BaseModel):
471
+ password: Password
472
+
473
+
474
+ class GroupIn(BaseModel):
475
+ name: str = Field(max_length=150)
476
+ permissions: list[int] = Field(default_factory=list)
477
+
478
+
479
+ class GroupUpdateIn(BaseModel):
480
+ name: str | None = Field(default=None, max_length=150)
481
+ permissions: list[int] | None = None
482
+
483
+
484
+ # ====================================================================== rutas
485
+ class _AuthRoute(APIRoute):
486
+ """Errores del núcleo y de formato como respuestas JSON descriptivas (ver docs/errores.md)."""
487
+
488
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
489
+ handler = super().get_route_handler()
490
+
491
+ async def wrapped(request: Request) -> Response:
492
+ # La IP queda disponible para el historial de cambios (se copia al hilo de la ruta).
493
+ kit = getattr(request.app.state, "djauth", None)
494
+ ip_token = request_ip.set(_client_ip(request, kit) if kit is not None else "")
495
+ try:
496
+ response = await handler(request)
497
+ except AuthError as exc:
498
+ return error_response(exc)
499
+ except RequestValidationError as exc:
500
+ return error_response(translate_validation(exc))
501
+ finally:
502
+ request_ip.reset(ip_token)
503
+ for name, value in API_HEADERS.items():
504
+ response.headers.setdefault(name, value)
505
+ return response
506
+
507
+ return wrapped
508
+
509
+
510
+ OAUTH2_SCHEME = "bearer" # valor de `token_type` en la respuesta de OAuth2 (RFC 6750)
511
+
512
+
513
+ def _client_ip(request: Request, kit: AuthKit) -> str:
514
+ if kit.settings.trust_proxy_headers and (forwarded := request.headers.get("x-forwarded-for")):
515
+ return forwarded.split(",")[0].strip()[:64]
516
+ return request.client.host if request.client else ""
517
+
518
+
519
+ def build_router(kit: AuthKit) -> APIRouter:
520
+ s = kit.settings
521
+ router = APIRouter(route_class=_AuthRoute, tags=["auth"])
522
+
523
+ def check_origin(request: Request) -> None:
524
+ # También en el inicio de sesión: nadie debe poder iniciar una sesión ajena desde otro sitio.
525
+ # El navegador envía Origin en los POST: si viene de otro sitio se rechaza aunque aún no haya
526
+ # cookie (evita que un sitio ajeno inicie sesión en nombre de la víctima con otra cuenta).
527
+ browser_request = request.headers.get("cookie") or request.headers.get("origin")
528
+ if request.method in UNSAFE_METHODS and browser_request and not same_origin(request.headers, s.trusted_origins):
529
+ raise csrf_error()
530
+
531
+ def staff(perm: str) -> Callable[..., AuthUser]:
532
+ def dependency(user: Annotated[AuthUser, Depends(require_staff)]) -> AuthUser:
533
+ if not user.has_perm(perm):
534
+ raise missing_permission_error([perm])
535
+ return user
536
+
537
+ return dependency
538
+
539
+ # Motivo opcional de un cambio de usuario: queda en el historial (`?reason=Cambio de área`).
540
+ Reason = Annotated[str, Query(max_length=255, description="Motivo del cambio, para el historial")]
541
+
542
+ def set_cookie(response: Response, token: str, request: Request) -> None:
543
+ # Secure siempre que la petición llegue por HTTPS, aunque no se haya configurado.
544
+ https = request.url.scheme == "https" or (
545
+ s.trust_proxy_headers and request.headers.get("x-forwarded-proto", "").lower() == "https"
546
+ )
547
+ response.set_cookie(
548
+ s.session_cookie_name,
549
+ token,
550
+ max_age=s.session_cookie_age,
551
+ path="/",
552
+ httponly=True,
553
+ secure=s.session_cookie_secure or https,
554
+ samesite=cast(Literal["lax", "strict", "none"], s.session_cookie_samesite), # validado en AuthKit
555
+ )
556
+
557
+ # ------------------------------------------------------------------ público
558
+ @router.get("/config", summary="Datos del sitio para las páginas de acceso")
559
+ def config() -> dict[str, Any]:
560
+ return {
561
+ "site_name": s.site_name,
562
+ "brand_color": s.brand_color,
563
+ "home_url": s.home_url,
564
+ "sessions": s.enable_sessions,
565
+ "bearer": s.enable_bearer,
566
+ "password_help": password_help_texts(s.password_min_length),
567
+ "username_field": s.username_field,
568
+ "user_fields": [f.describe() for f in kit.custom_fields],
569
+ "extra_fields": [
570
+ {
571
+ "name": f.name,
572
+ "label": f.label,
573
+ "type": f.type,
574
+ "choices": list(f.choices),
575
+ "required": f.required,
576
+ "help_text": f.help_text,
577
+ }
578
+ for f in s.extra_fields
579
+ ],
580
+ }
581
+
582
+ @router.post("/login", summary="Iniciar sesión (cookie)", dependencies=[Depends(check_origin)])
583
+ def login(body: LoginIn, request: Request, response: Response) -> dict[str, Any]:
584
+ if not s.enable_sessions:
585
+ raise PermissionDeniedError(
586
+ "El inicio de sesión con cookie está desactivado en este servicio.",
587
+ code="sessions_disabled",
588
+ hint="Pida un token en POST /token y envíelo como `Authorization: Bearer <token>`.",
589
+ )
590
+ ip = _client_ip(request, kit)
591
+ user = kit.authenticate(body.login, body.password, ip=ip)
592
+ kit.logout(request.cookies.get(s.session_cookie_name)) # nunca se reutiliza una sesión previa
593
+ set_cookie(response, kit.login(user, kind="session", ip=ip, user_agent=request.headers.get("user-agent", "")), request)
594
+ return {"user": user.to_dict()}
595
+
596
+ @router.post("/token", summary="Obtener un token Bearer (JSON o formulario OAuth2)")
597
+ async def token(request: Request) -> dict[str, Any]:
598
+ if not s.enable_bearer:
599
+ raise PermissionDeniedError(
600
+ "Los tokens Bearer están desactivados en este servicio.",
601
+ code="bearer_disabled",
602
+ hint="Inicie sesión con POST /login (cookie de sesión).",
603
+ )
604
+ raw = await request.body()
605
+ if len(raw) > 16_384:
606
+ raise ValidationError("La petición es demasiado grande (máximo 16 KB).", code="payload_too_large")
607
+ try:
608
+ if "json" in request.headers.get("content-type", ""):
609
+ data = json.loads(raw or b"{}")
610
+ else: # application/x-www-form-urlencoded, el flujo "password" de OAuth2 (botón Authorize de /docs)
611
+ data = {k: v[0] for k, v in parse_qs(raw.decode()).items()}
612
+ body = LoginIn(username=data.get("username"), email=data.get("email"), password=data.get("password", ""))
613
+ except (ValueError, AttributeError) as exc:
614
+ raise ValidationError(
615
+ {"username": ["Este campo es obligatorio."], "password": ["Este campo es obligatorio."]},
616
+ hint='Envíe JSON {"username": …, "password": …} o un formulario OAuth2 (grant_type=password).',
617
+ ) from exc
618
+ ip = _client_ip(request, kit)
619
+
620
+ def issue() -> dict[str, Any]:
621
+ user = kit.authenticate(body.login, body.password, ip=ip)
622
+ access = kit.login(user, kind="bearer", ip=ip, user_agent=request.headers.get("user-agent", ""))
623
+ return {
624
+ "access_token": access,
625
+ "token_type": OAUTH2_SCHEME,
626
+ "expires_in": s.bearer_token_age,
627
+ "user": user.to_dict(),
628
+ }
629
+
630
+ return await run_in_threadpool(issue)
631
+
632
+ @router.post("/logout", summary="Cerrar la sesión o revocar el token presentado", dependencies=[Depends(check_origin)])
633
+ def logout(request: Request, response: Response) -> dict[str, bool]:
634
+ kit.logout(request.cookies.get(s.session_cookie_name))
635
+ scheme, _, credential = (request.headers.get("authorization") or "").partition(" ")
636
+ if scheme.lower() == "bearer":
637
+ kit.logout(credential.strip())
638
+ response.delete_cookie(s.session_cookie_name, path="/")
639
+ return {"ok": True}
640
+
641
+ @router.post("/reset/confirm", summary="Fijar una contraseña nueva con un enlace de restablecimiento")
642
+ def reset_confirm(body: ResetConfirmIn) -> dict[str, bool]:
643
+ kit.reset_password(body.uid, body.token, body.new_password)
644
+ return {"ok": True}
645
+
646
+ # ------------------------------------------------------------------ mi cuenta
647
+ Me = Annotated[AuthUser, Depends(current_user)]
648
+
649
+ @router.get("/me", summary="Usuario autenticado, con sus grupos y permisos")
650
+ def me(user: Me) -> dict[str, Any]:
651
+ return user.to_dict()
652
+
653
+ @router.patch("/me", summary="Actualizar mis datos personales")
654
+ def update_me(body: ProfileIn, user: Me) -> dict[str, Any]:
655
+ if user.is_service:
656
+ raise ValidationError("Una llave de API no tiene perfil que modificar.", code="service_account")
657
+ updated = kit.update_user(user.id, body.model_dump(exclude_none=True), actor=None)
658
+ return {k: updated[k] for k in ("id", "username", "email", "first_name", "last_name", "full_name")}
659
+
660
+ @router.post("/me/password", summary="Cambiar mi contraseña (cierra mis otras sesiones)")
661
+ def change_my_password(body: PasswordChangeIn, user: Me, request: Request) -> dict[str, bool]:
662
+ if user.is_service:
663
+ raise ValidationError("Una llave de API no tiene contraseña que cambiar.", code="service_account")
664
+ scheme, _, credential = (request.headers.get("authorization") or "").partition(" ")
665
+ keep = credential.strip() if scheme.lower() == "bearer" else request.cookies.get(s.session_cookie_name)
666
+ kit.change_password(user, body.old_password, body.new_password, keep_session=keep)
667
+ return {"ok": True}
668
+
669
+ # ------------------------------------------------------------------ administración: usuarios
670
+ @router.get("/users", summary="Listar usuarios")
671
+ def list_users(
672
+ _: Annotated[AuthUser, Depends(staff("auth.view_user"))],
673
+ q: Annotated[str | None, Query(max_length=150)] = None,
674
+ is_active: bool | None = None,
675
+ is_staff: bool | None = None,
676
+ is_superuser: bool | None = None,
677
+ group: int | None = None,
678
+ order: Annotated[str, Query(max_length=30)] = "username",
679
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
680
+ offset: Annotated[int, Query(ge=0)] = 0,
681
+ ) -> dict[str, Any]:
682
+ items, total = kit.list_users(
683
+ q=q,
684
+ is_active=is_active,
685
+ is_staff=is_staff,
686
+ is_superuser=is_superuser,
687
+ group_id=group,
688
+ order=order,
689
+ limit=limit,
690
+ offset=offset,
691
+ )
692
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
693
+
694
+ @router.post("/users", status_code=201, summary="Crear un usuario")
695
+ def create_user(
696
+ body: UserCreateIn, actor: Annotated[AuthUser, Depends(staff("auth.add_user"))], reason: Reason = ""
697
+ ) -> dict[str, Any]:
698
+ data = body.model_dump()
699
+ custom = {f.name for f in kit.custom_fields}
700
+ fields = {k: data.pop(k) for k in list(data) if k in custom}
701
+ unknown = sorted(set(body.model_extra or {}) - custom)
702
+ if unknown:
703
+ raise ValidationError({k: ["Campo desconocido."] for k in unknown})
704
+ return kit.create_user(data.pop("username"), data.pop("password"), actor=actor, fields=fields, reason=reason, **data)
705
+
706
+ @router.get("/users/{user_id}", summary="Detalle de un usuario")
707
+ def get_user(user_id: int, _: Annotated[AuthUser, Depends(staff("auth.view_user"))]) -> dict[str, Any]:
708
+ return {**kit.get_user(user_id), "active_sessions": kit.count_sessions(user_id)}
709
+
710
+ @router.patch("/users/{user_id}", summary="Modificar un usuario")
711
+ def update_user(
712
+ user_id: int,
713
+ body: UserUpdateIn,
714
+ actor: Annotated[AuthUser, Depends(staff("auth.change_user"))],
715
+ reason: Reason = "",
716
+ ) -> dict[str, Any]:
717
+ unknown = sorted(set(body.model_extra or {}) - {f.name for f in kit.custom_fields})
718
+ if unknown:
719
+ raise ValidationError({k: ["Campo desconocido."] for k in unknown})
720
+ return kit.update_user(user_id, body.model_dump(exclude_unset=True), actor=actor, reason=reason)
721
+
722
+ @router.delete("/users/{user_id}", status_code=204, summary="Eliminar un usuario")
723
+ def delete_user(
724
+ user_id: int, actor: Annotated[AuthUser, Depends(staff("auth.delete_user"))], reason: Reason = ""
725
+ ) -> Response:
726
+ kit.delete_user(user_id, actor=actor, reason=reason)
727
+ return Response(status_code=204)
728
+
729
+ @router.post("/users/{user_id}/password", summary="Asignar una contraseña a un usuario")
730
+ def set_user_password(
731
+ user_id: int,
732
+ body: SetPasswordIn,
733
+ actor: Annotated[AuthUser, Depends(staff("auth.change_user"))],
734
+ reason: Reason = "",
735
+ ) -> dict[str, bool]:
736
+ kit.set_password(user_id, body.password, actor=actor, reason=reason)
737
+ return {"ok": True}
738
+
739
+ @router.post("/users/{user_id}/reset-link", summary="Generar un enlace para que el usuario fije su contraseña")
740
+ def reset_link(
741
+ user_id: int, request: Request, actor: Annotated[AuthUser, Depends(staff("auth.change_user"))]
742
+ ) -> dict[str, Any]:
743
+ token_value = kit.make_reset_token(user_id, actor=actor)
744
+ # El token va en el fragmento (#): el navegador no lo envía al servidor ni queda en los logs.
745
+ base = str(request.url).split("/api/users/")[0]
746
+ return {
747
+ "uid": user_id,
748
+ "token": token_value,
749
+ "url": f"{base}/reset#uid={user_id}&token={token_value}",
750
+ "expires_in": s.password_reset_timeout,
751
+ }
752
+
753
+ @router.post("/users/{user_id}/logout", summary="Cerrar todas las sesiones y tokens de un usuario")
754
+ def logout_user(user_id: int, actor: Annotated[AuthUser, Depends(staff("auth.change_user"))]) -> dict[str, bool]:
755
+ kit.logout_all(user_id, actor=actor)
756
+ return {"ok": True}
757
+
758
+ # ------------------------------------------------------------------ administración: grupos
759
+ @router.get("/groups", summary="Listar grupos (roles)")
760
+ def list_groups(_: Annotated[AuthUser, Depends(staff("auth.view_group"))]) -> dict[str, Any]:
761
+ return {"items": kit.list_groups()}
762
+
763
+ @router.post("/groups", status_code=201, summary="Crear un grupo")
764
+ def create_group(body: GroupIn, actor: Annotated[AuthUser, Depends(staff("auth.add_group"))]) -> dict[str, Any]:
765
+ return kit.create_group(body.name, body.permissions, actor=actor)
766
+
767
+ @router.get("/groups/{group_id}", summary="Detalle de un grupo")
768
+ def get_group(group_id: int, _: Annotated[AuthUser, Depends(staff("auth.view_group"))]) -> dict[str, Any]:
769
+ return kit.get_group(group_id)
770
+
771
+ @router.patch("/groups/{group_id}", summary="Modificar un grupo")
772
+ def update_group(
773
+ group_id: int, body: GroupUpdateIn, actor: Annotated[AuthUser, Depends(staff("auth.change_group"))]
774
+ ) -> dict[str, Any]:
775
+ return kit.update_group(group_id, body.model_dump(exclude_unset=True), actor=actor)
776
+
777
+ @router.delete("/groups/{group_id}", status_code=204, summary="Eliminar un grupo")
778
+ def delete_group(group_id: int, actor: Annotated[AuthUser, Depends(staff("auth.delete_group"))]) -> Response:
779
+ kit.delete_group(group_id, actor=actor)
780
+ return Response(status_code=204)
781
+
782
+ # ------------------------------------------------------------------ administración: permisos y registro
783
+ @router.get("/permissions", summary="Permisos disponibles")
784
+ def list_permissions(_: Annotated[AuthUser, Depends(require_staff)]) -> dict[str, Any]:
785
+ return {"items": kit.list_permissions()}
786
+
787
+ @router.get("/log", summary="Registro de acciones de la administración")
788
+ def list_log(
789
+ _: Annotated[AuthUser, Depends(staff("auth.view_logentry"))],
790
+ object_type: Annotated[str | None, Query(max_length=50)] = None,
791
+ object_id: Annotated[str | None, Query(max_length=50)] = None,
792
+ limit: Annotated[int, Query(ge=1, le=500)] = 100,
793
+ offset: Annotated[int, Query(ge=0)] = 0,
794
+ ) -> dict[str, Any]:
795
+ items, total = kit.list_log(limit=limit, offset=offset, object_type=object_type, object_id=object_id)
796
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
797
+
798
+ # ------------------------------------------------------------------ historial de cambios del usuario
799
+ CanSeeHistory = Annotated[AuthUser, Depends(staff("auth.view_userchange"))]
800
+ Action = Annotated[str | None, Query(pattern="^(" + "|".join(HISTORY_ACTIONS) + ")$")]
801
+ FieldName = Annotated[str | None, Query(max_length=100, description="Campo: email, groups, password, extra.telefono…")]
802
+
803
+ @router.get("/user-history", summary="Historial de cambios de los usuarios (incluye eliminados)")
804
+ def user_history(
805
+ _: CanSeeHistory,
806
+ user_id: int | None = None,
807
+ actor_id: int | None = None,
808
+ action: Action = None,
809
+ field: FieldName = None,
810
+ since: datetime | None = None,
811
+ until: datetime | None = None,
812
+ limit: Annotated[int, Query(ge=1, le=500)] = 50,
813
+ offset: Annotated[int, Query(ge=0)] = 0,
814
+ ) -> dict[str, Any]:
815
+ items, total = kit.user_history(
816
+ user_id, actor_id=actor_id, action=action, field=field, since=since, until=until, limit=limit, offset=offset
817
+ )
818
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
819
+
820
+ @router.get("/users/{user_id}/history", summary="Historial de cambios de un usuario")
821
+ def one_user_history(
822
+ user_id: int,
823
+ _: CanSeeHistory,
824
+ action: Action = None,
825
+ field: FieldName = None,
826
+ limit: Annotated[int, Query(ge=1, le=500)] = 50,
827
+ offset: Annotated[int, Query(ge=0)] = 0,
828
+ ) -> dict[str, Any]:
829
+ # Sin 404 si el usuario ya no existe: su historial se conserva y se puede consultar.
830
+ items, total = kit.user_history(user_id, action=action, field=field, limit=limit, offset=offset)
831
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
832
+
833
+ return router
834
+
835
+
836
+ # ====================================================================== instalación
837
+ def install(
838
+ kit: AuthKit,
839
+ app: Any,
840
+ *,
841
+ prefix: str = "/auth",
842
+ protect: bool = False,
843
+ exempt: Iterable[str] = (),
844
+ exempt_prefixes: Iterable[str] = (),
845
+ rules: Iterable[Rule] = (),
846
+ api_key: str | None = None,
847
+ ) -> None:
848
+ """Añade a la aplicación las rutas `{prefix}/api/*`, las páginas (`login`, `admin`, `account`,
849
+ `reset`) y, con `protect=True`, el middleware que exige autenticación en todo lo demás.
850
+
851
+ `rules` asocia rutas con permisos; `exempt` y `exempt_prefixes` dejan rutas públicas (sondas de
852
+ salud, por ejemplo); `api_key` es una credencial de máquina opcional con acceso total.
853
+ """
854
+ prefix = "/" + prefix.strip("/")
855
+ app.state.djauth = kit
856
+ app.add_exception_handler(AuthError, auth_error_handler)
857
+ app.include_router(build_router(kit), prefix=f"{prefix}/api")
858
+
859
+ def page(name: str) -> Callable[[], FileResponse]:
860
+ def serve() -> FileResponse:
861
+ return FileResponse(STATIC_DIR / f"{name}.html", headers=PAGE_HEADERS)
862
+
863
+ return serve
864
+
865
+ for name in ("login", "admin", "account", "reset"):
866
+ app.add_api_route(f"{prefix}/{name}", page(name), methods=["GET"], include_in_schema=False)
867
+ app.mount(f"{prefix}/static", StaticFiles(directory=STATIC_DIR), name="djauth-static")
868
+
869
+ if protect:
870
+ public = {
871
+ f"{prefix}/login",
872
+ f"{prefix}/reset",
873
+ f"{prefix}/api/login",
874
+ f"{prefix}/api/token",
875
+ f"{prefix}/api/config",
876
+ f"{prefix}/api/reset/confirm",
877
+ f"{prefix}/api/logout",
878
+ }
879
+ if not kit.settings.enable_sessions:
880
+ # Solo tokens: las páginas son cascarones estáticos; quien protege los datos es la API.
881
+ public |= {f"{prefix}/admin", f"{prefix}/account"}
882
+ app.add_middleware(
883
+ AuthMiddleware,
884
+ kit=kit,
885
+ login_url=f"{prefix}/login",
886
+ exempt={*public, *exempt},
887
+ exempt_prefixes=(f"{prefix}/static/", *exempt_prefixes),
888
+ rules=tuple(rules),
889
+ api_key=api_key,
890
+ )