Skip to content

06 认证、权限与多租户深挖问答 ​

怎么用:① 先背「一、1 分钟讲清认证体系」,这是所有问题的总纲,面试官打断你时也用它收尾;② 二里的「口述回答(背诵这段)」是可以原样说出口的口语稿,先结论后细节,数字和字段名要背准;③ 每个 Q 末尾的「别踩的雷」至少读两遍——字节的安全面会专门设陷阱让你自曝,踩了比不会更惨。


一、1 分钟讲清认证体系 ​

1.1 可背诵总述(约 60 秒) ​

这个项目的认证是双通道的:一条是给机器/系统集成用的 API Key,一条是给人用的会话 JWT。两条通道共用同一个 Gin 中间件 Auth,靠 token 的形态分流——符合 header.payload.signature 三段式的走本地 JWT 验签,其他走 API Key 的 SHA-256 查库。API Key 是 32 字节 crypto/rand 随机生成、加 binrag_ 前缀、base64url 编码,一共 50 个字符、256 位熵,库里只存 SHA-256 的 hex、明文只在创建响应里返回一次。

身份模型只有四个层次:bootstrap Key(唯一能做「改配置」和「授 MCP 权限」两个高危操作)、系统级 Key(owner_id IS NULL,能管 API Key、能看到所有知识库)、登录用户(OIDC/GitHub 会话,只看得到自己 owner_id 等于自己 userId 的知识库)、用户自助 MCP 凭据(用户自己生成的,粒度最小、只用于 MCP)。

多租户隔离落在两层:列表查询走 SQL 的 WHERE owner_id = $1,单资源操作走 handler 里的 canAccessKB 判断;越权一律返回 404 而不是 403,避免通过状态码枚举别人的资源 ID。

登录侧是标准授权码流程:state 防 CSRF、OIDC 额外用 nonce 防 ID Token 重放,回调成功后不把 JWT 放 URL,而是签一张 2 分钟有效、消费即删的一次性 ticket,前端拿 ticket 调 /auth/exchange 换 JWT。

1.2 双通道鉴权流程图 ​

                    ┌──────────────────────────────────────────────┐
HTTP 请求 ─────────▶│ Logger() → CORS() → RateLimit(qps)           │  router.go:67
                    └───────────────────┬──────────────────────────┘
                                        ▼
                    ┌──────────────────────────────────────────────┐
                    │ v1 组包装中间件(router.go:97-105)          │
                    │  GET /api/v1/eval/health → 直通(豁免)      │
                    └───────────────────┬──────────────────────────┘
                                        ▼
                    ┌───────────────────────── Auth (middleware.go:28-85) ─────────────┐
                    │ enabled=false?→ 直接放行(不写 Identity)        middleware.go:30-33 │
                    │ 取 Authorization: Bearer <token>                 middleware.go:35-39 │
                    │ token 空 → 401                                   middleware.go:40-44 │
                    └───────────────┬──────────────────────────────────────┬───────────┘
                                    │                                      │
              jwtShapeRe 匹配(三段式)                    不匹配(任意字符串)
                                    ▼                                      ▼
                authMgr.VerifyJWT(本地验签)              sha256(token) → hex
                失败 → 401,回退终止                       GetAPIKeyByHash(唯一索引,至多 1 次查询)
                成功 → Kind=oidc                          nil / !enabled → 401
                                                           成功 → TouchAPIKey(last_used_at)
                                                                  isBootstrap = (token == 配置明文)
                                                                  Kind=apikey
                                    │                                      │
                                    └──────────────┬───────────────────────┘
                                                   ▼
                          auth.SetIdentity(c, Identity{Kind, APIKeyID, IsBootstrap, UserID, Provider})
                                                   ▼
                                    handler 内权限判断(canAccessKB / requireSystemKey / requireUser)

1.3 权限模型层次(四层,务必背下来) ​

层次判定依据(代码)能做什么不能做什么
bootstrap Keymiddleware.go:79 bootstrapKey != "" && token == bootstrapKey(与配置明文比对,非数据库标记)PUT /api/v1/config(handler_config.go:194)、PUT /api-keys/:id/permissions(handler_key.go:162)+ 系统级 Key 的全部权限无(理论最高)
系统级 API Keyapi_keys.owner_id IS NULL;认证后 Kind=apikey管理 API Key(handler_key.go:41-48)、访问全部知识库(handler_kb.go:67)、GET /config改配置、授 MCP 权限(都需 bootstrap)
登录用户(OIDC/GitHub)Kind=oidc,UserID=claims.uid自己 owner_id 的知识库全生命周期、自己的 MCP 凭据自助管理(handler_mcp_my.go:39-46)、GET /config管 API Key(403)、看系统级/他人 KB(404)
用户自助 MCP 凭据api_keys.owner_id = users.idMCP 侧按 mcp_tools/mcp_kb_scope 授权(mcp/permission.go:13-35)在 REST 层实际上等价于系统级 Key(这是已知缺陷,见 Q5/Q6)

二、深挖问答 ​

Q1. 你们的 API Key 是怎么生成的?存到数据库里是什么形式? ​

面试官想考:你是不是真的经手过密钥生命周期,还是只会调库;对「哈希存密钥 vs 加密存密钥 vs 明文」有没有判断力。

口述回答(背诵这段):生成用的是 crypto/rand 取 32 字节随机数,也就是 256 位熵,然后加固定前缀 binrag_、用 base64 无填充编码,最终是一个 50 字符的字符串。存库只存 SHA-256 的 hex 摘要,列名 key_hash,加了 UNIQUE 约束;认证时把请求带的 token 重新算一次 SHA-256,用这个哈希去查唯一索引,命中且 enabled=true 就放行。所以数据库被拖走也反推不出明文,也不存在「解密拿到密钥」这条路径。

为什么不用 bcrypt/argon2?因为那些慢哈希是为低熵人类口令设计的,要抗字典和彩虹表,代价是每次校验几十到几百毫秒。我们的 Key 是 256 位均匀随机,字典攻击在数学上不成立,用慢哈希只会把每个请求的认证成本抬高几个数量级,得不偿失。为什么不用加盐同理——盐的作用是让相同口令产生不同哈希、打掉彩虹表和跨用户比对,而 256 位随机串本身不可能碰撞,加盐反而让「按 hash 精确查库」这个 O(1) 索引查找失效、被迫全表扫描再逐个比对。所以这里「无盐 SHA-256 + 唯一索引」是正确取舍。

但有一个必须说清的例外:种子用的 bootstrap_api_key 是人选的口令,熵可能很低,这时候无盐 SHA-256 就有被离线爆破的风险。DB 泄漏场景下它是整条链路最薄的一环。

讲解与备注:

  • 技术原理:API Key 属于「高熵 bearer token」,安全模型是 不可猜测性(256 bit),不是「不可记忆性」,因此不需要 KDF 的抗暴力特性;crypto/rand 是 CSPRNG,不能用 math/rand。
  • 代码位置:生成 internal/api/handler_key.go:75-81;哈希与落库 handler_key.go:83-90;校验 internal/api/middleware.go:61-65;存储层 internal/store/apikey.go:33-47(INSERT INTO api_keys (id, name, key_hash, enabled, created_at, owner_id))+ apikey.go:70-80(WHERE key_hash = $1);列定义 internal/store/schema.go:44。
  • 加分句:「我特意区分了『API Key 的高熵』和『bootstrap 口令的低熵』两种情况——前者用 SHA-256 是工程最优,后者其实应该改成强制随机生成并落盘到密钥管理系统,而不是让人在 YAML 里写一个口令。」

代码依据:internal/api/handler_key.go:75-96

go
// 生成 32 字节随机明文 Key
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil { Fail(c, CodeInternal, "生成 Key 失败"); return }
token := "binrag_" + base64.RawURLEncoding.EncodeToString(raw)

sum := sha256.Sum256([]byte(token))
key := store.APIKey{
	ID: uuid.New().String(), Name: req.Name,
	KeyHash: hex.EncodeToString(sum[:]), Enabled: true, CreatedAt: time.Now(),
}
if err := h.store.CreateAPIKey(c.Request.Context(), key); err != nil { Fail(c, CodeInternal, "创建 API Key 失败"); return }
OK(c, gin.H{"id": key.ID, "name": key.Name, "key": token})

追问链:

  • 追问:既然只有哈希,管理员把 Key 弄丢了怎么办? → 答:只能删掉重建,这正是设计意图——服务端不具备恢复明文的能力,也就没有「运维偷偷取用户密钥」的越权面。列表接口只返回 id/name/enabled/last_used_at 和 MCP 权限字段,连 key_hash 都不返回(handler_key.go:27-37),并且有测试断言响应体里既没有 key_hash 也没有明文(api_test.go:1065-1070)。
  • 追问:256 位随机够吗?为什么不是 128 位? → 答:128 位在密码学上已经不可暴力,但 API Key 是长期凭据、可能被日志/截图泄漏,且生成成本几乎为零,所以直接给满 SHA-256 的输出宽度。真正的风险不在长度,而在存储和传输(明文只在创建响应里出现一次、不要落日志)。
  • 追问:如果攻击者拿到数据库里的 key_hash,能直接拿去认证吗? → 答:不能。认证是「收到明文 → 算哈希 → 查库」,库里的哈希本身过不了第一道重算。这也是为什么我不用「哈希当凭据」的设计(有些系统会把 hash 当 token 直接用,那等于明文入库)。

别踩的雷:

  • ❌ 说「用 bcrypt 存 API Key」→ 立刻暴露你只背了「密码要加盐慢哈希」的口诀。正确说法:慢哈希是给人选口令用的;机器生成的 256 位随机 token 用一次 SHA-256 + 唯一索引即可,但低熵的 bootstrap 口令是例外。
  • ❌ 说「加了盐所以更安全」→ 加盐会破坏 WHERE key_hash = $1 的索引查找,而且对 256 位随机串没有增益。
  • ❌ 说「库里存的是加密后的 Key,可以解密还原」→ 那就等于明文存储,且引入了密钥管理难题。正确说法:只存摘要,不可逆。

Q2. 明文 Key 只返回一次,这个「一次」是怎么实现的? ​

面试官想考:你懂不懂「不给后端留明文」这个约束是怎么在代码结构上落地的,还是只是文档里写了句口号。

口述回答(背诵这段):实现上非常朴素但很关键——明文是 handler 函数内的一个局部变量,落库之后只出现一次,就是创建成功的响应体里。生成 → 算哈希 → 只把 key_hash 写进数据库 → 在同一个函数里把明文塞进响应 JSON 返回,函数返回后这个变量就没有任何引用,运行时不会再有第二处能取到它。

列表和详情接口用的是一份独立的视图结构 keyView,字段只有 id、name、enabled、last_used_at、created_at 加三个 MCP 权限字段,既没有 key_hash 也没有明文。这一点我是写了测试兜住的:创建之后立刻查列表,断言整个响应体里既不含 key_hash 字符串、也不含刚才那个明文(api_test.go:1065-1070)。

另外我要求 Key 不能进日志——请求日志中间件只打印 method、path、status 和耗时,不打印 query、body、Authorization 头,所以即使前端不小心把 Key 放在 query 里也不会有日志泄漏。用户自助的 MCP 凭据走的是同一套逻辑,明文同样只在创建响应里返回一次(handler_mcp_my.go:138)。

前端如果丢了明文,唯一的办法是吊销重建——我知道这有点不友好,但要换来「服务端零明文」,这个代价必须付。

讲解与备注:

  • 技术原理:敏感值的最短生命周期原则——明文只存在于「生成 → 返回」这一个函数栈帧里,不落库、不入缓存、不进日志、不出现在错误信息中。对比反例是很多系统把明文写进 api_keys 表并「加密存储」,那等于把风险从「不可逆摘要」降级成「密钥管理」。
  • 代码位置:系统级 internal/api/handler_key.go:96;用户自助 internal/api/handler_mcp_my.go:138;视图 handler_key.go:27-37;日志脱敏 internal/api/middleware.go:92-97;测试 internal/api/api_test.go:1065-1070。
  • 加分句:「我把『只返回一次』当成一个可测试的不变量,而不是一句注释——列表接口的响应体里出现 key_hash 或明文,测试就会红。」

代码依据:internal/api/handler_key.go:26-37(列表视图)+ api_test.go:1065-1070

go
// keyView API Key 列表视图(不含 hash,包级定义供 swag 解析)
type keyView struct {
	ID         string     `json:"id"`
	Name       string     `json:"name"`
	Enabled    bool       `json:"enabled"`
	LastUsedAt *time.Time `json:"last_used_at"`
	CreatedAt  time.Time  `json:"created_at"`
	MCPTools   []string   `json:"mcp_tools"`
	MCPKBScope string     `json:"mcp_kb_scope"`
	MCPKBIDs   []string   `json:"mcp_kb_ids"`
}
// 测试断言(api_test.go:1065-1070)
w = doReq(t, env.router, "GET", "/api/v1/api-keys", nil, testAPIKey)
body := w.Body.String()
if strings.Contains(body, "key_hash") || strings.Contains(body, plainKey) {
	t.Errorf("列表不应暴露 hash 或明文: %s", body)
}

追问链:

  • 追问:如果创建 Key 之后数据库写入失败,会不会明文泄漏? → 答:不会,顺序是「生成 → 先落库 → 成功后再返回明文」;落库失败会走 Fail(c, CodeInternal, "创建 API Key 失败") 直接返回错误,明文随栈帧销毁。反过来「先返回后落库」才是要避免的,那样用户会拿到一个库里不存在的 Key。
  • 追问:明文会不会被埋进 panic 栈或错误日志? → 答:代码里没有把 token 拼进任何 error(fmt.Errorf 只带静态文案),所以不会。这点我是刻意保持的:错误信息里绝不放凭据。
  • 追问:前端存在 localStorage 里被盗怎么办? → 答:这是前端存储问题,后端能做的是:① 支持随时吊销(硬删,立即生效,因为认证路径没有缓存);② 记录 last_used_at 便于发现异常使用;③ 用 MCP 权限字段做最小权限下发,而不是发一把全权 Key。第 ③ 点是我们比一般实现多做的一步。

别踩的雷:

  • ❌ 说「我们把明文加密存起来,需要时解密给用户看」→ 直接判负。正确说法:服务端不保留任何可还原明文的形态。
  • ❌ 说「明文存 Redis 缓存 1 小时方便用户查看」→ 同样致命。
  • ❌ 只讲「只返回一次」却答不出「列表接口为什么不返回 hash」→ 说明你只会背结论,不懂威胁面(hash 泄漏会被拿去撞库/对比)。

Q3. bootstrap_api_key 是怎么初始化的?为什么说用完要删?删了之后会发生什么? ​

面试官想考:你有没有想过「第一个 Key 从哪来」这个鸡生蛋问题,以及删掉引导凭据后的权限收敛后果——这是最能暴露设计成熟度的地方。

口述回答(背诵这段):初始化在应用启动装配阶段,函数叫 seedAPIKey:只有当配置里的 bootstrap_api_key 非空、并且 api_keys 整张表为空时,才把它的 SHA-256 写进去、名字叫 bootstrap。所以它是幂等的——重启不会重复插入,也不会覆盖已有 Key。判据是「表是否为空」而不是「bootstrap 的哈希是否存在」,这一点后面有个值得说的后果。

为什么建议用完就删?因为它的身份是明文比对出来的,不是数据库里的标记:中间件里就一句 isBootstrap := bootstrapKey != "" && token == bootstrapKey。也就是说,只要配置里还留着这行明文,它就永远是一把能改配置、能授 MCP 权限的最高权限钥匙,而且明文躺在 YAML 里,可能进了 Git、进了镜像、进了运维的截图。所以代码里种子成功后会打一条 warn 日志:「请立即从配置中移除 bootstrap_api_key 项」。

这里必须诚实说一个后果:一旦按日志提示把它从配置里删掉,系统里就再没有任何凭据具备 bootstrap 身份了,PUT /api/v1/config 和 PUT /api-keys/:id/permissions 会永久返回 403——配置改不了、MCP 权限授不了。目前没有任何「把 bootstrap 身份转移给某个普通 Key」的接口,只能靠重新在配置里写回明文并重启。这是一个用工程约定代替权限模型的设计缺陷。正确的做法是把 bootstrap 降级成数据库里的一个布尔标记或一个角色,让「凭据来源」和「权限等级」解耦,再给一个受审计的「引导权交接」流程;短期至少应该提供 is_bootstrap 列的迁移和一次显式交接。我在面试里会主动讲这个点,因为它体现的是「我知道我的权限模型只有一个隐式最高层,而且它是字符串比较出来的」。

还有个小坑我也能说清:判据是「表为空」,所以如果哪天有人把 Key 全删了再重启,bootstrap Key 会原地复活——这在应急场景下是救命的,但在安全语义上意味着「删除所有 Key」不能真正锁死系统。

讲解与备注:

  • 技术原理:**引导凭据(bootstrap credential)**是典型的自举问题。工业界的三种做法:① 配置明文种子 + 提示删除(本项目,最弱);② 首启生成随机凭据并打印到 stdout/一次性文件(更强,不留在配置里);③ 云上走 IAM/Secrets Manager 注入并有轮换。本项目选 ①,代价就是 Q3 里说的死锁。
  • 代码位置:种子 internal/app/app.go:307-332(装配调用点 app.go:76);身份判定 internal/api/middleware.go:79;两处 bootstrap 闸门 internal/api/handler_config.go:194-196、internal/api/handler_key.go:161-165;配置字段 internal/config/config.go:275;仓库配置 configs/config.yaml:250(当前为空串,即默认不种子)。
  • 加分句:「我现在能明确说出它的三个问题:身份来自明文比较而非数据库标记、缺失只有隐式最高层角色、删除引导凭据会造成永久 403——这三条都可以通过『is_bootstrap 列 + 显式交接接口 + 审计』一次性解决。」

代码依据:internal/app/app.go:307-332

go
// seedAPIKey 首次启动时用配置的 bootstrap Key 种子(表空时)
func seedAPIKey(ctx context.Context, st store.Store, bootstrap string) error {
	if bootstrap == "" { return nil }
	keys, err := st.ListAPIKeys(ctx)
	if err != nil { return err }
	if len(keys) > 0 { return nil }              // 幂等判据:表非空即跳过
	sum := sha256.Sum256([]byte(bootstrap))
	key := store.APIKey{ID: uuid.New().String(), Name: "bootstrap",
		KeyHash: hex.EncodeToString(sum[:]), Enabled: true}
	if err := st.CreateAPIKey(ctx, key); err != nil { return err }
	slog.Warn("已创建 bootstrap API Key,请立即从配置中移除 bootstrap_api_key 项")
	return nil
}

