Mark

Nyauth:项目核验与面试追问树

核验基线:E:\Proj\nya,HEAD 68de309,日期 2026-09-07。只读源码与测试;未读取用户现有 docs/interview/,未运行测试或服务。下文“已有测试”指检查到了测试代码,不表示本次测试通过;“建议”均未实施。协议依据与当前项目策略分开说明。

90 秒项目叙述

Nyauth 是一个用 Go 开发的自托管身份认证中心,核心功能是授权码加 PKCE、OIDC ID Token 和客户端管理。数据主要分两类:PostgreSQL 保存用户、客户端、加密签名密钥和邮件任务,Redis 保存会话、授权码及 token 撤销状态。

值得展开的是两个一致性问题。第一,多实例同时刷新同一个 refresh token 时,用 Redis Lua 原子消费旧 token,并一起登记新 refresh 和 access 元数据;重放会撤销整个 family,因此合法客户端的并发重试也可能导致重新登录。第二,验证邮件通过 PostgreSQL 事务同时写入 action token 和 outbox,worker 用 SKIP LOCKED 认领、租约回收和退避重试,解决业务已提交但发送任务丢失的问题。

这些设计的边界是:JWT 的在线撤销依赖验证方查询状态;Lua 不等于 Redis 故障切换下绝对不丢状态;邮件可以重复发送,租约不能让 SMTP 恰好发送一次。仓库有并发刷新和双实例 HTTP 集成测试,但生产规模和本人贡献需要用实际经历说明。

最后补一句本人事实:“我主要负责【模块】,最关键的一次决策是【选择及原因】,用【真实测试/运行记录】验证。”不要把整个仓库的功能自动说成全部由自己实现。

文本树索引

共 8 根问题、35 个追问节点。每个追问都注明触发条件;真实面试按回答择路,不要求一场面试问完。

Q1 一次授权码登录的完整链路
├─Q1.1 state、PKCE、nonce 分别保护什么
│ └─Q1.1.1 [错误纠偏] state 存在 Redis 就替客户端完成 CSRF 校验?
└─Q1.2 challenge/verifier 如何校验
  ├─Q1.2.1 有 client secret 还需要 PKCE 吗
  └─Q1.2.2 code 并发兑换和消费后故障
Q2 OIDC 与 JWT/JWKS 的信任边界
├─Q2.1 ID Token、Access Token 与 audience
│ └─Q2.1.1 nonce 缺失和重复使用
└─Q2.2 JWT 验证具体做什么
  └─Q2.2.1 密钥轮换、旧公钥与未知 kid
Q3 公有/机密客户端与 redirect
├─Q3.1 token endpoint 如何认证客户端
│ └─Q3.1.1 [错误纠偏] 给 SPA 固定 secret 就成为机密客户端?
└─Q3.2 redirect 注册和兑换时如何绑定
  └─Q3.2.1 localhost、动态端口与客户端修订
Q4 Refresh Rotation 的状态转换
├─Q4.1 Lua 原子提交哪些状态
│ ├─Q4.1.1 两个刷新请求竞态的完整结果
│ └─Q4.1.2 合法重试、family 撤销与 TTL
└─Q4.2 Redis 故障时的真实 HTTP 行为
  └─Q4.2.1 Lua JSON 精度和原子性的范围
Q5 多实例、Redis 重启与撤销
├─Q5.1 哪些状态共享,为什么不用 mutex
│ └─Q5.1.1 Redis 重启和 Cluster 能否直接支持
└─Q5.2 JWT 为什么仍查 Redis/PostgreSQL
  └─Q5.2.1 外部资源服务离线验签的撤销边界
Q6 PostgreSQL 邮件 Outbox
├─Q6.1 解决哪一个双写问题
│ └─Q6.1.1 故障发生在事务提交前后
└─Q6.2 SKIP LOCKED 与租约分别做什么
  ├─Q6.2.1 默认 20 条串行发送超过 2 分钟
  └─Q6.2.2 永久错误、退避与任务过期
Q7 SMTP 外部副作用和敏感数据
├─Q7.1 发信成功如何界定
│ └─Q7.1.1 Message-ID 能否保证去重
└─Q7.2 为什么 outbox 需要加密
  └─Q7.2.1 密钥不可用和恢复演练
Q8 Go 生命周期、测试和下一步
├─Q8.1 context/连接/事务由谁管理
│ └─Q8.1.1 Shutdown 是否真正等待完成
└─Q8.2 现有测试证明什么
  └─Q8.2.1 最有价值的后续验证

Q1 一次授权码登录的完整链路

**面试官:**从浏览器跳转到拿到 token 讲一遍,哪些参数属于安全边界?

