22 KiB
底层框架代码审查报告
审查对象:
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,改配置无效。
修复(两阶段):
CasServiceTicketValidator构造函数接收timeoutMs,注册时从IOptions<CasAuthenticationOptions>传入;- 补漏(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-... 报"单点登录失败"。
根因(四层)
- CAS 票据单次有效:
ST-7来自更早会话(00:41 首个实例),已被消费或过期。curl 直调serviceValidate实测 CAS 返回<cas:authenticationFailure code="INVALID_TICKET">Ticket 'ST-7-...' not recognized</cas:authenticationFailure>——任何重复校验必然失败,这是 CAS 协议设计(防重放),非缺陷。 - 应用当时未运行:51835 端口无监听进程(netstat 确认),回调 URL 无法被服务。
- UX 缺陷:修复前失效票据回调 →
AuthenticateResult.Fail→ 未认证 → 路径不在保护集 → 落 MVC 裸 404,用户感知为"登录失败"。 - 潜在超时风险:实测 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 会话改造(9/1 需求:关闭失效 / 1 分钟过期 / 32 位)
需求:① 关闭浏览器 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。