Files
yunhai.netcore.tool/docs/底层框架代码审查报告.md
T
wanghu 8c840aa933
build / build-push (push) Failing after 40s
git
2026-09-02 10:44:33 +08:00

22 KiB
Raw Blame History

底层框架代码审查报告

审查对象:YHDocPrintCore(业务侧,YhCore.*)+ YHBasePlatformDotnet(底层框架,Yh.Platform.*) 审查日期:2026-09-01 | 审查范围:Startup / 中间件 / 公共类 / 框架注册与验签链路 / CAS 认证 / OpenApi 客户端

结论

整体迁移质量良好:DI 生命周期、配置绑定、异常链路、签名协议与 Java 侧对齐度高,测试套件 98/98 通过。 审查发现 5 个 P1 契约缺陷(3 个静态审查 + 2 个冒烟触发)与 4 个 P2 健壮性问题,均已修复。


一、P1 问题(已修复)

1.1 管道顺序缺陷:/oapi 应用身份(AppKey)被下游中间件清空

位置:YhCore.WebApi/Startup.cs(管道顺序)+ Yh.Platform.Auth/UserContextMiddleware.cs

现象:原管道 AppAuth(2) → Authentication(3) → UseYhUserContext(4) → ...。AppAuthMiddleware 验签通过后写入 UserContextHolder.Set(LoginUser{AppKey})(位置 2),而 UserContextMiddleware 的 finally 在请求结束前无条件 UserContextHolder.Clear()(位置 4 更内层,其 finally 在整条链路末尾执行)。结果:/oapi 业务 Controller 中 UserContextHolder.Get() 恒为 null,AppAuthMiddleware 注释承诺的"业务可经 UserContextHolder 读取 AppKey"失效。

修复:调整管道顺序,AppAuth 移到 UseYhUserContext 之后——Authentication → SsoProtection → UseYhUserContext → AppAuth → LegacyAdapter → Authorization。UserContextMiddleware 的 finally 在 AppAuth 写入之后才执行,AppKey 可存活到业务层;且不再被误清空。

修复前: ApiAccessLog → GlobalException → AppAuth → Auth → YhUserContext → Legacy → Sso → Authorization
修复后: ApiAccessLog → GlobalException → Auth → Sso → YhUserContext → AppAuth → Legacy → Authorization

1.2 业务自定义错误码被 ErrorCodeUtil.From 吞成 Error=1

位置:YhCore.Common/Exceptions/BizException.cs

现象:ErrorCodeUtil.From(int) 对未知码 _ => ErrorCode.Error(Code=1)。业务 throw new BizException("库存不足", 5001) 经映射后 Code 变成 1,前端收到 code:1 而非 code:5001,错误码契约失效。

修复:未知码返回保留原始 code 的 RawErrorCode(code, "")(内部 record),msg 由业务消息承载。已知码(0/2/3/4/5/6/405)映射不变。

1.3 CAS 回跳开放重定向

位置:Yh.Platform.Auth/CasAuthenticationHandler.cs

现象:HandleAuthenticateAsync 中 Response.Redirect(returnUrl) 未校验来源,攻击者可诱导已登录用户访问 https://app/api/x?returnUrl=https://evil.com,登录后浏览器被跳转到外站(开放重定向)。

