
一、项目背景最近[统一APP] 用户要绑定投档平台用户接口必须满足安全规范传入参数必须加密,出参必须解密。算法合规必须使用国密 SM4 对称加密不能用 AES 等国外算法整体加密所有业务参数整体加密密文统一放在data字段传输GET 是?data密文POST 是{data:密文}全链路加密请求入参解密、响应出参加密进出都走data字段向后兼容原有老投档 APP 的接口不能受任何影响两套体系并行。最开始的思路很直接写一个ActionFilter动作过滤器贴在控制器上方法执行前解密、执行后加密。结果一调试直接踩坑解密日志显示成功URL 也改成明文了但控制器拿到的参数永远是 null。这个问题的根源本质是没搞懂ASP.NET WebAPI 的「请求管道分层」—— 不是代码写错了是放错了地方。二、为什么 Action 过滤器做不了入参解密很多人对 WebAPI 的 “过滤器” 有误解觉得它是请求的第一道门。但实际上Action 过滤器只是控制器层面的拦截在它之前已经发生了很多关键步骤。2.1 先搞懂一个请求在 WebAPI 里的 “两层管道”很多人只知道 “过滤器”不知道 WebAPI 的请求处理其实分两层独立的管道**第一层消息处理管道HttpMessageHandler 管道**最外层、最前端的管道由DelegatingHandler组成责任链负责原始 HTTP 请求的最前置处理比如跨域、日志、参数解密。第二层控制器处理管道路由匹配到控制器之后才会进入由各种过滤器AuthorizationFilter、ActionFilter、ExceptionFilter组成负责业务层面的拦截。用大白话打个比方消息处理管道 小区大门口的安保系统所有人进小区第一个经过控制器过滤器 你家楼下的单元门门禁只有找到你家在哪栋楼了才会刷单元门。2.2 为什么 Action 过滤器里改参数没用我们把从请求到达到控制器执行的完整节点按顺序列出来一眼就能看懂阶段所属管道核心动作能做什么 / 不能做什么1消息处理管道接收原始 HTTP 请求可修改请求地址、请求头、请求体✅ 可以改 URL、改请求体、改头❌ 还没路由不知道要进哪个控制器2消息处理管道末尾路由匹配根据 URL 找到对应的控制器和方法确定目标 Action3控制器管道模型绑定把 URL 参数、请求体解析成控制器方法的入参对象参数绑定完成后续再改 URL / 请求体也不会重新绑定4控制器管道AuthorizationFilter授权过滤器校验 Token、权限可以拦截请求但改参数已经晚了5控制器管道ActionFilter.OnActionExecuting方法执行前的最后一步参数早已绑定完成6控制器管道控制器 Action 方法执行业务逻辑真正处理业务7控制器管道ActionFilter.OnActionExecuted方法执行后处理响应结果8回到消息处理管道响应返回经过消息处理器的后置逻辑可以修改响应内容这就是核心真相**Action 过滤器在第 5 步而模型绑定在第 3 步就已经完成了。**你在第 5 步把 URL 从?data密文改成了明文参数但是 “前台登记员”模型绑定早就登记完了办事员控制器拿到的还是空参数 —— 改了也白改。2.3 那为什么不用 AuthorizationFilter有人会问授权过滤器在第 4 步比 Action 早放那里行不行答案是还是不行。因为 AuthorizationFilter 的定位是「权限校验」它执行的时候模型绑定同样已经完成了只是还没执行 Action 方法而已。改参数的时机依然不对。三、最优解用 DelegatingHandler 做入参解密3.1 什么是 DelegatingHandler消息处理器它是ASP.NET WebAPI 消息处理管道的核心组件采用责任链模式串联起来所有请求和响应都会按顺序经过每一个消息处理器。它有两个非常关键的特性是过滤器不具备的最前置执行在路由匹配、模型绑定之前就执行这时候修改请求 URL、请求体后续的模型绑定能直接生效双向拦截请求进来的时候走一遍前置逻辑响应出去的时候走一遍后置逻辑相当于 “进出门都能检查”。我们的 SM4 入参解密必须放在这个阶段 —— 只有在模型绑定之前把密文解开后续参数才能正常绑定。3.2 为什么出参加密可以放在 Action 过滤器里因为响应加密是在业务方法执行之后处理Action 过滤器的OnActionExecuted刚好在这个时机完全够用。当然你也可以把出参加密也放到消息处理器里统一在一个地方处理分开的好处是加密和控制器业务绑定更紧密可以灵活控制单个接口是否加密。3.3 最终的技术选型结合我们的业务需求最终的组合方案是功能选用组件原因GET/POST 入参解密DelegatingHandler消息处理器必须在模型绑定前执行才能让参数正常绑定响应出参加密ActionFilter动作过滤器业务方法执行后处理灵活控制接口粒度Token 权限校验AuthorizationFilter授权过滤器路由匹配后、业务执行前符合授权校验的定位四、UnifiedAppDecryptHandler 的实现原理我们的解密消息处理器核心是继承DelegatingHandler重写SendAsync方法。很多人只会抄代码不知道里面的执行逻辑我们逐段讲透。4.1 完整代码using Castle.Core.Logging; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Configuration; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using WebSite.App_Start; using static CastArchives.EncryptTools.smEncryptUtils; namespace CastArchives.Web.Areas.Mobile.APPFilters { /// summary /// 统一APP入参解密消息处理器 /// 管道位置路由匹配、模型绑定之前 /// 功能GET查询参数解密 POST请求体解密 /// /summary public class UnifiedAppDecryptHandler : DelegatingHandler { // 对接约定密文字段名统一为data private const string CipherTextFieldName data; // 路由前缀白名单只处理统一APP的接口不影响老业务 private readonly HashSetstring _decryptRoutePrefixes new HashSetstring(StringComparer.OrdinalIgnoreCase) { api/mobile/UnifiedApp, api/mobile/todoApp, api/mobile/ordersApp // 其他业务接口前缀 }; private static readonly ILogger Logger LogConfig.IocContainer.ResolveILoggerFactory().Create(typeof(UnifiedAppDecryptHandler)); /// summary /// 管道核心处理方法 /// /summary protected override async TaskHttpResponseMessage SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { string requestPath request.RequestUri.AbsolutePath.TrimStart(/); // 1. 白名单过滤不在范围内的接口直接放行 bool isInWhiteList _decryptRoutePrefixes.Any(prefix requestPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); if (!isInWhiteList) { // 调用base.SendAsync就是把请求传给管道里的下一个处理器 return await base.SendAsync(request, cancellationToken); } try { Logger.Info($统一APP入参解密开始路径{requestPath}方式{request.Method}); // 2. 按请求方式执行解密 if (request.Method HttpMethod.Post || request.Method HttpMethod.Put) { await DecryptRequestBody(request); } else if (request.Method HttpMethod.Get) { DecryptRequestQuery(request); } Logger.Info($统一APP入参解密成功路径{requestPath}); } catch (Exception ex) { Logger.Error($统一APP入参解密失败路径{requestPath}错误{ex.Message}, ex); // 解密失败直接返回400不进入后续管道 return request.CreateErrorResponse(System.Net.HttpStatusCode.BadRequest, $参数解密失败{ex.Message}); } // 3. 解密完成继续往后传走路由、模型绑定、控制器 return await base.SendAsync(request, cancellationToken); } #region POST请求体解密 /// summary /// 解密POST请求体外层{data:密文} → 明文JSON /// /summary private async Task DecryptRequestBody(HttpRequestMessage request) { // 1. 读取原始请求体 string rawBody await request.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(rawBody)) { throw new Exception(请求体不能为空); } // 2. 解析外层JSON提取密文 Dictionarystring, string outerObj; try { outerObj JsonConvert.DeserializeObjectDictionarystring, string(rawBody); } catch (JsonException) { throw new Exception(请求体格式错误必须为标准JSON格式); } if (outerObj null || !outerObj.TryGetValue(CipherTextFieldName, out string cipherText) || string.IsNullOrWhiteSpace(cipherText)) { throw new Exception($未找到加密参数字段{CipherTextFieldName}); } // 3. SM4解密 string sm4Key GetSm4SecretKey(); bool isHexString true; string decryptedJson MainSm4.Decrypt_ECBUnifiedApp(sm4Key, isHexString, cipherText); if (string.IsNullOrWhiteSpace(decryptedJson)) { throw new Exception(解密结果为空请检查密文与密钥是否匹配); } // 4. 关键用明文重构请求体 // 因为请求体是只读的流读取完就不能再读了必须重新new一个StringContent后续模型绑定才能正常读取 request.Content new StringContent(decryptedJson, Encoding.UTF8, application/json); } #endregion #region GET查询参数解密 /// summary /// 解密GET查询参数?data密文 → 明文参数串 /// /summary private void DecryptRequestQuery(HttpRequestMessage request) { var queryParams request.GetQueryNameValuePairs() .ToDictionary(k k.Key, v v.Value, StringComparer.OrdinalIgnoreCase); if (!queryParams.TryGetValue(CipherTextFieldName, out string cipherText) || string.IsNullOrWhiteSpace(cipherText)) { return; } // SM4解密 string sm4Key GetSm4SecretKey(); bool isHexString true; string decryptedQuery MainSm4.Decrypt_ECBUnifiedApp(sm4Key, isHexString, cipherText); if (string.IsNullOrWhiteSpace(decryptedQuery)) { throw new Exception(GET参数解密失败解密结果为空); } // 格式校验必须是keyvalue格式避免解密失败得到乱码 if (!decryptedQuery.Contains() || decryptedQuery.StartsWith({) || decryptedQuery.StartsWith([)) { throw new Exception($GET参数解密后格式错误期望keyvalue格式实际{decryptedQuery}); } // 重构请求URL替换成明文参数 string baseUrl request.RequestUri.GetLeftPart(UriPartial.Path); request.RequestUri new Uri(${baseUrl}?{decryptedQuery}); } #endregion #region 辅助方法 private string GetSm4SecretKey() { string key ConfigurationManager.AppSettings[UnifiedApp_SM4_SecretKey]; if (string.IsNullOrWhiteSpace(key)) { throw new Exception(SM4密钥未配置请检查Web.config中的UnifiedApp_SM4_SecretKey); } return key; } #endregion } }4.2 几个关键的设计细节新手最容易踩坑1为什么要用白名单前缀匹配消息处理器是全局的所有请求都会经过。如果不加白名单老 APP 的接口、PC 端的接口也会被解密直接就报错了。用前缀匹配的好处是以后新增统一 APP 的接口只要前缀符合自动生效不用一个个加特性。2POST 解密为什么要重新 new StringContent这是另一个隐形坑HTTP 请求体是一个 “只能读一次的流”。你用ReadAsStringAsync()读完之后流的位置就到末尾了后续模型绑定再去读的时候就读不到内容了。所以必须解密完成后用明文重新创建一个StringContent赋值给request.Content相当于 “换了一个新的请求体”后续的模型绑定才能正常读取。这也是为什么入参解密必须在消息处理器里做 —— 你在 Action 过滤器里读请求体读完模型绑定早就用完了根本没法重构。3解密失败为什么直接返回不往后传解密失败说明请求不合法要么密文错了要么密钥不对要么有人恶意传参。这时候直接返回 400 错误不用进入路由、控制器减少无效的资源消耗也能提前拦截非法请求。五、配套组件响应加密过滤器出参加密逻辑相对简单放在 Action 过滤器里即可贴在控制器上生效。using Castle.Core.Logging; using Newtonsoft.Json; using System; using System.Configuration; using System.Net.Http; using System.Text; using System.Web.Http.Filters; using WebSite.App_Start; using static CastArchives.EncryptTools.smEncryptUtils; namespace CastArchives.Web.Areas.Mobile.APPFilters { /// summary /// 统一APP响应加密过滤器 /// 执行时机Action方法执行后响应返回前 /// /summary [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple false)] public class UnifiedAppEncryptAttribute : ActionFilterAttribute { private static readonly ILogger Logger LogConfig.IocContainer.ResolveILoggerFactory().Create(typeof(UnifiedAppEncryptAttribute)); public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext) { // 异常响应不加密方便排查问题上线前可按需开启 if (actionExecutedContext.Exception ! null || actionExecutedContext.Response null) { base.OnActionExecuted(actionExecutedContext); return; } try { // 读取控制器返回的明文响应 string plainResponse actionExecutedContext.Response.Content.ReadAsStringAsync().Result; if (string.IsNullOrWhiteSpace(plainResponse)) { base.OnActionExecuted(actionExecutedContext); return; } // SM4整体加密 string sm4Key GetSm4SecretKey(); bool isHexString true; string encryptedData MainSm4.Encrypt_ECB(sm4Key, isHexString, plainResponse); // 包裹为约定格式返回 var result new { data encryptedData }; string encryptedResponse JsonConvert.SerializeObject(result); // 重构响应内容 actionExecutedContext.Response.Content new StringContent(encryptedResponse, Encoding.UTF8, application/json); Logger.Info($响应加密成功路径{actionExecutedContext.Request.RequestUri.AbsolutePath}); } catch (Exception ex) { Logger.Error($响应加密失败错误{ex.Message}, ex); // 安全降级加密失败也返回明文不阻断业务 } base.OnActionExecuted(actionExecutedContext); } private string GetSm4SecretKey() { string key ConfigurationManager.AppSettings[UnifiedApp_SM4_SecretKey]; if (string.IsNullOrWhiteSpace(key)) { throw new Exception(SM4密钥未配置请检查Web.config); } return key; } } }六、3 步接入项目1. 注册消息处理器打开App_Start/WebApiConfig.cs在配置里注册处理器public static class WebApiConfig { public static void Register(HttpConfiguration config) { // 启用属性路由 config.MapHttpAttributeRoutes(); // 注册统一APP解密消息处理器必须放在路由注册之后 config.MessageHandlers.Add(new UnifiedAppDecryptHandler()); // 原有默认路由 config.Routes.MapHttpRoute( name: DefaultApi, routeTemplate: api/{controller}/{action}/{id}, defaults: new { id RouteParameter.Optional } ); } }确保Global.asax里调用了GlobalConfiguration.Configure(WebApiConfig.Register);。2. 控制器贴加密特性在需要加密返回的控制器上贴[UnifiedAppEncrypt][RoutePrefix(api/mobile/todoApp)] [UnifiedAppEncrypt] // 该控制器所有接口自动加密返回 public class TodoAppController : MobileApiBaseController { [HttpGet] [Route(GetEnterpriseTodoList)] public IHttpActionResult GetEnterpriseTodoList() { // 业务代码完全不用改正常返回明文 return Success(data, 获取成功); } }3. 配置密钥在Web.config里添加 SM4 密钥配置必须是约定好的appSettings add keyUnifiedApp_SM4_SecretKey value你的秘钥 / /appSettings七、消息处理器的常见问题请求体重复读取问题如果业务里需要多次读取请求体不要直接用ReadAsStringAsync()可以先把流读出来存到内存再重置流的位置我们这里因为要整体替换请求体直接重构 Content 即可。异步上下文问题SendAsync是异步方法不要在里面用HttpContext.Current可能会拿不到要获取请求信息直接用request对象即可。异常处理消息处理器里的异常不会被控制器的异常过滤器捕获必须自己 try-catch 处理直接构造错误响应返回。执行顺序多个消息处理器的执行顺序和注册顺序一致先注册的先执行请求、后执行响应我们的解密处理器尽量往前放在路由、权限之前。八、总结本质上做框架开发就一句话在正确的管道阶段做正确的事。要改原始请求、要在模型绑定前处理 → 选消息处理器要做权限校验、业务前置检查 → 选授权过滤器要处理方法执行前后的业务逻辑 → 选 Action 过滤器要统一处理异常 → 选异常过滤器。