fix(client-api): support HTTP and HTTPS by default (#237)
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user