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,172 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiKey es una credencial para /api/v2, alternativa al ADMIN_API_KEY único de
|
||||
// entorno (que sigue funcionando como llave maestra para no romper lo que ya
|
||||
// depende de él). A diferencia de esa llave maestra, cada ApiKey:
|
||||
// - Solo funciona desde la IP/CIDR que se le asignó (obligatoria, no opcional
|
||||
// — a diferencia del patrón de Pagos Externos, aquí se decidió exigirla
|
||||
// siempre porque esta llave puede dar acceso a datos internos, no solo a
|
||||
// pedir un cobro).
|
||||
// - Solo puede usar los scopes (grupos de endpoints) que se le habilitaron.
|
||||
// - Se puede revocar individualmente sin afectar a otras integraciones.
|
||||
type ApiKey struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
|
||||
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
|
||||
IPPermitida string `json:"ip_permitida" gorm:"column:ip_permitida;size:60;not null"` // IP exacta o CIDR, obligatoria
|
||||
Scopes string `json:"scopes" gorm:"column:scopes;size:300"` // comma-separated, ej: "oss,query_runner"
|
||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
||||
UltimoUsoAt *time.Time `json:"ultimo_uso_at" gorm:"column:ultimo_uso_at"`
|
||||
UltimoUsoIP string `json:"ultimo_uso_ip" gorm:"column:ultimo_uso_ip;size:60"`
|
||||
}
|
||||
|
||||
func (ApiKey) TableName() string { return "api_keys" }
|
||||
|
||||
// GenerarApiKeyToken crea un token aleatorio de 32 bytes y su hash SHA-256,
|
||||
// mismo esquema que ServicioPagoExterno: el token crudo se devuelve una sola
|
||||
// vez, en la base solo queda el hash.
|
||||
func GenerarApiKeyToken() (raw string, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
|
||||
}
|
||||
raw = "sak_" + hex.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func apiKeyTokenPreview(raw string) string {
|
||||
if len(raw) <= 8 {
|
||||
return raw
|
||||
}
|
||||
return "..." + raw[len(raw)-6:]
|
||||
}
|
||||
|
||||
func CreateApiKey(k *ApiKey) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarApiKeyToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
k.TokenHash = hash
|
||||
k.TokenPreview = apiKeyTokenPreview(raw)
|
||||
if err := app.Http.Database.DB.Create(k).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func RegenerarApiKeyToken(id uint) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarApiKeyToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"token_hash": hash, "token_preview": apiKeyTokenPreview(raw)})
|
||||
if result.Error != nil {
|
||||
return "", result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", fmt.Errorf("api key no encontrada")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func GetAllApiKeys(limit, offset int) ([]ApiKey, int64, error) {
|
||||
var items []ApiKey
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&ApiKey{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetApiKeyByID(id uint) (*ApiKey, error) {
|
||||
var k ApiKey
|
||||
if err := app.Http.Database.DB.First(&k, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// FindApiKeyActivaByToken resuelve la llave a partir del token crudo recibido
|
||||
// en el header Authorization/X-API-Key. Solo hace match si está activa.
|
||||
func FindApiKeyActivaByToken(rawToken string) (*ApiKey, error) {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
var k ApiKey
|
||||
if err := app.Http.Database.DB.Where("token_hash = ? AND activa = ?", hash, true).First(&k).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func UpdateApiKey(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteApiKey(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ApiKey{}, id).Error
|
||||
}
|
||||
|
||||
// RegistrarUsoApiKey deja constancia de la última vez (y desde qué IP) que se
|
||||
// usó la llave, para poder detectar llaves zombis o uso desde un origen raro.
|
||||
func RegistrarUsoApiKey(id uint, ip string) {
|
||||
now := time.Now()
|
||||
app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"ultimo_uso_at": now, "ultimo_uso_ip": ip})
|
||||
}
|
||||
|
||||
// IPPermitida valida la IP del caller contra el CIDR/IP exacta configurada.
|
||||
// A diferencia de ServicioPagoExterno, aquí es obligatoria: una ApiKey sin
|
||||
// IP válida configurada nunca debe dar acceso (fail-closed).
|
||||
func (k *ApiKey) IPValida(ip string) bool {
|
||||
entrada := strings.TrimSpace(k.IPPermitida)
|
||||
if entrada == "" {
|
||||
return false
|
||||
}
|
||||
callerIP := net.ParseIP(ip)
|
||||
if callerIP == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(entrada, "/") {
|
||||
_, red, err := net.ParseCIDR(entrada)
|
||||
return err == nil && red.Contains(callerIP)
|
||||
}
|
||||
permitida := net.ParseIP(entrada)
|
||||
return permitida != nil && permitida.Equal(callerIP)
|
||||
}
|
||||
|
||||
// ScopesList devuelve los scopes habilitados para esta llave.
|
||||
func (k *ApiKey) ScopesList() []string {
|
||||
return SplitModulos(k.Scopes)
|
||||
}
|
||||
|
||||
// TieneScope indica si la llave puede usar ese grupo de endpoints.
|
||||
func (k *ApiKey) TieneScope(scope string) bool {
|
||||
for _, s := range k.ScopesList() {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user