**参考回答:**客户端产生本次请求的随机 verifier、state 和 nonce,发送 client_id、redirect_uri、scope、S256 challenge。Nyauth 先检查客户端与登记回调,再校验授权类型、scope、PKCE、nonce,登录和同意后保存带绑定信息的授权码。客户端带 code、原 redirect 和 verifier 请求 token 端点。服务器认证客户端,重新检查绑定、用户与客户端授权版本,然后原子消费 code,签发 access token;openid 请求附 ID Token,offline_access 且客户端允许 refresh grant 才签 refresh token。失败时不能跳过绑定,也不能把 code 消费失败当成功。

证据:internal/auth/handler.go:360-424(授权入口)、501-503(同意状态)、601-694(code 兑换);internal/session/store.go:53-69(code 数据)、137-150(原子消费脚本)。本回答描述当前链路,没有声称支持 OAuth/OIDC 的所有扩展或已经通过一致性认证。

Q1.1 → state、PKCE、nonce

**触发:**上答连续提了三种随机量。**问:**它们能互相替代吗?

**答:**state 主要帮助客户端把回调绑定到先前发起的浏览器事务,也可关联登录后跳转状态;PKCE 绑定授权请求与兑换请求,截获 code 的一方若不知道 verifier 就不能兑换;nonce 被放入 ID Token,客户端与本次登录保存值比较,防止 token 被替换或重放。不要机械地说三者绝对不能承担相近保护:符合条件的 PKCE 和 OIDC nonce 也能提供 CSRF 保护,但前提是客户端把它们绑定到这一次浏览器事务。Nyauth 当前允许 state 为空,要求所有授权码使用 S256,并要求 openid 请求有 nonce。这是项目策略。OAuth 安全最佳实践 §2.1

Q1.1.1 → 错误回答纠偏

**触发:**候选人说“state 存进 Redis 就能防客户端 CSRF”。**问:**究竟由谁比较哪个值?

**答:**应改成“客户端在回调校验 state 与发起事务的绑定;身份中心返回相同 state”。项目把 state 保存进 ConsentData(handler.go:501-503),这有助于完整回传,但不能代替下游客户端的回调比较。也不能因此反过来认定身份中心有 CSRF 漏洞:它的登录/同意接口还有自身会话与 CSRF 边界,和 OAuth 客户端回调是不同位置。改进 SDK 时,才考虑提供自动保存、绑定和一次性消费 state 的封装,不把建议说成已实现。

Q1.2 → verifier 校验

**触发:**上答说截获 code 不能兑换。**问:**给出具体计算和输入边界。

**答:**challenge 是 BASE64URL(SHA256(verifier)),不带填充;verifier 长度 43—128,字符必须属于协议允许集合,服务端拒绝非法格式再比较散列结果。Nyauth 的 internal/auth/pkce.go:9-37 只接受 S256,兑换还同时比对 client_id、redirect 和授权版本,PKCE 不是唯一防线。现有 RFC 测试向量在 internal/auth/auth_test.go:48-59,边界测试在 282-300RFC 7636 §4.1—4.2

Q1.2.1 → client secret 与 PKCE

**触发:**候选人认为 PKCE 仅为移动应用准备。**问:**有 secret 的后端还用吗?

**答:**secret 证明客户端身份,PKCE 证明兑换方持有发起本次授权时的 verifier,保护对象不同。当前 handler 不按公有/机密类型豁免 PKCE(412-416),这是更严格且清晰的统一策略。不能通过“传了 secret”降级到 plain 或接受缺失 challenge;新增兼容模式需要明确风险和测试,而不是在兑换失败后自动回退。

Q1.2.2 → code 消费与故障窗口

**触发:**候选人说 code 是一次性的。**问:**两个请求一起兑换,或消费后签名失败怎么办?

**答:**先验证绑定,再以记录版本进行 Redis 原子消费,保留 used 标记;两个合法并发兑换只有一个能消费。第二个经 reuse 路径拒绝。消费与之后签名/元数据写入不是跨系统事务,所以消费后签发失败可能没有 token 返回,需要重新授权;不能恢复旧 code 反复兑换。此处优先保守拒绝。handler.go:656-690 分开显示消费、签发和 ID Token 签发步骤;现有双实例测试为 internal/server/ha_integration_test.go:412。不要把它概括成“授权全过程 exactly-once”。

Q2 OIDC 与 JWT/JWKS 的信任边界

**面试官:**OAuth 和 OIDC 有什么区别,你的项目实现体现在哪里?