修复:新增 IsLocalUrl 校验(仅放行 / 开头的本地相对路径,拒绝 //host、/\host、绝对地址),Challenge 拼 service 与回调回跳两处均校验;非法 returnUrl 回退 /。

1.4 UserContextMiddleware 构造注入 Scoped 服务,服务启动即崩溃

位置:Yh.Platform.Auth/UserContextMiddleware.cs

现象:IUserDetailsProvider 注册为 Scoped,而中间件在构造函数注入它。ASP.NET Core 的 UseMiddleware<T> 在管道构建(启动)时用 ActivatorUtilities 从根容器激活中间件,构造含 Scoped 依赖直接抛 InvalidOperationException: Cannot resolve scoped service 'Yh.Platform.Auth.IUserDetailsProvider' from root provider,WebApi 起不来(此前迁移冒烟未重启实例,缺陷被掩盖;本轮验证 /oapi 时首次触发)。

修复:改为 ASP.NET Core 官方模式——构造仅保留 RequestDelegate(Singleton),Scoped 依赖 IUserDetailsProvider 移到 InvokeAsync(HttpContext, IUserDetailsProvider) 方法注入(每请求由 DI 从请求作用域解析)。同步更新 AuthContextTests 两处调用。

1.5 服务端验签密钥链不读配置,合法签名恒 401

位置:Yh.Platform.AppAuth/SignKeyResolver.cs

现象:GetSecretAsync 解析链仅 Redis(sign:app:{appKey}) → IMemoryCache → UserBase 兜底,不消费 yh:app-auth 的 AppKey/AppSecret;且 AppAuthOptions.TrustedApps 对应的 PropertyAppSecretProvider 从未被注册(IAppSecretProvider 写死为 SignKeyResolverAppSecretProvider)。本地联调无 Redis 预置 key、无 UserBase → secret 恒为 null → 任意合法签名都 401。此前冒烟只断言"无签名/假签名 → 401",未验证"合法签名 → 通过",契约缺陷未被发现。

修复:解析链头部增加「配置内置应用」层——appKey == yh:app-auth:AppKey 时直接使用配置的 AppSecret(覆盖"A 以自身密钥调 B 的 /oapi,B 用配置密钥验签"的自服务/联调场景);第三方应用密钥仍走 Redis → Cache → UserBase。配置密钥从此对服务端验签生效。


二、P2 问题(已修复)

2.1 MemoryReplayGuard 并发下防重放失效

位置:Yh.Platform.AppAuth/ReplayGuard.cs

现象:TryGetValue 检查后 _seen[key] = ... 写入,非原子。两个并发相同签名请求(同 appKey/timestamp/nonce)可同时通过检查同时放行,防重放被绕过。

修复:改为 ConcurrentDictionary.TryAdd 原子插入;未过期命中拒绝,过期条目允许覆盖。

2.2 SignCanonical 非法 URL 编码导致 500

位置:Yh.Platform.AppAuth/SignCanonical.cs

现象:Uri.UnescapeDataString 对未编码的 %(如 ?a=100%)抛 UriFormatException,AppAuthMiddleware 验签前调用 BuildTarget 未捕获 → 兜底返回 code:6 内部错误,而非 401。

修复:UrlDecode 捕获 UriFormatException 原样返回;恶意参数验签必失败 → 正确返回 401。

2.3 CasAuthenticationOptions.TicketValidateTimeoutMs 配置未生效

位置:Yh.Platform.Auth/CasServiceTicketValidator.cs + AuthServiceCollectionExtensions.cs + CasAuthenticationHandler.cs

现象:配置项定义了(默认 5000ms)但 validator 硬编码 5000,改配置无效。

修复(两阶段):

  1. CasServiceTicketValidator 构造函数接收 timeoutMs,注册时从 IOptions<CasAuthenticationOptions> 传入;
  2. 补漏(9/1 SSO 故障排查发现):CasAuthenticationHandler 此前绕过 DI、自建 validator 时只传 URL 不传超时 → 配置仍不生效。改为构造注入 DI 注册的 CasServiceTicketValidator,注册源升级 IOptionsMonitor<CasAuthenticationOptions>.CurrentValue。业务侧 yh:sso:TicketValidateTimeoutMs 提升到 10000(实测 CAS serviceValidate 延迟 0.5~4.5s+ 波动,5s 默认值存在偶发超时风险)。

2.4 AppAuthMiddleware 手拼 JSON 转义不完整

位置:YhCore.WebApi/Middleware/AppAuthMiddleware.cs

现象:"{\"code\":4,\"msg\":\"" + message.Replace("\"", "\\\"") + ... 仅转义引号,含反斜杠/换行时产出非法 JSON(当前 message 均为内部常量,风险低)。

修复:改用 System.Text.Json.JsonSerializer.Serialize。


三、观察项(未改动,建议后续处理)

# 位置 说明 建议
3.1 OpenApiOptions.Enabled / YhAuthOptions.Enabled 配置项定义了但注册链路未消费,yh:open:Enabled=false 仍会注册客户端并装配门面 消费方明确后接入(涉及门面装配语义,需测试覆盖)
3.2 Startup CORS AllowAnyOrigin 宽松,注释已标注待 CAS 接入后按 yh.sso.allow-origins 收紧 上线前提供允许源清单后收紧
3.3 反代部署 BuildServiceUrl 依赖 request.Scheme/Host,反代后 HTTPS 终止时 service 可能错配 生产配置 AppBaseUrl 或加 UseForwardedHeaders
3.4 CasServiceTicketValidator 回调低频,每次 new SocketsHttpHandler,可接受 高并发可换共享 HttpClient
3.5 GlobalExceptionMiddleware OperationCanceledException(客户端断开)走兜底 code:6 若需区分可前置 catch
3.6 OpenApiClientBase GetRawText() 二次反序列化,大响应内存开销 数据量大时改流式/直接反序列化
3.7 SsoProtectionMiddleware 直接读 IConfiguration 而非 IOptions,且保留旧节 Yh:Sso:Enabled 兼容 框架稳定后移除旧节回退逻辑

四、验证结果

  • ✅ 框架重编(Release):0 警告 0 错误
  • ✅ 框架测试套件:98/98 通过(Abstractions 8 / AppAuth 42 / Auth 11 / OpenApi 37)——9 处修复零回归
  • ✅ 业务侧编译(Debug):0 错误(仅 net5.0 EOL 警告 NETSDK1138)
  • ✅ 框架 Release net5.0 产物已拷回 E:\YHXT\net\Library(时间戳 2026-09-01 10:03)
  • ✅ /oapi 冒烟矩阵(实例 51835 实启验证):
用例 结果 断言
无签名 GET /oapi/hello 401 {code:4,"msg":"缺少签名头…"} 未带票据被拒
合法签名 GET /oapi/hello(无 Cookie) 404(直达路由层) 验签通过、匿名直访成功
合法签名 + 排序 query 404 签名口径含 query 正确
假签名 401 {code:4} 票据非法被拒
无 Cookie GET /api/xxx 302 → CAS 登录页 SSO 保护不受影响
无 Cookie GET /mobiapi/xxx 302 → CAS 登录页 SSO 保护不受影响

备注:编译期间遇到 MSB3021/MSB3027 文件锁定,根因为**上次冒烟测试遗留的 YhCore.WebApi 实例进程(PID 21104,监听 51999)**仍持有 bin 下 DLL 句柄,Stop-Process 结束后编译恢复。排查路径:netstat -ano | grep <PID> → 确认连 Redis → taskkill。

五、修复文件清单

业务侧(YHDocPrintCore)

  • src/YhCore.WebApi/Startup.cs — 管道顺序调整(1.1)
  • src/YhCore.Common/Exceptions/BizException.cs — 未知码透传(1.2)
  • src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs — JSON 序列化(2.4)
  • src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs — 显式放行 /oapi(渠道契约,1.1 配套)

框架侧(YHBasePlatformDotnet)

  • src/Yh.Platform.Auth/CasAuthenticationHandler.cs — 开放重定向修复(1.3)
  • src/Yh.Platform.Auth/UserContextMiddleware.cs — Scoped 构造注入改方法注入(1.4)
  • tests/Yh.Platform.Auth.Tests/AuthContextTests.cs — 同步新调用约定(1.4)
  • src/Yh.Platform.AppAuth/SignKeyResolver.cs — 配置内置应用密钥层(1.5)
  • src/Yh.Platform.AppAuth/ReplayGuard.cs — 并发防重放(2.1)
  • src/Yh.Platform.AppAuth/SignCanonical.cs — 非法编码容错(2.2)
  • src/Yh.Platform.Auth/CasServiceTicketValidator.cs — 超时配置接入(2.3)
  • src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs — 超时配置接入(2.3)

六、追加:BizException 退役(9/1)

业务异常统一改为直接使用框架 DException(Yh.Platform.Abstractions),BizException 包装类已删除:

  • src/YhCore.Common/Exceptions/BizException.cs 已删除;ErrorCodeUtil.From(int) 独立为 ErrorCodeUtil.cs(int → IErrorCode 映射保留,业务抛码仍可用)
  • GlobalExceptionMiddleware:原先逻辑完全相同的 catch (BizException) + catch (DException) 合并为单个 catch (DException)
  • WrapBizExceptionAttribute 更名 WrapDExceptionAttribute(当前无业务标注,改名安全)
  • 业务写法:throw new DException(ErrorCodeUtil.From(5001), "库存不足");仅提示语用 new DException("提示语")(默认 Custom=5)

七、追加:SSO 单点登录失败排查(9/1)

现象:浏览器打开 http://localhost:51835/signin-cas?ticket=ST-7-... 报"单点登录失败"。

根因(四层)

  1. CAS 票据单次有效:ST-7 来自更早会话(00:41 首个实例),已被消费或过期。curl 直调 serviceValidate 实测 CAS 返回 <cas:authenticationFailure code="INVALID_TICKET">Ticket 'ST-7-...' not recognized</cas:authenticationFailure>——任何重复校验必然失败,这是 CAS 协议设计(防重放),非缺陷。
  2. 应用当时未运行:51835 端口无监听进程(netstat 确认),回调 URL 无法被服务。
  3. UX 缺陷:修复前失效票据回调 → AuthenticateResult.Fail → 未认证 → 路径不在保护集 → 落 MVC 裸 404,用户感知为"登录失败"。
  4. 潜在超时风险:实测 CAS serviceValidate 延迟 0.5~4.5s+ 波动,框架默认超时 5s 且此前配置未真正生效(见 2.3 补漏),CAS 慢时登录偶发失败。

修复(框架 2 处 + 业务 2 处,第一轮)

  • 框架 CasAuthenticationHandler:构造注入 DI 注册的 CasServiceTicketValidator(2.3 补漏),并补回旧版有、迁移时丢失的 Context.User = principal(SignInAsync 只写 Cookie 不改当前请求身份,缺它后续中间件误判未认证)。
  • 框架 AuthServiceCollectionExtensions:validator 注册源 IOptions → IOptionsMonitor<CasAuthenticationOptions>.CurrentValue。
  • 业务 SsoProtectionMiddleware:CAS 回调路径(yh:sso:signInCallbackPath)纳入保护集——失效票据回调自动 302 弹回 CAS 登录页重新登录,不再裸 404。
  • 业务 appsettings.json:yh:sso:TicketValidateTimeoutMs: 10000(吸收 0.5~4.5s+ 延迟波动)。

验证(实例 51835 实启,第一轮)

用例 修复前 修复后
旧票据 GET /signin-cas?ticket=ST-7-... 404 302 → CAS 登录页(自动重登)
GET / 302 → CAS 302 → CAS(不变)
无 Cookie GET /api/GetUserInfo 302 → CAS 302 → CAS(不变)
无签名 GET /oapi/Test 401 401(不变)
GET /swagger/index.html 200 200(不变)

⚠️ 此轮验证只覆盖了失败路径:真实成功路径(票据校验 → Cookie → 回跳)依赖真实 CAS 账号,无法端到端验证。随后用本地 Mock CAS 服务器补测,立刻暴露一个更深层的框架级缺陷(见附录八)——第一轮的"302 弹回登录页"只是防循环表象,票据校验实际从未被触发。

给使用者的操作指引:CAS 票据单次有效且时效短——登录流程必须从受保护页面(/ 或 /api/xxx)发起,不要收藏/刷新带 ticket 的回调 URL(刷新必失败)。失败后回到 / 重新走一遍即可。

八、追加:SSO 成功路径端到端验证——DefaultScheme 缺陷(9/1 第二轮)

背景:附录七修复后,用本地 Mock CAS(Temp/mock-cas.js,自动登录 + 单次票据语义)通过环境变量 yh__sso__CasServerUrlPrefix 覆盖指向 Mock,对成功路径做全链路验证。结果发现:票据校验请求从未送达 CAS(Mock 日志只有 /cas/login,无 /serviceValidate),回调 2ms 即返回——校验在网络请求之前就被跳过。

根因(框架级,P0)

AuthServiceCollectionExtensions.AddAuthentication 中 DefaultAuthenticateScheme = CookieScheme。UseAuthentication 每请求只调用 AuthenticateAsync(默认 scheme) 恢复 Cookie 登录态;CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)在回调请求上没有任何触发点:

