Saltar a contenido

Autenticacion JWT y RBAC

GexCom implementa autenticacion basada en JWT (JSON Web Tokens) con control de acceso basado en roles (RBAC) y proteccion contra fuerza bruta con lockout persistente.

Flujo de Autenticacion

sequenceDiagram
    actor Usuario
    participant GUI as Streamlit / API
    participant AS as AuthService
    participant LAS as SqlLoginAttemptStore
    participant DB as PostgreSQL

    Usuario->>GUI: email + password
    GUI->>AS: login(email, password)
    AS->>LAS: get_attempt(email)
    LAS->>DB: SELECT login_attempt
    DB-->>LAS: LoginAttempt | None

    alt Cuenta bloqueada (TTL activo)
        AS-->>GUI: Failure("Cuenta bloqueada")
    else No bloqueada
        AS->>DB: SELECT usuario WHERE email=?
        DB-->>AS: Usuario

        alt Password correcto (bcrypt.verify)
            AS->>LAS: reset_attempts(email)
            AS->>AS: jwt.encode({sub, rol, exp})
            AS-->>GUI: Success(LoginResponse{token})
        else Password incorrecto
            AS->>LAS: increment_attempts(email)
            alt intentos >= 5
                AS->>LAS: set_lockout(email, TTL=15min)
            end
            AS-->>GUI: Failure("Credenciales invalidas")
        end
    end

JWT — Estructura del Token

{
  "sub": "uuid-del-usuario",
  "rol": "ADMINISTRADOR",
  "exp": 1743600000
}
Campo Descripcion
sub UUID del usuario autenticado
rol ADMINISTRADOR o NOTIFICADOR
exp Timestamp de expiracion (480 min desde emision)

Algoritmo: HS256
Expiracion: 480 minutos (configurable via JWT_EXPIRATION_MINUTES)

Validacion del Token (API)

# src/gexcom/interfaces/api/dependencies.py
def get_current_user(credentials: HTTPAuthorizationCredentials) -> CurrentUser:
    payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=[settings.jwt_algorithm])
    user_id = payload.get("sub")
    rol = payload.get("rol")
    if user_id is None or rol is None:
        raise HTTPException(401, "Token invalido: claims faltantes")
    return CurrentUser(usuario_id=UUID(user_id), rol=RolUsuario(rol))

RBAC — Control de Acceso por Roles

Roles del Sistema

Rol Descripcion Permisos
ADMINISTRADOR Acceso total CRUD completo, usuarios, reportes, config
NOTIFICADOR Acceso operativo Procesos, sujetos, notificaciones, audiencias

Verificacion en GUI (Streamlit)

Cada pagina protegida llama a check_permission(user, accion):

# src/gexcom/interfaces/gui/auth_guard.py
def check_permission(user: UsuarioSesion, accion: str) -> bool:
    return container.authorization_service.can(user.rol, accion)

Si el usuario no tiene permiso, se muestra un mensaje de error y se detiene el renderizado con st.stop().

Verificacion en API (FastAPI)

# Endpoint solo para ADMINISTRADOR
@router.get("/usuarios")
def list_usuarios(
    user: Annotated[CurrentUser, Depends(require_admin)],
    container: Annotated[ServiceContainer, Depends(get_container)],
) -> list[UsuarioResponse]:
    ...

# require_admin en dependencies.py
def require_admin(user: Annotated[CurrentUser, Depends(get_current_user)]) -> CurrentUser:
    if user.rol != RolUsuario.ADMINISTRADOR:
        raise HTTPException(403, "Se requiere rol ADMINISTRADOR")
    return user

Lockout Persistente

Configuracion

Parametro Valor
Intentos maximos 5 fallidos consecutivos
Duracion lockout 15 minutos (TTL)
Persistencia Base de datos (login_attempts tabla)
Reset Automatico al autenticarse correctamente

Por que Persistente?

A diferencia de lockouts en memoria, el lockout de GexCom sobrevive a reinicios del servidor y aplica correctamente en ambientes multi-proceso (Streamlit + API corriendo simultaneamente).

# src/gexcom/infrastructure/security/login_attempt_store.py
class SqlLoginAttemptStore:
    def is_locked(self, email: str) -> bool:
        attempt = self._get(email)
        if attempt is None:
            return False
        if attempt.bloqueado_hasta and datetime.utcnow() < attempt.bloqueado_hasta:
            return True
        return False

Hasher de Contrasenas

GexCom usa bcrypt via passlib:

# src/gexcom/infrastructure/security/password_hasher.py
class BcryptPasswordHasher:
    def hash(self, plain: str) -> str:
        return bcrypt.hash(plain)

    def verify(self, plain: str, hashed: str) -> bool:
        return bcrypt.verify(plain, hashed)

Las contrasenas nunca se almacenan en texto plano. Solo se guarda el hash bcrypt.

Backlog P03 — Mejoras Pendientes

Items en backlog

Los siguientes items de seguridad estan documentados para P03:

ID Hallazgo Prioridad
API-RATE Sin rate limiting HTTP en /auth/login HIGH
API-RBAC Sin RBAC granular en endpoints API (solo admin/no-admin) HIGH
API-HEADERS Sin security headers HTTP (HSTS, CSP, X-Frame-Options) MEDIUM
API-CORS CORS allow_origins=["*"] debe restringirse en produccion MEDIUM