**参考回答:**OAuth 解决委托授权,Access Token 面向资源访问;OIDC 在其上提供身份断言,ID Token 面向登录客户端。Nyauth 的 openid scope 触发 ID Token,内容包含 issuer、用户 sub、客户端 aud、时间信息和 nonce,并按获准 claims 输出用户资料。不能因为 Access Token 里也有 sub,就把它当通用登录凭据。项目的 token_use 是额外类型约束,不意味着任何第三方都会自动检查它。

证据:internal/auth/handler.go:669-691internal/auth/token.go:251-308,311-357

Q2.1 → audience 与 token 混用

**触发:**上答区分两个 token。**问:**aud 到底是谁,签名正确为何还不能随便用于业务 API?

**答:**ID Token 的 aud 是本次登录客户端。当前 Access Token 的 aud 也写成 client_id(token.go:258-261),这是本项目现有模型,不应说已经有独立资源服务器 audience 模型。消费方要限定自己的预期 audience,并验证 token 类型和权限;否则发给 A 的 token 可能被 B 接受。Nyauth 自身在线验证还对照 Redis 中 client_id。若扩展为多个独立 API,需要明确资源标识、audience 与 scope 的契约,不能只加一个“JWT 验签成功”中间件。

Q2.1.1 → nonce 的标准与项目策略

**触发:**候选人说 OIDC 的 nonce 总是必填。**问:**这是规范要求还是你的实现要求?

**答:**在 OIDC 授权码流程的基础规范里 nonce 是可选;Nyauth 对含 openid 的授权码请求要求非空(handler.go:418-422),属于更严格的策略。token 端点使用 code 里存的 nonce,而不是让调用方重新提供。重复使用相同 nonce 发起新授权请求,当前服务端没有全局 nonce 去重记录;客户端必须每次生成并绑定随机值。code 一次性消费也不能替代客户端校验 nonce,否则仍不能正确绑定 ID Token 与浏览器事务。OIDC 授权请求

Q2.2 → JWT 校验

**触发:**上答提到验签。**问:**完整校验清单是什么,kid 可以信吗?

**答:**未验签的 header 只用来找候选公钥,不能作为可信授权依据。固定接受 RS256,要求非空 kid,从受信的本地 key store 查公钥,再验证签名、iss、exp、iat、nbf 和时钟偏差;Access Token 还校验 token_use、jti、单 aud,并与在线元数据核对。token.go:360-387 固定算法,311-345 做在线绑定。未知 kid 不能从 token 自带任意 URL 下载 key,也不能随便换一把 key 试成功就接受。

Q2.2.1 → 轮换与签名路径成本

**触发:**上答说只信本地 key store。**问:**多实例如何避免同时轮换,旧 token 是否失效?

**答:**轮换在 PostgreSQL 事务中用 advisory lock 串行化;旧 signing key 转成 verification,去除加密私钥,公钥保留最长签名 token TTL 加 2 分钟偏差。JWKS 返回 signing 和未过期 verification key。相应证据为 internal/auth/jwk.go:43-53,135-160,168-192,232-247。外部客户端应缓存 JWKS,在未知 kid 时受控刷新。当前 GetPrivateKey 每次查询、解密和解析(105-132),不能说有内存缓存;若签发性能成为瓶颈,再用实测决定缓存,并处理轮换失效与私钥常驻内存的代价。

Q3 公有/机密客户端与 redirect

**面试官:**客户端认证与用户认证是什么关系?

**参考回答:**用户认证回答“哪个用户登录”,客户端认证回答“哪个应用在兑换 token”,两者都不能替代授权许可和 code 绑定。机密客户端能在服务器保存凭据,公有客户端如浏览器应用无法保密。Nyauth 对公有客户端用 client_id 标识并要求 PKCE;机密客户端通过 secret 认证。client_id 本来就是公开标识,不该当秘密。

证据:internal/auth/handler.go:978-1008internal/client/store.go:606-617(秘密哈希校验);pkg/models/client.go:215-221(精确回调匹配)。

Q3.1 → token endpoint 认证方法

**触发:**上答说机密客户端使用 secret。**问:**Basic、表单和 public 分支分别怎么处理?

**答:**当前支持 Basic 或表单 client_id/client_secret,拒绝同时使用,避免歧义。公有客户端只在允许 public 的 grant 路径、无 Basic、secret 为空时通过;机密客户端必须有非空 secret,store 校验失败则拒绝。授权码兑换还要求该 client_id 等于 code 记录;刷新也校验 token 归属,错误客户端不能撤销受害者 family。后一点有 internal/auth/auth_test.go:184-198 的测试,不能只测“返回错误”而忽视是否改坏状态。

Q3.1.1 → 错误回答纠偏

**触发:**候选人说给 SPA 配一个固定 secret 就能提高认证安全性。**问:**打包到 JavaScript 后谁能看到?

