feat: API Key 前缀改为 sk-ot
- apikey 包 prefix 常量 sk- → sk-ot(生成/校验/展示前缀统一驱动) - 鉴权错误提示同步更新为 sk-ot 格式 - 测试断言改用常量,避免硬编码长度 - 前端 curl 示例与 README/PLANNING 文档同步
This commit is contained in:
+2
-2
@@ -275,8 +275,8 @@ Anthropic /v1/messages ──┘
|
|||||||
|
|
||||||
#### 5.3.3 API Key
|
#### 5.3.3 API Key
|
||||||
|
|
||||||
- 格式:`sk-` + 48 位随机 base62,**创建时仅展示一次**。
|
- 格式:`sk-ot` + 48 位随机 base62,**创建时仅展示一次**。
|
||||||
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-aB3c…`);请求时哈希后查表。
|
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-otaB3c…`);请求时哈希后查表。
|
||||||
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
|
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
|
||||||
- 限额检查用 Redis 计数,与用户级限流叠加。
|
- 限额检查用 Redis 计数,与用户级限流叠加。
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
|
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
|
||||||
- **前端**:Dashboard / 管理总览骨架屏加载态
|
- **前端**:Dashboard / 管理总览骨架屏加载态
|
||||||
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
|
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
|
||||||
- **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
|
- **API Key**:`sk-ot` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
|
||||||
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
|
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
|
||||||
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
|
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
|
||||||
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
|
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package apikey 生成与管理 API Key:sk- + 48 位 base62 随机串。
|
// Package apikey 生成与管理 API Key:sk-ot + 48 位 base62 随机串。
|
||||||
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
|
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
|
||||||
package apikey
|
package apikey
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
keyLen = 48
|
keyLen = 48
|
||||||
prefix = "sk-"
|
prefix = "sk-ot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
|
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
|
||||||
@@ -34,7 +34,7 @@ func Hash(key string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix 展示前缀:sk-aB3cD5…(前 12 字符)
|
// Prefix 展示前缀:sk-ot…(前 12 字符)
|
||||||
func Prefix(key string) string {
|
func Prefix(key string) string {
|
||||||
if len(key) <= 12 {
|
if len(key) <= 12 {
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -3,32 +3,35 @@ package apikey
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestGenerateValid(t *testing.T) {
|
func TestGenerateValid(t *testing.T) {
|
||||||
plain, hash, prefix, err := Generate()
|
plain, hash, keyPrefix, err := Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate: %v", err)
|
t.Fatalf("Generate: %v", err)
|
||||||
}
|
}
|
||||||
if !Valid(plain) {
|
if !Valid(plain) {
|
||||||
t.Fatalf("generated key invalid: %q", plain)
|
t.Fatalf("generated key invalid: %q", plain)
|
||||||
}
|
}
|
||||||
if len(plain) != len("sk-")+48 {
|
if len(plain) != len(prefix)+keyLen {
|
||||||
t.Fatalf("unexpected key length: %d", len(plain))
|
t.Fatalf("unexpected key length: %d", len(plain))
|
||||||
}
|
}
|
||||||
|
if keyPrefix != plain[:12] {
|
||||||
|
t.Fatalf("prefix mismatch: %s vs %s", keyPrefix, plain[:12])
|
||||||
|
}
|
||||||
if Hash(plain) != hash {
|
if Hash(plain) != hash {
|
||||||
t.Fatal("hash mismatch")
|
t.Fatal("hash mismatch")
|
||||||
}
|
}
|
||||||
if prefix != plain[:12] {
|
|
||||||
t.Fatalf("prefix mismatch: %s vs %s", prefix, plain[:12])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashStable(t *testing.T) {
|
func TestHashStable(t *testing.T) {
|
||||||
if Hash("sk-test") != Hash("sk-test") {
|
if Hash("sk-ot-test") != Hash("sk-ot-test") {
|
||||||
t.Fatal("hash not stable")
|
t.Fatal("hash not stable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidRejects(t *testing.T) {
|
func TestValidRejects(t *testing.T) {
|
||||||
cases := []string{"", "sk-abc", "abc-123456789012345678901234567890123456789012345678", "sk-1234567890123456789012345678901234567890123456789"}
|
tooShort := prefix + "abc"
|
||||||
|
tooLong := prefix + "1234567890123456789012345678901234567890123456789"
|
||||||
|
wrongPrefix := "abc-ot123456789012345678901234567890123456789012345678"
|
||||||
|
cases := []string{"", tooShort, wrongPrefix, tooLong}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if Valid(c) {
|
if Valid(c) {
|
||||||
t.Fatalf("expected invalid: %q", c)
|
t.Fatalf("expected invalid: %q", c)
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func (g *Gateway) Auth(c *gin.Context) {
|
|||||||
key = strings.TrimSpace(c.GetHeader("x-api-key"))
|
key = strings.TrimSpace(c.GetHeader("x-api-key"))
|
||||||
}
|
}
|
||||||
if !apikey.Valid(key) {
|
if !apikey.Valid(key) {
|
||||||
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-... or x-api-key: sk-...")
|
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-ot... or x-api-key: sk-ot...")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const baseURL = `${window.location.origin}/v1`
|
|||||||
<div class="space-y-3 p-4 leading-relaxed">
|
<div class="space-y-3 p-4 leading-relaxed">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</p>
|
<p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</p>
|
||||||
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</span> \</p>
|
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-ot..."</span> \</p>
|
||||||
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-edge pt-3 text-muted">
|
<div class="border-t border-edge pt-3 text-muted">
|
||||||
@@ -136,7 +136,7 @@ const baseURL = `${window.location.origin}/v1`
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-5">
|
<div class="card p-5">
|
||||||
<p class="font-mono text-xs text-muted">sk-…</p>
|
<p class="font-mono text-xs text-muted">sk-ot…</p>
|
||||||
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
|
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
|
||||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||||
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function daysAgo(n: number): string {
|
|||||||
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
|
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
|
||||||
const baseURL = `${window.location.origin}/v1`
|
const baseURL = `${window.location.origin}/v1`
|
||||||
const curlCmd = `curl ${baseURL}/chat/completions \\
|
const curlCmd = `curl ${baseURL}/chat/completions \\
|
||||||
-H "Authorization: Bearer sk-xxxx" \\
|
-H "Authorization: Bearer sk-ot-xxxx" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
|
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
|
||||||
const copied = ref('')
|
const copied = ref('')
|
||||||
|
|||||||
Reference in New Issue
Block a user