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:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user