diff --git a/Attributes/AesBodyAttribute.cs b/Attributes/AesBodyAttribute.cs new file mode 100644 index 0000000..4773b20 --- /dev/null +++ b/Attributes/AesBodyAttribute.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.AspNetCore.Mvc; +using SimApi.ModelBinders; + +namespace SimApi.Attributes; + +[AttributeUsage(AttributeTargets.Parameter)] +public class AesBodyAttribute : ModelBinderAttribute +{ + public Type KeyProvider { get; set; } = typeof(AesBodyProviderBase); + + public AesBodyAttribute() + { + // 指定使用自定义的模型绑定器 + BinderType = typeof(AesBodyModelBinder); + Name = KeyProvider.FullName; + } +} \ No newline at end of file diff --git a/Attributes/SimApiSignAttribute.cs b/Attributes/SimApiSignAttribute.cs new file mode 100644 index 0000000..9c2b0b1 --- /dev/null +++ b/Attributes/SimApiSignAttribute.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using SimApi.Exceptions; +using SimApi.Helpers; +using SimApi.ModelBinders; + +namespace SimApi.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class SimApiSignAttribute : ActionFilterAttribute +{ + protected Type KeyProvider { get; set; } = typeof(SimApiSignProviderBase); + + public override void OnActionExecuting(ActionExecutingContext context) + { + if (context.HttpContext.RequestServices.GetService(KeyProvider) is not SimApiSignProviderBase keyProvider) + { + throw new SimApiException(400, "未配置签名器"); + } + + string? appId = null; + if (!string.IsNullOrEmpty(keyProvider.AppIdName)) + { + appId = context.HttpContext.Request.Query[keyProvider.AppIdName] + .FirstOrDefault() ?? context.HttpContext.Request.Headers[keyProvider.AppIdName] + .FirstOrDefault(); + + if (string.IsNullOrEmpty(appId)) + { + throw new SimApiException(400, $"获取{keyProvider.AppIdName}失败"); + } + } + + // 4. 通过接口获取密钥(解耦的核心:不再直接依赖数据库) + var key = keyProvider.GetKey(appId); + if (string.IsNullOrEmpty(key)) + { + throw new SimApiException(400, "获取签名KEY失败"); + } + + var timestamp = context.HttpContext.Request.Query[keyProvider.TimestampName] + .FirstOrDefault() ?? context.HttpContext.Request.Headers[keyProvider.TimestampName] + .FirstOrDefault(); + if (string.IsNullOrEmpty(timestamp)) + { + throw new SimApiException(400, $"{keyProvider.TimestampName}不能为空"); + } + + var nonce = context.HttpContext.Request.Query[keyProvider.NonceName] + .FirstOrDefault() ?? context.HttpContext.Request.Headers[keyProvider.NonceName] + .FirstOrDefault(); + if (string.IsNullOrEmpty(nonce)) + { + throw new SimApiException(400, $"{keyProvider.NonceName}不能为空"); + } + + if (!int.TryParse(timestamp, out var ts)) + { + throw new SimApiException(400, $"{keyProvider.TimestampName}格式错误"); + } + + if (keyProvider.QueryExpires != 0) + { + if (ts > SimApiUtil.TimestampNow + 2) + { + throw new SimApiException(400, "请校准本地时间"); + } + + if (ts + keyProvider.QueryExpires < SimApiUtil.TimestampNow) + { + throw new SimApiException(400, "请求已过期"); + } + + if (keyProvider.DuplicateRequestProtection) + { + var cache = context.HttpContext.RequestServices.GetRequiredService(); + if (cache.GetString("SignQuery:" + nonce) == null) + { + cache.SetString("SignQuery:" + nonce, timestamp, new DistributedCacheEntryOptions() + { + SlidingExpiration = TimeSpan.FromSeconds(keyProvider.QueryExpires + 2) + }); + } + else + { + throw new SimApiException(400, "重复请求"); + } + } + } + + var signStr = string.Empty; + foreach (var item in keyProvider.SignFields) + { + signStr += $"{item}="; + signStr += context.HttpContext.Request.Query[item] + .FirstOrDefault() ?? context.HttpContext.Request.Headers[item] + .FirstOrDefault(); + signStr += "&"; + } + + signStr += $"{keyProvider.TimestampName}={ts}&{keyProvider.NonceName}={nonce}&{key}"; + var sign = context.HttpContext.Request.Query[keyProvider.SignName] + .FirstOrDefault() ?? context.HttpContext.Request.Headers[keyProvider.SignName] + .FirstOrDefault(); + if (SimApiUtil.Md5(signStr) != sign) + { + throw new SimApiException(400, "签名错误"); + } + } +} \ No newline at end of file diff --git a/ModelBinders/AesBodyModelBinder.cs b/ModelBinders/AesBodyModelBinder.cs new file mode 100644 index 0000000..297bcca --- /dev/null +++ b/ModelBinders/AesBodyModelBinder.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using SimApi.Communications; +using SimApi.Helpers; + +namespace SimApi.ModelBinders; + +public class AesBodyModelBinder : IModelBinder +{ + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var request = bindingContext.HttpContext.Request; + request.EnableBuffering(); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var requestBody = await reader.ReadToEndAsync(); + request.Body.Position = 0; + + if (string.IsNullOrEmpty(requestBody)) + { + bindingContext.ModelState.AddModelError("", "请求体不能为空"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + SimApiOneFieldRequest? aesRequest; + try + { + aesRequest = JsonSerializer.Deserialize>(requestBody, SimApiUtil.JsonOption); + } + catch (JsonException ex) + { + bindingContext.ModelState.AddModelError("", $"请求体格式错误:{ex.Message}"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + if (aesRequest == null || string.IsNullOrEmpty(aesRequest.Data)) + { + bindingContext.ModelState.AddModelError("", "请求体缺少密文Data字段"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + // 3. 根据配置获取appId(参考上一节代码) + var keyProvider = + bindingContext.HttpContext.RequestServices.GetService(Type.GetType(bindingContext.BinderModelName!)!) as + AesBodyProviderBase; + string? appId = null; + if (!string.IsNullOrEmpty(keyProvider!.AppIdName)) + { + appId = bindingContext.HttpContext.Request.Query[keyProvider.AppIdName] + .FirstOrDefault() ?? bindingContext.HttpContext.Request.Headers[keyProvider.AppIdName] + .FirstOrDefault(); + + if (string.IsNullOrEmpty(appId)) + { + bindingContext.ModelState.AddModelError("", + $"未找到{keyProvider.AppIdName}"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + } + + // 4. 通过接口获取密钥(解耦的核心:不再直接依赖数据库) + var key = keyProvider.GetKey(appId); + if (string.IsNullOrEmpty(key)) + { + bindingContext.ModelState.AddModelError("", "获取密钥失败(应用不存在或密钥未配置)"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + // 5. 解密和反序列化(保持原有逻辑) + try + { + var jsonStr = SimApiAesUtil.Decrypt(aesRequest.Data, key); + if (string.IsNullOrEmpty(jsonStr)) + { + bindingContext.ModelState.AddModelError("", "解密失败"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + var targetType = bindingContext.ModelType; + var deserializedModel = JsonSerializer.Deserialize(jsonStr, targetType, SimApiUtil.JsonOption); + if (deserializedModel == null) + { + bindingContext.ModelState.AddModelError("", "反序列化失败"); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + bindingContext.Result = ModelBindingResult.Success(deserializedModel); + } + catch (Exception ex) + { + bindingContext.ModelState.AddModelError("", $"处理异常:{ex.Message}"); + bindingContext.Result = ModelBindingResult.Failed(); + } + } +} \ No newline at end of file diff --git a/ModelBinders/AesBodyProviderBase.cs b/ModelBinders/AesBodyProviderBase.cs new file mode 100644 index 0000000..9fbe90b --- /dev/null +++ b/ModelBinders/AesBodyProviderBase.cs @@ -0,0 +1,17 @@ +namespace SimApi.ModelBinders; + +/// +/// 密钥提供器接口(抽象密钥获取逻辑) +/// +public abstract class AesBodyProviderBase +{ + public string? AppIdName { get; set; } = "appId"; + + + /// + /// 根据appId获取对应的密钥 + /// + /// 应用ID + /// 密钥(返回null表示获取失败) + public abstract string? GetKey(string? appId); +} \ No newline at end of file diff --git a/ModelBinders/SimApiSignProviderBase.cs b/ModelBinders/SimApiSignProviderBase.cs new file mode 100644 index 0000000..4af7d4c --- /dev/null +++ b/ModelBinders/SimApiSignProviderBase.cs @@ -0,0 +1,43 @@ +namespace SimApi.ModelBinders; + +public abstract class SimApiSignProviderBase +{ + /// + /// appId字段的名称 + /// + public string? AppIdName { get; set; } = "appId"; + + /// + /// 时间戳的字段名 + /// + public string TimestampName { get; set; } = "timestamp"; + + /// + /// 随机字符串的字段名 + /// + public string NonceName { get; set; } = "nonce"; + + /// + /// 签名的字段名 + /// + public string SignName { get; set; } = "sign"; + + /// + /// 请求过期时间, 如果为0, 不校验timestamp + /// + public int QueryExpires { get; set; } = 5; + + /// + /// 如果开启,必须配置redis, 每次请求将会缓存nonce + /// + public bool DuplicateRequestProtection { get; set; } = true; + + public string[] SignFields { get; set; } = ["appId"]; + + /// + /// 根据appId获取对应的密钥 + /// + /// 应用ID + /// 密钥(返回null表示获取失败) + public abstract string? GetKey(string? appId); +} \ No newline at end of file