回调 /signin-cas?ticket=ST-… 进入
  └─ UseAuthentication → 只查 Cookie(无)→ 未认证
       └─ SsoProtectionMiddleware:带 ticket + 未认证 → 直接 401 防循环(附录七引入)
            └─ CasAuthenticationHandler.HandleAuthenticateAsync 从未执行 → 票据从不校验 → 登录永远失败

即:第一轮"防循环"把合法票据路径一并拦截,与 DefaultScheme 错误叠加,导致任何 CAS 登录都不可能成功(Mock 端到端立即暴露,真实账号难以察觉)。

修复(框架 1 处 + 业务 1 处)

  • 框架 AuthServiceCollectionExtensions:DefaultAuthenticateScheme 改为 YhAuthDefaults.CasScheme。CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 AuthenticateAsync(CookieScheme) 恢复登录态,因此语义等价"先 Cookie 恢复、后 ticket 校验",每请求行为不变、仅补上票据校验触发点。
  • 业务 SsoProtectionMiddleware:回调路径已认证且已 302(handler 验票成功 Response.Redirect)时短路管道——handler 返回 NoResult 不短路,继续走 MVC 端点匹配会因 /signin-cas 无 action 返回 404 覆盖 302(实测现象),短路保住回跳响应。

验证(Mock CAS 端到端,实例 51835 实启)

