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
@@ -0,0 +1,157 @@
package controllers
import (
"math"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// scopesValidos son los grupos de /api/v2 que hoy sí respetan RequireScope.
// El resto de endpoints de /api/v2 sigue funcionando solo con la llave
// maestra (ADMIN_API_KEY) mientras se migran en una fase posterior.
var scopesValidos = map[string]bool{
"oss": true,
"query_runner": true,
"usuarios": true,
"pasarelas": true,
}
// ApiKeysIndex renderiza el panel de administración de API keys.
func ApiKeysIndex(c *fiber.Ctx) error {
return c.Render("api_keys", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetApiKeys(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := models.GetAllApiKeys(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
type apiKeyReq struct {
Nombre string `json:"nombre"`
IPPermitida string `json:"ip_permitida"`
Scopes []string `json:"scopes"`
Activa bool `json:"activa"`
}
func (r apiKeyReq) validar() (scopes []string, err error) {
if strings.TrimSpace(r.Nombre) == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "nombre es requerido")
}
if strings.TrimSpace(r.IPPermitida) == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "ip_permitida es requerida (IP exacta o CIDR)")
}
for _, s := range r.Scopes {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
}
if !scopesValidos[s] {
return nil, fiber.NewError(fiber.StatusBadRequest, "scope inválido: "+s)
}
scopes = append(scopes, s)
}
if len(scopes) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "selecciona al menos un scope")
}
return scopes, nil
}
// CreateApiKeyHandler crea una nueva API key y devuelve el token en texto
// plano — es la única respuesta donde vendrá completo.
func CreateApiKeyHandler(c *fiber.Ctx) error {
var req apiKeyReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
scopes, err := req.validar()
if err != nil {
return c.Status(err.(*fiber.Error).Code).JSON(fiber.Map{"error": err.Error()})
}
item := &models.ApiKey{
Nombre: strings.TrimSpace(req.Nombre),
IPPermitida: strings.TrimSpace(req.IPPermitida),
Scopes: models.JoinModulos(scopes),
Activa: true,
CreadoPorID: extraerUserID(c),
}
tokenPlano, err := models.CreateApiKey(item)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"ok": true,
"id": item.ID,
"token": tokenPlano,
"aviso": "Guarda este token ahora: no se volverá a mostrar completo.",
})
}
func UpdateApiKeyHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
var req apiKeyReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
scopes, verr := req.validar()
if verr != nil {
return c.Status(verr.(*fiber.Error).Code).JSON(fiber.Map{"error": verr.Error()})
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"ip_permitida": strings.TrimSpace(req.IPPermitida),
"scopes": models.JoinModulos(scopes),
"activa": req.Activa,
}
if err := models.UpdateApiKey(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func RegenerarApiKeyHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
tokenPlano, err := models.RegenerarApiKeyToken(uint(id))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "token": tokenPlano, "aviso": "El token anterior dejó de funcionar. Guarda este ahora, no se volverá a mostrar."})
}
func DeleteApiKeyHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteApiKey(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+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()
}
}
+59 -42
View File
@@ -12,30 +12,31 @@ func AdminApiRoutes(api fiber.Router) {
// ─── Spec (documentación completa de la API) ─────────────────────────────
h.Get("/spec", controllers.AdminApiSpec)
// ─── Usuarios ────────────────────────────────────────────────────────────
h.Get("/users", controllers.GetUsers)
h.Get("/users/:id", controllers.GetUser)
h.Post("/users", controllers.CreateUser)
h.Put("/users/:id", controllers.UpdateUser)
h.Delete("/users/:id", controllers.DeleteUser)
// ─── Usuarios / Roles / Módulos / Submódulos ─────────────────────────────
// Todo el sistema de permisos queda bajo un único scope "usuarios": una
// ApiKey con este scope puede crear/editar cuentas y roles, así que es
// tan sensible como tener acceso administrativo completo al panel.
admin := h.Group("", middlewares.RequireScope("usuarios"))
admin.Get("/users", controllers.GetUsers)
admin.Get("/users/:id", controllers.GetUser)
admin.Post("/users", controllers.CreateUser)
admin.Put("/users/:id", controllers.UpdateUser)
admin.Delete("/users/:id", controllers.DeleteUser)
// ─── Roles ───────────────────────────────────────────────────────────────
h.Get("/roles", controllers.GetRoles)
h.Post("/roles", controllers.CreateRole)
h.Put("/roles/:id", controllers.UpdateRole)
h.Delete("/roles/:id", controllers.DeleteRole)
admin.Get("/roles", controllers.GetRoles)
admin.Post("/roles", controllers.CreateRole)
admin.Put("/roles/:id", controllers.UpdateRole)
admin.Delete("/roles/:id", controllers.DeleteRole)
// ─── Módulos ─────────────────────────────────────────────────────────────
h.Get("/modules", controllers.GetModules)
h.Post("/modules", controllers.CreateModule)
h.Put("/modules/:id", controllers.UpdateModule)
h.Delete("/modules/:id", controllers.DeleteModule)
admin.Get("/modules", controllers.GetModules)
admin.Post("/modules", controllers.CreateModule)
admin.Put("/modules/:id", controllers.UpdateModule)
admin.Delete("/modules/:id", controllers.DeleteModule)
// ─── Submódulos ──────────────────────────────────────────────────────────
h.Get("/submodules", controllers.GetSubmodules)
h.Post("/submodules", controllers.CreateSubmodule)
h.Put("/submodules/:id", controllers.UpdateSubmodule)
h.Delete("/submodules/:id", controllers.DeleteSubmodule)
admin.Get("/submodules", controllers.GetSubmodules)
admin.Post("/submodules", controllers.CreateSubmodule)
admin.Put("/submodules/:id", controllers.UpdateSubmodule)
admin.Delete("/submodules/:id", controllers.DeleteSubmodule)
// ─── Servidores ──────────────────────────────────────────────────────────
h.Get("/servidores", controllers.GetServidor)
@@ -270,6 +271,13 @@ func AdminApiRoutes(api fiber.Router) {
h.Post("/pagos-externos/servicios/:id/regenerar-token", controllers.RegenerarTokenServicioPagoHandler)
h.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler)
// ─── API Keys de /api/v2 ────────────────────────────────────────────────
h.Get("/api-keys", controllers.GetApiKeys)
h.Post("/api-keys", controllers.CreateApiKeyHandler)
h.Put("/api-keys/:id", controllers.UpdateApiKeyHandler)
h.Delete("/api-keys/:id", controllers.DeleteApiKeyHandler)
h.Post("/api-keys/:id/regenerar-token", controllers.RegenerarApiKeyHandler)
// ─── uMind: chat con IA embebible por tenant ───────────────────────────────
h.Get("/umind/tenants", controllers.GetUmindTenants)
h.Post("/umind/tenants", controllers.CreateUmindTenantHandler)
@@ -282,15 +290,18 @@ func AdminApiRoutes(api fiber.Router) {
h.Get("/umind/historial", controllers.GetUmindHistorialHandler)
// ─── OSS API (almacenamiento) ────────────────────────────────────────────
// La gestión de credenciales de storage (CRUD de config) queda solo para
// la llave maestra; el scope "oss" de una ApiKey solo permite operar
// archivos (subir/listar/borrar), no ver ni cambiar las credenciales.
h.Get("/oss-api", controllers.GetOssApiConfigs)
h.Get("/oss-api/active", controllers.GetActiveOssApiList)
h.Post("/oss-api", controllers.CreateOssApiConfig)
h.Put("/oss-api/:id", controllers.UpdateOssApiConfig)
h.Delete("/oss-api/:id", controllers.DeleteOssApiConfig)
h.Get("/oss-api/browser", controllers.OssBrowserList)
h.Get("/oss-api/browser/url", controllers.OssBrowserSignedURL)
h.Delete("/oss-api/browser/object", controllers.OssBrowserDelete)
h.Post("/oss-api/browser/upload", controllers.OssBrowserUpload)
h.Get("/oss-api/browser", middlewares.RequireScope("oss"), controllers.OssBrowserList)
h.Get("/oss-api/browser/url", middlewares.RequireScope("oss"), controllers.OssBrowserSignedURL)
h.Delete("/oss-api/browser/object", middlewares.RequireScope("oss"), controllers.OssBrowserDelete)
h.Post("/oss-api/browser/upload", middlewares.RequireScope("oss"), controllers.OssBrowserUpload)
// ─── Telegram ────────────────────────────────────────────────────────────
h.Get("/telegram", controllers.GetTelegramConfigs)
@@ -340,25 +351,31 @@ func AdminApiRoutes(api fiber.Router) {
h.Get("/websms/logs", controllers.GetWebSmsLogs)
// ─── Pasarelas de pago ───────────────────────────────────────────────────
h.Get("/pasarelas/bold/config", controllers.GetBoldConfigAPI)
h.Post("/pasarelas/bold/save", controllers.SaveBoldConfig)
h.Get("/pasarelas/bold/logs", controllers.BoldWebhookLogs)
h.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI)
h.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb)
h.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogsPaginated)
h.Get("/pasarelas/paypal/config", controllers.GetPaypalConfigAPI)
h.Post("/pasarelas/paypal/save", controllers.SavePaypalConfigWeb)
// Credenciales de Bold/dLocal/PayPal: scope "pasarelas" propio.
pasarelas := h.Group("/pasarelas", middlewares.RequireScope("pasarelas"))
pasarelas.Get("/bold/config", controllers.GetBoldConfigAPI)
pasarelas.Post("/bold/save", controllers.SaveBoldConfig)
pasarelas.Get("/bold/logs", controllers.BoldWebhookLogs)
pasarelas.Get("/dlocal/config", controllers.GetDlocalConfigAPI)
pasarelas.Post("/dlocal/save", controllers.SaveDlocalConfigWeb)
pasarelas.Get("/dlocal/logs", controllers.DlocalPaymentLogsPaginated)
pasarelas.Get("/paypal/config", controllers.GetPaypalConfigAPI)
pasarelas.Post("/paypal/save", controllers.SavePaypalConfigWeb)
// ─── Query Runner ────────────────────────────────────────────────────────
h.Get("/query-runner/connections", controllers.GetConxDbList)
h.Get("/query-runner/databases", controllers.GetDatabases)
h.Get("/query-runner/tables", controllers.GetTables)
h.Get("/query-runner/relations", controllers.GetRelations)
h.Get("/query-runner/test", controllers.TestConnection)
h.Post("/query-runner/run", controllers.RunQuery)
h.Post("/query-runner/run-batch", controllers.RunBatchQuery)
h.Get("/query-runner/history", controllers.GetHistory)
h.Get("/query-runner/columns", controllers.GetTableColumnsHandler)
// SQL arbitrario contra cualquier conexión configurada: es de lo más
// sensible que expone /api/v2, así que una ApiKey scoped necesita el
// scope "query_runner" explícito para todo este grupo.
qr := h.Group("/query-runner", middlewares.RequireScope("query_runner"))
qr.Get("/connections", controllers.GetConxDbList)
qr.Get("/databases", controllers.GetDatabases)
qr.Get("/tables", controllers.GetTables)
qr.Get("/relations", controllers.GetRelations)
qr.Get("/test", controllers.TestConnection)
qr.Post("/run", controllers.RunQuery)
qr.Post("/run-batch", controllers.RunBatchQuery)
qr.Get("/history", controllers.GetHistory)
qr.Get("/columns", controllers.GetTableColumnsHandler)
// ─── Hostinger ───────────────────────────────────────────────────────────
h.Get("/hostinger/vps", controllers.GetHostingerVPS)
+10
View File
@@ -341,6 +341,16 @@ func UserRoutes(app fiber.Router) {
protected.Post("/pagos-externos/servicios/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarTokenServicioPagoHandler)
protected.Get("/pagos-externos/solicitudes", controllers.GetSolicitudesPagoExternoHandler)
// ─── API Keys de /api/v2 (token + IP obligatoria + scopes) ────────────────
// Muy sensible: un scope "usuarios" o "query_runner" filtrado equivale a
// dar acceso administrativo — solo un administrador puede administrarlas.
protected.Get("/api-keys", middlewares.MenuMiddleware, controllers.ApiKeysIndex)
protected.Get("/api-keys/list", controllers.GetApiKeys)
protected.Post("/api-keys", middlewares.SoloAdmin, controllers.CreateApiKeyHandler)
protected.Put("/api-keys/:id", middlewares.SoloAdmin, controllers.UpdateApiKeyHandler)
protected.Delete("/api-keys/:id", middlewares.SoloAdmin, controllers.DeleteApiKeyHandler)
protected.Post("/api-keys/:id/regenerar-token", middlewares.SoloAdmin, controllers.RegenerarApiKeyHandler)
// ─── uMind: chat con IA embebible por tenant ───────────────────────────────
// Sensible: un tenant queda ligado a una config de IA (API key) y controla
// desde qué dominios se puede llamar al widget, así que crear/editar es