Autenticación y autorización
Gestión Civis usa autenticación stateless basada en JWT y autorización RBAC (control de acceso por roles y permisos), complementada con roles funcionales para actos institucionales.
Autenticación (JWT)
Generación de tokens
En app/core/auth.py, _generate_tokens(user) emite dos tokens firmados con
HS256 y SECRET_KEY:
| Token | Caducidad | type |
Uso |
|---|---|---|---|
| Access | 15 min (ACCESS_TOKEN_EXPIRES) |
access |
Autoriza cada petición. |
| Refresh | 7 días (REFRESH_TOKEN_EXPIRES) |
refresh |
Renueva el access token. |
access_payload = {
"user_id": user.id,
"entidad_id": user.entidad_id,
"exp": now + timedelta(minutes=15),
"type": "access",
}
Endpoints de autenticación
Blueprint auth_bp, prefijo /api/auth:
| Método | Ruta | Rate limit | Propósito |
|---|---|---|---|
POST |
/auth/login |
10/min | Login con dni_nie + password (+ totp_code si MFA). |
POST |
/auth/refresh |
30/min | Renueva el access token a partir del refresh. |
GET |
/auth/me |
— | Datos del usuario autenticado (mantener sesión). |
POST |
/auth/me/mfa/setup |
— | Genera secreto TOTP + QR. |
POST |
/auth/me/mfa/confirm |
— | Activa MFA verificando un código. |
POST |
/auth/me/mfa/disable |
— | Desactiva MFA (requiere contraseña). |
GET |
/auth/me/mfa/status |
— | Estado de MFA. |
Validaciones del login
POST /auth/login valida, en orden: existencia del usuario, cuenta no bloqueada
por intentos fallidos, contraseña correcta, usuario activo, MFA (si procede) y
contraseña no expirada. Emite eventos MOS: USUARIO_LOGIN,
USUARIO_LOGIN_FALLIDO, USUARIO_LOGIN_BLOQUEADO.
Respuesta del login
{
"mensaje": "Login correcto",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"user": { "id": 1, "dni_nie": "...", "rol_nombre": "...", "permisos": [...], "..." : "..." },
"must_change_password": false
}
El objeto user (construido por _build_user_payload) incluye, entre otros:
identidad, rol_id/rol_nombre, departamento_*, rol_funcional_*, la lista
de permisos efectivos (permisos), mfa_enabled y los datos de la entidad.
Flujo en el frontend
src/api.js añade Authorization: Bearer <access_token> en cada petición. Ante
un 401, el interceptor de respuesta llama a /api/auth/refresh con el refresh
token y reintenta la petición original. Los tokens se guardan en localStorage
y el estado vive en AuthContext.
Decoradores de control de acceso
Definidos en app/decorators.py.
@token_required
- Preflight CORS: deja pasar
OPTIONScon204. - Extracción del token (orden): cabecera
Authorization: Bearer, luego query paramtoken, luego campo de formulariotoken. - Decodificación:
jwt.decode(token, SECRET_KEY, algorithms=["HS256"]); un token expirado produce el códigoAUTH-TOKEN-EXPIRED. - Resolución de usuario: carga el
Usuarioporuser_id/sub, comprueba que está activo y que laentidad_iddel token coincide. - Inyección: pasa
current_useryentidad_idcomo primeros dos argumentos del handler.
Convención de argumentos
Todo handler decorado con @token_required recibe current_user, entidad_id
como primeros parámetros. @permiso_required y @rol_funcional_required
dependen de esa convención (leen args[0] y args[1]).
@permiso_required(codigo)
@token_required
@permiso_required("registro:entrada:crear")
def crear_registro(current_user, entidad_id):
...
- Admite un string (permiso exacto) o una lista (basta tener uno → OR).
- El rol "Administrador del Sistema" siempre pasa (puerta de admin).
- Comprueba el permiso contra los permisos efectivos del usuario
(
get_codigos_permiso()). Si falta →403.
@rol_funcional_required(nombre)
Para actos institucionales (p. ej. que apruebe el Alcalde o calcule el
Interventor). Valida el cargo del usuario (rol_funcional), no un permiso
técnico. Admite lista (OR) y el Administrador del Sistema siempre pasa.
Modelo RBAC
erDiagram
USUARIO ||--o| ROL : "rol_id"
USUARIO ||--o| ROLFUNCIONAL : "rol_funcional_id"
USUARIO ||--o| DEPARTAMENTO : "departamento_id"
ROL }o--o{ PERMISO : "rol_permisos"
USUARIO }o--o{ PERMISO : "usuario_permisos (adicionales)"
USUARIO ||--o{ USUARIOPERMISOREVOCADO : "revocados"
USUARIOPERMISOREVOCADO }o--|| PERMISO : ""
| Modelo | Tabla | Propósito |
|---|---|---|
Usuario |
usuarios |
Usuario del sistema. |
Rol |
roles |
Rol RBAC (conjunto de permisos). |
Permiso |
permisos |
Permiso atómico con código canónico. |
RolFuncional |
roles_funcionales |
Cargo institucional (Alcalde, Interventor…). |
Departamento |
departamentos |
Unidad organizativa (jerárquica, con DIR3). |
UsuarioPermisoRevocado |
usuario_permiso_revocado |
Negación explícita de un permiso. |
Permisos efectivos
Usuario.get_codigos_permiso() calcula el conjunto efectivo:
def get_codigos_permiso(self):
permisos_rol = {p.codigo for p in self.rol.permisos} if self.rol else set()
permisos_adicionales = {p.codigo for p in self.permisos}
permisos_revocados = {r.permiso.codigo for r in self.permisos_revocados}
return (permisos_rol | permisos_adicionales) - permisos_revocados
Permisos del rol ∪ permisos directos − permisos revocados.
Formato del código de permiso
Convención: modulo:recurso:accion (a veces con subnivel adicional).
registro:entrada:crear
presupuesto:clasificacion:ver
contabilidad:facturas:contabilizar
admin:sistema:gestionar
RBAC vs. roles funcionales
| Aspecto | Rol RBAC (Rol) |
Rol funcional (RolFuncional) |
|---|---|---|
| Para qué | Acceso técnico a funciones del sistema. | Autoridad institucional para actos formales. |
| Decisión | @permiso_required("...") |
@rol_funcional_required("Alcalde") |
| Relación con permisos | Muchos-a-muchos con Permiso. |
Sin permisos; es un cargo. |
| Persistencia | Cambia con la organización del acceso. | Persiste aunque cambie la persona que ocupa el cargo. |
Reglas irrenunciables del proyecto
- Nunca nombrar roles RBAC en el código para decidir accesos: usar
siempre permisos (
@permiso_required). - Para actos institucionales (aprobar, calcular, firmar como cargo) la autorización va por rol funcional, no por permiso, porque el cargo persiste cuando cambia la persona.
Seguridad de contraseñas y MFA
- Las contraseñas se almacenan con bcrypt (
password_hash); la propiedadpasswordes de solo escritura yverificar_password()valida. - Control de intentos fallidos y bloqueo temporal (
failed_login_attempts,locked_until). - Caducidad de contraseña y
must_change_password. - MFA/TOTP opcional con
pyotp; el secreto se guarda cifrado (EncryptedString).