追问链:

  • 追问:为什么不直接生成一个随机 Key 打印在启动日志里? → 答:那比配置明文好,但不适合容器化——日志会被采集、留存、也可能被围观。更稳的是首启写一次性文件或用 Secrets Manager 注入;我现在的实现选了最省事的一种,这是明确的取舍而不是疏忽(需现场确认部署侧的 Secrets 注入方式)。
  • 追问:如果配置里改了 bootstrap 明文,但表里已经有 Key 了,生效吗? → 答:不生效。种子只在表为空时执行,之后配置里的值只被当作「比对基准」用于 isBootstrap 判定,不会写库。所以会出现「用新明文调接口能过认证(因为比对用的是新明文)但库里的旧哈希 Key 依然是 bootstrap 身份判定之外的 Key」这种微妙状态——严格说这是配置与数据的双份真相,也是该重构的理由之一。
  • 追问:普通系统级 Key 能不能把 bootstrap 身份「授予」别人? → 答:不能,也没有这个接口。PUT /api-keys/:id/permissions 本身要求 bootstrap,属于「只有最高权限才能授最高权限」的自锁结构。

别踩的雷:

  • ❌ 说「bootstrap 是数据库里的 is_bootstrap 字段」→ 假话,代码里没有这个列(schema.go 的 api_keys 无该字段),判定完全靠配置文件明文比较。这是最容易被抓的编造点。
  • ❌ 说「删掉配置就没人能改配置了,这是安全加固」→ 只讲一半。要主动补上「所以这是个设计缺陷,应该改成角色体系」,否则显得你不知道自己把系统锁死了。
  • ❌ 说「bootstrap Key 删掉后就找不回来了」→ 不准确:删掉的是配置里的明文,数据库里的那行 Key 还在;重新写回相同的明文即可恢复 bootstrap 身份(无需重建 Key)。

Q4. 你们请求进来之后的中间件顺序是什么?认证怎么区分 API Key 和会话 JWT? ​

面试官想考:你清不清楚「谁先执行、谁能短路」,以及双凭据分流为什么不能靠前缀猜。

