feat: 建立商品目录接入基础 (#132)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Package integration 提供给受信任第三方脚本的 JSON 接口。
|
||||
// 它与网页登录和 /api/v1/client 完全隔离,不能复用 Cookie 或 X-Client-Id。
|
||||
package integration
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
)
|
||||
|
||||
const sourceContextKey = "catalog_integration_source"
|
||||
|
||||
// RequireCatalogToken 校验商品目录接口的专用 Bearer Token。
|
||||
func RequireCatalogToken(cfg config.CatalogIntegrationConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !cfg.Enabled() {
|
||||
integrationError(c, http.StatusServiceUnavailable, "INTEGRATION_DISABLED",
|
||||
"商品目录接口尚未配置", true, nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
provided, ok := bearerToken(c.GetHeader("Authorization"))
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(provided), []byte(cfg.Token)) != 1 {
|
||||
integrationError(c, http.StatusUnauthorized, "UNAUTHORIZED",
|
||||
"接口凭据无效", false, nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(sourceContextKey, cfg.Source)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
func integrationSource(c *gin.Context) string {
|
||||
value, _ := c.Get(sourceContextKey)
|
||||
source, _ := value.(string)
|
||||
return source
|
||||
}
|
||||
|
||||
func integrationError(c *gin.Context, status int, code, message string, retryable bool, details any) {
|
||||
if details == nil {
|
||||
details = gin.H{}
|
||||
}
|
||||
c.JSON(status, gin.H{"error": gin.H{
|
||||
"code": code, "message": message, "retryable": retryable,
|
||||
"request_id": c.GetHeader("X-Request-Id"), "details": details,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
)
|
||||
|
||||
func TestRequireCatalogToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
for _, tc := range []struct {
|
||||
name, header string
|
||||
cfg config.CatalogIntegrationConfig
|
||||
want int
|
||||
}{
|
||||
{"未启用", "", config.CatalogIntegrationConfig{}, http.StatusServiceUnavailable},
|
||||
{"缺少", "", config.CatalogIntegrationConfig{Source: "script", Token: token}, http.StatusUnauthorized},
|
||||
{"错误", "Bearer wrong", config.CatalogIntegrationConfig{Source: "script", Token: token}, http.StatusUnauthorized},
|
||||
{"正确", "Bearer " + token, config.CatalogIntegrationConfig{Source: "script", Token: token}, http.StatusNoContent},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/protected", RequireCatalogToken(tc.cfg), func(c *gin.Context) {
|
||||
if integrationSource(c) != tc.cfg.Source {
|
||||
t.Fatalf("source=%q", integrationSource(c))
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
if resp.Code != tc.want {
|
||||
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), token) || strings.Contains(resp.Body.String(), "wrong") {
|
||||
t.Fatal("响应泄露接口凭据")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user