# 场景 结果
A / → Mock 登录 → 回调验票 → Cookie → 回跳 / 302 → 200,Cookie 落盘 ✓
B 带 Cookie GET /api/GetUserInfo 200(登录态生效)✓
C 复用已消费票据 GET /signin-cas?ticket=同前 401 防循环页 ✓(Mock 第二次校验返回 INVALID_TICKET)
D 无 Cookie GET /api/GetUserInfo 302 → CAS ✓
E 无签名 GET /oapi/Test 401(SSO 不覆盖)✓

Mock 日志互证:02:35:09 /cas/serviceValidate?ticket=ST-MOCK-… 送达并成功、票据被消费;复用同一票据时 Mock 返回 INVALID_TICKET → 应用 401。

框架重编 0 错误、测试 98/98 通过、业务编译 0 错误;Yh.Platform.Auth.dll 已更新至 E:\YHXT\net\Library。

教训:SSO 类链路缺陷必须覆盖成功路径验证——失败路径的"防循环 401/302"表象可能掩盖"校验从未发生"的框架级问题。无真实账号时用 Mock CAS 全链路补测是必要手段。


需求:① 关闭浏览器 Cookie 失效;② 过期时间 1 分钟;③ Cookie 长度为 32 位。

实现(框架 Yh.Platform.Auth 3 处 + 业务 1 处):