口述回答(背诵这段):全局中间件是 Logger → CORS → RateLimit 三件套按这个顺序挂在引擎上,然后 /api/v1 这个组再挂一个包装过的认证中间件。包装的原因是我们给评测服务的健康检查 /api/v1/eval/health 做了单独豁免——Gin 不允许静态路由和 /eval/*path 通配同时存在,所以只能在中间件里判 Method == GET && Path == /api/v1/eval/health 就直接 Next()。

顺序上的两个细节我特意记过:第一,CORS 在限流之前,所以浏览器预检 OPTIONS 会直接返回 204,既不吃限流令牌也不走认证;第二,限流在认证之前,这是个优点也是缺点——优点是未认证的洪水请求在进数据库之前就被挡住,缺点是匿名请求会消耗公共配额,可能把正常用户挤掉。

认证分流我没有用 binrag_ 前缀做判断,而是用形态:一个正则 ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$,匹配三段式的 token 就走本地 JWT 验签,验签失败直接 401,不再回退去查 API Key;不匹配的才走「重算 SHA-256 → 查唯一索引」的 API Key 流程。为什么不用前缀?因为历史 Key 和外部系统发来的自定义 Key 未必带我们的前缀,用前缀分流会把它们直接判死;而且前缀是用户可见可伪造的,用它做安全分支等于把分支条件交给攻击者。用形态分流是「凭据本身的密码学结构」在决定走哪条路。

「验签失败不回退查库」也是刻意的:否则任何一个伪造的三段式 JWT 都会额外触发一次数据库查询,等于给了一个零成本的放大攻击入口,而且失败语义会变得含糊。

讲解与备注:

  • 技术原理:**中间件短路(Abort)**是 Gin 的核心语义——c.Abort() 阻止后续 handler 执行。认证中间件必须保证「所有失败路径都 Abort」,本项目 5 条失败路径全部配了 Fail + Abort(middleware.go:41-44, 51-54, 68-71, 73-76),只有成功路径 c.Next()。
  • 代码位置:全局中间件 internal/api/router.go:65-67;v1 组与豁免包装 router.go:95-105;Auth 全部逻辑 internal/api/middleware.go:28-85;形态正则 middleware.go:18-21;分流 middleware.go:47-65;CORS/限流 middleware.go:102-129。
  • 加分句:「分流用的是密码学形态而不是业务前缀,因为前缀是攻击者可以随便写的东西,而三段式结构至少保证我只会把有 JWT 形状的输入交给 JWT 库,避免把 API Key 喂给 JWT 解析器。”

代码依据:internal/api/middleware.go:18-21, 46-65

go
// jwtShapeRe JWT 三段式结构判别(不使用 binrag_ 前缀作为认证分支条件):
var jwtShapeRe = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`)

// JWT 形态:本地验签(一次),失败直接 401,不再查询 API Key
if jwtShapeRe.MatchString(token) {
	claims, err := authMgr.VerifyJWT(token)
	if err != nil {
		slog.Warn("会话 JWT 校验失败", "err", err)
		Fail(c, CodeUnauthorized, "无效或过期的会话")
		c.Abort(); return
	}
	auth.SetIdentity(c, auth.Identity{Kind: auth.KindUser, UserID: claims.UserID, Provider: claims.Provider})
	c.Next(); return
}
// 非 JWT 形态:现有 API Key 流程(至多一次查询)
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])

追问链:

  • 追问:为什么限流放在认证前面?会不会有安全问题? → 答:放前面是为了让匿名洪水在触达数据库之前就被拒,属于「最便宜的资源先拦」。副作用是匿名请求也消耗配额,正确做法是分级限流:按 IP 做粗粒度兜底 + 按 Key/用户做细粒度配额,并且认证成功后再叠加一层按身份的限流。
  • 追问:OPTIONS 预检返回 204 是不是意味着预检不校验权限? → 答:预检本来就不该校验业务权限,浏览器预检不带凭据。真正的风险是 CORS 用了 Allow-Origin: *(middleware.go:104),任意站点都能在浏览器里调我们的 API;因为我们用 Authorization 头而不是 Cookie 传凭据,经典 CSRF 不成立,但这仍是应该收紧成白名单的地方。
  • 追问:认证中间件里有一次数据库写(TouchAPIKey),为什么放在认证路径? → 答:为了让 last_used_at 反映真实使用情况。代价是每个请求一次 UPDATE,写放大很明显,而且认证链路从「一次读」变成「读+写」,没法用只读副本分担。改进方向是内存计数 + 定时批量 flush,或者按 Key 做时间窗节流(我们 MCP 审计那套 buffered channel 异步模式可以直接复用)。

别踩的雷:

  • ❌ 说「用 binrag_ 前缀区分 API Key 和 JWT」→ 与代码相反,且测试里专门有一条「非 binrag_ 前缀但已登记的 Key 必须 200」(auth_flow_test.go:46-50)。
  • ❌ 说「JWT 验签失败会自动降级去查 API Key」→ 代码明确不回退。这是一条「看起来体贴、实际是放大攻击面」的设计,别说反。
  • ❌ 把限流说成「按 IP 限流」→ 实际是进程内单例令牌桶,全局共享,不区分 IP/Key(middleware.go:120)。

Q5. 「非 JWT 形态的 token 一律写成 Kind=apikey」——这个实现有没有风险? ​

面试官想考:这是本项目的核心陷阱题。 他要看你会不会在自己代码里发现「凭据来源没被区分」导致的越权面。答不出来会很难看,主动答出来是强加分。

口述回答(背诵这段):有风险,而且我认为这是我这个项目里最该修的一处设计缺陷。 中间件在 API Key 分支里写身份时,只写了 Kind = "apikey"、APIKeyID 和 IsBootstrap 三个信息,从来没有把 key.OwnerID 带进 Identity。而权限判断那边是这么写的:if id.Kind == auth.KindAPIKey { return true }——意思是「只要你是 API Key,就放行所有知识库」。

问题在于我们的 api_keys 表里其实有两类 Key:owner_id IS NULL 的是系统级 Key,owner_id 非空的是用户在 /api/v1/mcp/my/key 自助生成的 MCP 凭据(一个用户最多一个,靠部分唯一索引保证)。这两类 Key 认证后拿到的 Kind 完全一样,都是 apikey。结果就是:一个普通登录用户在浏览器里点一下「生成我的 MCP 凭据」,拿到的这把 Key 在 REST 层就是系统级权限——能列出、修改、删除任意租户的知识库和文档,能读别人的 chunk 和历史,甚至能调 /api/v1/api-keys 去创建、删除、启停任意 Key(只有 is_bootstrap 那一关还拦着改配置和授 MCP 权限)。

而且这个缺陷没有任何测试覆盖:handler_mcp_my_test.go 全程是用会话 JWT 去调「我的 MCP」接口的,从来没有「拿用户 Key 去打知识库接口」这种用例,所以 CI 不会拦。

正确做法很清楚:① Identity 里要带上 OwnerID(或者更彻底,扩成一个 Kind 枚举:system_key / user_key / session);② 授权判断改成按 owner_id 收敛——系统级 Key 才走「全放行」,用户 Key 走和登录用户一样的 owner_id == 该用户 判断,尤其不应该是「也放行全部」;③ 补三条测试:用户 Key 读他人 KB 必须 404、用户 Key 调 API Key 管理必须 403、系统级 Key 行为不变。风险面还可以进一步收窄:用户自助凭据本来只该用于 MCP,那就让它在 REST 层的权限面小于等于登录用户,甚至干脆只允许访问 /mcp/*。

讲解与备注:

  • 技术原理:认证(Authentication)与授权(Authorization)之间必须传递完整的「主体属性」。本项目把主体压缩成了 Kind 一个字符串,丢失了「是哪个租户的 Key」这个关键属性,导致授权层无法做正确的收敛。这是典型的 confused deputy / 主体属性丢失类缺陷。
  • 代码位置:中间件写身份 internal/api/middleware.go:79-83(只设置 Kind/APIKeyID/IsBootstrap);Identity 结构 internal/auth/identity.go:16-33(有 APIKeyID、IsBootstrap、UserID、Provider,但没有 OwnerID 字段——所以修复要动这里);越权放行点 internal/api/handler_kb.go:67;Key 的系统级/用户级区分只存在于数据层 internal/store/store.go:84-85(OwnerID string,注释写明 "" = 系统级 Key)与部分唯一索引 internal/store/schema.go:93-96;MCP 侧有 owner 收敛可作对照 internal/mcp/auth.go:23,77。
  • 加分句:「我把这个当成主体属性丢失问题而不是一行 if 的 bug——所以修法是先给 Identity 补 OwnerID,再让授权层按 owner 收敛,最后补三条回归测试;只改 handler_kb.go 那一行是治标。」

代码依据:internal/api/middleware.go:78-83 + internal/api/handler_kb.go:62-71

go
// middleware.go:78-83 —— 身份里没有 OwnerID
_ = s.TouchAPIKey(ctx, key.ID)
isBootstrap := bootstrapKey != "" && token == bootstrapKey
auth.SetIdentity(c, auth.Identity{Kind: auth.KindAPIKey, APIKeyID: key.ID, IsBootstrap: isBootstrap})
c.Set("api_key_id", key.ID)
c.Set("is_bootstrap", isBootstrap)

// handler_kb.go:62-71 —— 只看 Kind,不看 OwnerID
func (h *handler) canAccessKB(c *gin.Context, kb *store.KnowledgeBase) bool {
	id := auth.IdentityOf(c)
	if id.Kind == auth.KindAPIKey { return true }   // ← 用户级 MCP 凭据同样命中这里
	return kb.OwnerID != nil && *kb.OwnerID == id.UserID
}

追问链:

  • 追问:那实际影响有多大?最坏能干什么? → 答:最坏是完整的多租户数据平面穿透:枚举并读取所有租户的知识库列表(ListAllKBs 不带 where)、拉取任意文档原文(/documents/:id/raw)和 chunk 内容(/chunks/:id)、删除别人的知识库和文档,以及在系统里新建一把全权 Key 长期驻留(POST /api/v1/api-keys 对任何 Kind=apikey 都放行)。唯一还拦着的是改配置和授 MCP 权限。
  • 追问:为什么 MCP 那边没这个问题? → 答:因为 MCP 层是另一套认证代码(internal/mcp/auth.go:47-79),它把 OwnerID 放进 keyCtx,gateway 再按 owner 解析出实际可访问范围(mcp/permission.go:42-56 的 Resolve)。所以同一把 Key 在 MCP 面是安全的、在 REST 面是越权的——两套认证实现、两套语义,这本身就是我要合并它们的原因。
  • 追问:你们有 404 兜底吗,能不能减轻影响? → 答:不能。404 只保护「单个资源被枚举」,挡不住 GET /knowledge-bases 这种列表接口把别人的库 ID 全吐出来,挡不住 POST /api/v1/api-keys 这种创建动作。

别踩的雷:

  • ❌ 答「没有风险,因为用户 Key 只有一个用户能拿到」→ 直接判负。风险不是「别人拿到你的 Key」,而是「你拿自己的 Key 就能越权访问别人」。
  • ❌ 说「我们在 canAccessKB 里判断了 owner」→ 只对登录用户成立。必须在被追问前主动说出「Kind==KindAPIKey 这一支是无条件放行的」。
  • ❌ 声称「已经修好了 / 有测试覆盖」→ 代码没修、也没有对应用例。诚实说「识别到了,修法是 A/B/C,测试要补 X/Y/Z」才是加分姿态。

Q6. 多租户隔离具体是在哪一层做的?知识库归属字段长什么样? ​

面试官想考:你的隔离是「SQL 层的强制约束」还是「应用层的自觉约定」,以及你知道不知道两者在可维护性上的差别。

口述回答(背诵这段):归属字段是 knowledge_bases.owner_id,类型是可空的 TEXT,语义是:NULL 表示系统级知识库(由系统级 Key 创建、也只有系统级 Key 能访问),非空表示归属某个登录用户、值是 users.id。这个字段没有外键约束,注释里写了原因是不做级联、用户删除不在本版范围内——这是个明确的简化。

写入时机是「由 API 层按当前身份显式决定」:创建知识库时,如果身份是登录用户就把 owner_id 设成自己的 userId,如果是系统级 Key 就留 NULL。也就是说归属不是数据库默认值、也不是触发器,而是 handler 里的一行 if。

读取隔离分两套:列表接口走 SQL 过滤——登录用户调 ListKBsByOwner,SQL 是 WHERE owner_id = $1;系统级 Key 调 ListAllKBs,没有任何 where。单资源接口走 handler 内存判断——统一先 GetKB 拿到记录和它的 owner_id,再调 canAccessKB:系统级 Key 直接放行,登录用户要求 owner_id 非空且等于自己的 userId。文档、任务、chunk、视频这些接口都不能直接判自己的归属,而是先反查到所属知识库再复用同一个 canAccessKB,比如 chunk 是「chunk → document → kb」两级回溯。

检索侧还有第三层:登录用户提问时如果没指定知识库,会展开成自己名下所有 KB 的 ID 列表下推给检索层,如果没有可访问的库就直接 400,而不是「不过滤」——这是 fail-closed。

我要诚实说的两点:第一,我们没有数据库级的强制隔离,没有 RLS、也没有把所有查询收敛到一个「带租户条件的仓储入口」,隔离正确性靠每个 handler 记得调 canAccessKB,这是有风险的约定;第二,认证层丢失了 OwnerID,导致用户自助 MCP 凭据被当成系统级 Key 全量放行(见上一题)。后者是现在就必须修的,前者我会用「RLS 或统一查询入口」的方式收敛。

讲解与备注:

  • 技术原理:租户隔离的三种强度:① 应用层判断(本项目,最弱,靠自觉);② 数据层强制(PostgreSQL RLS + SET LOCAL app.tenant_id,或所有查询强制拼租户条件);③ 物理隔离(分库分表)。安全面试里被问到「隔离在哪一层」,标准加分答案是「应用层是最后一道,但真正的护栏应该在数据层」。
  • 代码位置:字段与注释 internal/store/schema.go:80-83(ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS owner_id TEXT;)、结构体 internal/store/store.go:38-46;写入 internal/api/handler_kb.go:121-124;列表分支 handler_kb.go:143-157;两种 SQL internal/store/kb.go:35-37(全量)、kb.go:55-57(按 owner);单资源判断 handler_kb.go:62-83;chunk 两级回溯 internal/api/handler_chunk.go:57-71;检索展开 internal/api/handler_chat.go:28-57。
  • 加分句:「我把隔离拆成三层讲:列表走 SQL、单资源走 handler 复用同一个判断函数、检索走 KB 白名单下推。诚实说第一层和第三层是可靠的,第二层是约定式的——所以我会补 RLS 或者把所有访问收敛到一个带租户参数的仓储方法上。」

代码依据:internal/api/handler_kb.go:62-71, 143-157

go
// 系统级 API Key → 全部(含系统级与用户级);登录用户 → 仅 owner_id == UserID
func (h *handler) canAccessKB(c *gin.Context, kb *store.KnowledgeBase) bool {
	id := auth.IdentityOf(c)
	if id.Kind == auth.KindAPIKey { return true }
	return kb.OwnerID != nil && *kb.OwnerID == id.UserID
}
// ListKBs:按身份选择查询
if id.Kind == auth.KindUser {
	kbs, err = h.store.ListKBsByOwner(ctx, id.UserID)   // WHERE owner_id = $1
} else {
	kbs, err = h.store.ListAllKBs(ctx)                  // 无 where
}

追问链:

  • 追问:为什么 owner_id 不加外键? → 答:注释里的理由是「用户删除不在本版范围 + 保持分库分表扩展性」。代价是可能出现悬空 owner——用户记录没了但知识库还在,数据会变成「孤儿租户数据」,谁都不能通过正常接口访问到(除非系统级 Key)。真要修,我会加 REFERENCES users(id) ON DELETE SET NULL 或者明确做「用户注销时把 KB 转成系统级/删除」的流程。
  • 追问:ListAllKBs 完全没有 where,不怕被滥用吗? → 答:它只在系统级 Key 分支被调用,本身是给「运维/管理员视角」用的;风险在于谁能进入这个分支——这正是 Q5 的缺陷所在,用户级 Key 也能进来。所以这条查询必须和身份层一起修,不能单独看。
  • 追问:如果未来要支持「知识库共享给多个用户」或「团队」怎么办? → 答:owner_id 单字段模型就不够了,要引入 kb_members(kb_id, user_id, role) 关联表,并把 canAccessKB 从「等于 owner」改成「成员表命中 + 角色满足」。现在这个单字段设计只覆盖了「个人 + 系统级」两种形态,是明确的范围裁剪。

别踩的雷:

  • ❌ 说「我们用 PostgreSQL 的 RLS 做多租户」→ 本项目没有 RLS(schema.go 里没有 CREATE POLICY / ENABLE ROW LEVEL SECURITY)。说了就是编造。
  • ❌ 说「隔离完全在数据库做,应用层不判断」→ 相反,单资源判断全在 handler 里。
  • ❌ 说「owner_id 有外键约束」→ 没有外键,注释里写得很清楚。
  • ❌ 只谈登录用户,忘了承认「系统级 Key 是设计上的全量可见」——要主动点明这是有意为之(运维视角),而不是漏洞;真正的问题是用户级 Key 被错误地归入了系统级。

Q7. 越权为什么返回 404 而不是 403?两者你们是怎么分的? ​

面试官想考:你有没有信息泄露(资源存在性枚举)的意识,以及会不会把「鉴权失败」和「资源不存在」混成一个语义。

口述回答(背诵这段):我的原则是:「这个资源存不存在于你的世界里」用 404,「你的凭据类型不被允许做这件事」用 403。

具体来说,凡是「资源存在但当前身份无权访问」的场景,我一律返回 404 + 和真不存在完全一样的文案。比如别人家的知识库,我 GetKB 成功拿到了记录,但 canAccessKB 返回 false,这时候返回的是 404 "知识库不存在",和他真的传了一个乱码 ID 得到的响应逐字节一致。原因很简单:如果返回 403,攻击者就能拿一堆 ID 去刷,403 等于告诉他「这个 ID 是真实存在的」,他就能把整个系统的资源 ID 空间枚举出来——即使他读不到内容,这份「存在性地图」本身就是有价值的情报,可以配合其他漏洞用。这条规则我在知识库、文档、任务、chunk、视频、评测任务提交这些地方全都贯彻了,chunk 甚至做了「chunk → document → kb」三级校验,只要有一环断裂(包括文档已删除但向量库里还有残留向量这种脏数据)就返回 404。

403 我保留给「和资源无关的身份类型问题」:比如会话 JWT 去调 API Key 管理接口、非 bootstrap Key 去改配置或授 MCP 权限、API Key 去调「我的 MCP」用户自助接口——这几种情况下用户已经知道这个接口存在(就是他自己点的),没有存在性可泄露,用 403 语义更准确,也方便前端提示「请用账号登录」或者「权限不足」。

落到测试上,kb_isolation_test.go 里对用户 B 访问用户 A 的知识库断言了 GET/PUT/DELETE 三个都是 404,而不是 403。

讲解与备注:

  • 技术原理:信息泄露最小化。403 vs 404 是经典取舍:安全侧偏 404(防枚举),可用性/调试侧偏 403(错误更明确)。业界常见折中是「对已认证但无权的主体返回 404,对未认证返回 401」,本项目是这个思路;也有系统对真实存在的资源返回 403 但加随机延迟,避免时序侧信道——本项目没做时序混淆,代码中未找到。
  • 代码位置:404 实施点 internal/api/handler_kb.go:183-186(Get)、:222-225(Update)、:270-273(Delete)、handler_doc.go:57-60, 208-211, 296-299、handler_task.go:35-38, 68-71、handler_chunk.go:57-71、handler_video.go:47-50、proxy_eval.go:105-108;403 实施点 handler_key.go:41-48、handler_key.go:161-165、handler_config.go:194-196、handler_mcp_my.go:39-46;测试 kb_isolation_test.go:45-54。
  • 加分句:「我把 404/403 的选择当成信息泄露模型的一部分来讲:404 防的是『存在性枚举』,403 表达的是『凭据类型不对』,两者的威胁面不同,所以不能一刀切。」

代码依据:internal/api/handler_kb.go:173-188

go
func (h *handler) GetKB(c *gin.Context) {
	kb, err := h.store.GetKB(c.Request.Context(), c.Param("id"))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) { Fail(c, CodeNotFound, "知识库不存在"); return }
		Fail(c, CodeInternal, "查询知识库失败"); return
	}
	if !h.canAccessKB(c, kb) {
		Fail(c, CodeNotFound, "知识库不存在")   // 越权:与真不存在同码同文案
		return
	}
	OK(c, toKBView(*kb))
}

追问链:

  • 追问:返回 404 会不会让用户困惑「我的库怎么没了」? → 答:会,这是可用性代价。我的缓解方式是前端把「点进来 404」统一处理成「无权访问或已删除」,并且在列表接口里用户本来也看不到那个库,多数场景不会触发。真要做到既安全又好用,可以给不同的资源分配不可枚举的随机 ID(我们用的是 UUID,这一点已经做到了),这样「猜 ID」本身就不成立,404 的困惑也可以接受。
  • 追问:那你们的资源 ID 是自增还是 UUID? → 答:全部是服务端生成的 UUID(handler_kb.go:107 知识库、handler_doc.go:92 文档、handler_chunk.go:28-31 还会校验 chunk_id 是合法 UUID 才去查向量库)。UUID 让「枚举」在成本上不成立,这也是为什么敢用 404。
  • 追问:kvView 为什么要把 owner_id 剔掉? → 答:kbView(handler_kb.go:18-25)只暴露 id/name/description/strategy/时间戳,不含 owner_id——归属字段只在服务端授权判断里用,不回传给客户端,避免泄露「这个库属于谁的哪个 userId」。有测试用 containsJSONKey 断言创建响应里不含 owner_id(kb_isolation_test.go:22-24)。

别踩的雷:

  • ❌ 说「越权返回 403」→ 与代码相反(KB/文档/任务/chunk/视频/eval 全是 404)。正确说法:越权和不存在统一 404,只有身份类型不允许才 403。
  • ❌ 说「404 是为了掩盖资源存在性」但举的例子是「未登录访问」→ 未登录是 401(middleware.go:41),不是 404。三码分工要背清:401 = 没凭据/凭据无效;403 = 凭据类型不被允许;404 = 资源不可见(不存在或越权)。
  • ❌ 把「错误信息文案统一」说成「返回同样的错误对象」→ 我们统一的是 code/文案,data 字段在失败时是 null(response.go:31-33 不设 Data)。细节别夸大。

Q8. 完整讲一下你们的 OIDC 登录流程。state 和 nonce 分别防什么? ​

面试官想考:OIDC 授权码流程你有没有真正跑通过,还是只会念名词;CSRF 与重放两个威胁的边界清不清楚。

口述回答(背诵这段):流程是标准的授权码模式,分四步。

第一步,用户点登录,前端打到 GET /api/v1/auth/oidc/{provider}/login。后端先校验这个 provider 存在并且类型是 OIDC,然后 生成一个 32 字节随机的 state,如果是 OIDC 还额外生成一个 nonce,把「state → {provider, nonce, 过期时间}」存进一张内存表,TTL 是 10 分钟,最后带着 state 和 nonce 302 到 issuer 的授权页。注意 provider 名字在 login 时只用来查表,回调时是以 state 里记录的那个 provider 为准,这样就防住了「回调 URL 里的 provider 参数被伪造」。

第二步,用户在 IdP 认证完,浏览器带着 code 和 state 回到 GET /api/v1/auth/oidc/{provider}/callback。后端做的第一件事是 Consume(state):加锁、查表、立刻删除、再检查是否过期——「先删后判」保证同一个 state 在并发下只有一个请求能拿到,重放必然失败。state 不存在或过期就直接跳 /login?error=...,不建会话、不建用户。

第三步,用 code 去 token 端点换 ID Token,然后用 go-oidc 的 Verifier 验签:验 JWKS 签名、issuer、audience、exp/iat/nbf,我再额外显式校验一次 nonce 必须等于登录时生成并存进 state 的那个值,还会双保险地检查一次 nbf 和 subject 非空。这里我们没有调 userinfo 端点,身份完全来自 ID Token。

第四步,用 (provider, subject) 做 upsert 拿到用户,然后不把 JWT 放进 URL,而是签一张 2 分钟有效的一次性 ticket,302 到 /login?ticket=xxx;前端拿 ticket 去 POST /api/v1/auth/exchange 换 JWT。

state 防的是 CSRF:防止攻击者用自己的 code 诱导受害者的浏览器去完成回调、把受害者会话绑到攻击者账号上。nonce 防的是 ID Token 重放/混淆:nonce 是我们生成、写进 state、又通过授权请求传给 IdP 的,IdP 必须原样写进 ID Token 的 nonce claim;攻击者拿一个旧 ID Token 来兑换时对不上。两者作用的对象不同:state 保护「回调这个动作」,nonce 保护「令牌这份数据」。GitHub 没有 ID Token,所以只用了 state。

讲解与备注:

  • 技术原理:OIDC 授权码流程的三重校验:① state(CSRF,应用侧生成);② nonce(ID Token 重放绑定,应用侧生成,IdP 回填);③ code 一次性(IdP 侧保证,短时效)。ID Token 本身是 JWS,验签依赖 JWKS,issuer 必须与我们配置的 issuer 严格相等(不能信任 token 自称的 iss 去做 discovery)。
  • 代码位置:BeginLogin internal/auth/auth.go:117-133;CompleteLogin auth.go:138-152;state 存储与原子消费 internal/auth/ticket.go:28-73(TTL 常量 ticket.go:13);授权 URL 带 nonce internal/auth/oidc.go:120-122;ID Token 全量校验 oidc.go:127-172(nonce 校验在 :161、nbf 双保险 :165、subject 非空 :168);GitHub 无 nonce internal/auth/github.go:68-70;ticket 换 JWT handler_auth.go:153-165。
  • 加分句:「我总结成一句话:state 保护回调动作、nonce 保护令牌数据、code 由 IdP 保证一次性——三者不可互相替代,这也是为什么 GitHub 那条线我仍然坚持校验 state。」

代码依据:internal/auth/auth.go:138-152 + internal/auth/ticket.go:61-73

go
// CompleteLogin 回调处理:原子消费 state(不存在/过期立即失败)→ Provider 校验。
func (m *Manager) CompleteLogin(ctx context.Context, code, state string) (*UserInfo, string, error) {
	providerName, nonce, ok := m.states.Consume(state)
	if !ok { return nil, "", fmt.Errorf("state 无效或已过期") }
	p, ok := m.providers[providerName]
	if !ok { return nil, "", fmt.Errorf("未知 provider: %s", providerName) }
	info, err := p.ExchangeAndVerify(ctx, code, nonce)
	if err != nil { return nil, "", err }
	return info, providerName, nil
}
// Consume 原子「读取+删除」:state 不存在、已过期或类型不符 → ok=false;成功仅一次
func (s *stateStore) Consume(state string) (provider, nonce string, ok bool) {
	s.mu.Lock(); defer s.mu.Unlock()
	e, exists := s.m[state]
	if !exists { return "", "", false }
	delete(s.m, state)
	if time.Now().After(e.ExpiresAt) { return "", "", false }
	return e.Provider, e.Nonce, true
}

追问链:

  • 追问:state 为什么用内存 map 而不是 Redis? → 答:单机/桌面形态下内存足够简单,零依赖。但多副本就会出问题:login 落在副本 A、callback 打到副本 B 就找不到 state,登录直接失败;滚动发布同理。生产多副本要么粘性会话,要么把 state/ticket 放到 Redis 并加 TTL。这是我知道的明确局限,代码里没有共享存储方案。
  • 追问:内存 map 会不会被刷爆? → 答:会。BeginLogin 是公开无认证接口,攻击者可以反复调用来插入 state 条目,而清理只发生在「下一次 New 或 Consume」时顺带做,没有条目数上限、没有后台 GC。修法是加容量上限 + 定时清理 + 在入口加限流(现在默认不限流,见 Q16)。
  • 追问:如果 IdP 返回的 ID Token 的 sub 是数字怎么办? → 答:go-oidc 会严格解析失败。我们给了个 permissive_sub 兜底开关:开启后用同一套 JWKS 自己验签,然后手动做 iss/aud/exp/nbf/nonce 的完整校验,只把数字 sub 确定性地转成字符串。安全强度不打折,反例测试列了 5 种(nonce 错、iss 错、exp 过期、aud 不含 client_id、sub 是布尔)都要求收敛失败(oidc_test.go:238-267)。

别踩的雷:

  • ❌ 说「state 是防重放的」→ 不准确:state 主要防 CSRF,防 ID Token 重放的是 nonce。两者混说会被纠正。
  • ❌ 说「回调时从 URL 里取 provider 参数来选 Provider」→ 代码是从 state 里取 provider(auth.go:139-146),这正是防参数伪造的设计。
  • ❌ 说「JWT 直接拼在重定向 URL 里返回前端」→ 代码刻意只回 ticket(handler_auth.go:134),且有测试断言回调 URL 里不含 token(auth_flow_test.go:308-311)。凭证进 URL 会留在浏览历史/Referer/网关日志里。
  • ❌ 说「我们调了 userinfo 端点拿用户信息」→ 代码明确不调(oidc.go:126 注释「不调用 userinfo 端点」)。

Q9. PKCE 你们做了吗?没做的话风险在哪? ​

面试官想考:你会不会主动承认「缺了某个标准推荐项」,以及知不知道 PKCE 到底解决什么问题(很多人只知道名字)。

口述回答(背诵这段):没做,这是明确的缺口。 我全仓搜过 code_challenge、code_verifier、S256,一个都没有,oauth2.Config 里也没带 PKCE 参数。

PKCE 解决的问题是:授权码在传递过程中被第三方截获后,截获者能直接拿它去换 token。它给每次授权加一个客户端生成的随机值:授权请求里带 code_challenge(verifier 的哈希),换 token 时带原始 code_verifier,IdP 校验两者匹配才发 token。这样光有 code 是换不到 token 的,还需要那个只存在于发起方内存里的 verifier。

在我们这个场景下风险有多大,我要诚实分层说:我们是标准的 confidential client——后端有 client_secret(配置里 client_id/client_secret 都是必填,config.go:632-637 校验),而且回调是 HTTPS 的服务端端点,code 不经过浏览器 JS、不经过 SPA 的地址栏。授权码注入/截获的主要场景是公共客户端(手机 App、SPA)或者回调地址可被劫持,那种情况下 PKCE 是必需品;对我们这种有 secret 的服务端客户端,PKCE 是纵深防御,缺了不是致命,但属于「标准推荐项没做」。

所以我的定位是:低优先级但不该省。修起来成本很低——在 BeginLogin 里生成 verifier 并存进 state 记录(我们 state 里已经存了 provider 和 nonce,加一个字段就行),授权 URL 带上 code_challenge + code_challenge_method=S256,ExchangeAndVerify 里用 oauth2.VerifierOption(verifier) 带上 code_verifier。有了 PKCE,即使哪天 secret 泄漏或者回调被劫持,攻击者也换不到 token。

讲解与备注:

  • 技术原理:PKCE(RFC 7636)本质是「把授权码绑定到一个只有发起方能出示的秘密上」,等价于「用一次性 verifier 替换掉固定的 client_secret 作为换 token 的第二因素」。OAuth 2.1 已把 PKCE 列为所有客户端类型的推荐/必需项。
  • 代码位置:无 PKCE 的证据 —— internal/auth/oidc.go:68-74(oauth2.Config 只设 ClientID/ClientSecret/Endpoint/RedirectURL/Scopes)、oidc.go:120-122(授权 URL 只加 nonce 参数)、oidc.go:131(p.oauthCfg.Exchange(ctx, code) 不带 verifier option);client_secret 必填校验 internal/config/config.go:632-637;state 记录结构体可扩展的位置 internal/auth/ticket.go:27-32(stateEntry{Provider, Nonce, ExpiresAt})。
  • 加分句:「我能分清公共客户端必须 PKCE 和机密客户端 PKCE 是纵深防御——我们说清了风险等级,也知道改动只需要动 stateEntry、AuthCodeURL 和 Exchange 三处。」

代码依据:internal/auth/oidc.go:120-135

go
func (p *oidcProvider) AuthCodeURL(state, nonce string) string {
	return p.oauthCfg.AuthCodeURL(state, oauth2.SetAuthURLParam("nonce", nonce))
}   // ← 只带了 nonce,没有 code_challenge / code_challenge_method

func (p *oidcProvider) ExchangeAndVerify(ctx context.Context, code, nonce string) (*UserInfo, error) {
	ctx, cancel := context.WithTimeout(ctx, discoveryTimeout)
	defer cancel()
	rawIDToken, err := p.oauthCfg.Exchange(ctx, code)   // ← 没有 code_verifier
	if err != nil { return nil, fmt.Errorf("授权码交换失败: %w", err) }

追问链:

  • 追问:那你们靠什么替代 PKCE 的防护? → 答:① client_secret(机密客户端的基本盘);② state 一次性消费——即使别人截到 code,他用 code 去换 token 时他并不知道我们生成的 state,回调会被拒;③ ID Token 的 nonce 校验让「换来的 token 不是本次登录的」也会被拒。但这些都是换 token 之后的校验,PKCE 的价值在换 token 这一步本身就失败,所以不能完全替代。
  • 追问:如果 secret 泄漏了会怎样? → 答:攻击者可以伪造回调、用自己的 IdP 账号换取我们的会话,这是 OIDC 里 secret 泄漏的固有后果。缓解手段是 secret 轮换 + 校验 hd/email_verified 之类的策略 + 限定回调地址白名单;PKCE 能让「光有 secret + 截到 code」也不足以换 token。
  • 追问:为什么不属于"必须修"? → 答:因为我们不是公共客户端。如果这个项目要出一个桌面端走系统浏览器登录、或者前端直接拿着 client_id 去换 token,那 PKCE 就必须马上补——顺便说,这个项目确实有桌面端形态,所以在桌面端登录场景我倾向于把 PKCE 列进必做清单。

别踩的雷:

  • ❌ 说「PKCE 是用来加密授权码的」→ 错。PKCE 不加密任何东西,它是给授权码绑定一个一次性校验值,防的是 code 被截获后直接兑换。
  • ❌ 说「我们有 client_secret 所以不需要 PKCE」→ 结论方向对但话太满。正确说法:机密客户端下 PKCE 是纵深防御而非必需,但 OAuth 2.1 已把它列为推荐项,且我们有桌面端形态,所以应当补上。
  • ❌ 编造实现细节(说「我们用了 S256」)→ 代码里完全没有,一旦被要求看代码就是硬伤。

Q10. ticket 一次性票据是怎么保证「只能用一次」的?为什么不用 JWT 或者直接放 token? ​

面试官想考:一次性语义的并发正确性(是不是 check-then-act),以及「凭证不落 URL」的威胁意识。

口述回答(背诵这段):保证一次性靠的是**「读+删」在同一个锁里完成**,而不是先查再删。Consume 的实现是:拿 sync.Mutex → 查 map → 不管过没过期先 delete → 再判是否过期 → 返回内容。这样即使 8 个请求同时拿同一个 ticket 打进来,只有第一个能查到并删掉,其余全部拿不到,重放必然失败。这个并发行为我是写了测试的:8 个 goroutine 并发消费同一个 state 和同一个 ticket,断言各自只成功一次(ticket_test.go:64-93)。

时效上 ticket 是 2 分钟,state 是 10 分钟,都在常量里定义。另外 New 的时候会顺带跑一次清理,把已过期的条目删掉,避免内存无限增长(但没有容量上限,这是我承认的局限)。

为什么不用 JWT 当这个中间凭据? 因为 JWT 是自包含的、无法撤销、也无法判断是否被用过——它天生适合「短时、可重复验证」的会话,不适合「一次性」。要做一次性就得在服务端额外维护一个已用 jti 的黑名单,那和直接用服务端随机票据比,只是把存储换了名字,还多背了一个 JWT 库的验签成本。

为什么不直接把 JWT 拼在重定向 URL 里返回? 因为 URL 会进浏览器历史、进 Referer 头、进反代和网关的访问日志、还可能被前端埋点上报。JWT 一旦进 URL 就等于泄漏了一条长期会话凭据。所以我的设计是:回调只回一个 2 分钟、消费即毁的 ticket,前端用 POST body 拿它去换 JWT,JWT 只走响应体、之后只在 Authorization 头里出现。测试里专门断言了回调跳转 URL 中不含 token(auth_flow_test.go:308-311)。

讲解与备注:

  • 技术原理:一次性令牌的正确实现必须满足 原子性(不能 check-then-act)——用「先删后判」把「读」和「失效」合成一个临界区,天然幂等且防并发重放。对比错误实现:「查存在 → 校验 → 执行业务 → 删除」,中间任何一步失败或并发都会留下窗口。
  • 代码位置:ticket 存储 internal/auth/ticket.go:84-139(Consume :118-130、TTL :15);state 同构 ticket.go:34-82;签发 auth.go:155-157;换 JWT auth.go:160-166;回调跳转 handler_auth.go:134;测试 internal/auth/ticket_test.go:39-61(一次性+过期)、:64-93(并发)、:96-123(顺带清理)。
  • 加分句:「一次性凭据的正确写法是先删再判,把『读取』和『作废』压进同一个临界区,这样并发重放自动退化成一次成功、N 次失败。」

代码依据:internal/auth/ticket.go:117-130

go
// Consume 原子「读取+删除」:ticket 不存在/已过期 → ok=false;成功后立即删除,重放失败
func (s *ticketStore) Consume(ticket string) (userID, provider string, ok bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	e, exists := s.m[ticket]
	if !exists { return "", "", false }
	delete(s.m, ticket)                     // ← 先删,保证并发下只有一人命中
	if time.Now().After(e.ExpiresAt) { return "", "", false }
	return e.UserID, e.Provider, true
}

追问链:

  • 追问:多副本部署下这个内存 map 会怎样? → 答:会直接坏掉——签发在副本 A、兑换打到副本 B 就找不到 ticket,用户登录失败。滚动发布期间同理。所以多副本必须把 ticket/state 挪到 Redis 并带 TTL,或者做会话粘性。这是我明确知道的局限,当前实现按单机/桌面形态设计。
  • 追问:过期时间为什么是 2 分钟和 10 分钟? → 答:state 10 分钟覆盖「用户打开 IdP 页面、输账号密码、可能还要过一次 MFA」的正常时长;ticket 2 分钟只覆盖「浏览器 302 落地 → 前端 JS 发一个 POST」这一步,链路极短,所以给得很紧。这两个数字本质是「人类操作时长」与「机器回调时长」的区别。
  • 追问:ticket 泄漏了怎么办(比如被浏览器插件读走)? → 答:窗口只有 2 分钟且一次性,攻击者要先于前端兑换;一旦前端先兑换成功,攻击者拿到的就是废票。另外 ticket 不绑定 IP/UA 是我可以补的加固项——加绑定会更安全,但会让移动网络切换 IP 的用户登录失败,属取舍。

别踩的雷:

  • ❌ 说「我们用 JWT 做过一次性 ticket,靠 exp 保证一次性」→ exp 只能保证过期,不能保证只用一次。
  • ❌ 说「查一下有没有用过,用过就拒绝」→ 这就是 check-then-act,并发下有窗口。要说先删后判、在同一把锁里。
  • ❌ 说「state 和 ticket 存在 Redis」→ 实际是进程内 map(make(map[string]...) + sync.Mutex),没有外部依赖。
  • ❌ 把 state 的 10 分钟和 ticket 的 2 分钟记反。

Q11. 回调地址是怎么拼的?OIDC discovery 失败会发生什么? ​

面试官想考:多环境配置的正确姿势(配置驱动而非请求驱动),以及启动期失败的处理策略(fail fast 还是降级)。

口述回答(背诵这段):回调地址是启动期一次性计算并固定的,不随请求变化。逻辑在 NewManager 里:先把 oidc.public_url 去掉尾斜杠作为 base,然后按 provider 类型拼——OIDC 类型拼 <public_url>/api/v1/auth/oidc/{name}/callback,GitHub 拼 <public_url>/api/v1/auth/github/callback;如果 provider 配置里显式给了 redirect_url,就以它为准。配置校验层面也卡了:oidc.enabled=true 时 public_url 必填(否则装配失败),显式的 redirect_url 必须是带协议头的合法 URL。

为什么必须固定、不能从请求里推导? 因为如果按 Host 头去拼回调地址,攻击者改一个 Host 就可能把授权码引到自己的域上,这是典型的 Host header injection 漏洞。所以回调地址只能来自服务端配置,这是安全要求而不是实现偏好。反过来,public_url 必须配成外部真实可达的地址(域名或反代入口),因为它是给 IdP 看的,走内网地址 IdP 回调不过来。

discovery 的行为是 fail fast:NewOIDCProvider 在启动期带着 15 秒超时去拉 issuer 的 .well-known/openid-configuration,失败就直接返回错误,NewManager 再往上抛,最终应用启动失败。我选 fail fast 而不是「先起来、登录时再发现」的原因是:登录是不可用的核心功能,配置错了就应该在发布阶段暴露,而不是等线上第一个用户点登录才炸——而且那种延迟爆炸还很难排查。同理,如果配了 permissive_sub,启动期还会额外拉一次 discovery 拿 jwks_uri 供降级校验复用。

附带一个部署要点:discovery 和 JWKS 都在启动期完成并缓存,所以认证路径上没有任何外部网络调用,登录回调不会因为 IdP 抖动而二次超时。

讲解与备注:

  • 技术原理:回调地址的三条铁律:① 服务端配置固定(防 Host 注入/开放重定向);② 必须在 IdP 后台登记白名单(本项目 README/配置注释里也写明了两种回调路径需要登记);③ 一律 HTTPS。discovery 是 OIDC 的元数据发现机制,提供 authorization_endpoint/token_endpoint/jwks_uri。
  • 代码位置:拼装 internal/auth/auth.go:59-75;必填校验 internal/config/config.go:614-616、redirect_url 合法性 :650-654;discovery internal/auth/oidc.go:52-62(超时 oidc.go:17)、fetchJWKSURI oidc.go:90-114;装配失败传播 internal/app/app.go:143-150;配置注释里的回调登记说明 configs/config.yaml oidc 段。
  • 加分句:「回调地址我一律从服务端配置推导,绝不用请求的 Host 头——这是防 Host 注入的硬要求;discovery 我选启动期 fail fast,让配置错误在发布阶段暴露,而不是在用户点登录时暴露。」

代码依据:internal/auth/auth.go:59-75

go
if cfg.PublicURL == "" { return nil, fmt.Errorf("oidc.enabled=true 时 public_url 必填") }
base := strings.TrimRight(cfg.PublicURL, "/")
ctx := context.Background()
for _, pc := range cfg.Providers {
	if _, dup := m.providers[pc.Name]; dup {
		return nil, fmt.Errorf("登录 Provider name 重复: %s", pc.Name)
	}
	redirect := pc.RedirectURL
	if redirect == "" {
		if pc.Type == config.ProviderTypeOIDC {
			redirect = fmt.Sprintf("%s/api/v1/auth/oidc/%s/callback", base, pc.Name)
		} else {
			redirect = fmt.Sprintf("%s/api/v1/auth/github/callback", base)
		}
	}

追问链:

  • 追问:多个环境(本地开发、预发、生产)怎么区分? → 答:靠配置——每个环境一份 config.yaml 加可选的 config.local.yaml 本地覆盖(config.go:347-358),public_url 各环境不同。本地开发如果不需要登录,oidc.enabled=false 就不会去 discovery(代码里 if !cfg.Enabled { return m, nil } 在 auth.go:56-58 提前返回)。
  • 追问:oidc.enabled=false 时会怎样? → 答:Manager 仍然可以构造(JWT 签发/验签、ticket 流程都可用,测试里就是靠 NewManager(nil) 造环境的),但 providers 为空 → /auth/providers 返回空数组,/auth/oidc/{p}/login 返回 404「登录 Provider 不存在」。
  • 追问:discovery 失败能不能降级到只用配置里的端点? → 答:理论上可以(配置里已经有 issuer),但我们没做,因为这会让「IdP 元数据变了而缓存没更新」这类问题变成隐性故障。诚实的说法是:当前是 fail fast,没有降级路径;如果哪天需要「IdP 短暂不可用时也要能启动」,可以加一个「上次成功 discovery 的本地缓存」开关,但必须显式打开并告警。
  • 追问:JWKS 轮换怎么办? → 答:go-oidc 的 Verifier 用 remote key set,遇到了不认识的 kid 会自动重新拉一次 JWKS,所以 IdP 轮换签名密钥不需要重启我们的服务。这是用库而不是自己实现验签的一个实际收益。

别踩的雷:

  • ❌ 说「回调地址根据请求的 Host 动态生成」→ 这是 Host 注入漏洞,正好踩雷。
  • ❌ 说「discovery 失败会降级到默认端点继续启动」→ 与代码相反,是直接启动失败。
  • ❌ 说「每次回调都会去 discovery 拉元数据」→ discovery 和 JWKS 都在启动期完成并缓存,认证路径零外部调用。
  • ❌ 把 public_url 说成「可以留空,代码会自动推断」→ 必填,空则装配失败。

Q12. 你们的 JWT 用什么算法、带哪些 claims、有效期多久?jwt_secret 留空会怎样? ​

面试官想考:JWT 安全的基本功(算法混淆攻击、密钥来源、claims 完备性),以及你有没有把「随机密钥」当成安全做法而不是隐患。

口述回答(背诵这段):算法是 HS256,对称密钥,而且只允许 HS256。验签那边我做了三重收口:一是显式判断 t.Method != jwt.SigningMethodHS256 就直接拒绝,二是 jwt.WithValidMethods(["HS256"]),三是 jwt.WithIssuer("binrag")。第一、二条是针对 alg 混淆攻击——最典型的是攻击者把 header 改成 alg: none 伪造无签名 token,或者把 HS256 改成 RS256 试图用公钥当 HMAC 密钥。我专门写了测试覆盖 alg=none 和错误算法两种场景。

claims 只有五个:uid 是用户 ID、provider 是登录来源(github 或自定义 OIDC 名)、iss 固定 binrag、iat、exp。我刻意没有放 name 这类展示信息——展示信息按 userId 回查数据库拿,令牌里只放授权判断必需的稳定标识,令牌越小、泄漏面越小,也不会出现「改了昵称要重发令牌」的问题。另外也没有 aud、没有 jti、没有 sub,这几点在 Q13 说,属于我承认的缺口。

有效期来自配置 oidc.jwt_expire_minutes,代码里 applyDefaults 的兜底是 120 分钟,Manager 里还有一个 120 分钟的二次兜底防止默认值没跑;但我们仓库的配置实际写的是 1200 分钟(20 小时)。这个差异我要主动讲:代码默认偏保守,但部署配置放宽到了 20 小时,理由是想减少重登次数,代价是令牌泄漏后的可利用窗口变长,配合「没有吊销机制」这个现状,我认为 20 小时偏长,理想值是几十分钟到 2 小时 + 刷新机制。

jwt_secret 留空的行为是:启动时用 crypto/rand 生成 32 字节随机密钥,只放在进程内存里。好处是不会硬编码在配置/仓库里;代价有两个,都必须说清楚:第一,进程重启后旧密钥丢了,所有已签发的会话全部失效,用户被强制重登;第二,多副本部署时每个副本先生成自己的密钥,结果同一张 JWT 只能在签发它的那个副本上验签通过,其他副本一律 401——表现为用户随机掉线。所以我的结论是:单机/桌面形态留空是安全的默认,生产多副本必须显式配置同一个 jwt_secret,并且应该由 Secrets 管理而不是写在 YAML 里。

讲解与备注:

  • 技术原理:JWT 的三类常见漏洞——① alg 混淆/none(必须白名单算法,不能信任 header 里的 alg);② 密钥弱或泄漏(HS256 是对称的,jwt_secret 泄漏 = 任意伪造会话,所以它是最高敏感级别的配置,和数据库密码同级);③ claims 校验不全(必须校验 iss,有 aud 就必须校验 aud,exp 依赖库)。claims 最小化原则:不放展示数据、不放权限快照(避免「改了权限但旧令牌仍是老权限」)。
  • 代码位置:签发 internal/auth/jwt.go:42-54;验签 jwt.go:61-76;密钥生成 jwt.go:28-39;TTL 兜底 internal/auth/auth.go:13,45-48;配置默认 internal/config/config.go:548-550;仓库实际值 configs/config.yaml:270(jwt_secret: "")、:272(jwt_expire_minutes: 1200);测试 internal/auth/jwt_test.go:13-126(往返、篡改、过期、错误 iss、alg=none、错误算法、错误密钥、自动密钥)。
  • 加分句:「我把 jwt_secret 和数据库密码放在同一个敏感级别——因为 HS256 是对称的,拿到密钥就能伪造任意用户的会话;留空随机生成在单机是安全默认,在多副本就是可用性事故。」

代码依据:internal/auth/jwt.go:30-39, 61-72

go
func NewSigner(secret string) (*Signer, error) {
	if secret == "" {
		b := make([]byte, 32)
		if _, err := rand.Read(b); err != nil { return nil, fmt.Errorf("生成 JWT 密钥失败: %w", err) }
		return &Signer{secret: b}, nil     // 仅进程内持有,重启后旧会话失效
	}
	return &Signer{secret: []byte(secret)}, nil
}
...
_, err := jwt.ParseWithClaims(token, claims,
	func(t *jwt.Token) (any, error) {
		if t.Method != jwt.SigningMethodHS256 {          // 显式拒绝 alg=none / RS256
			return nil, fmt.Errorf("不支持的签名算法: %v", t.Header["alg"])
		}
		return s.secret, nil
	},
	jwt.WithIssuer(jwtIssuer),
	jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
)

追问链:

  • 追问:为什么用 HS256 不用 RS256? → 答:单服务自身既签又验,对称密钥最简单、最快、无密钥分发问题。RS256 的价值在于「签发方和验证方分离」——比如有多个服务需要独立验签、但不该持有签名私钥时。如果我们未来把鉴权下沉到网关或者拆多个微服务,就应该换成 RS256/ES256,让各服务只持有公钥。
  • 追问:claims 里为什么没有 aud? → 答:因为我们只有一个受众(自己的 API)。但这是缺口:没有 aud 意味着令牌无法绑定使用场景,如果哪天这个 JWT 被复用到别的服务(比如那个 Python 评测服务)就会被接受。补法是签发时加 aud=binrag-api 并在验签时用 jwt.WithAudience。
  • 追问:20 小时有效期是你们故意的吗? → 答:配置层面是故意放宽的(减少重登),但从安全角度我不同意这个默认——尤其是没有刷新和吊销机制的前提下,20 小时意味着一个被盗令牌可以持续用一整天。正确组合是「短有效期(15-60 分钟)+ refresh token(可吊销)+ 敏感操作二次校验」。
  • 追问:jwt_secret 会被 GET /config 回传给前端吗? → 答:不会。GET /config 的响应是白名单拼出来的视图结构,只包含 LLM 的 model/temperature/max_tokens/timeout、embedder 的 model/dimension、retriever、reranker、strategy、loader、mcp 这些字段,根本没有 api_key 和 jwt_secret 的字段位(handler_config.go:60-116 视图结构 + :156-164 赋值),只读组里也只回 postgres.dsn(掩码后)等六项。

别踩的雷:

  • ❌ 说「有效期 120 分钟」而不提仓库实际配了 1200 → 会被要求现场看配置。正确说法:代码兜底 120 分钟,仓库配置 1200 分钟。
  • ❌ 说「留空会自动生成并持久化,重启不掉线」→ 代码只放在进程内存,不落盘。重启就掉线。
  • ❌ 说「claims 里有用户的 name/email」→ 没有,只有 uid/provider。有 Name 字段的是 auth.UserInfo(IdP 返回的),不是 JWT。
  • ❌ 说「我们校验了 nbf」→ 我们签发时没有设置 nbf;验签侧 jwt/v5 会校验「如果存在 nbf」。别把 OIDC 的 ID Token 校验(那里确实显式查了 nbf,oidc.go:165)和会话 JWT 混为一谈。

Q13. 你们有 refresh token 吗?令牌被盗了怎么吊销? ​

面试官想考:会不会为了"演示项目够用"而放弃安全运营能力;能否给出符合工业标准的替代方案。

口述回答(背诵这段):都没有,这是我很明确的短板,也是如果我继续演进这个系统第一优先要补的东西。

现状是:只有一张 HS256 的访问令牌,有效期配的是 20 小时,没有 refresh token、没有 jti、没有黑名单、没有 /auth/logout。结果就是——一旦 JWT 泄漏,在它过期之前没有任何办法作废。我们能立刻吊销的只有 API Key(因为是查库+enabled 判断,删了立刻 401),会话令牌是无状态的、撤销不了的。这个不对称我自己很清楚:机器凭据可吊销,人凭据反而不能。

我的改进方案分三层,也是面试里最值得展开的部分:

第一层,缩短访问令牌 + 引入 refresh token:access token 缩到 15 分钟到 1 小时,refresh token 长效但必须落库(存哈希 + 绑定 userId/设备/过期时间),刷新时校验数据库状态,这样「吊销」就变成了「删掉 refresh 记录」,最多半小时就自然失效。refresh token 要一次性轮换(用旧的换新的、旧的立即失效),配合复用检测可以识别令牌被盗。

第二层,如果暂时不想引入 refresh,就上「版本号 + 校验」:在 claims 里加 jti,服务端维护一个「已吊销 jti」的集合(Redis,TTL 等于令牌剩余有效期);或者更省事——在 users 表加一个 token_version,签令牌时带进去,验签后比对当前值,改密码/踢下线就自增版本号,等于一次性作废该用户所有令牌。代价是每次请求多一次查询或缓存查询,可以接受。

第三层,运营兜底:敏感操作(改配置、授 MCP 权限、删知识库)做二次确认或要求重新认证;把登录、刷新、敏感操作写进审计;令牌只发到 HttpOnly 的 Cookie(如果前端同源)或者短期内存,别长期放 localStorage。

讲解与备注:

  • 技术原理:有状态 vs 无状态令牌的取舍——JWT 的无状态优势(零存储、水平扩展、无 DB 查询)与它的代价(不可撤销)是同一枚硬币。工业界的常见折中是「短效无状态 access token + 长治可撤销 refresh token」,或者「无状态 + 撤销名单(Redis,TTL = 剩余有效期)」。
  • 代码位置:claims 无 jti/aud internal/auth/jwt.go:16-20;无吊销逻辑(全仓搜 revoke/blacklist/refresh_token 无命中);对比「可吊销」的 API Key 路径 internal/api/middleware.go:72(!key.Enabled → 401)+ internal/store/apikey.go:96-105;有效期配置 configs/config.yaml:272(1200 分钟)。
  • 加分句:「我特别在意一个不对称:我们的 API Key 可以立刻吊销,会话令牌却不行——这正好说明「无状态带来的扩展性」和「可撤销性」是要显式权衡的,而我目前只拿到了前者。所以我准备用 token_version 或 Redis 撤销名单补上后者。」

代码依据:internal/auth/jwt.go:42-54(claims 中无 jti/aud)+ internal/api/middleware.go:72

go
// jwt.go:44-52 —— claims 只有 uid / provider / iss / iat / exp
claims := SessionClaims{
	UserID: userID, Provider: provider,
	RegisteredClaims: jwt.RegisteredClaims{
		Issuer: jwtIssuer, IssuedAt: jwt.NewNumericDate(now),
		ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
	},
}
// middleware.go:72 —— 只有 API Key 有「可立刻吊销」的检查
if key == nil || !key.Enabled { Fail(c, CodeUnauthorized, "无效或已停用的 API Key"); c.Abort(); return }

追问链:

  • 追问:加 jti 黑名单,那 JWT 不就变成"有状态"了?性能怎么办? → 答:变成「有状态的部分」——但只对被吊销的令牌有状态:Redis 里只存被撤销的 jti(或用户级 token_version),TTL 就是令牌剩余寿命,条目数等于「吊销动作数」而不是「在线用户数」,量级小得多。用户级 token_version 更省,因为一个用户只有一行。
  • 追问:为什么不用短期令牌 + 每次请求都查库来确认用户状态? → 答:那等于放弃无状态的全部收益,每个请求多一次 DB 往返(我们已经在 last_used_at 上吃过一次这个亏)。所以正确解是「短期 + 只在刷新时查库」,把 DB 压力集中到低频的刷新动作上。
  • 追问:用户改了密码/被停用,怎么办? → 答:现在做不到即时生效,只能等令牌过期(最长 20 小时)。这是必须修的:最小改动是 users 表加状态列 + 校验,配合 token_version 自增。
  • 追问:登出呢? → 答:当前没有登出接口(代码中未找到 /auth/logout)。前端只能丢掉本地令牌,服务端不知情。有了 refresh 表或 token_version 之后,登出就是「删 refresh 记录 + version 自增」。

别踩的雷:

  • ❌ 说「JWT 存在服务端 session 里可以随时删」→ 那就不是 JWT 方案了,与你前面讲的无状态令牌自相矛盾。
  • ❌ 说「有效期短所以不需要吊销」→ 20 小时并不短,且没有 refresh,等于拿「长有效期 + 不可撤销」换便利,是最差组合。
  • ❌ 说「我们用 Redis 做了令牌黑名单」→ 代码中不存在,说了就是编造。
  • ❌ 承认没有 refresh 之后就不给方案 → 一定要接上「短 access + 可吊销 refresh / token_version」的具体修法,这才是面试官想听的。

Q14. GitHub 不是标准 OIDC,你们怎么接的?同邮箱不同 provider 的用户会合并吗? ​

面试官想考:你知不知道 OIDC 和 OAuth2 的本质差别(身份层 vs 授权层),以及用户标识该怎么设计(用 email 还是用稳定 ID)。

口述回答(背诵这段):GitHub 严格来说不是 OIDC Provider——它没有面向普通用户的 OIDC discovery 端点,所以拿不到标准 ID Token。所以我把 Provider 抽象成两种类型:type=oidc 走标准授权码 + ID Token 校验;type=oauth2 是GitHub 的内置适配,配置校验里明确写了「type=oauth2 只允许 name=github」,想接 GitLab 这类就得自己实现一个 Provider。

GitHub 这条线的流程是:授权码换 access token,然后用 access token 调一次 GET /user,取里的数字 id 作为身份标识。这里有三个刻意的决定:第一,subject 用数字 ID 而不是 login 或 email——login 是用户名、可以改名,email 可能为空、也可能变,只有数字 id 是稳定不变的;第二,用 strconv.FormatInt 把它转成字符串,这样和 OIDC 的字符串 sub 在数据模型上统一;第三,scope 默认只申请 read:user,是 GitHub 的最小权限,特意不申请 email,所以我们大部分 GitHub 用户的 email 是空的——这是刻意的隐私取舍。另外 access token 只在这一步用一次,不落库、不打日志。

同邮箱不同 provider 不会合并。 用户的唯一键是 (provider, subject) 联合唯一,upsert 是 INSERT ... ON CONFLICT (provider, subject) DO UPDATE SET name, email。所以同一个人用 GitHub 登录一次、用公司 OIDC 登录一次,会得到两条 users 记录、两套知识库,互相看不到对方的数据。这里也是我明确知道的坑:没有 email 归并、没有账号绑定,因此也没法做「组织内多 IdP 统一身份」。要修有两条路:一是受控邮箱归并——只在 IdP 明确返回 email_verified=true 时才做归并,否则会出现「用户随便在低信任 IdP 上填一个同事邮箱就接管别人数据」的严重越权;二是显式的账号绑定流程——用户先登录 A,再在设置页里授权关联 B,服务端写一张绑定表。我倾向后者,因为前者把安全边界交给了最弱的那个 IdP。

讲解与备注:

  • 技术原理:OAuth2 是授权框架,OIDC 是建立在它之上的身份层。OIDC 的 ID Token(JWS)自带 issuer/audience/nonce/exp,可离线校验;纯 OAuth2 只有 access token,身份必须额外调 API 获取,且没有签名自证。用户标识(subject)必须满足:稳定、唯一、不可变、不依赖用户可改字段——所以用 sub(OIDC)/数字 ID(GitHub),绝不用 email 或 username。
  • 代码位置:类型常量 internal/config/config.go:291-295;oauth2 仅允许 github 校验 config.go:643-646;GitHub Provider internal/auth/github.go:31-61(scope 默认 read:user :43)、AuthCodeURL 仅 state :68-70、ExchangeAndVerify :74-113(subject 取数字 ID :112-113,u.ID == 0 报错 :109-111);upsert internal/store/user.go:24-41;唯一约束 internal/store/schema.go:66。
  • 加分句:「GitHub 那条线我用数字 ID 而不是 email 做 subject,并且刻意不申请 email scope——身份标识必须稳定且不可被用户改名,而邮件地址既可能为空也可能被改,更不能作为跨 IdP 的合并依据。」

代码依据:internal/auth/github.go:41-44, 109-113

go
scope := cfg.Scope
if len(scope) == 0 {
	scope = []string{"read:user"} // 最小权限,不请求 email
}
...
var u struct {
	ID int64 `json:"id"`; Login string `json:"login"`; Name string `json:"name"`; Email string `json:"email"`
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { ... }
if u.ID == 0 { return nil, fmt.Errorf("GitHub /user 缺少稳定数字 ID") }
// subject 用数字 ID 字符串(不用 email 作身份)
return &UserInfo{Subject: strconv.FormatInt(u.ID, 10), Name: u.Login, Email: u.Email}, nil

追问链:

  • 追问:不用 email 做 subject,那你不就没法跨 IdP 识别同一个人了吗? → 答:对,这是刻意的取舍——身份识别(同一 IdP 内的稳定标识) 和 身份归并(跨 IdP 认为是同一个人) 是两件事。识别必须用 sub,归并必须是一个受控的、显式的流程,不能靠一个可伪造的 email 字段顺手做掉。
  • 追问:那如果产品要求"同一个人在 GitHub 和公司 SSO 看到的知识库是一样的"怎么办? → 答:做账号绑定表 user_links(user_id, provider, subject, verified_at),在已登录状态下完成绑定(类似 "Connect GitHub account"),归并后的数据访问按主账号收敛。绝不做「登录时按 email 自动合并」。
  • 追问:GitHub 的 access token 存在哪? → 答:不存。注释明确写了「access token 仅本次使用,不写日志、不落库」(github.go:73)。我们没有做「代表用户调用 GitHub API」的功能,所以根本不需要持久化第三方 token——这也省掉了「第三方 token 怎么加密存储」这一整类风险。
  • 追问:GitHub 没有 nonce,会不会更弱? → 答:会影响的是「ID Token 重放」这一类攻击,但 GitHub 根本没有 ID Token 可重放;身份是实时调 /user 换取的,等价于每次登录都重新确认身份。所以我们仍然强制校验 state 防 CSRF,这是它唯一需要防的。

别踩的雷:

  • ❌ 说「GitHub 也是 OIDC,我们用 discovery」→ GitHub 没有面向用户的 OIDC discovery(代码注释 github.go:18-20 明确写了这点),说了就是不懂。
  • ❌ 说「我们用 email 作为用户唯一标识」→ 相反,唯一键是 (provider, subject),email 只用于展示。
  • ❌ 说「同邮箱会自动合并成一个账号」→ 不会合并且不能合并(没有绑定表、没有 email 唯一约束)。
  • ❌ 说「我们为了拿 email 申请了 user:email scope」→ 默认 scope 是 read:user,刻意不申请 email。

Q15. 配置里的敏感字段(api_key、dsn、jwt_secret)怎么保证不泄漏给前端或者日志?maskDSN 有没有 bug? ​

面试官想考:你有没有把「配置接口」当成一个独立的攻击面来做白名单,以及能不能发现自己代码里掩码函数的不完备。

口述回答(背诵这段):做法分三块。

第一块是结构性白名单,而不是逐字段过滤。 GET /api/v1/config 的响应不是把配置结构体直接序列化,而是手工拼了一组视图结构:LLMView 只有 model、temperature、max_tokens、timeout;EmbedderView 只有 model、dimension;RerankerView 只有 model、top_n。也就是说 API Key 这类字段在响应结构里根本没有位置,不是「被过滤掉了」而是「不存在」。这是我认为比黑名单过滤更可靠的做法——黑名单漏一个字段就是泄漏,白名单漏一个字段只是少了个功能。

第二块是隐藏启动级配置,只回必须展示的。 Postgres DSN、向量库 host、服务端口、上传目录、worker 数、chunk 策略这些放在 read_only 组里返回,并且标记 needs_restart: true。DSN 走掩码:maskDSN 用 postgres://user:pass@host 的形式解析,把 password 段替换成 ****。

第三块是日志脱敏。 请求日志只打方法、路径、状态码、耗时,不打 query、不打 body、不打 Authorization 头;认证失败的日志只记 error,不记 token 本身;写密钥这类操作也没有把明文拼进任何错误信息。

然后我要主动承认 maskDSN 有一个真实的 bug:它的实现是「先判断开头是不是 postgres://,不是就原样返回整个 DSN」。而 PostgreSQL 的连接串至少有三种常见写法——postgres://...、postgresql://...、还有 keyword/value 形式(host=... user=... password=...)。只有第一种会被掩码,后两种会把包含密码的完整串直接回给前端。这是典型的「只覆盖了 happy path 的字符串处理」。修法有三层:① 立刻用 net/url.Parse 统一解析所有 URI 形式并把 User 字段里的密码替换掉;② 对 keyword/value 形式做正则替换 password=\S+;③ 更根本的做法是干脆不回传 DSN,只回「是否已配置」或者 host:port/dbname 这种可展示片段——因为前端其实不需要看到完整 DSN。我倾向第 ③ 种。

还有一个相关的点:PUT /config 成功之后会把整份配置(包括各种 api_key、jwt_secret、dsn)序列化成 YAML 写回磁盘,所以那个文件本身的权限和保管就很重要——我在实现里用的是「建临时文件 + Sync + rename」的原子写,os.CreateTemp 默认 0600,所以落盘后权限是收紧的;但仓库里那份 configs/config.yaml 是 644,属于部署环节要收的债。

讲解与备注:

  • 技术原理:配置接口的设计原则是「输出白名单 + 输入白名单」。输出侧用独立视图 DTO(本项目做法,handler_config.go:60-116);输入侧用指针式 patch(ConfigUpdateRequest,handler_config.go:11-19),请求体无法触达 dsn/port/oidc 等启动级字段。掩码的常见坑:只处理一种 URI 形式、掩码长度泄漏密码长度、日志里 %+v 打印整个结构体。
  • 代码位置:视图 DTO internal/api/handler_config.go:60-116(ConfigView/MutableConfigView/LLMView/EmbedderView/RerankerView);赋值 :156-164;只读组 :165-172;maskDSN :294-328(bug 在 :300-303:前缀不是 postgres:// 就 return dsn);输入白名单 :11-19;原子写 internal/config/manager.go:79-104;日志脱敏 internal/api/middleware.go:88-99。
  • 加分句:「输出侧我用的是独立视图 DTO 而不是过滤原结构体——密钥字段在响应类型里根本不存在;同时我承认 maskDSN 只认 postgres:// 一个前缀,postgresql:// 和 keyword/value 形式会原样泄漏密码,根因是拿字符串前缀判断而不是用 net/url 解析。」

代码依据:internal/api/handler_config.go:294-303

go
// maskDSN 隐藏 DSN 中的密码(postgres://user:pass@host 形式)
func maskDSN(dsn string) string {
	if dsn == "" { return "" }
	// 形如 postgres://user:password@host/db —— 掩码 password 部分
	prefix := "postgres://"
	if len(dsn) <= len(prefix) || dsn[:len(prefix)] != prefix {
		return dsn          // ← BUG:postgresql:// 与 keyword/value 形式原样返回(含密码)
	}
	rest := dsn[len(prefix):]
	// ... 找到 '@' 与 ':' 后拼成 prefix + user + ":" + "****" + rest

追问链:

  • 追问:GET /config 谁能看? → 答:任意已认证身份——普通 API Key、任何登录用户都能看(handler_config.go:143-175 没有身份分支)。这本身是个信息泄露面:向量库 host、端口、上传目录、worker 数、chunker 策略都暴露了,足够攻击者画基础设施拓扑。改法很简单:把只读组收敛到系统级 Key 或加一个明确的角色判断。
  • 追问:那 jwt_secret 会不会被回传? → 答:不会,MutableConfigView 里没有任何 jwt/oidc 字段,OIDC 配置压根不在可读列表里。但要注意它的输入侧也进不去(ConfigUpdateRequest 没有 oidc 字段),所以它只能在启动时从 YAML 读——这也意味着 jwt_secret 只能靠改文件+重启来轮换。
  • 追问:写回磁盘的配置里带着明文密钥,怎么保证安全? → 答:三条:① 文件权限收到 600(os.CreateTemp 默认 0600,rename 后保留);② 配置文件不进镜像、不进 Git(用挂载或 Secrets 注入);③ 长期方向是把密钥从配置文件里拆出去,改用环境变量/Secrets Manager 注入到进程内存,配置文件只留非敏感项。目前的实现是「一份 YAML 里什么都有」,这是债。
  • 追问:日志里会不会打印完整配置? → 答:会有一处风险——配置更新成功时打的是 slog.Info("配置更新成功", "path", h.cfgMgr.Path())(handler_config.go:277),只打了路径没打内容。但如果未来有人加 "cfg", newCfg 这种日志,就会把全部密钥打进日志,所以我在代码里坚持「日志字段显式列举、绝不整个结构体丢进日志」的约定。

别踩的雷:

  • ❌ 说「我们用正则脱敏所有敏感字段」→ 实际是独立视图 DTO 结构性剔除,机制不同,说错了显得没读代码。
  • ❌ 说「maskDSN 能处理各种 DSN 格式」→ 它只认 postgres://,这是要被挑出来的 bug,主动承认反而好看。
  • ❌ 说「GET /config 需要 bootstrap 权限」→ 只有 PUT 需要 bootstrap,GET 任意已认证身份即可。
  • ❌ 说「密钥从环境变量注入」→ 本项目没有字段级环境变量覆盖,只有 BINRAG_CONFIG 指定配置文件路径(config.go:331)。说错了就等于承认没读过配置加载逻辑。

Q16. 你们的限流怎么做的?多副本下有什么问题? ​

面试官想考:你会不会把「加了个 limiter」当成限流完成了,以及能不能说清单机限流在分布式下的失效方式。

口述回答(背诵这段):实现是令牌桶,用 golang.org/x/time/rate,包在全局中间件里,位置在 CORS 之后、认证之前。参数就一个 rate_limit_qps:速率等于 qps,桶容量(burst)也等于 qps;qps <= 0 时不限流,直接放行。

然后我要主动交代四个问题:

第一,默认是关的。 applyDefaults 里没有给 rate_limit_qps 设默认值,零值就是 0,等于不限流;仓库里那份配置也明确写着 rate_limit_qps: 0。所以「我们有限流」这句话目前的真实含义是「我们有限流能力,但默认没开」。这在演示项目里可以接受,在真实部署里是个必须改的默认值——安全的默认值应该是「开一个保守的阈值」而不是「关掉」。

第二,它是进程内单例,不区分维度。 rate.NewLimiter 只建了一个 limiter,全局共享,不按 IP、不按 Key、不按用户。后果是:一个客户端打满配额,其他所有用户一起 429,等于把限流器本身变成了 DoS 工具。

第三,限流在认证之前,匿名请求也吃配额。 好处是匿名洪水在碰数据库前就被拦掉;坏处是匿名流量能挤占已认证用户的配额。正确结构是两级:认证前按 IP 做粗粒度兜底(防洪水),认证后按 Key/用户 ID 做细粒度配额(防单租户滥用),再对 /auth/*、/chat 这类高成本/高风险端点单独设更严的阈值。

第四,多副本时每个副本各自一份配额。 我们用的是内存 limiter,N 个副本的实际总配额是 N × qps,而且负载均衡不均匀时会「同一个客户端在 A 副本被限流、切到 B 副本又能打」。要真正全局一致,得把令牌桶放到 Redis(用 Lua 做原子扣减,或者直接用 Redis 的滑动窗口/漏桶),代价是每次请求多一次 Redis 往返——通常可接受,因为限流本来就是为了保护后面的重资源。

另外还有一个方向性的问题:我们的真实成本瓶颈是 LLM 调用(一次问答可能几秒、几十秒),用 QPS 限流其实不匹配——更应该做并发数限流(semaphore,限制同时在跑的问答数量)和按用户的 token/次数配额,而不是简单地按请求速率。

讲解与备注:

  • 技术原理:限流四要素 —— 维度(IP / Key / 用户 / 租户 / 全局)、算法(令牌桶允许突发、漏桶平滑、滑动窗口精确、固定窗口有边界突刺)、作用位置(前置保护 vs 后置保护)、一致性(单机 vs 分布式)。本项目在四个维度上都取了最简。
  • 代码位置:中间件 internal/api/middleware.go:116-129;挂载顺序 internal/api/router.go:67;配置字段 internal/config/config.go:276;无默认值(config.go:524-539 的 Server 段不含该项);仓库配置 configs/config.yaml:252(rate_limit_qps: 0)。
  • 加分句:「我们真正的成本中心是 LLM 推理,QPS 限流对它是错配的——应该按并发数和 token 配额限流。这也是为什么我宁愿把 rate_limit_qps 默认设为 0 而不是随手给个 10:随手一个数字会给人『已经保护好了』的错觉,而实际该做的是并发闸门 + 租户配额。」

代码依据:internal/api/middleware.go:116-129

go
// RateLimit 全局限流中间件;qps <= 0 时不限制
func RateLimit(qps int) gin.HandlerFunc {
	if qps <= 0 {
		return func(c *gin.Context) { c.Next() }     // ← 默认配置下就是这条(rate_limit_qps: 0)
	}
	limiter := rate.NewLimiter(rate.Limit(qps), qps)  // ← 进程内单例,burst = qps,不区分 IP/Key
	return func(c *gin.Context) {
		if !limiter.Allow() {
			Fail(c, http.StatusTooManyRequests, "请求过于频繁,请稍后再试")
			c.Abort(); return
		}
		c.Next()
	}
}

追问链:

  • 追问:为什么 429 不在错误码常量列表里? → 答:因为 Fail 的约定是「HTTP 状态码 = 业务码」,这里直接传了 http.StatusTooManyRequests(429),没有为它单独定义 CodeRateLimited 常量。是个小的一致性瑕疵,补一个常量就行(response.go:6-16 只定义了 OK/400/401/403/404/409/500/502/503)。
  • 追问:为什么把限流放在认证之前而不是之后? → 答:成本顺序——限流是为了保护后面的数据库和 LLM,越靠前越省。但代价是匿名也有配额,所以正确做法不是「前或后」而是「前后都有,粒度不同」。
  • 追问:/auth/* 那几个公开接口限流了吗? → 答:没有专门限流,只吃全局桶(而全局桶默认还是关的)。这是要重点补的:BeginLogin 会往内存 map 里插 state 条目(无上限),/auth/exchange 会被暴力尝试 ticket,/auth/providers 会被刷。这三个都应该有独立的小配额 + 必要的防刷。
  • 追问:SSE 流式问答怎么限流? → 答:现在按「请求数」限,但一个 SSE 连接可能持续几十秒,占用的是一个长连接和一个 goroutine,QPS 完全不能反映它对资源的占用。所以流式接口更应该走并发连接数限制(比如每用户最多 2 个并发问答)+ 服务端超时。

别踩的雷:

  • ❌ 说「我们做了基于 Redis 的分布式限流」→ 代码是内存 rate.Limiter,没有 Redis。
  • ❌ 说「默认限流是 100 QPS」→ 没有默认值,零值即不限流,仓库配置也是 0。
  • ❌ 说「按 IP 限流」→ 单桶全局共享,不看 IP。
  • ❌ 被问「限流粒度」时只说「全局」就结束 → 一定要接上「应该分维度、并且对 LLM 这种重资源应该用并发数而不是 QPS」,这才是加分点。

Q17. HTTP Server 的超时和 panic 恢复你们做了吗? ​

面试官想考:你有没有生产运维意识——这两个是「不出事时没人管、出事时全站不可用」的经典配置。

口述回答(背诵这段):两个都没做,这是我很明确的运维债。

超时方面:启动 HTTP server 的时候只设了 Addr 和 Handler,没有设 ReadTimeout、ReadHeaderTimeout、WriteTimeout、IdleTimeout,也没有设 MaxHeaderBytes。这意味着 Go 的默认行为是「不超时」,只有一个很宽松的 ReadHeaderTimeout 兜底(实际上 Go 对 header 读取有默认保护,但连接可以长期挂着)。后果有两个:① 慢速攻击(slowloris)——攻击者用极慢的速度发请求头和 body,每个连接占一个 goroutine 和文件描述符,几千个连接就能把服务拖垮;② SSE 流式问答没有服务端上限——客户端不读、不断开,连接就一直挂着,handler_chat.go 的循环会一直等 channel。

修法很直接:给 http.Server 设 ReadHeaderTimeout(比如 5 秒,这是防 slowloris 最关键的一项)、ReadTimeout、WriteTimeout、IdleTimeout(后面几个要给 SSE 留特例,因为长连接的 WriteTimeout 会误杀流式响应——通常做法是对 SSE 路由单独用一个不设 WriteTimeout 的 handler 或者用 http.ResponseController 延长写超时)。

panic 恢复方面:路由是用 gin.New() 建的,只挂了 Logger、CORS、RateLimit 三个中间件,没有挂 gin.Recovery。gin.Default() 会自动带 Recovery,我们没用它是因为想自定义日志格式,但把 Recovery 一起丢掉了。后果是:任何一个 handler 里的 panic(比如空指针、切片越界、第三方库的意外)都会导致这个连接被 net/http 层兜底 recover 掉——连接中断、客户端拿到的是截断的响应而不是统一的 JSON 500 错误,而且没有结构化日志和告警,排查时只能去翻 stderr。对前端来说这表现为「偶发的请求失败/JSON 解析错误」,非常难归因。

修法就是一行:r.Use(gin.Recovery()),或者写一个自定义 Recovery(记录堆栈到 slog、返回统一的 {code:500,message:"内部错误"}、并且绝不把 panic 内容回给客户端以免泄漏堆栈里的敏感信息)。两块加起来改动不到 20 行,属于「成本极低、收益极高」的那类修复,我会排在第一梯队。

讲解与备注:

  • 技术原理:超时是防御资源耗尽的第一道闸;Go 的 http.Server 零值 = 永不超时,必须显式设置。Recovery 是「故障隔离」:单个请求的 panic 不应该影响连接语义的一致性,更不应该让错误信息以裸堆栈形式外泄。
  • 代码位置:cmd/server/main.go:39-42(只有 Addr/Handler)、桌面版 cmd/desktop/main.go:42(&http.Server{Handler: a.Router()});中间件挂载 internal/api/router.go:66-67(gin.New() + 三个中间件,无 Recovery);SSE 循环 internal/api/handler_chat.go:172-194;业务侧超时对照(有做的部分):LLM 60s internal/config/config.go:441-443、OIDC/GitHub 15s/10s internal/auth/oidc.go:17、internal/auth/github.go:16。
  • 加分句:「我把这两条归为『成本极低但后果极大』的修复:超时缺一项就是 slowloris 的入口,Recovery 缺一项就是一个 panic 变成一堆无法归因的前端报错。而且我很清楚 SSE 是这里的特例——WriteTimeout 会把流式响应误杀,所以要么给流式路由单独放宽,要么用 ResponseController 动态延长。」

代码依据:cmd/server/main.go:39-42 + internal/api/router.go:65-67

go
// cmd/server/main.go:39-42 —— 无任何超时设置
server := &http.Server{
	Addr:    fmt.Sprintf(":%d", cfg.Server.Port),
	Handler: a.Router(),
}
// internal/api/router.go:65-67 —— gin.New() 且未挂 Recovery
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(Logger(), CORS(), RateLimit(deps.Config.RateLimitQPS))

追问链:

  • 追问:不设 ReadTimeout 到底会怎样? → 答:Go 的 ReadTimeout 覆盖「从连接建立起读完整个请求体」的时间。不设的话,攻击者可以每隔几十秒发一个字节,连接永远不超时,每个连接消耗一个 goroutine + 一个 fd。ReadHeaderTimeout 是最该先设的,因为它直接限制「恶意连接在读完 header 前能拖多久」,而且不会影响正常的大文件上传(上传慢是 body 阶段)。
  • 追问:上传接口有 1GB 的上限(upload_max_size_mb: 1024),设短 ReadTimeout 会不会误杀正常上传? → 答:会,这正是超时要按路由分档的原因。全局设一个宽松的 ReadTimeout 保护大多数接口,对上传这种需要长 body 时间的路由单独放宽,或者在 handler 里用 http.ResponseController.SetReadDeadline 动态延长。不能一刀切也得设个上界,否则又回到无超时状态。
  • 追问:加了 Recovery 之后,500 响应要不要带真实错误? → 答:绝对不能。panic 信息里可能包含 SQL、文件路径、甚至凭据。正确做法是「响应只回统一的『内部错误』+ 一个 trace id,完整堆栈只进日志」,这样既有可观测性又不泄漏内部结构。
  • 追问:你们现在 500 的响应会不会泄漏内部错误? → 答:会有一处。chat 接口在引擎报错时直接拼了原始错误:Fail(c, CodeInternal, "问答失败: "+err.Error())(handler_chat.go:109),上游 LLM/向量库的错误串可能带出 URL、模型名甚至请求内容。这是个应该改成「统一文案 + 日志带详情」的点。

别踩的雷:

  • ❌ 说「Go 默认有超时保护」→ http.Server 零值就是永不超时,必须显式设置。
  • ❌ 说「我们用了 gin.Default() 所以有 Recovery」→ 代码是 gin.New()(router.go:66),没有 Recovery。这是很容易被 grep 出来的谎。
  • ❌ 说「我们给 SSE 设了 60 秒超时」→ 没有;而且真设了反而会误杀正常的长回答。
  • ❌ 承认没做就结束 → 必须给出修法,并且主动说出 SSE 与 WriteTimeout 的冲突这个细节,这是内行了。

Q18. 对话历史接口只按 session_id 查,会不会有越权? ​

面试官想考:你能不能在自己最熟的业务接口里发现「没有归属字段」这种隐蔽的越权——很多人只会盯着知识库,忘了历史、日志这类「二级数据」。

口述回答(背诵这段):会,这是一个真实的横向越权风险,而且是那种最容易被忽略的类型。

具体情况是:GET /api/v1/chat/history?session_id=xxx 的实现只有一句「按 session_id 查最近 N 条」——没有任何归属校验。而 session_id 是客户端自己传的:问答接口的 chatRequest 里 session_id 只是 binding:"required",既不校验格式、也不与用户绑定。更根本的是,chat_history 这张表的字段只有 session_id / role / content / sources / created_at,根本没有 user_id 或 owner 列,也没有租户维度的索引。

所以攻击模型是:任何已认证身份(任何一把 API Key、任何登录用户)只要猜到或撞到别人的 session_id,就能读到别人完整的问答原文——包括 sources 字段里的引用片段,那可能包含知识库正文。如果有前端用时间戳或者可预测的字符串做 session_id,这就是可以批量枚举的漏洞;即使 session_id 是 UUID,「没有归属校验」也意味着任何一次 session_id 泄漏(URL 分享、截图、日志、埋点)都直接变成数据泄漏。

为什么其他接口没这个问题、偏偏这里漏了? 因为其他资源(知识库、文档、任务、chunk)都有可以回溯到知识库的路径,我们统一用 canAccessKB 兜住了;而会话历史是一条「挂在用户身上、但不挂在知识库上」的数据,它既没有 owner 字段、也没有走 canAccessKB 的路径,就落在了隔离机制之外。这也是我总结的一条经验:做多租户的时候,真正容易漏的不是主实体,而是那些「附属数据」——历史、日志、导出、缓存、搜索结果。

修法分三步:① 数据模型:chat_history 加 user_id(登录用户)或 api_key_id/tenant_id 列并加索引,写入时由服务端根据当前身份填,绝不信任客户端;② 查询收敛:Get 的 SQL 强制带 AND (user_id = $2 OR $2 = '') 这种租户条件(或者更好——所有查询走一个带租户参数的仓储方法,让「忘记加条件」在编译层面不可能);③ 入口校验:session_id 用 UUID 格式校验(和 chunk_id 一样,handler_chunk.go:28-31 就是这么做的),并且当 session 不存在或不属于当前用户时统一返回空列表或 404,不要区分「不存在」和「无权」。另外,写入侧也要注意:现在 history 是由 RAG 引擎在问答过程中异步 append 的,身份信息没有传递到那一层,所以修复要打通「身份 → 引擎 → 历史存储」这条链,这也是它当初被漏掉的结构性原因。

讲解与备注:

  • 技术原理:横向越权(IDOR) 的判定标准是「对象标识是否由客户端提供 + 服务端是否校验归属」。本项目其他资源靠 canAccessKB 做了校验,而 history 的标识(session_id)是客户端给的、且服务端完全没有归属概念,属于典型 IDOR 隐患。修复的正确层次是数据层加租户列,而不是只在 handler 里加一个「查一下这个 session 是不是我的」——后者在没有 owner 列的情况下根本无从判断。
  • 代码位置:接口 internal/api/handler_history.go:18-31(只取 query 的 session_id 直接查);请求体 internal/api/handler_chat.go:16(SessionID string `json:"session_id" binding:"required"` );存储层 internal/store/history.go:11-15(HistoryStore 接口签名只有 sessionID/role/content/sources,无身份参数)、:23-29(Append)、:32-62(Get 只有 WHERE session_id = $1);表结构 internal/store/schema.go:50-57(无 owner 列);对照实现(有校验的写法)internal/api/handler_chunk.go:28-31, 57-71。
  • 加分句:「我把这条归为『隔离机制覆盖不到的数据』——我们的 canAccessKB 是靠「资源 → 知识库」这条链路生效的,而会话历史挂在用户身上、不挂在知识库上,所以它天然落在隔离之外。做多租户时最该做的是把所有可枚举的附属数据都列一遍(历史、日志、导出、缓存),而不是只保护主实体。」

代码依据:internal/api/handler_history.go:18-30 + internal/store/schema.go:50-57

go
// handler_history.go —— 只按 session_id 查,无归属校验
func (h *handler) GetHistory(c *gin.Context) {
	sessionID := c.Query("session_id")
	if sessionID == "" { Fail(c, CodeBadRequest, "缺少 session_id"); return }
	msgs, err := h.history.Get(c.Request.Context(), sessionID, 0)
	if err != nil { Fail(c, CodeInternal, "查询对话历史失败"); return }
	OK(c, msgs)
}
// schema.go —— chat_history 没有 user/owner 维度
CREATE TABLE IF NOT EXISTS chat_history (
    id BIGSERIAL PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
    content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chat_history_session ON chat_history(session_id, created_at);

追问链:

  • 追问:那 session_id 是 UUID 吗,很难猜吧? → 答:session_id 是客户端传的、我们不做任何格式校验,所以它是不是 UUID 完全取决于前端,服务端不能假设它不可枚举。就算它是 UUID,「不可预测」也不能替代「访问控制」——不可预测性是纵深防御,不是授权机制。而且 session_id 会出现在 URL query 里(/chat/history?session_id=),进浏览器历史、进反代日志,泄漏面本来就大。
  • 追问:为什么 chunk 接口做了三级校验,history 却漏了? → 答:因为 chunk 的 payload 里有 document_id,可以回溯到文档再回溯到知识库;而 history 的每一行没有任何可回溯的关联键。这也说明「基于关联链路的隔离」有个前提——链路必须存在。所以正解是给 history 加 owner 列,而不是指望关联。
  • 追问:还有哪些接口是同样的问题? → 答:我排查过一遍:/tasks/:id 能通过 task.KBID 回溯、/videos/:id/stream 和 /documents/:id/raw 通过文档回溯、/chunks/:id 通过 payload 的 document_id 回溯、/eval/tasks 提交时校验 kb_id——只有 /chat/history 是完全没有归属概念的。(注:/api/v1/chat/enhancements 是纯静态能力列表,无数据泄漏。)
  • 追问:修的时候要注意什么? → 答:① 写入侧要由服务端填 user_id,不能信客户端;② 历史属于「用户数据」,用户注销/删除时应该一并清理(现在 chat_history 没有任何清理入口,除了 Clear(sessionID) 这个未暴露的内部方法);③ 加索引 (user_id, session_id, created_at) 保证带租户条件的查询仍然走索引,不能因为加了安全条件就退化成全表扫描。

别踩的雷:

  • ❌ 说「我们有校验,session_id 是 UUID 猜不到」→ 服务端没有校验 session_id 格式,UUID 也是前端决定;且不可预测 ≠ 有访问控制。
  • ❌ 说「history 表有 user_id」→ schema 里没有这一列,说了就是编造。
  • ❌ 只把问题说成「缺一个 if」→ 根因是数据模型缺租户维度,只加 if 无解(没有字段可比)。这个层次差别能体现深度。
  • ❌ 忘了主动说自己排查过别的接口 → 「我系统性检查了所有可枚举资源,只有 history 漏了」这句很值钱,说明你是在做体系化审计而不是碰运气。

Q19. 审计日志你们怎么做的?REST 接口有审计吗? ​

面试官想考:你有没有意识到「谁会做什么」的可追溯性是企业级系统的硬需求,以及能不能诚实说清现状是「半成品」。

口述回答(背诵这段):现状是只有 MCP 侧有审计,REST 侧基本没有——这是一个明显不对称,也是我认为企业级场景下必须补齐的短板。

MCP 侧做得相对完整:有一张 mcp_audit_logs 表,字段包括 api_key_id、tool_name、params(截断后的参数 JSON)、params_len(截断前的原始长度)、status(success/error)、error_message、duration_ms、created_at,索引建在 created_at 和 (api_key_id, created_at) 上。写入是异步的:有一个 AuditSink,用带缓冲的 channel(容量 1024)承接事件,后台 worker 串行落库;投递是非阻塞的,队列满了就丢弃并打 warn 日志,绝不影响主请求耗时。参数会按配置截断(默认 2000 字符)并记录截断前的长度——这个细节是为了「既不落敏感大字段、又保留『这条记录被截断过』的证据」。表结构里刻意没有 Secret/Token 列,这是设计约束而不是巧合。

REST 侧的问题:AppendAuditLog 在整个代码库里唯一的调用点是 MCP 的工具执行路径,API 侧一次都没写。所以通过 REST 做的这些高危操作——创建/删除/启停 API Key、授予 MCP 权限、PUT /config 改配置、删除知识库和文档——在数据库里没有任何痕迹。出事之后你只能靠 last_used_at 和外部访问日志倒推,而 last_used_at 又只记录「最后用了一次」,历史上谁在什么时候干了什么完全不可查。

而且异步审计本身也有取舍要讲清:它是「可用性优先」的设计——队列满会丢事件、进程崩溃会丢缓冲区内的事件(Shutdown 里有 flush,但如果是被 kill -9 就没有机会)。对审计来说,「丢事件」在合规场景下是不可接受的。所以正确的分级是:普通操作可以异步丢弃(可观测性),高危操作必须同步落库(合规性与不可抵赖性)——比如「创建/删除 Key、改配置、授权限、删数据」这五类就应该走同步写 + 事务内写,写失败就让业务失败,宁可操作做不成也不能没有记录。

补齐方案:① 建一张统一的 audit_logs,带上 actor(user_id 或 api_key_id)、action、resource_type/resource_id、before/after 摘要(脱敏后)、IP、UA、request_id;② 高危操作同步写、普通操作异步写;③ 把当前的 MCP 审计并进去或者保持 MCP 专用表但共用写入通道;④ 审计表只允许 INSERT,禁止 UPDATE/DELETE(可以用数据库权限或者只授予 INSERT 的角色来兜底),避免「先做事后擦日志」。

讲解与备注:

  • 技术原理:审计 ≠ 日志。审计的四个要求:完整性(关键操作不可漏)、不可篡改(append-only)、可归因(谁、何时、从哪、对什么做了什么)、可留存(独立于业务数据的生命周期)。本项目只满足了 MCP 侧的「可归因 + 参数截断」,缺完整性(可丢)和覆盖度(REST 没有)。
  • 代码位置:审计表 internal/store/schema.go:104-118;写入 internal/store/audit.go:10-17;异步 sink internal/mcp/audit.go:17-94(非阻塞投递 + 丢弃 :52-69、flush/Shutdown :73-83、worker :86-94);唯一调用点 internal/mcp/tools.go:124;生命周期挂载 internal/app/app.go:182-207, 280-291(Close 时 flush);截断长度配置 internal/config/config.go:286-287(AuditParamLimit 默认 2000,config.go:544-546)。
  • 加分句:「我把审计按『能不能丢』分级:普通操作异步可丢(可观测性),高危操作必须同步落库(合规与不可抵赖)。当前实现全走异步丢弃,所以我在补 REST 审计时会先把那五六类高危操作切成同步写 + 事务内写。」

代码依据:internal/mcp/audit.go:52-69

go
// Submit 非阻塞投递审计事件:截断参数(默认 ≤2000 字符)并记录截断前原始长度(spec N4)。
// 队列满 → 丢弃并 warn,绝不影响 MCP 主请求耗时。
func (s *AuditSink) Submit(log store.AuditLog) {
	if s.closed.Load() { return }
	log.ParamsLen = len(log.Params)                     // 截断前原始长度(字节)
	if s.paramLimit > 0 {
		if runes := []rune(log.Params); len(runes) > s.paramLimit {
			log.Params = string(runes[:s.paramLimit])
		}
	}
	select {
	case s.ch <- log:
	default:                                            // ← 队列满:丢弃(审计可丢,业务不阻塞)
		slog.Warn("MCP 审计队列已满,丢弃该审计事件", "api_key_id", log.APIKeyID, "tool", log.ToolName)
	}
}

追问链:

  • 追问:审计里会不会记录敏感参数? → 答:会记录 params 的截断内容,这是有意的——审计的价值就在于能还原操作。但我们做了三条约束:① 截断长度可配(默认 2000 字符);② 记录 params_len 保留「原始有多长」的信息;③ 表结构里没有 Secret/Token 列(schema.go:103 注释明确写「绝不存 Secret」)。剩下要补的是对 params 做字段级脱敏(把 api_key、password 这类键的值替换成掩码),因为现在只是按长度截断,如果用户往参数里塞了密钥,还是会被记下来。
  • 追问:那 api_key_id 删了之后审计还能追溯吗? → 答:不能。表里 api_key_id 没有外键、删除 Key 也不清理审计,所以记录会变成指向一个不存在 ID 的孤儿。从「可追溯」的角度这是缺陷(查不出是哪把 Key),从「审计不可被删除」的角度反而是个意外的好处。正确做法是审计里保存 Key 的名称快照 + 不级联删除,兼顾两者。
  • 追问:为什么 MCP 需要审计而 REST 当时没做? → 答:因为 MCP 是开放给外部 AI Agent 调用的接口,调用方不可控、工具会执行检索甚至写操作,所以最先想到了审计;REST 是我们自己的前端在调,就被当成了「可信通道」。这恰恰是典型的思维盲区——内部接口同样需要对高危操作留痕,因为出问题的往往是内部人的误操作或者被盗凭据。

别踩的雷:

  • ❌ 说「我们有完整的审计体系」→ REST 侧一条都没有,被 grep AppendAuditLog 就露馅。
  • ❌ 说「审计是同步写的,不会丢」→ 是异步 + 队列满丢弃(audit.go:63-68),这点必须自己先讲出来。
  • ❌ 说「审计表里记录了密钥明文方便排查」→ 与设计约束相反,表结构里没有任何 Secret 列。
  • ❌ 把审计和请求日志混为一谈 → 请求日志只有 method/path/status/耗时(middleware.go:92-97),既没有身份也没有操作对象,不具备审计能力。这个区分能体现你对「审计 ≠ 日志」的理解。

Q20. 数据表结构和迁移方式你怎么评价?为什么没有用 migration 工具? ​

面试官想考:你的数据库工程判断力——建表方式、约束完备性、演进能力,而不是背字段。

口述回答(背诵这段):先说事实:建表完全在代码里做,没有 SQL 迁移文件、也没有迁移库。internal/store/schema.go 里有两部分——一部分是基础 DDL(CREATE TABLE IF NOT EXISTS 建六张表),另一部分是追加迁移(ALTER TABLE ... ADD COLUMN IF NOT EXISTS 给已有表补列),由 Migrate 在应用启动时按固定顺序执行一遍,全部靠 IF NOT EXISTS 保证幂等。启动流程是「连库 → Migrate → 种子 Key → 装配组件」,迁移失败就启动失败。

核心表是六张:knowledge_bases、documents、ingest_tasks、api_keys、chat_history、users,加上 MCP 审计表 mcp_audit_logs 一共七张。几个我要点出来的设计细节:第一,主键全部是应用生成的 UUID 文本(不是自增、不是 uuid 类型),好处是 ID 不可枚举、可以在插入前就知道 ID(我们的文档和任务是一起生成的,任务 ID 会回填到文档上);第二,api_keys.key_hash 有 UNIQUE 约束,这是认证路径正确性的基础;第三,api_keys.owner_id 上是个「部分唯一索引」 WHERE owner_id IS NOT NULL,用于保证「每用户至多一个自助凭据」同时不限制系统级 Key;第四,唯一强外键在 documents.kb_id → knowledge_bases(id) ON DELETE CASCADE,删库时文档级联删除;而 ingest_tasks 刻意没有外键(注释说是为了保持分库分表扩展性),改成删库时手动清理任务——这两处不一致我是承认的,属于演进过程中留下的不同选择。

这套做法的优点:零依赖、部署简单(不用额外跑迁移容器)、幂等安全、任何环境起来就是最新结构,对「单服务 + 桌面端」这种形态很合适。

缺点也很明确,我列四条:① 没有版本号记录——不知道当前库是哪个版本的 schema,也无法做「跳版本升级」的判断;② 没有 down 迁移,只能向前,一旦某次变更写错了,回滚要靠人工;③ 不支持破坏性变更——改列类型、改约束、数据回填这些事用 ADD COLUMN IF NOT EXISTS 是做不了的,只能靠人工 SQL;④ 迁移和应用启动耦合——多副本同时启动会并发执行 DDL(好在 IF NOT EXISTS 让大部分能容忍,但并非所有 DDL 都安全)。所以如果这个系统要继续演进,我会引入 golang-migrate 或 goose:迁移文件带版本号、单实例执行(用 advisory lock 或者单独的迁移 job)、schema_migrations 表记录已应用版本,应用启动只做校验而不做变更。

讲解与备注:

  • 技术原理:数据库演进的两条路线——代码内建表(零依赖,适合小项目和单机) vs 版本化迁移文件(可审计、可回滚、适合多人协作与生产)。判断分水岭是「是否有破坏性变更需求 + 是否有多个环境需要协调」。本项目的追加迁移全是加列,属于最安全的一类变更。
  • 代码位置:DDL internal/store/schema.go:6-68;追加迁移常量 schema.go:71-101;审计表 schema.go:104-118;Migrate 顺序 schema.go:121-145;启动调用 internal/app/app.go:70-74;文档外键与级联 schema.go:17;任务表无外键与手动清理 schema.go:31 + internal/store/kb.go:113-119;部分唯一索引 schema.go:95;幂等测试 internal/store/migrate_pg_test.go:13-69(真实 PG 连跑两次 Migrate + 断言同 owner 插第二条被唯一索引拒绝)、internal/store/schema_test.go:11-42(pgxmock 按序断言 8 条迁移语句)。
  • 加分句:「这套迁移方式的适用边界很清楚:『只加列、不改成、单实例』时它是最省事且安全的;一旦出现破坏性变更或者多环境版本协调,就必须换成版本化迁移——而版本化的第一个收益不是回滚,是『能知道现在是什么版本』。」

代码依据:internal/store/schema.go:121-145(节选)

go
// Migrate 执行建表语句(幂等)
func (s *pgStore) Migrate(ctx context.Context) error {
	if _, err := s.pool.Exec(ctx, schemaDDL); err != nil { return err }
	// 追加迁移(schemaDDL 的 CREATE TABLE IF NOT EXISTS 对已有表不生效,需显式 ALTER)
	if _, err := s.pool.Exec(ctx, kbStrategyMigration); err != nil { return err }
	if _, err := s.pool.Exec(ctx, chatHistorySourcesMigration); err != nil { return err }
	if _, err := s.pool.Exec(ctx, kbOwnerMigration); err != nil { return err }
	if _, err := s.pool.Exec(ctx, apiKeyMCPPermissionsMigration); err != nil { return err }
	if _, err := s.pool.Exec(ctx, apiKeyOwnerMigration); err != nil { return err }
	if _, err := s.pool.Exec(ctx, ingestTasksWarningMigration); err != nil { return err }
	_, err := s.pool.Exec(ctx, mcpAuditLogsDDL)
	return err
}

追问链:

  • 追问:为什么主键用 TEXT 而不是 UUID 类型或 BIGSERIAL? → 答:TEXT 存 UUID 最省事、跨语言无歧义,而且 ID 由应用生成,可以在插入前就把 ID 用在别处(我们上传文档时是先造 docID 和 taskID,把 taskID 写进文档记录,再分别插入);用 BIGSERIAL 则必须插完才知道 ID,还要多一次 UPDATE,而且自增 ID 可枚举。代价是 TEXT 比定长类型占用大、索引也大一些,对这种量级完全可接受。
  • 追问:缺哪些索引? → 答:我知道几处:knowledge_bases.owner_id 没有索引,而 ListKBsByOwner 是 WHERE owner_id = $1,用户多了会退化成扫描;chat_history 只有 (session_id, created_at) 索引,没有 user 维度(和 Q18 的越权是同一个根因);ingest_tasks.kb_id 也没有索引。这三个在数据量上来之后都是明确的性能债。
  • 追问:chunk_ids 为什么用 TEXT[] 数组而不是关联表? → 答:因为 chunk 主要活在向量库里(Qdrant),我们只在删文档时需要「把这个文档的所有 chunk 从向量库和 BM25 索引里删掉」,所以只需要一份 ID 列表。用数组避免了一次跨库 JOIN,代价是数组无法做索引友好的关联查询,也没有引用完整性——如果数组里某个 chunk 在向量库已经不存在,删除时会报错但我们只打 warn 继续(handler_doc.go:307-317)。这是「以向量库为主存储」的取舍。
  • 追问:mcp_audit_logs.api_key_id 为什么没有外键? → 答:为了审计记录不被级联删除——审计的价值之一是「即使凭据被删也留下痕迹」,加外键反而会带来「删 Key 就删证据」的风险。代价是查不出是哪把 Key(ID 成孤儿),所以更好的做法是同时存一份 Key 名称快照。

别踩的雷:

  • ❌ 说「我们用 golang-migrate/goose 管理迁移」→ 仓库里没有任何 .sql 文件和迁移库依赖,说了就是编造。
  • ❌ 说「所有表之间都有外键保证完整性」→ 只有 documents.kb_id 一个强外键;ingest_tasks、mcp_audit_logs、knowledge_bases.owner_id 都没有。
  • ❌ 说「用了 JSONB 存权限和策略」→ 全库没有任何 JSONB 列;MCP 权限是 TEXT[] + TEXT,知识库策略和引用来源是存 JSON 字符串的 TEXT。
  • ❌ 说「迁移有版本号、可以回滚」→ 两个都没有,只能向前、靠 IF NOT EXISTS 幂等。
  • ❌ 只说优点不说债 → 被问「这套方案什么时候会崩」时要有答案:破坏性变更 + 多副本并发启动就是它崩的两个场景。

Q21.(收尾加分题)如果让你重构这个项目的认证鉴权,你会怎么做? ​

面试官想考:你能不能把前面承认的一堆缺陷,收敛成一个有优先级、有取舍的整体方案——这题答好可以覆盖掉前面所有减分。

口述回答(背诵这段):我会分三步,第一步先修「错了」的,第二步再补「缺了」的,第三步才是「更优雅」的,不会一上来就重写。

第一步,修主体属性丢失和越权面——这是唯一有真实数据穿透风险的。 具体做三件事:① 把 Identity 从「只有 Kind 一个字符串」升级成带完整主体属性的结构,至少补上 OwnerID,最好把 Kind 细化成 session / system_key / user_key 三种;② 授权层做按主体收敛:只有 system_key 才走全量放行,user_key 和 session 走同一套「owner_id 或 session_id 必须属于自己」的判断;③ 补回归测试——用户 Key 读他人 KB 必须 404、用户 Key 调 API Key 管理必须 403、系统级 Key 行为不变。同时把会话历史那条越权一起修(chat_history 加 user_id 列 + 查询强制带租户条件 + session_id 格式校验)。

第二步,补安全基线和运营能力。 ① 权限模型:把 bootstrap 从「配置明文比较」改成数据库里的角色/标记,并提供一个受审计的「引导权交接」接口,解决「删了配置就永久 403」的死锁;② 令牌:短效 access token + 可撤销的 refresh token(或 token_version 兜底),补 aud 和 jti,提供登出接口;③ 部署基线:HTTP server 设超时、挂 Recovery、限流改成「按 IP 粗粒度 + 按主体细粒度」并且默认开启;④ 审计:把高危操作(建删 Key、改配置、授权限、删数据)改成同步审计落库,普通操作保持异步。

第三步,结构性收敛。 ① 把「REST 认证」和「MCP 认证」这两套各自实现、语义不同的认证合并成一个主体解析 + 一套权限判断,这样才不会出现「同一把 Key 在两个面权限不同」这种问题;② OIDC 补 PKCE(尤其桌面端场景);③ state/ticket 挪到 Redis 支持多副本;④ 迁移换成版本化工具。

我的排序逻辑很简单:有实际数据泄漏和越权风险的第一优先(第一步);有明确运维事故风险的第二优先(超时/Recovery/限流/审计);影响扩展性和优雅度的最后做。 而在这个过程中我不会引入重量级依赖——比如权限模型我先用「角色列 + 一个显式交接接口」,不急着上 Casbin 这类框架,因为我们只有四个层次,用框架的收益还抵不上学习成本。

讲解与备注:

  • 技术原理:重构认证鉴权的心法是先修正确性(correctness),再修完备性(completeness),最后修优雅性(elegance)——因为正确性问题会直接变成安全事件,完备性问题只是体验/运营问题,优雅性问题只在规模变大时才有成本。另外要有一个「不做什么」的清单来显示克制。
  • 代码位置(作为重构依据的现状):Identity 结构 internal/auth/identity.go:16-33;越权放行 internal/api/handler_kb.go:67;bootstrap 明文比对 internal/api/middleware.go:79;无 refresh/吊销 internal/auth/jwt.go:16-20;超时缺失 cmd/server/main.go:39-42;无 Recovery internal/api/router.go:66;限流默认关闭 configs/config.yaml:252;审计缺口 internal/mcp/tools.go:124(唯一调用点);两套认证实现 internal/api/middleware.go:28-85 vs internal/mcp/auth.go:47-79。
  • 加分句:「我的第一优先级不是把架构做得多漂亮,而是先把『用户 Key 能越权读别人数据』这条修掉——因为那是唯一一条会被真实利用的路径;超时、Recovery 这类是『成本极低、后果极大』的顺手一起做;而迁移工具、PKCE、Redis 化这些属于工程整洁度,排在后面。我更愿意用『什么不做』来证明我有取舍能力,比如这个阶段我不会引入权限框架。」

代码依据:internal/auth/identity.go:16-33(需要扩展的主体模型)

go
// Identity 当前请求身份(由认证中间件写入 gin.Context)。
type Identity struct {
	Kind        string // "apikey" | "oidc"
	APIKeyID    string // 仅 Kind=apikey 时有效
	IsBootstrap bool   // 是否 bootstrap Key
	UserID      string // 仅 Kind=oidc 时有效(users.id)
	Provider    string // 仅 Kind=oidc 时有效
	// ← 缺失:APIKey 的 OwnerID。修复第一步就是在这里补上,
	//   并把 Kind 细化为 session / system_key / user_key,
	//   这样 handler_kb.go:67 的「Kind==apikey 全放行」才能改成按 owner 收敛。
}

追问链:

  • 追问:为什么不用 Casbin 这类权限框架? → 答:规模不匹配。我们现在只有四个权限层次、资源类型也不多(KB/文档/任务/Key),用策略引擎的收益抵不上引入依赖、学习 DSL、调试策略的成本。等出现「多角色 + 资源级细粒度授权 + 组织架构」这类需求时再上,那时候策略引擎才划算。先把手写的授权收敛到一个函数里,比先上框架更重要。
  • 追问:改动这么大,怎么保证不 regression? → 答:三件事:① 先把越权用例写成失败测试(用户 Key 读他人 KB 必须是 404),改完让它们变绿;② kb_isolation_test.go 里已有的隔离断言全部保留,作为回归基线;③ 分两步上线——先让 Identity 带上 OwnerID 但授权逻辑不变(纯加字段,可观测),观察一段时间后再切换授权分支,出问题可以单独回滚第二步。
  • 追问:你觉得这个项目最值得骄傲的认证设计是什么? → 答:双通道 + 形态分流那一个设计:API Key 走哈希查库、会话走本地验签,两者共用一个中间件但互不干扰,验签失败不回退,认证路径零外部网络调用,并且用「一次性 ticket + JWT 不入 URL」把会话凭据从 URL 里彻底赶了出去。这套东西在演示项目里算做得比较克制、也比较正确的部分——它的缺陷(主体属性丢失)是实现细节上的,不是方向上的。

别踩的雷:

  • ❌ 开口就说「我会用 Casbin/Spring Security 重写一遍」→ 显得没有规模感和取舍能力。正确姿势:先说优先级和最小改动,再说引入框架的门槛。
  • ❌ 只列技术方案不谈风险 → 一定要说清「为什么第一步是修越权」——因为只有它会被真实利用,这是排序的依据。
  • ❌ 对现状含糊其辞(「大致上权限是清楚的」)→ 面试官已经知道有缺陷,越具体越显得你掌握全局。
  • ❌ 说「这些我都会在两周内全部做完」→ 不真实。更好的说法是「第一步我可以在一两天内做完并加测试;第二步需要配合部署配置调整;第三步属于下一个迭代」。

三、安全自查清单(面试主动抛出,显示成熟度) ​

用法:被问到「你觉得你这个项目有什么安全问题」时,不要等追问,直接说「我自己做过一轮安全自查,按严重度排了序,最高的一条是……」。主动亮清单远比被挖出来强。

#风险影响修法优先级
1主体属性丢失:中间件写 Identity 时只写 Kind=apikey,不带 OwnerID,授权层 Kind==KindAPIKey 无条件放行(middleware.go:79、handler_kb.go:67)用户自助 MCP 凭据在 REST 层等价系统级 Key:跨租户读改删 KB/文档/chunk/历史,并可创建/删除任意 API KeyIdentity 补 OwnerID + Kind 细化为 session/system_key/user_key + 授权按 owner 收敛 + 补 3 条回归测试P0
2/chat/history 无归属校验:仅按客户端传入的 session_id 查询,表无 user 列(handler_history.go:19-25、schema.go:50-57)任何已认证身份猜到/拿到 session_id 即可读他人问答原文与引用片段(横向越权/IDOR)chat_history 加 user_id + 索引;查询强制带租户条件;session_id 做 UUID 格式校验;写入侧由服务端填身份P0
3无 gin.Recovery:gin.New() 未挂 Recovery(router.go:66)handler panic → 连接中断、无统一 500、无结构化日志,前端表现为难归因的偶发失败r.Use(gin.Recovery()) 或自定义 Recovery(记堆栈到日志、响应只回统一文案+trace id,不回堆栈)P0(改动 <5 行)
4HTTP Server 无任何超时(cmd/server/main.go:39-42)slowloris 可耗尽 goroutine/fd;SSE 长连接无上限设 ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout;SSE 路由单独放宽或用 ResponseController 动态延长P0
5限流默认关闭且单机单桶:rate_limit_qps 无默认值、仓库配置为 0(config.go:524-539、configs/config.yaml:252);rate.NewLimiter 进程内单例、不区分 IP/Key(middleware.go:120)无限流;开启后单客户端可挤占全局配额;多副本实际配额 ×N默认给保守阈值;按 IP 粗粒度 + 按主体细粒度两级;多副本用 Redis 原子扣减;对 /auth/*、/chat 单独设阈值P1
6JWT 无刷新/无吊销/无 jti/无 aud,有效期仓库配置 1200 分钟(jwt.go:16-20、configs/config.yaml:272)令牌被盗后在整个 TTL 内无法作废(最长 20 小时);令牌无法绑定受众短效 access(15-60min)+ 可撤销 refresh(落库、一次性轮换);或 users.token_version 自增实现「一键踢下线」;补 aud/jti 与登出接口P1
7bootstrap 身份靠配置明文比较(middleware.go:79)① 明文长期躺在 YAML/Git/镜像里;② 按提示删除配置后 PUT /config 与授 MCP 权限永久 403(无交接接口);③ 非恒定时间比较改为数据库角色/is_bootstrap 标记;提供受审计的引导权交接接口;启动期不回显明文P1
8state/ticket 为进程内 map(ticket.go:35-42,92-99)多副本/滚动发布登录失败;公开的 BeginLogin 可无上限插入条目(无容量上限、GC 仅顺带触发)迁移到 Redis(带 TTL);加条目数上限 + 定时清理;对 /auth/* 限流P1
9REST 侧无审计:AppendAuditLog 唯一调用点是 MCP 工具(mcp/tools.go:124)建删 Key、授权限、改配置、删数据均无痕迹,不可追溯统一 audit_logs(actor/action/resource/before-after/IP/UA/trace);高危操作同步写,普通异步;表只允许 INSERTP1
10maskDSN 只认 postgres:// 前缀(handler_config.go:300-303)postgresql:// 与 keyword/value 形式 DSN 会带明文密码回传给前端用 net/url.Parse 统一解析并清空密码;keyword/value 走正则;更彻底:不回传 DSN,只回「已配置」或 host/dbnameP1
11GET /config 任意已认证身份可读(handler_config.go:143-175 无身份分支)泄露向量库 host、端口、上传目录、worker 数、chunk 策略等基础设施信息(攻击面测绘)只读组收敛到系统级 Key 或引入管理员角色P2
12OIDC 无 PKCE(全仓无 code_challenge)授权码被截获后可被直接兑换(对机密客户端是纵深防御缺失;桌面端/公共客户端风险更高)stateEntry 加 verifier 字段;授权 URL 带 code_challenge(S256);Exchange 带 code_verifierP2
13CORS 全放开 Allow-Origin: *(middleware.go:104)任意站点可在用户浏览器内调用 API(无 Cookie 凭据故非经典 CSRF,但放大凭据泄漏利用面)配 Origin 白名单;生产环境禁用 *P2
14评测代理路径无白名单 + 透传 Authorization(proxy_eval.go:69-75、router.go:141)客户端可控路径直达上游微服务(安全性依赖 Python 侧路由);用户凭据被转发给上游服务上游路径白名单(如仅 /tasks、/datasets、/health);转发前剥离 Authorization/Cookie,仅注入内部令牌P2
15仅上传接口有 body 上限(handler_doc.go:42-43)/chat、/config、/eval/* 等 JSON 端点可被大 body 打内存(叠加无超时更危险)全局 MaxBytesReader 中间件,按路由给不同上限P2
16AuthEnabled=false 功能退化(middleware.go:30-33 不写 Identity → handler_kb.go:65-71 对系统级 KB 一律 404)「关认证」无法用于本地开发,且该路径无测试覆盖关闭认证时写入一个显式的「本地开发主体」,或明确文档化该模式仅供健康检查P3
17写接口不校验资源存在(store/apikey.go:96-105 不检查 RowsAffected)对不存在 id 的 toggle/delete 返回 200,与「404=不存在」的约定不一致,也掩盖了调用错误检查影响行数,为 0 返回 404P3
18无密钥轮换 / 无 expires_at(api_keys 无过期列)Key 永久有效直到被删;无法做定期轮换加 expires_at + 轮换接口(新旧并存宽限期)P3
19多个写接口无事务已核实的部分:上传时「文件落盘 → 建文档 → 建任务」是三个独立操作,中间失败会留下孤儿文件(handler_doc.go:95-136);跨表事务边界未全部核实,需现场确认关键路径加事务或用 outbox;文件失败时补偿删除需现场确认
20error 直接拼进响应:Fail(c, CodeInternal, "问答失败: "+err.Error())(handler_chat.go:109)上游错误串可能带出 URL/模型名/内部细节响应只回统一文案,详情只进日志P3

四、诚实承认的不足与改进 ​

用法:主动说,每条都带「为什么当时这么选 + 现在怎么改」。被问到「你觉得哪里做得不好」时按这张表挑 3-4 条讲,不要一次全倒出来。

#不足(一句话)当时的取舍改进方案
1认证主体属性丢失:Identity 里没有 API Key 的 OwnerID,导致用户自助 MCP 凭据在 REST 层被当成系统级 Key,存在跨租户越权与权限提升早期只有「系统级 Key」一种 Key,Kind=apikey 就等价于管理员;后来加用户自助凭据时只改了数据层(owner_id 列 + 部分唯一索引),忘了同步升级认证与授权层① Identity 补 OwnerID(identity.go:16-33);② Kind 细化为 session/system_key/user_key;③ 授权层按 owner 收敛(handler_kb.go:67 改为按主体判断);④ 补 3 条回归测试(用户 Key 越权读他人 KB=404、用户 Key 管 Key=403、系统 Key 行为不变)
2/chat/history 没有归属校验,session_id 由客户端自选且不计入租户维度会话历史被当成「辅助数据」,只服务于前端上下文回显;做多租户时只保护了「知识库及其下游」这条链路,忽略了不挂在知识库上的附属数据① chat_history 加 user_id + (user_id, session_id, created_at) 索引;② HistoryStore 接口签名带身份参数,查询强制租户条件;③ session_id 做 UUID 校验、无权与不存在统一返回;④ 把「身份 → RAG 引擎 → 历史存储」这条链打通
3JWT 只有一张长时效令牌:无 refresh、无 jti、无吊销、无登出,仓库配置有效期 20 小时为了少重登、少存状态,选择了最简单的单令牌方案;无状态带来的扩展性收益是真实的,但放弃了可撤销性① access 缩到 15-60 分钟;② 引入落库的 refresh token 并一次性轮换(或先用 users.token_version 实现「一键踢下线」,成本更低);③ 补 aud/jti 与 /auth/logout;④ 把配置默认值改回 120 分钟
4bootstrap 是配置明文比较出来的权限,且缺失只有它一个最高层解决了「第一个 Key 从哪来」的自举问题,最简单直接① is_bootstrap 落到数据库列或引入角色表;② 提供受审计的「引导权交接」接口;③ 首启改为生成随机凭据并写入一次性文件/Secrets,而不是让人在 YAML 里写字面值;④ 用恒定时间比较替换 ==
5state/ticket 存在进程内存,多副本不可用,且无容量上限单机与桌面端零依赖,不需要 Redis① 迁 Redis(SET NX EX + 原子 GETDEL);② 加条目数上限与后台定时 GC;③ 过渡期用粘性会话并文档化限制;④ 对公开的 /auth/* 加独立限流
6REST 侧没有审计,只有 MCP 工具有审计;且 MCP 审计是「可丢弃」的异步写MCP 面向外部 Agent 调用、风险最早被意识到;REST 被当成「自家前端的可信通道」,是典型的思维盲区统一审计表 + 分级写入:高危操作同步落库(事务内),普通操作保持异步;表只授 INSERT;params 做字段级脱敏(而非仅长度截断);记录 trace id 串联
7运维基线缺失:HTTP Server 无超时、未挂 Recovery、限流默认关闭且单机单桶优先级都给了业务功能(RAG 编排、多媒体解析),运维项被排到后面,属于个人项目的典型偏差① ReadHeaderTimeout 等四项超时(SSE 特例处理);② gin.Recovery() + 统一 500 + trace id;③ 限流默认开启并分维度;④ 这些加起来改动很小,属于第一梯队
8隔离是约定式的,不是强制的:没有 RLS,也没有统一的带租户查询入口,靠每个 handler 记得调 canAccessKB为了查询灵活、避免把所有 SQL 都包一层;store.GetKB 的注释也是「权限判断由 API 层负责」① 引入 PostgreSQL RLS(SET LOCAL app.tenant_id)或把所有访问收敛到带租户参数的仓储方法;② 给 knowledge_bases.owner_id 补索引;③ 用测试扫描「所有 handler 是否都调了权限判断」
9配置与密钥管理粗糙:一份 YAML 里同时有 dsn、jwt_secret、各家 api_key;PUT /config 会把全部配置(含密钥)写回磁盘;maskDSN 只覆盖 postgres://图部署简单,一个配置文件搞定所有环境① 密钥从配置文件拆出,改由环境变量/Secrets 注入内存;② maskDSN 改用 net/url 解析或干脆不回传 DSN;③ GET /config 收敛到管理员身份;④ 配置文件权限 600 且不进镜像
10迁移能力只有「加列」:无版本号、无 down、不支持破坏性变更,且与应用启动耦合(多副本并发执行 DDL)减少了依赖和部署步骤,对小项目很省事引入 golang-migrate/goose:迁移文件带版本、schema_migrations 记录状态、迁移由单独 job/单实例执行(advisory lock)、应用启动只校验版本
11用户身份不做归并:(provider, subject) 唯一,同邮箱不同 IdP 是两套账号两套数据邮箱不可信(可能为空、可能被改、未校验 email_verified),不敢用它做归并做显式的账号绑定流程(已登录后授权关联第二个 IdP,写 user_links 表),而不是登录时按邮箱自动合并——自动合并会把安全边界交给最弱的那个 IdP
12无 PKCE:OIDC 授权流程只做了 state + nonce我们是机密客户端(有 client_secret),PKCE 当时被当成「公共客户端才需要」补 PKCE:stateEntry 加 verifier、授权 URL 带 code_challenge(S256)、Exchange 带 code_verifier;尤其桌面端形态下应列为必做

五、背诵清单 ​

面试前 5 分钟扫一遍,每条都能一句话说全。加粗的数字和字段名必须背准。

  1. 双通道:API Key 走「SHA-256 查唯一索引」,会话 JWT 走「三段式形态 → 本地 HS256 验签」,分流靠 jwtShapeRe 不用 binrag_ 前缀;验签失败直接 401,不回退查库(middleware.go:21,47-65)。
  2. API Key 参数:crypto/rand 32 字节 = 256 bit → 前缀 binrag_ → base64url 无填充 = 50 字符;库里只存 SHA-256 hex(key_hash UNIQUE);明文只在创建响应里出现一次(handler_key.go:75-96)。
  3. 不加盐的理由:慢哈希(bcrypt)是为低熵人类口令设计的;256 位随机串无字典空间,加盐反而破坏 WHERE key_hash = $1 的索引查找。例外:低熵的 bootstrap 口令无盐 SHA-256 有离线爆破风险(apikey.go:70-80)。
  4. 四层权限:bootstrap(配置明文比较,可改配置 + 授 MCP 权限)→ 系统级 Key(owner_id IS NULL,可管 Key + 看全部 KB)→ 登录用户(只看自己 owner_id 的 KB)→ 用户自助 MCP 凭据(本该最窄,目前被错误地当成系统级)。
  5. 多租户两层:列表走 SQL WHERE owner_id = $1(kb.go:55-57),单资源走 handler 的 canAccessKB(handler_kb.go:62-71),检索走 KB 白名单下推(handler_chat.go:28-57);没有 RLS,是约定式隔离。
  6. 404 vs 403:401 = 没凭据;403 = 凭据类型不被允许;404 = 资源不可见(不存在或越权,同码同文案),防的是存在性枚举;有测试断言 B 访问 A 的库 GET/PUT/DELETE 全是 404(kb_isolation_test.go:46-54)。
  7. state 与 nonce 分工:state 防 CSRF(保护回调动作),nonce 防 ID Token 重放(保护令牌数据),code 一次性由 IdP 保证;state 的消费是「加锁 + 先删后判过期」,TTL 10 分钟;provider 从 state 里取而不是从 URL 参数取(auth.go:139-146、ticket.go:61-73)。
  8. ticket 机制:登录成功后签 2 分钟 一次性 ticket,JWT 绝不进 URL(只跳 /login?ticket=);并发测试断言 8 个 goroutine 消费同一 ticket 只成功 1 次(ticket.go:118-130、ticket_test.go:64-93);缺点是进程内 map,多副本失效、无容量上限。
  9. JWT 参数:HS256 且只允许 HS256(显式拒绝 alg=none/RS256 + WithValidMethods + WithIssuer("binrag"));claims 只有 uid/provider/iss/iat/exp;代码兜底 120 分钟、仓库配置 1200 分钟;jwt_secret 留空 → 随机 32 字节只存内存 → 重启掉线 + 多副本互不认(jwt.go:30-39,61-72)。
  10. 待修的 P0 三件事:① 用户自助 MCP 凭据在 REST 层被当系统级 Key(Kind=apikey 不带 OwnerID → handler_kb.go:67 全放行,跨租户越权);② /chat/history 只按客户端传的 session_id 查、表无 user 列(横向越权);③ gin.New() 无 Recovery + http.Server 无任何超时。这三条我都能主动讲出修法与测试方案。

附:本文件所有引用的文件对照 ​

文件关键内容
internal/api/router.go路由表、中间件挂载顺序、eval health 豁免包装
internal/api/middleware.goAuth 双通道、Logger/CORS/RateLimit、jwtShapeRe
internal/api/response.go{code,message,data} 约定与错误码常量
internal/api/handler_auth.go登录/回调/exchange/me、ticket 跳转
internal/api/handler_key.goKey 生成、明文返回一次、权限更新、bootstrap 闸门
internal/api/handler_kb.gocanAccessKB、owner 写入、404 语义、kbView 剔除 owner_id
internal/api/handler_chat.goresolveKBScope 租户检索收敛
internal/api/handler_history.go历史查询(无归属校验)
internal/api/handler_mcp_my.go用户自助 MCP 凭据、kb_ids 归属校验
internal/api/handler_config.go配置视图 DTO、bootstrap 校验、maskDSN
internal/api/proxy_eval.go评测代理、kb_id 越权校验、body 复原
internal/auth/auth.go / jwt.go / ticket.go / oidc.go / github.go / identity.goProvider 注册、JWT 签发验签、一次性票据、OIDC/GitHub 流程、Identity 模型
internal/store/schema.go全部 DDL 与幂等追加迁移
internal/store/store.go / apikey.go / user.go / kb.go / history.go / audit.go结构体定义、Key 存储、用户 upsert、KB 查询、历史、审计写入
internal/config/config.go / manager.go全部配置字段与默认值、local 覆盖合并、热更新
internal/app/app.go装配顺序、seedAPIKey、MCP 挂载、审计生命周期
internal/mcp/auth.go / permission.go / audit.go / tools.goMCP 独立认证、KB 权限三态、异步审计
cmd/server/main.goHTTP Server 启动(无超时)

持续学习,持续构建。