From d2a0bebe16738455e08433604a1869ec321d6aad Mon Sep 17 00:00:00 2001 From: chuan Date: Sat, 15 Aug 2026 18:35:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=B0=E4=B8=BB=E9=A2=98=E4=B8=8E=20Key=20=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/modules/admin-console.md | 4 +- docs/modules/user-access-management.md | 1 + internal/plugin/access_test.go | 21 +++++ internal/plugin/app_test.go | 6 ++ internal/plugin/config.go | 7 +- internal/plugin/key_management.go | 4 +- internal/plugin/management_test.go | 7 +- internal/web/ui.html | 123 ++++++++++++++----------- 9 files changed, 113 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 3ee288e..f79aa1d 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ CGO_ENABLED=1 go test ./... 管理页面提供: -- 自动生成或手动输入 Key,完整 Key 可随时复制; +- 自动生成或手动输入至少 6 位的 Key,列表脱敏显示,点击脱敏文本可复制完整 Key; - `active` / `disabled` 状态切换,以及不可恢复但保留历史的归档; - CPA 自动选择上游,或严格绑定一个 OAuth/API Key 上游凭证; - 按客户端请求模型配置允许列表; diff --git a/docs/modules/admin-console.md b/docs/modules/admin-console.md index e63f464..4d20142 100644 --- a/docs/modules/admin-console.md +++ b/docs/modules/admin-console.md @@ -22,7 +22,7 @@ Key 列表展示: -- 用户名称和完整 Key; +- 用户名称和脱敏 Key; - 启用、禁用或归档状态; - 当前余额和额度; - 活跃并发与并发上限; @@ -130,7 +130,7 @@ Key 列表展示: - 管理台不直接操作 SQLite,只调用公开管理接口。 - 统计卡片和图表使用服务端汇总,不从当前明细页推算。 - 业务校验由服务端完成,前端校验只用于尽早提示。 -- 完整 Key 仅在受管理认证保护的 Key 接口和页面中显示。 +- 完整 Key 仅由受管理认证保护的 Key 接口返回;页面列表脱敏显示,点击后复制完整值。 - 上游 Token、Cookie 和原始凭证不进入管理台。 - 界面保持单个嵌入资源,避免运行时依赖外部 CDN。 - 窄屏下表单和工具栏自动重排,明细表保留横向滚动能力。 diff --git a/docs/modules/user-access-management.md b/docs/modules/user-access-management.md index 8d37eeb..ae58a21 100644 --- a/docs/modules/user-access-management.md +++ b/docs/modules/user-access-management.md @@ -54,6 +54,7 @@ Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系 - “允许全部模型”开启时,不再读取单独的模型规则。 - “允许全部模型”关闭时,至少需要一条模型规则。 +- 手工输入的 Key 必须为 6-256 个不含空白或控制字符的字符;留空时自动生成。 - 不含 `*` 的规则执行精确匹配,例如 `gpt-5.6-sol`。 - `*` 可以匹配任意长度的文本,例如 `deepseek-*`、`*-flash` 或 `gpt-*-codex`。 - 空模型名不能通过模型准入。 diff --git a/internal/plugin/access_test.go b/internal/plugin/access_test.go index 4a40f49..1ad0a92 100644 --- a/internal/plugin/access_test.go +++ b/internal/plugin/access_test.go @@ -186,6 +186,27 @@ func TestCreateKeyCopiesDefaultRuleSnapshot(t *testing.T) { } } +func TestCreateKeyRejectsShortSecret(t *testing.T) { + app := NewApp() + if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\n"))); err != nil { + t.Fatal(err) + } + response := managementCallBody(t, app, http.MethodPost, managementBase+routeKeys, []byte(`{"name":"short","secret":"12345"}`)) + if response.StatusCode != http.StatusBadRequest || !strings.Contains(string(response.Body), "6-256") { + t.Fatalf("short Key response=%d body=%s", response.StatusCode, response.Body) + } +} + +func TestManagedSecretValidation(t *testing.T) { + for secret, want := range map[string]bool{ + "12345": false, "000000": true, "alice-000000": true, "with space": false, strings.Repeat("x", 257): false, + } { + if got := validManagedSecret(secret); got != want { + t.Fatalf("validManagedSecret(%q)=%v, want %v", secret, got, want) + } + } +} + func TestBillingAdmissionConcurrencyAndUsageSettlement(t *testing.T) { app := NewApp() if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil { diff --git a/internal/plugin/app_test.go b/internal/plugin/app_test.go index 624ede2..ddd1205 100644 --- a/internal/plugin/app_test.go +++ b/internal/plugin/app_test.go @@ -65,6 +65,12 @@ func TestLifecycleConfigYAMLUsesBase64WireEncoding(t *testing.T) { } } +func TestConfigRejectsShortBootstrapKey(t *testing.T) { + if _, err := decodeConfig([]byte("bootstrap_key: 12345\n")); err == nil { + t.Fatal("short bootstrap_key unexpectedly accepted") + } +} + func TestUsageRecordWireFieldsMatchTargetContract(t *testing.T) { raw, err := json.Marshal(UsageRecord{}) if err != nil { diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 3459b91..60dfaf6 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -33,8 +33,11 @@ func decodeConfig(raw []byte) (Config, error) { if strings.TrimSpace(cfg.DatabasePath) == "" { return Config{}, fmt.Errorf("database_path 不能为空") } - if strings.TrimSpace(cfg.BootstrapName) == "" || strings.TrimSpace(cfg.BootstrapKey) == "" { - return Config{}, fmt.Errorf("bootstrap_name 和 bootstrap_key 不能为空") + if strings.TrimSpace(cfg.BootstrapName) == "" { + return Config{}, fmt.Errorf("bootstrap_name 不能为空") + } + if !validManagedSecret(cfg.BootstrapKey) { + return Config{}, fmt.Errorf("bootstrap_key 必须为 6-256 个不含空白或控制字符的字符") } return cfg, nil } diff --git a/internal/plugin/key_management.go b/internal/plugin/key_management.go index bb31bcd..468d2e6 100644 --- a/internal/plugin/key_management.go +++ b/internal/plugin/key_management.go @@ -124,7 +124,7 @@ func (a *App) createManagedKey(body []byte) ManagementResponse { } } if !validManagedSecret(secret) { - return managementError(http.StatusBadRequest, "invalid_key", "Key 必须为 1-256 个不含空白或控制字符的字符") + return managementError(http.StatusBadRequest, "invalid_key", "Key 必须为 6-256 个不含空白或控制字符的字符") } keyID, err := generatedToken("key_") if err != nil { @@ -465,7 +465,7 @@ func generatedToken(prefix string) (string, error) { } func validManagedSecret(secret string) bool { - if secret == "" || len(secret) > 256 { + if len(secret) < 6 || len(secret) > 256 { return false } for _, value := range secret { diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 8945810..6fc0deb 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -454,11 +454,16 @@ func TestUsageResourceServesTablePage(t *testing.T) { if strings.Contains(page, "