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.
- djauth/__init__.py +82 -0
- djauth/__main__.py +98 -0
- djauth/api.py +890 -0
- djauth/backends.py +130 -0
- djauth/core.py +1280 -0
- djauth/database.py +148 -0
- djauth/decorators.py +119 -0
- djauth/errors.py +107 -0
- djauth/fields.py +157 -0
- djauth/hashers.py +54 -0
- djauth/models.py +200 -0
- djauth/plantilla/.dockerignore.tpl +8 -0
- djauth/plantilla/.env.example.tpl +15 -0
- djauth/plantilla/.github/workflows/ci.yml.tpl +22 -0
- djauth/plantilla/.gitignore.tpl +12 -0
- djauth/plantilla/Dockerfile.tpl +26 -0
- djauth/plantilla/README.md.tpl +83 -0
- djauth/plantilla/alembic.ini.tpl +39 -0
- djauth/plantilla/migrations/env.py.tpl +52 -0
- djauth/plantilla/migrations/script.py.mako.tpl +24 -0
- djauth/plantilla/migrations/versions/0001_productos.py.tpl +35 -0
- djauth/plantilla/pyproject.toml.tpl +46 -0
- djauth/plantilla/src/__paquete__/__init__.py.tpl +3 -0
- djauth/plantilla/src/__paquete__/api/__init__.py.tpl +0 -0
- djauth/plantilla/src/__paquete__/api/deps.py.tpl +12 -0
- djauth/plantilla/src/__paquete__/api/router.py.tpl +8 -0
- djauth/plantilla/src/__paquete__/api/v1/__init__.py.tpl +0 -0
- djauth/plantilla/src/__paquete__/api/v1/productos.py.tpl +74 -0
- djauth/plantilla/src/__paquete__/core/__init__.py.tpl +0 -0
- djauth/plantilla/src/__paquete__/core/config.py.tpl +41 -0
- djauth/plantilla/src/__paquete__/core/security.py.tpl +50 -0
- djauth/plantilla/src/__paquete__/db.py.tpl +41 -0
- djauth/plantilla/src/__paquete__/main.py.tpl +52 -0
- djauth/plantilla/src/__paquete__/models/__init__.py.tpl +5 -0
- djauth/plantilla/src/__paquete__/models/producto.py.tpl +24 -0
- djauth/plantilla/src/__paquete__/schemas/__init__.py.tpl +0 -0
- djauth/plantilla/src/__paquete__/schemas/producto.py.tpl +36 -0
- djauth/plantilla/src/__paquete__/services/__init__.py.tpl +0 -0
- djauth/plantilla/src/__paquete__/services/productos.py.tpl +46 -0
- djauth/plantilla/tests/__init__.py.tpl +0 -0
- djauth/plantilla/tests/conftest.py.tpl +35 -0
- djauth/plantilla/tests/test_app.py.tpl +42 -0
- djauth/py.typed +0 -0
- djauth/routers.py +404 -0
- djauth/settings.py +127 -0
- djauth/startproject.py +271 -0
- djauth/static/account.html +51 -0
- djauth/static/account.js +34 -0
- djauth/static/admin.html +167 -0
- djauth/static/admin.js +346 -0
- djauth/static/common.js +99 -0
- djauth/static/djauth.css +99 -0
- djauth/static/login.html +26 -0
- djauth/static/login.js +38 -0
- djauth/static/reset.html +27 -0
- djauth/static/reset.js +30 -0
- djauth/validators.py +139 -0
- fastapi_djauth-1.4.0.dist-info/METADATA +133 -0
- fastapi_djauth-1.4.0.dist-info/RECORD +62 -0
- fastapi_djauth-1.4.0.dist-info/WHEEL +4 -0
- fastapi_djauth-1.4.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|