**答:**所有拿到应用的人都能提取,不能建立秘密持有者身份。正确方案是作为 public client 用 PKCE,或用 BFF 将 secret 和 token 保存在受控服务端;BFF 又要保护 cookie、CSRF 和代理接口。不能把公有客户端名称改成“机密”就改变威胁模型。Nyauth 的 public 分支正是明确不接收 secret,避免制造虚假的认证保障。

Q3.2 → redirect 校验顺序

**触发:**上答说 code 绑定客户端。**问:**如何阻止攻击者用自己的回调偷到 code?

**答:**先查注册列表做完整字符串匹配;未知 client 或回调不匹配直接在身份中心报错,不能跳转到未经验证的 URI。确定回调受信后,才把授权错误和 state 返回过去。兑换时还要求 redirect 与 code 中记录一致、仍在客户端列表。源码为 handler.go:371-385,621-625;不是 host 匹配、前缀匹配,也没有 *.example.com 通配回调。

Q3.2.1 → 注册策略与修订

**触发:**面试官提出本地开发或修改回调。**问:**localhost 和端口如何处理,修改配置后的旧 code 呢?

**答:**注册时禁止 userinfo/fragment,允许 HTTPS,以及 localhost/loopback IP 的 HTTP(internal/client/service.go:52-68)。当前仍精确匹配登记的端口,不自动支持 native app 动态端口。code 保存 client authorization revision,兑换要求等于当前版本;回调/权限的相关修改使旧授权失效(handler.go:621-623token.go:616-625)。这是现有能力边界;不要把“loopback HTTP 例外”说成完整 native-app 回调兼容方案。

Q4 Refresh Rotation 的状态转换

**面试官:**你写了 Redis Lua 来避免重复签发,请画出 old、used、new 和 family 的状态变化。

**参考回答:**Refresh Token 是高熵随机字符串,Redis key 使用摘要;初始 token 有 family 和用户索引。刷新先在 Go 校验客户端、scope/claims、用户与授权状态,生成新随机 refresh 并签出候选 access。Lua 才是提交边界:比较旧记录、检查 family 撤销标记,写新 refresh 与 access metadata,更新 family 索引,删除旧 refresh,写 used 标记。Lua 成功后才向调用方返回 pair。不能把“生成过两段签名字符串”与“对外成功签发两份可用 token”混为一谈。

证据:internal/auth/token.go:394-455internal/session/store.go:207-257,620-651,698-739

Q4.1 → 原子提交内容

**触发:**上答把候选 access 先签出来。**问:**为什么还要把 access 元数据一起写进 Lua?

**答:**如果先轮换 refresh,再单独写 access metadata,第二个请求可能在两步之间触发 family 撤销,随后第一步的调用者又把 access 写回,造成撤销后复活。现在两类 key 一起加入 family,replay 一次清除;revoked marker 拦截继续写入。Lua 用原始 JSON 字节比较 expected,不仅依赖 Go 之前那次可能过时的读取(store.go:230-256)。原子性针对这次 Redis 脚本,不涵盖 PostgreSQL 用户状态变化或最终 HTTP 传输。

Q4.1.1 → 两个刷新请求竞态

**触发:**候选人说“只会有一个成功”。**问:**先成功的 token 最后一定还有效吗?

**答:**不一定。A 先提交得到新 pair;B 使用相同旧 token,看到 used 后撤销整族。A 即使已经收到 HTTP 200,它的新 refresh 和在线 access 元数据也可能被 B 删除,下一次访问失败。测试 internal/server/ha_integration_test.go:661-762 正在检查一个成功、一个 invalid_grant,以及后继 family 被撤销。面试应把“只准消费一次”和“成功结果持续有效”分开。攻击者抢先也同样会触发撤销,因为服务端通常无法判断哪一个是真实客户端。

Q4.1.2 → 合法重试和 TTL

**触发:**上答承认合法客户端也可能被踢下线。**问:**两个标签页或响应丢失怎么处理?

**答:**客户端应合并同一凭据的并发刷新,跨标签页可协调刷新;响应丢失不能盲目无限重试旧 token。当前严格 reuse 策略没有 grace window,必要时重新登录。若未来要短时返回同一结果,需绑定请求身份、保护缓存的新凭据并限制有效窗口,会改变安全取舍。used 与 revoked 标记都有 TTL,不是永久重放数据库;脚本按 refresh TTL 留 used,并按较长的 refresh/access TTL 保留 family 索引(store.go:250-256)。更久的历史 token 过期后通常只返回无效,不能声称任何历史重放都能定位并撤销当前后代。RFC 9700 刷新令牌保护

