Saltar a contenido

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

@token_required
def handler(current_user, entidad_id, ...):
    ...
  • Preflight CORS: deja pasar OPTIONS con 204.
  • Extracción del token (orden): cabecera Authorization: Bearer, luego query param token, luego campo de formulario token.
  • Decodificación: jwt.decode(token, SECRET_KEY, algorithms=["HS256"]); un token expirado produce el código AUTH-TOKEN-EXPIRED.
  • Resolución de usuario: carga el Usuario por user_id/sub, comprueba que está activo y que la entidad_id del token coincide.
  • Inyección: pasa current_user y entidad_id como 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 propiedad password es de solo escritura y verificar_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).