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 Key | middleware.go:79 bootstrapKey != "" && token == bootstrapKey(与配置明文比对,非数据库标记) | PUT /api/v1/config(handler_config.go:194)、PUT /api-keys/:id/permissions(handler_key.go:162)+ 系统级 Key 的全部权限 | 无(理论最高) |
| 系统级 API Key | api_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.id | MCP 侧按 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
// 生成 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
// 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
// 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
// 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
// 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;两种 SQLinternal/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
// 系统级 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
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)。 - 代码位置:
BeginLogininternal/auth/auth.go:117-133;CompleteLoginauth.go:138-152;state 存储与原子消费internal/auth/ticket.go:28-73(TTL 常量ticket.go:13);授权 URL 带 nonceinternal/auth/oidc.go:120-122;ID Token 全量校验oidc.go:127-172(nonce 校验在:161、nbf 双保险:165、subject 非空:168);GitHub 无 nonceinternal/auth/github.go:68-70;ticket 换 JWThandler_auth.go:153-165。 - 加分句:「我总结成一句话:state 保护回调动作、nonce 保护令牌数据、code 由 IdP 保证一次性——三者不可互相替代,这也是为什么 GitHub 那条线我仍然坚持校验 state。」
代码依据:internal/auth/auth.go:138-152 + internal/auth/ticket.go:61-73
// 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
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;换 JWTauth.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
// 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;discoveryinternal/auth/oidc.go:52-62(超时oidc.go:17)、fetchJWKSURIoidc.go:90-114;装配失败传播internal/app/app.go:143-150;配置注释里的回调登记说明configs/config.yamloidc 段。 - 加分句:「回调地址我一律从服务端配置推导,绝不用请求的
Host头——这是防 Host 注入的硬要求;discovery 我选启动期 fail fast,让配置错误在发布阶段暴露,而不是在用户点登录时暴露。」
代码依据:internal/auth/auth.go:59-75
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
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
// 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是用户名、可以改名,strconv.FormatInt把它转成字符串,这样和 OIDC 的字符串sub在数据模型上统一;第三,scope 默认只申请read:user,是 GitHub 的最小权限,特意不申请同邮箱不同 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 Providerinternal/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);upsertinternal/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
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:emailscope」→ 默认 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
// 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
// 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 60sinternal/config/config.go:441-443、OIDC/GitHub 15s/10sinternal/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
// 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
// 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;异步 sinkinternal/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
// 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(节选)
// 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;无 Recoveryinternal/api/router.go:66;限流默认关闭configs/config.yaml:252;审计缺口internal/mcp/tools.go:124(唯一调用点);两套认证实现internal/api/middleware.go:28-85vsinternal/mcp/auth.go:47-79。 - 加分句:「我的第一优先级不是把架构做得多漂亮,而是先把『用户 Key 能越权读别人数据』这条修掉——因为那是唯一一条会被真实利用的路径;超时、Recovery 这类是『成本极低、后果极大』的顺手一起做;而迁移工具、PKCE、Redis 化这些属于工程整洁度,排在后面。我更愿意用『什么不做』来证明我有取舍能力,比如这个阶段我不会引入权限框架。」
代码依据:internal/auth/identity.go:16-33(需要扩展的主体模型)
// 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 Key | Identity 补 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 行) |
| 4 | HTTP 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 |
| 6 | JWT 无刷新/无吊销/无 jti/无 aud,有效期仓库配置 1200 分钟(jwt.go:16-20、configs/config.yaml:272) | 令牌被盗后在整个 TTL 内无法作废(最长 20 小时);令牌无法绑定受众 | 短效 access(15-60min)+ 可撤销 refresh(落库、一次性轮换);或 users.token_version 自增实现「一键踢下线」;补 aud/jti 与登出接口 | P1 |
| 7 | bootstrap 身份靠配置明文比较(middleware.go:79) | ① 明文长期躺在 YAML/Git/镜像里;② 按提示删除配置后 PUT /config 与授 MCP 权限永久 403(无交接接口);③ 非恒定时间比较 | 改为数据库角色/is_bootstrap 标记;提供受审计的引导权交接接口;启动期不回显明文 | P1 |
| 8 | state/ticket 为进程内 map(ticket.go:35-42,92-99) | 多副本/滚动发布登录失败;公开的 BeginLogin 可无上限插入条目(无容量上限、GC 仅顺带触发) | 迁移到 Redis(带 TTL);加条目数上限 + 定时清理;对 /auth/* 限流 | P1 |
| 9 | REST 侧无审计:AppendAuditLog 唯一调用点是 MCP 工具(mcp/tools.go:124) | 建删 Key、授权限、改配置、删数据均无痕迹,不可追溯 | 统一 audit_logs(actor/action/resource/before-after/IP/UA/trace);高危操作同步写,普通异步;表只允许 INSERT | P1 |
| 10 | maskDSN 只认 postgres:// 前缀(handler_config.go:300-303) | postgresql:// 与 keyword/value 形式 DSN 会带明文密码回传给前端 | 用 net/url.Parse 统一解析并清空密码;keyword/value 走正则;更彻底:不回传 DSN,只回「已配置」或 host/dbname | P1 |
| 11 | GET /config 任意已认证身份可读(handler_config.go:143-175 无身份分支) | 泄露向量库 host、端口、上传目录、worker 数、chunk 策略等基础设施信息(攻击面测绘) | 只读组收敛到系统级 Key 或引入管理员角色 | P2 |
| 12 | OIDC 无 PKCE(全仓无 code_challenge) | 授权码被截获后可被直接兑换(对机密客户端是纵深防御缺失;桌面端/公共客户端风险更高) | stateEntry 加 verifier 字段;授权 URL 带 code_challenge(S256);Exchange 带 code_verifier | P2 |
| 13 | CORS 全放开 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 |
| 16 | AuthEnabled=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 返回 404 | P3 |
| 18 | 无密钥轮换 / 无 expires_at(api_keys 无过期列) | Key 永久有效直到被删;无法做定期轮换 | 加 expires_at + 轮换接口(新旧并存宽限期) | P3 |
| 19 | 多个写接口无事务 | 已核实的部分:上传时「文件落盘 → 建文档 → 建任务」是三个独立操作,中间失败会留下孤儿文件(handler_doc.go:95-136);跨表事务边界未全部核实,需现场确认 | 关键路径加事务或用 outbox;文件失败时补偿删除 | 需现场确认 |
| 20 | error 直接拼进响应: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 引擎 → 历史存储」这条链打通 |
| 3 | JWT 只有一张长时效令牌:无 refresh、无 jti、无吊销、无登出,仓库配置有效期 20 小时 | 为了少重登、少存状态,选择了最简单的单令牌方案;无状态带来的扩展性收益是真实的,但放弃了可撤销性 | ① access 缩到 15-60 分钟;② 引入落库的 refresh token 并一次性轮换(或先用 users.token_version 实现「一键踢下线」,成本更低);③ 补 aud/jti 与 /auth/logout;④ 把配置默认值改回 120 分钟 |
| 4 | bootstrap 是配置明文比较出来的权限,且缺失只有它一个最高层 | 解决了「第一个 Key 从哪来」的自举问题,最简单直接 | ① is_bootstrap 落到数据库列或引入角色表;② 提供受审计的「引导权交接」接口;③ 首启改为生成随机凭据并写入一次性文件/Secrets,而不是让人在 YAML 里写字面值;④ 用恒定时间比较替换 == |
| 5 | state/ticket 存在进程内存,多副本不可用,且无容量上限 | 单机与桌面端零依赖,不需要 Redis | ① 迁 Redis(SET NX EX + 原子 GETDEL);② 加条目数上限与后台定时 GC;③ 过渡期用粘性会话并文档化限制;④ 对公开的 /auth/* 加独立限流 |
| 6 | REST 侧没有审计,只有 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 分钟扫一遍,每条都能一句话说全。加粗的数字和字段名必须背准。
- 双通道:API Key 走「SHA-256 查唯一索引」,会话 JWT 走「三段式形态 → 本地 HS256 验签」,分流靠
jwtShapeRe不用binrag_前缀;验签失败直接 401,不回退查库(middleware.go:21,47-65)。 - API Key 参数:
crypto/rand32 字节 = 256 bit → 前缀binrag_→ base64url 无填充 = 50 字符;库里只存 SHA-256 hex(key_hash UNIQUE);明文只在创建响应里出现一次(handler_key.go:75-96)。 - 不加盐的理由:慢哈希(bcrypt)是为低熵人类口令设计的;256 位随机串无字典空间,加盐反而破坏
WHERE key_hash = $1的索引查找。例外:低熵的 bootstrap 口令无盐 SHA-256 有离线爆破风险(apikey.go:70-80)。 - 四层权限:bootstrap(配置明文比较,可改配置 + 授 MCP 权限)→ 系统级 Key(
owner_id IS NULL,可管 Key + 看全部 KB)→ 登录用户(只看自己owner_id的 KB)→ 用户自助 MCP 凭据(本该最窄,目前被错误地当成系统级)。 - 多租户两层:列表走 SQL
WHERE owner_id = $1(kb.go:55-57),单资源走 handler 的canAccessKB(handler_kb.go:62-71),检索走 KB 白名单下推(handler_chat.go:28-57);没有 RLS,是约定式隔离。 - 404 vs 403:401 = 没凭据;403 = 凭据类型不被允许;404 = 资源不可见(不存在或越权,同码同文案),防的是存在性枚举;有测试断言 B 访问 A 的库 GET/PUT/DELETE 全是 404(
kb_isolation_test.go:46-54)。 - state 与 nonce 分工:state 防 CSRF(保护回调动作),nonce 防 ID Token 重放(保护令牌数据),code 一次性由 IdP 保证;state 的消费是「加锁 + 先删后判过期」,TTL 10 分钟;provider 从 state 里取而不是从 URL 参数取(
auth.go:139-146、ticket.go:61-73)。 - ticket 机制:登录成功后签 2 分钟 一次性 ticket,JWT 绝不进 URL(只跳
/login?ticket=);并发测试断言 8 个 goroutine 消费同一 ticket 只成功 1 次(ticket.go:118-130、ticket_test.go:64-93);缺点是进程内 map,多副本失效、无容量上限。 - 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)。 - 待修的 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.go | Auth 双通道、Logger/CORS/RateLimit、jwtShapeRe |
internal/api/response.go | {code,message,data} 约定与错误码常量 |
internal/api/handler_auth.go | 登录/回调/exchange/me、ticket 跳转 |
internal/api/handler_key.go | Key 生成、明文返回一次、权限更新、bootstrap 闸门 |
internal/api/handler_kb.go | canAccessKB、owner 写入、404 语义、kbView 剔除 owner_id |
internal/api/handler_chat.go | resolveKBScope 租户检索收敛 |
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.go | Provider 注册、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.go | MCP 独立认证、KB 权限三态、异步审计 |
cmd/server/main.go | HTTP Server 启动(无超时) |