Q4.2 → Redis 故障的实际行为

**触发:**上答说失败关闭。**问:**Redis 断开现在返回什么?

**答:**不能说都返回 503。当前 token.go:397-398 把读取状态错误压成 ErrInvalidToken,446-450 也把非 reuse 的 rotation 错误压成无效;handler.go:780-786 对刷新服务错误统一返回 HTTP 400 invalid_grant。它没有放行未知状态,但混淆了凭据无效和依赖故障,客户端可能错误清空登录。这是当前不足。建议保留 unavailable 错误分类、给适当 server_error,并在日志指标保留脱敏原因;这是建议,尚未改代码。

Q4.2.1 → Lua 的边界与数值精度

**触发:**候选人说“放进 Lua 就没任何一致性问题”。**问:**脚本之外的超时和 JSON 怎么办?

**答:**Lua 防其他命令插入脚本中间,但不消除响应丢失:Redis 可能已提交而客户端只看到超时;更不等同跨数据库事务或故障切换不丢数据。当前脚本将 Go 生成的 expected/access JSON 原样写入,仅解码读取 family 字符串,避免把大整数经 Lua double 重编码而失真(store.go:212-238,246-256)。测试应覆盖大于 2^53 的字段与真实 Redis;项目 AGENTS.md 特别要求 Lua 语义变更用真实 Redis,emulator 不足以证明正确。Redis Lua 执行语义

Q5 多实例、Redis 重启与撤销

**面试官:**这个项目支持多个 API 实例,需要共享什么,增加实例是否等于高可用?

**参考回答:**需要共享用户/客户端/密钥等 PostgreSQL 状态、会话/授权码/token 等 Redis 状态;实例使用一致 issuer 和密钥配置。JWK 轮换靠数据库锁,token 状态靠 Redis 原子操作。API 多副本只能处理部分实例故障,不能自动解决共享数据库、Redis、网络和密钥服务的可用性。代码和 Compose 提供拓扑,不是生产 SLA 证据。

证据:internal/session/store.go:19-51internal/auth/jwk.go:232-247docker-compose.ha.yml:19,160-163 配置外部 Redis 及两个应用实例;docker-compose.yml:53-66 展示开发 Redis 行为。

Q5.1 → mutex 与共享状态

**触发:**上答提到共享 Redis。**问:**用 sync.Mutex 保护刷新不行吗?

**答:**mutex 只在当前进程有效,两个实例分别持锁仍能同时刷新,重启也会丢掉 used/family。应把不变量交给共同存储;SQL 唯一约束/事务或 Redis CAS/Lua 都可,但必须选择一种权威状态。Nyauth 当前使用 Redis,因此不能在它故障时临时切换到各实例本地 map 接受刷新,除非另建完整一致性协议,而这不在当前实现内。

Q5.1.1 → Redis 重启与 Cluster

**触发:**候选人宣称 Redis 也已“高可用”。**问:**仓库配置能证明什么?

**答:**开发 Compose 的 Redis 关闭 RDB/AOF,使用 noeviction(docker-compose.yml:63);重启会丢会话与 token 元数据,依赖在线状态的 token 会失效,需要重新登录。HA Compose 接外部 Redis,不定义它的持久化或故障转移。store 使用 *redis.Client,脚本涉及多个没有统一 hash tag 的 key,并动态构造 family key,不能直接声称支持 Redis Cluster。官方要求脚本访问 key 显式作为输入;若上 Cluster,需先重做 key 分区、客户端和跨槽约束,再验证。Redis 脚本 key 约束

Q5.2 → JWT 的在线状态

**触发:**上答说 Redis 重启让 Access Token 失效。**问:**JWT 不是无状态吗?

**答:**JWT 是签名封装格式,不强制系统无状态。Nyauth 每次 ValidateAccessToken 查 jti metadata,对照 subject/client/scope/auth_version,再检查用户状态、授权撤销时间和客户端修订(token.go:311-345,561-627)。这样可以尽快撤销,代价是 Redis/数据库查询、延迟和可用性依赖;自包含签名提供可验证断言,在线状态提供动态失效,两者可以并存。

Q5.2.1 → 离线验证的限制

**触发:**上答说能立即撤销。**问:**外部 API 只拉 JWKS 离线验签,也能立即感知吗?

**答:**不能。删 Redis metadata 不改变已签出的 JWT,纯离线验证方可能接受到 exp。应明确哪些资源采用在线 introspection/状态查询,哪些允许短 TTL 的撤销延迟;带本地缓存也要把缓存 TTL 计入最坏延迟。当前源码证明 Nyauth 自身在线验证,不证明所有下游都这么做。登录会话、refresh family、Access Token 和下游应用会话的注销效果也要分别解释,不能一句“退出登录全部 token 立即失效”概括。

