fix(client-api): support HTTP and HTTPS by default (#237)

This commit is contained in:
QiuSW
2026-09-07 17:25:55 +08:00
parent 76c94e33e0
commit 71f7751754
5 changed files with 71 additions and 102 deletions
+3 -25
View File
@@ -7,9 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"net"
"net/http"
"os"
"strings"
"time"
@@ -20,26 +18,10 @@ import (
"gorm.io/gorm"
)
// Only TLS or explicitly trusted loopback reverse proxies may carry a key.
// The Agent's insecure-HTTP exception does not apply to client credentials.
func secure(r *http.Request) bool {
if r.TLS != nil {
return true
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return false
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback() && os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO") == "true" && r.Header.Get("X-Forwarded-Proto") == "https"
}
func RequireHTTPS(c *gin.Context) {
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
// Transport does not grant identity or permissions; secrets remain non-cacheable.
func NoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if !secure(c.Request) {
c.AbortWithStatusJSON(426, gin.H{"code": 426, "message": "客户端密钥管理仅允许 HTTPS"})
return
}
c.Next()
}
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
@@ -48,10 +30,6 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
deny := func(status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
if !secure(c.Request) {
deny(426, "客户端密钥仅允许 HTTPS")
return
}
// Never accept a credential supplied through a URL or cookie fallback.
for k := range c.Request.URL.Query() {
if sensitive(k) || strings.EqualFold(k, "token") {
+46 -57
View File
@@ -10,7 +10,6 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -54,38 +53,40 @@ func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
}
}
for _, e := range routes {
grant := clientkey.Grant{Module: e.Module}
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
if err != nil {
t.Fatal(err)
}
path := e.Path
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
path = strings.ReplaceAll(path, param, "1")
}
req := httptest.NewRequest(e.Method, "https://example.test/api/client/v1"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 200
if e.Capability != "read" {
want = 403
}
if rr.Code != want {
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
}
if e.Capability == "write" {
grant.Write = true
} else if e.Capability != "read" {
grant.Actions = []string{e.Capability}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req.Clone(context.Background()))
if rr.Code != 200 {
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
for _, scheme := range []string{"http", "https"} {
grant := clientkey.Grant{Module: e.Module}
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
if err != nil {
t.Fatal(err)
}
path := e.Path
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
path = strings.ReplaceAll(path, param, "1")
}
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 200
if e.Capability != "read" {
want = 403
}
if rr.Code != want {
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
}
if e.Capability == "write" {
grant.Write = true
} else if e.Capability != "read" {
grant.Actions = []string{e.Capability}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req.Clone(context.Background()))
if rr.Code != 200 {
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
}
}
}
}
@@ -118,17 +119,22 @@ func TestRevocationTransportRedactionAndAudit(t *testing.T) {
return rr
}
path := "/api/client/v1/pdd-products"
if send(false, token, path).Code != 426 || send(true, "jwt", path).Code != 401 || send(true, token, path+"?token=invalid").Code != 400 {
t.Fatal("transport or credential fallback accepted")
}
rr := send(true, token, path)
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
t.Fatal("redaction failed")
for _, tlsOn := range []bool{false, true} {
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
t.Fatal("credential fallback accepted")
}
rr := send(tlsOn, token, path)
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
t.Fatal("redaction failed")
}
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
t.Fatal("cache or audit headers missing")
}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
t.Fatal(err)
}
if send(true, token, path).Code != 401 || calls != 1 {
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
t.Fatal("revocation not immediate")
}
var logs []clientkey.Audit
@@ -157,20 +163,3 @@ func TestAuditUnavailableDoesNotExecute(t *testing.T) {
t.Fatal("executed without audit")
}
}
func TestForwardedProtoOnlyTrustedLoopback(t *testing.T) {
t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "true")
r := httptest.NewRequest(http.MethodGet, "http://example.test", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.RemoteAddr = "192.0.2.1:1234"
if secure(r) {
t.Fatal("untrusted forwarded header")
}
r.RemoteAddr = "127.0.0.1:1234"
if !secure(r) {
t.Fatal("trusted proxy rejected")
}
t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "false")
if secure(r) {
t.Fatal("implicit proxy trust")
}
}
+20 -18
View File
@@ -56,24 +56,26 @@ func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
db, s := fixture(t)
h := clientkey.Handler{DB: db, Modules: s.Modules}
for _, role := range []string{"admin", "purchaser", "client", ""} {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
c.Next()
})
router.POST("/keys", middleware.RequireRoleKey("admin"), RequireHTTPS, h.Create)
req := httptest.NewRequest("POST", "https://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 403
if role == "admin" {
want = 200
}
if rr.Code != want {
t.Fatalf("role %q status %d", role, rr.Code)
}
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
t.Fatal("one-time secret cacheable")
for _, scheme := range []string{"http", "https"} {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
c.Next()
})
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 403
if role == "admin" {
want = 200
}
if rr.Code != want {
t.Fatalf("role %q status %d", role, rr.Code)
}
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
t.Fatal("one-time secret cacheable")
}
}
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
routes := Inventory()
catalog := Catalog(routes)
h := clientkey.Handler{Modules: catalog}
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), RequireHTTPS)
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
management.GET("", h.List)
management.GET("/modules", h.Catalog)
management.POST("", h.Create)
+1 -1
View File
@@ -14,7 +14,7 @@
<el-table-column label="操作" width="255"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row, true)">查看授权</el-button><el-button link type="primary" :disabled="!row.enabled || saving" @click="openEditor(row)">编辑授权</el-button><el-button link type="danger" :disabled="!row.enabled || saving" @click="disable(row)">停用</el-button></template></el-table-column>
</el-table>
<el-pagination v-if="total" v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next, total" @current-change="load" />
<p class="muted">客户端地址:/api/client/v1。仅允许 HTTPS;不开放支付、账号权限管理及敏感密钥读取。</p>
<p class="muted">客户端地址:/api/client/v1。支持 HTTP / HTTPS;HTTP 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。</p>
</template>
</el-card>
<el-dialog v-model="editorOpen" :title="readonly ? '查看授权' : editing ? '编辑授权' : '创建客户端密钥'" width="min(1080px, 95vw)" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" :before-close="closeEditor">