文件 变更
SessionTicketDataFormat.cs(新增) 自定义 ISecureDataFormat<AuthenticationTicket>:Protect 把完整票据存服务端内存、返回 32 位 key(Guid N 格式);Unprotect 查表并校验 ExpiresUtc(过期返回 null 并清理)。key 为 128 位随机熵,不可枚举
AuthServiceCollectionExtensions.cs Cookie 注册:options.TicketDataFormat = new SessionTicketDataFormat();ExpireTimeSpan 由 yh:auth:CookieExpireMinutes 决定(默认 1 分钟);SlidingExpiration = false(固定过期不滑动)
CasAuthenticationHandler.cs IsPersistent: true → false(会话级 Cookie,关闭浏览器失效;旧版 _legacy 本为 false,系迁移回归,已修正)
业务 appsettings.json CookieExpireHours: 8 → CookieExpireMinutes: 1(注:文件曾被外部覆盖一次,CookieName 现为用户保存的 .yh.auth)

关键技术发现:.NET 5 的 CookieAuthenticationOptions.SessionStore 并不能缩短 Cookie——官方源码(v5.0.17 CookieAuthenticationHandler.HandleSignInAsync)显示即便配置了 SessionStore,cookie 值仍是 TicketDataFormat.Protect(精简票据) 的加密长串(CfDJ8…),只是把完整用户票据移到了服务端。要实现"Cookie 32 位",必须自定义 TicketDataFormat(Protect 落库返回 key),而非依赖 SessionStore。首次尝试(SessionTicketStore + SessionStore)经最小复现程序(独立 net5.0 Web 项目直连框架 DLL)证实无效后放弃。