Q6 PostgreSQL 邮件 Outbox

**面试官:**Outbox 解决的究竟是哪两个动作的一致性?

**参考回答:**数据库里的账户动作与“需要发信”的记录一起提交,避免业务更新成功而进程在调用 SMTP 前崩溃导致任务丢失。发送本身在事务外异步执行,减少长事务和对 SMTP 的耦合。它把问题变为可重试的任务处理,不提供 PostgreSQL 与 SMTP 的共同原子提交。

证据:internal/account/store.go:94-140(action、邮件、审计同事务);internal/account/outbox.go:108-175(认领)、424-478(事务外发送);注册回滚测试在 internal/database/registration_integration_test.go:283

Q6.1 → 同事务边界

**触发:**上答说“业务和 outbox 一起写”。**问:**代码如何保证没有漏掉其中一个?

**答:**ReplaceActionAndQueueEmail 开事务,Tx 版本在同一事务撤销被替代的 action、插入新 action、插入加密邮件和审计。注册等更大业务事务可以调用 Tx 版本,任何一步失败交给上层回滚。不能让 helper 内另开独立事务,否则注册失败也可能留下可发送邮件。这里方法边界比选了什么 ORM 更重要,也方便用故障注入检查整体回滚。

Q6.1.1 → 提交前后崩溃

**触发:**面试官要求具体时序。**问:**提交前崩溃、提交后 worker 未认领、SMTP 成功后未标 sent,各怎样?

**答:**提交前由数据库回滚,动作和邮件都不成立;提交后邮件 pending 可被后续 worker 取走;认领后崩溃则租约过期回收;SMTP 已成功而 sent 更新没提交,后来会再发,有重复窗口。前三类保障依赖 PostgreSQL 正确持久化与恢复;最后一类不能靠本地事务消除。需要把“任务可恢复”与“用户永远只收到一封”分开承诺。

Q6.2 → SKIP LOCKED 与租约

**触发:**上答说可被多个 worker 处理。**问:**SQL 锁既然存在,为什么还要租约?

**答:**SKIP LOCKED 只在认领事务期间跳过别人正在锁的行;事务提交后锁释放,不能跨整个 SMTP 调用持有。认领同时写 status=sending、locked_by、locked_at 和 attempt_count,后续 worker只认领到期任务或过期租约。结果回写要求 sending 且 locked_by 匹配,防新 worker已接手后旧 worker覆盖数据库状态(outbox.go:133-150,185-192,217-227)。它未校验每次 claim 的独立 attempt token/租约截止时间,不能夸大为严格的每次执行 fencing。

Q6.2.1 → 批量串行超过租约

**触发:**候选人说“2 分钟足够一次发送”。**问:**一次认领 20 条,最后一条何时开始?

**答:**当前默认 BatchSize=20、Lease=2 分钟,认领后串行循环 Send,没有续租;单封 SMTP 默认发送超时 30 秒(outbox.go:395-405,445-478smtp.go:125-129)。前几封较慢就可能让队尾在开始前租约过期,被其他 worker 认领,旧 worker仍会发送。源码支持这个风险时序,但本次未做复现。可选修正是减少预取、逐条认领,或带每次 claim token 的续租/发送前校验;仍不能消除外部 SMTP 的最后一个竞态。简历“租约防重复执行”应改为“租约回收与持有者条件回写”。

Q6.2.2 → 错误分类和重试

**触发:**上答说发送失败可重试。**问:**永久错误是否都丢弃?

**答:**当前仅 permanent recipient 错误直接 MarkEmailRejected;配置、认证、TLS 等错误不能说都会 rejected,它们在 dispatcher 留为 failed,并由 mailruntime 记录结果/更新熔断状态(outbox.go:528-550internal/mailruntime/manager.go:436-491)。临时错误按 1、2、4…分钟退避,最高 64 分钟,没有 jitter,直到任务过期;retryDelay 位于 outbox.go:582-589。这是指数退避封顶,不是“最多重试七次”。实际送达承诺还受邮件有效期、熔断恢复和运维修复影响。

Q7 SMTP 外部副作用和敏感数据

**面试官:**队列都可靠了,为什么邮件还是可能重复或没有到达收件箱?

**参考回答:**数据库记录可靠与外部邮件交付是两层。SMTP 服务器在 DATA 完成后接受责任,但后续可能退信、垃圾邮件过滤;网络也可能让客户端不确定服务器是否已接受。系统只能分类、重试和观测,不能把 SMTP 返回成功解释成用户已阅读或必达。Nyauth 已避免将成功 DATA 之后的 QUIT 失败当作发送失败。

