鉴权体系:令牌、会话与三层凭据
专栏:new-api 源码拆解 · 第 3 / 12 篇:::info 学习目标
完成本篇后你能够:区分 new-api 的三层凭据及各自用途;逐步追踪 TokenAuth 中间件的完整校验链;解释 token 缓存的「突变 fence」设计防的是什么竞态。
前置:第 2 篇完成。预计时长:50 分钟。
:::
:::note 本章术语速查(新手建议先读)
- sk-xxx:API 密钥的惯用前缀(sk = secret key),OpenAI 带起来的习惯。
- JWT:一种自包含的登录凭证(里面直接带着用户信息与签名),无需每次查库。
- PAT(Personal Access Token):个人访问令牌,给脚本用的长期凭证。
- session(会话):浏览器登录后的状态,可单独注销。
- CIDR 白名单:按 IP 网段(如 10.0.0.0/24)限制来源。
- 突变 fence(栅栏):缓存并发技巧——数据变更前先抬版本号并删缓存,防止旧数据被写回。
- hash 缓存:Redis 的哈希结构,一个 key 存多个字段(余额、状态……)。 :::
new-api 的鉴权首先是分流:控制台流量(浏览器 session)与模型流量(sk-xxx)是两套完全独立的体系——中间件入口不同、凭据格式不同、校验路径不同。而模型侧的 TokenAuth 还要兼容数十种客户端的取 key 习惯。本篇从外到内走完整条链。
三层凭据先分清
三者权限面一致,差别在使用场景与生命周期:session JWT 登录态可刷新、可注销单个会话;PAT 适合脚本长期使用;sk-xxx 面向模型调用,携带额度、分组与模型限制信息。
多协议取 key:兼容是网关的天职
不同生态的客户端把 key 放在不同位置,TokenAuth 开头就全部接住:
// middleware/auth.go:354-381(节选)
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// WebSocket:Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, ...
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" { ... }
// Anthropic 客户端:/v1/messages 走 x-api-key 头
if strings.Contains(c.Request.URL.Path, "/v1/messages") { ... }
// Gemini 客户端:?key= 查询参数或 x-goog-api-key
...
// 剥掉 "sk-" 前缀;按 "-" 切分——第二段若是数字,
// 是管理员指定的渠道 pin(sk-xxx-<channelId>)
parts := strings.Split(key, "-")
五种来源(Authorization: Bearer、WebSocket 子协议、x-api-key、?key=/x-goog-api-key、mj-api-secret)全部归一到同一个内部格式。客户端怎么习惯,网关就怎么接——这是兼容层的存在意义。
校验链:五步从 key 到上下文
取到 key 后的完整校验顺序(每步失败即 4xx 中止):
- token 校验:
model.ValidateUserToken——状态(启用/禁用)、过期时间、剩余额度。先查 Redis hash 缓存,未命中回源 DB; - IP 白名单:令牌可配 CIDR 白名单,来源 IP 不在列表即拒绝;
- 用户状态:经 user 缓存查用户是否被禁用;
- 分组判定:令牌分组覆盖用户默认分组(需在可用分组集合内且有倍率配置,
auto除外)——判定代码见下,机制详解在第 4 篇; - 写上下文:把
token_id、token_quota、token_group、模型限制、usingGroup等写入 gin 上下文,供下游所有模块使用。
// middleware/auth.go:484-506(节选)
userGroup := userCache.Group
tokenGroup := token.Group
if tokenGroup != "" {
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden,
fmt.Sprintf("无权访问 %s 分组", tokenGroup))
return
}
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden,
fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
}
}
userGroup = tokenGroup // 令牌可指定分组,覆盖用户默认分组
}
common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
突变 fence:防「旧快照回写」的缓存设计
token 校验在热路径上,new-api 用 Redis hash 缓存。并发下有个经典难题:
时刻 T1:请求 A 读缓存(余额 100)
时刻 T2:请求 B 扣费成功,DB 余额更新为 90,删缓存
时刻 T3:请求 A 未命中回源 DB(读到 90),准备写回缓存
真正的风险在 T1 与 T2.5 之间:请求 C 可能基于 T1 的旧快照通过了余额检查。new-api 的解法是「突变 fence」——任何 token 元数据变更先抬升 fence 版本并删缓存,之后所有回源读者写缓存前比对 fence,版本变了就放弃回写:
// model/token_cache.go:22-33
func invalidateTokenCacheForMutation(key string) error {
if !common.RedisEnabled || key == "" {
return nil
}
// 抬升 fence 并丢弃缓存哈希:任何读者都不能基于(或回写)
// 突变前的状态
err := common.RDB.Set(ctx, getTokenCacheFenceKey(key), 1,
time.Duration(tokenCacheFenceSeconds)*time.Second).Err()
...
return common.RDB.Del(ctx, getTokenCacheKey(key)).Err()
}
余额的实际扣减走独立的 Lua 原子路径(第 8、10 篇),缓存里只是可容忍短暂陈旧的元数据快照。「什么数据可以缓存、缓存失效时谁有资格回写」想清楚,缓存系统才不会变成 bug 制造机。
控制台侧:session JWT 与 PAT 的分流
控制台凭据在 classifyDashboardCredential 里按 JWT 声明分流:
// middleware/auth.go:185-224(节选)
func classifyDashboardCredential(c *gin.Context) (...) {
raw, ok := authorizationToken(c.GetHeader("Authorization"))
...
identity, internal, err := service.ParseDashboardAccessToken(raw)
if internal {
_, user, err := service.ValidateLoginSession(identity) // 内部 dashboard JWT
...
}
patUser, err := model.ValidateAccessToken(raw) // 32 位 access token
...
}
内部 session JWT(HS256)绑定了 SessionID / UserAuthVersion / SessionVersion——改密码递增版本使全部旧凭据失效,单个会话可独立注销;PAT 则适合脚本长期使用。Admin/Root 的写操作在鉴权链内自动进入审计。账号体系的外围还有:OAuth(GitHub/Discord/OIDC/LinuxDO/数据库配置的自定义 provider)、Passkey(WebAuthn 注册/登录四步流程)、TOTP 二步验证(登录返回 require_2fa 走二段验证)、casbin 策略授权(周期同步)。
常见踩坑
- 令牌指定了不可用分组——
无权访问 X 分组的 403 来自可用分组集合校验,先查用户分组的可用范围(第 4 篇); - 绕过 ORM 直接改库改余额——fence 不触发、缓存不失效,行为不可预期;元数据变更必须走模型层的变更函数;
- PAT 当 sk-xxx 用——
classifyDashboardCredential只服务控制台路由,relay 路由只认TokenAuth体系; - 令牌开了模型限制但模型名拼写不一致——限制匹配是精确匹配,走
model_limits。
随堂练习(带验收标准)
- 创建两个 token:一个默认分组、一个指定分组,分别请求。验收:能解释 usingGroup 的判定来源与 403 的触发点;
- 打开 Redis
MONITOR,发起一次请求,找出 token 缓存的 hash 读写命令与 fence key。验收:能指出 fence key 的 TTL 与删除时机; - 在
middleware/auth.go里列出 TokenAuth 取 key 的全部五种来源,测试你常用客户端实际走的是哪一种; - 进阶:给某令牌配置 IP 白名单(CIDR),从白名单内/外各请求一次,观察拒绝信息。