Agrega API Keys scoped (token + IP obligatoria + alcance) para /api/v2

Alternativa al ADMIN_API_KEY único de entorno, que sigue funcionando
como llave maestra para no romper integraciones existentes.

- Modelo ApiKey: token hasheado, IP/CIDR obligatoria (fail-closed sin
  IP), scopes habilitados, último uso (fecha + IP).
- AdminApiAuth() acepta ahora tanto la llave maestra como una ApiKey;
  nuevo middleware RequireScope(scope) para gatear grupos de rutas.
- Fase 1: scopes aplicados a lo más sensible — oss (archivos),
  query_runner (SQL arbitrario), usuarios (usuarios/roles/módulos),
  pasarelas (credenciales de pago). El resto de /api/v2 sigue con
  la llave maestra hasta una fase 2.
- Panel /app/api-keys: crear/editar/revocar, token visible solo al
  crear, scopes por checkbox, IP obligatoria.
This commit is contained in:
Lizandro GD
2026-08-10 16:33:57 +00:00
parent 21d8cd5260
commit 40dfaf5773
8 changed files with 759 additions and 49 deletions
+40 -7
View File
@@ -5,15 +5,18 @@ import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// AdminApiAuth protege /api/v2. Acepta dos tipos de credencial:
// 1. ADMIN_API_KEY de entorno — llave maestra, acceso total, la que ya usan
// las integraciones existentes (no se retira para no romperlas).
// 2. Una ApiKey emitida desde /app/api-keys — token propio, hasheado en DB,
// restringida a una IP/CIDR obligatoria y a los scopes que se le hayan
// habilitado. RequireScope() más adelante en la cadena decide si esa
// llave puede usar el grupo de endpoints puntual.
func AdminApiAuth() fiber.Handler {
return func(c *fiber.Ctx) error {
apiKey := os.Getenv("ADMIN_API_KEY")
if apiKey == "" {
return c.Status(503).JSON(fiber.Map{"error": "ADMIN_API_KEY not configured"})
}
token := ""
auth := c.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
@@ -22,11 +25,41 @@ func AdminApiAuth() fiber.Handler {
if token == "" {
token = c.Get("X-API-Key")
}
if token == "" || token != apiKey {
if token == "" {
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
}
if masterKey := os.Getenv("ADMIN_API_KEY"); masterKey != "" && token == masterKey {
c.Locals("api_key_master", true)
return c.Next()
}
key, err := models.FindApiKeyActivaByToken(token)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
}
if !key.IPValida(c.IP()) {
return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"})
}
go models.RegistrarUsoApiKey(key.ID, c.IP())
c.Locals("api_key", key)
return c.Next()
}
}
// RequireScope gatea un grupo de rutas a un scope puntual. La llave maestra
// (ADMIN_API_KEY de entorno) siempre pasa; una ApiKey scoped solo pasa si
// tiene ese scope habilitado.
func RequireScope(scope string) fiber.Handler {
return func(c *fiber.Ctx) error {
if master, _ := c.Locals("api_key_master").(bool); master {
return c.Next()
}
key, ok := c.Locals("api_key").(*models.ApiKey)
if !ok || key == nil || !key.TieneScope(scope) {
return c.Status(403).JSON(fiber.Map{"error": "Esta API key no tiene el scope '" + scope + "'"})
}
return c.Next()
}
}