证据:internal/account/smtp.go:144-207internal/account/smtp_test.go:184-248,295-341internal/account/outbox.go:470-478

Q7.1 → DATA 成功的含义

**触发:**上答说 QUIT 失败不重试。**问:**哪个具体结果标识本次 SMTP 已接收?

答:writer.Close() 完成 DATA 并获得成功响应后,当前 Send 会返回成功;QUIT 是尽力关闭连接,失败不触发再次投递(smtp.go:196-207)。但是如果服务器接受邮件后成功响应丢失,DATA close 仍可能报错;或发送成功、MarkEmailSent 失败,任务会重试。应把这两种未知结果窗口明确告诉面试官,不能仅靠调整错误判断承诺零重复。

Q7.1.1 → Message-ID 与幂等

**触发:**候选人提出用 Message-ID 去重。**问:**现在的 ID 稳定吗,收件方一定遵守吗?

**答:**当前每次 buildMIMEMessage 随机生成 Message-ID(smtp.go:343-350),重试不是同一个 ID。建议可以把业务邮件 ID 稳定映射到 Message-ID,方便追踪,但 SMTP 并不承诺按此去重;若改用支持幂等键的供应商 API,还要确认其保留期与冲突语义。重复邮件中的动作 token应由服务端一次性消费/过期规则保护,减轻重复副作用,仍不意味着邮件去重已经完成。

Q7.2 → outbox 为什么加密

**触发:**上答说动作 token 在邮件里。**问:**数据库只存 token hash 就没有泄露风险了吗?

**答:**action 表用 token hash 查找,但待发送邮件正文含原始链接/token,若 outbox 明文就绕过 hash 的保护。因此 action claims 和邮件分别以 envelope 加密,AAD 绑定记录身份/用途;发信前解密验证。成功或过期后还清理密文,缩短保留期。源码为 internal/account/service.go:471-511,543-562internal/account/outbox.go:185-190。这防的是数据库数据单独泄露,不保护已被攻陷且持有主密钥的服务进程;两种威胁不能混淆。

Q7.2.1 → 恢复与密钥故障

**触发:**上答提到 envelope。**问:**恢复了数据库却缺少密钥会怎样?

**答:**不能解密私钥或待发送邮件,也不能换新随机主密钥假装恢复成功。需要把数据库备份与受控密钥备份/恢复流程一起验证,限制访问且不打印秘密。当前解密失败不发送,走任务失败;仓库有篡改拒绝测试 internal/account/outbox_test.go:136 和恢复验证测试 internal/database/recovery_integration_test.go:19。这些是已有代码,不是本次恢复演练。可用性故障与密文认证失败应在脱敏告警里分开,不能把原始邮件/密钥放进错误日志。

Q8 Go 生命周期、测试和下一步

**面试官:**除了协议和 SQL,你能举出 Go 工程方面值得肯定和需要改进的地方吗?

**参考回答:**优点是 context 贯穿存储和 SMTP、数据库池复用、事务有回滚路径、Rows/连接及时释放,dispatcher 使用 ticker 且退出时 Stop,多任务错误用 errors.Join 汇总。SMTP 用 socket deadline 和 context.AfterFunc 关闭连接,不能仅把 context 参数传进去就认为 net/smtp 自动取消。需要继续核查的是 shutdown 的等待关系、批量租约和错误分类,不会因为有 context/测试就称系统已全面可靠。

证据:internal/account/outbox.go:123-175,478,510-524internal/account/smtp.go:156-177internal/server/server.go:804-884cmd/nyauth/main.go:125-173

Q8.1 → 资源所有权

**触发:**上答说资源会释放。**问:**请求 handler、worker 和 main 谁应该关闭共享连接池?

**答:**main 创建并最终关闭 PostgreSQL/Redis 客户端,单次 handler 只释放自身 rows/tx,不能关共享池;worker取消时停止新工作并释放本次资源,最后由拥有者等待 worker退出再关池。事务提交成功后 defer Rollback 通常只是无效清理,不能把它当第二次业务操作;真正要检查的是错误路径、查询 rows.Err 和取消后的处理。当前 main 在 Run 返回后 defer 关闭池,因而 Run 是否真的等到子任务结束很关键。

Q8.1.1 → Shutdown 等待漏洞候选

**触发:**候选人回答“调用 http.Server.Shutdown 就保证优雅退出”。**问:**谁等待 Shutdown 返回?