验证(Mock CAS 端到端 + 真实配置冒烟):

需求 验证结果
③ Cookie 32 位 Set-Cookie: .yh.auth=3a3f40993606438e86d8e756b99a6f07 = 32 字符 ✓
① 关闭浏览器失效 Set-Cookie 无 Max-Age/Expires(会话 Cookie)+ IsPersistent=false ✓
② 1 分钟过期 登录后立即带 Cookie 访问 / → 200;65 秒后 → 302 弹回 CAS 登录页(服务端票据过期)✓

框架双目标(net5.0/net8.0)编译 0 错误 0 警告、测试 98/98 通过、业务编译 0 错误;DLL 已更新至 E:/YHXT/net/Library。

注意:票据存进程内存(ConcurrentDictionary)——应用重启后所有登录态失效(用户需重新登录);多实例部署时各实例票据互不可见(如需跨实例共享可换 Redis 实现)。

附录九补充:退出即吊销——显式登出服务端票据立即失效(9/1 第二轮)

背景:附录九初版只覆盖"登录态自然过期/关浏览器"。审查发现缺口:SignOutAsync(业务 GET /api/Logout)只删 Cookie,服务端内存票据残留到 ExpireTimeSpan 过期(默认 1 分钟)——期间若旧 Cookie key 被恢复/复用,登录态仍有效。

修复(框架 Yh.Platform.Auth 2 处):

文件 变更
SessionTicketDataFormat.cs 新增 Revoke(string? key):删除指定票据(显式退出立即失效);新增分钟级摊还清理 CleanupExpiredIfDue()(Unprotect/Revoke 路径触发,Interlocked 门闩保证并发下每分钟最多一次全量扫描,防内存膨胀)
AuthServiceCollectionExtensions.cs SessionTicketDataFormat 提升为 DI 单例(services.AddSingleton,TicketDataFormat 与事件共享同一票据表);Cookie 注册挂 options.Events.OnSigningOut:SignOutAsync 时从请求 Cookie 读 32 位 key → Revoke(key) → 服务端票据立即删除

语义闭环:① 关闭浏览器 → 会话 Cookie 删除(服务端无法感知浏览器关闭,1 分钟过期兜底);② 显式退出(/api/Logout)→ Cookie 删除 + 服务端票据立即吊销,不等过期。

验证(Mock CAS 端到端,实例 51835 实启):

步骤 期望 实测
登录流 → Cookie(32 位 key) 200 + Cookie 落盘 ✓
带 Cookie 访问 /api/GetUserInfo 200 ✓
GET /api/Logout(带 Cookie) 200 ✓
Logout 后旧 key 手动放回访问 302 弹回 CAS(服务端已吊销;旧行为下会 200) ✓ 302

框架测试 106/106 通过(Auth 19 = 原 11 + 新增 SessionTicketDataFormatTests 8 项:32 位 key 往返 / 未知 key null / Revoke 立即失效 / 过期 null / 未过期正常 / Revoke 不影响其他票据等),双目标编译 0 错误 0 警告;Yh.Platform.Auth.dll(Release net5.0)已更新至 E:/YHXT/net/Library。