鉴权体系:令牌、会话与三层凭据

专栏:new-api 源码拆解 · 第 3 / 12 篇
new-api鉴权Token

:::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 习惯。本篇从外到内走完整条链。

三层凭据先分清

图表(newapi-auth-tokens.md)

三者权限面一致,差别在使用场景与生命周期: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-keymj-api-secret)全部归一到同一个内部格式。客户端怎么习惯,网关就怎么接——这是兼容层的存在意义。

校验链:五步从 key 到上下文

取到 key 后的完整校验顺序(每步失败即 4xx 中止):

  1. token 校验model.ValidateUserToken——状态(启用/禁用)、过期时间、剩余额度。先查 Redis hash 缓存,未命中回源 DB;
  2. IP 白名单:令牌可配 CIDR 白名单,来源 IP 不在列表即拒绝;
  3. 用户状态:经 user 缓存查用户是否被禁用;
  4. 分组判定:令牌分组覆盖用户默认分组(需在可用分组集合内且有倍率配置,auto 除外)——判定代码见下,机制详解在第 4 篇;
  5. 写上下文:把 token_idtoken_quotatoken_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 策略授权(周期同步)。

常见踩坑

  1. 令牌指定了不可用分组——无权访问 X 分组 的 403 来自可用分组集合校验,先查用户分组的可用范围(第 4 篇);
  2. 绕过 ORM 直接改库改余额——fence 不触发、缓存不失效,行为不可预期;元数据变更必须走模型层的变更函数;
  3. PAT 当 sk-xxx 用——classifyDashboardCredential 只服务控制台路由,relay 路由只认 TokenAuth 体系;
  4. 令牌开了模型限制但模型名拼写不一致——限制匹配是精确匹配,走 model_limits

随堂练习(带验收标准)

  1. 创建两个 token:一个默认分组、一个指定分组,分别请求。验收:能解释 usingGroup 的判定来源与 403 的触发点;
  2. 打开 Redis MONITOR,发起一次请求,找出 token 缓存的 hash 读写命令与 fence key。验收:能指出 fence key 的 TTL 与删除时机;
  3. middleware/auth.go 里列出 TokenAuth 取 key 的全部五种来源,测试你常用客户端实际走的是哪一种;
  4. 进阶:给某令牌配置 IP 白名单(CIDR),从白名单内/外各请求一次,观察拒绝信息。

← 返回文章列表