**答:**当前 Run 启协程等待信号后调用 Shutdown,主路径却在 ListenAndServe 返回 ErrServerClosed 后直接返回(server.go:866-882);worker goroutine 也没有统一 join。根据这个控制流,无法保证 main 会等待全部 HTTP 请求和后台 worker完成再关闭池/退出。这是源码审查发现的生命周期风险,本次未做信号退出复现。建议让 Run 明确等待 Shutdown 完成,并在统一取消/截止期限下等待后台任务;超时再记录未完成工作,利用 outbox 租约恢复,不声称已修复。Go Shutdown 的等待要求

Q8.2 → 测试证据分层

**触发:**面试官要求用证据而非形容词评价可靠性。**问:**哪些行为已有测试,哪些还缺证据?

**答:**单元层有 PKCE 向量和非法输入,miniredis 层有并发 refresh、错误 client不改状态、family 撤销;真实 Redis 测试使用两个 store;HTTP 集成测试有两个应用实例共享后端并并发兑换/刷新。Outbox 既有 fake store 的分派/错误分类,也有 PostgreSQL 注册事务失败回滚。路径分别为 auth/auth_test.go:48,184,242,282session/store_test.go:355-612session/store_redis_integration_test.go:94,155server/ha_integration_test.go:412,661database/registration_integration_test.go:283(均在 internal 下)。本次未运行,且环境变量未配置时集成测试会 Skip,普通 go test 退出成功不证明它们实际执行。

Q8.2.1 → 下一轮验证顺序

**触发:**上答承认有运行空白。**问:**只有半天,你优先验证哪几件?

**答:**第一,用真实 Redis 跑现有并发刷新/授权码用例,并验证大整数、TTL、响应丢失后的客户端行为;第二,用慢 SMTP 夹具让 20 条串行任务跨越租约,观察重复发送和旧 worker回写;第三,带进行中 HTTP 请求及 outbox任务发送退出信号,确认 shutdown 是否等待。再做数据库提交后失败/服务恢复。每项记录输入、时序、可见结果,不只报测试数。部署规模、压测数据、本人贡献和线上事故记录必须由本人补充,不能从代码规模推导。

简历主张核验及建议措辞

简历主张 当前证据与边界 建议
Go OAuth/OIDC、Code+PKCE、ID Token、RS256/JWKS 与 Client 管理 核心链路有实现;不代表完整协议扩展、一致性认证或第三方审计。 保留核心能力,准备讲严格 S256/nonce 策略与 audience 模型。
Docker Compose 单机与多实例部署 docker-compose.ymldocker-compose.ha.yml 有定义,双实例集成测试存在;本次没部署。 “提供单机与双实例 Compose 方案”;只有本人实际执行并留证据才写“完成部署验证”。
Redis Lua Rotation、Replay Detection、一次性消费、避免重复签发 脚本和测试支持;并发后一个 200 的 pair 仍可能因 reuse被撤销,Redis超时被映射成 invalid_grant。 “以 Redis Lua 原子轮换并登记令牌元数据,检测重放后撤销 family”。
跨实例状态经 Redis 协调 会话/授权码/token 状态如此;用户、客户端、签名密钥和 Outbox 由 PostgreSQL 协调。 不要扩张为“全部状态只经 Redis”或“Redis Cluster 已验证”。
PostgreSQL Outbox、SKIP LOCKED、2 分钟租约防重复执行 事务与认领属实;租约无续租,默认20条串行,SMTP仍可重复。 “通过 SKIP LOCKED 并发认领,使用租约回收及持有者条件回写,支持失败退避与任务过期”。
负责系统设计/核心开发 当前源码无法证明个人贡献、独立程度和生产规模。 补足真实模块、关键决策、一次失败与修复、实际验证结果。

最有价值的简历调整,是把“防重复执行”改成准确机制,并准备回答“为什么仍会重复”;这比增加更多安全术语更能证明理解。

本次核查与运行空白

已核查 Git 状态、项目规则、授权/客户端/令牌/密钥/Redis脚本/邮件事务和分派/SMTP/生命周期主要路径,以及相关测试入口和 Compose 关键拓扑。源码仓库始终只读;现有未跟踪 docs/interview/ 保持不变。没有读出秘密配置值,没有安装依赖、运行测试、启动服务、验证生产环境。

真实 Redis 测试需要 NYAUTH_TEST_REDIS_ADDR,HA HTTP 测试还需要 NYAUTH_TEST_DATABASE_DSN;未设置会跳过(internal/session/store_redis_integration_test.go:29-31internal/server/ha_integration_test.go:860-863)。本章只能标记“实现存在、测试代码存在”,不能标记“本次运行通过、已上线、已验收”。涉及 Redis Cluster、故障转移、邮件供应商投递率、吞吐/延迟/SLA、真实贡献占比的内容,都需要另有证据。