在分布式系統的交互中,一般有兩種方式
一、服務頒發給消費方Token令牌,消費方在請求的時候攜帶上令牌
二、加密參數處理,服務方與消費方協調加密解密處理方式
二者各有各的用途和場景,這里不必多言。今天要說的是對第二種交互方式的一點小擴展。
❓問題
一般我們是雙方協定好加密解密方式,在消費方加密參數模型,發起請求,服務方在接收到后,進行解密操作,拿到真實的參數模型后,再進行邏輯處理。
嗯,流程是這樣的,沒啥問題。但是解密參數這一過程卻有點小麻煩,為何?因為需要解密,我們定義的接收參數模型已經變更了,就像這樣。
加密參數模型:
其中的Parameter經過解密后,它也許是一個Order模型、又或者是一個Product模型,不管怎么說,解密后的參數模型才是我們所真正需要的。也是因為如此,我們也無法使用框架為我們帶來的模型驗證(盡管可以通過其它方式來實現),並且我們需要在每個Action內部重復編寫幾乎相同的解密邏輯代碼。
真實參數模型:
更糟糕的是,它使我們的API代碼變得晦澀難懂,因為它們幾乎都是一樣的
怎么辦?有沒有一種方法既可以達到解密的目的,又可以讓我們的API還原本來的樣貌呢?答案是有的。
❗解決
目的:1,自動完成解密。2,使用模型驗證。3,解耦API,做它本身自己的事。
那么說到這兒就已經比較明顯了,首先,需要剝離解密邏輯,解耦API方法,還原它本來的樣子,其次需要繼續使用框架的模型驗證,那么ActionFilter無法使用,因為此時框架已經完成了對模型的校驗。因此我們可以初步得到這樣一條結論,我們需要在API收到請求后,還未進入API的ActionFilter之前,完成解密的過程,並修改請求傳入的參數。
方案一,自定義中間件
中間件是可行的,它在請求中執行的地方符合我們的要求。
第一步,自定義Attribute,標識解密方法
可以加一些自己的內容,比如圖中所示的簽名加密方式、參數解密方式什么的,用來給API方法自定義配置解密方式。最主要的還是起到一個標識的作用,在需要執行解密過程的方法上打上此Attribute即可。
此時,我們的API已經和原本的樣子並無差異,如果此方法不需要解密,去掉AutoDecryptModel
標識即可。
第二步,自定義中間件,完成解密過程。
public async Task Invoke(HttpContext context)
{
HttpRequest request = context.Request;
if (request.Method.Equals("POST"))
{
//從應用緩存獲取需要自動解密的請求路徑
var autoDecryptCacheConfigs =
_cache.Get<List<AutoDecryptCacheConfig>>(MemoryCacheConstants.AutoDecryptMethodTemplates);
if (request.Path.HasValue)
{
var path = request.Path.Value ?? "";
var decryptConfig = autoDecryptCacheConfigs.FirstOrDefault(o => path.EndsWith(o.RouteTemplate));
//如果當前請求匹配需要自動解密
if (decryptConfig is { Mandatory: true })
{
//讀取body
request.Body.Position = 0;
using StreamReader sr = new StreamReader(request.Body);
string requestBody = await sr.ReadToEndAsync();
request.Body.Position = 0;
_logger.LogInformation(
$"當前路徑:{path},請求參數:{requestBody},解密配置:{{ {GetDecryptConfigDescription(decryptConfig)} }}");
//解析參數格式是否符合自動解密 不符合則直接傳遞
var encryptParameter = (JsonConvert.DeserializeObject<EncryptParameterRequest>(requestBody)) ??
new EncryptParameterRequest();
if (!string.IsNullOrWhiteSpace(encryptParameter.Sign) &&
!string.IsNullOrWhiteSpace(encryptParameter.Parameter))
{
//驗證簽名
if (VerifySign(encryptParameter, decryptConfig.SecuritySignCryptType, decryptConfig.Key))
{
//簽名對比通過 開始解密
var decryptParameter = DecryptParameter(encryptParameter.Parameter,
decryptConfig.SecurityParameterDecryptType, decryptConfig.Key);
if (!string.IsNullOrWhiteSpace(decryptParameter))
{
_logger.LogInformation($"當前路徑:{path},請求參數:{requestBody},解密結果:{decryptParameter}");
//寫入解密參數
MemoryStream stream = new MemoryStream();
StreamWriter writer = new StreamWriter(stream);
await writer.WriteAsync(decryptParameter);
await writer.FlushAsync();
//寫入
request.Body = stream;
//重置指針
request.Body.Position = 0;
}
}
else
{
throw new Exception("422-簽名對比失敗,請求不通過");
}
}
else
{
throw new Exception("422-簽名或加密參數不能為空");
}
}
}
}
//拋給下一個中間件
await _next.Invoke(context);
}
只貼出了最主要的Invoke方法,可以看出,做的事情就是,匹配當前請求的路徑是不是需要進行解密操作,需要解密的話,進行解密操作后再將真實的參數模型重新寫入請求中。
這里有兩點需要注意:
-
中間件是拿不到Action的上下文的一些信息的。所以我們在這里其實也不知道它請求的是哪個控制器的哪個方法,因此也無法確定該方法是不是有我們自定義的
AutoDecryptModel
標識,也就是說我們不知道這個進來的請求到底需不需要進行解密操作,由此引出接下來的第三步,來確定需要進行解密的請求。 -
讀取與修改請求的Body。這里不進行過多的闡述,總之我們需要在讀取請求的Body之前,先執行一個這樣的方法:
public async Task Invoke(HttpContext context) { //允許讀取請求的Body context.Request.EnableBuffering(); //拋給下一個中間件 await _next.Invoke(context); }
這個中間件需要放在解密中間件之前,詳細原因請查看此篇文章:解決Asp.Net Core 3.1 中無法讀取HttpContext.Request.Body的問題
第三步,確定需要進行解密的請求。
通過第二步我們可以得到,我們需要讓解密中間件知道哪些請求是需要進行解密操作的。這里采用的方法是在程序啟動時候,通過反射拿到所有具有AutoDecryptModel特性的Action,將其請求路徑記錄下來並保存到內存緩存中。此方式在程序的整個生命周期中,只會執行一次。
修改Host代碼:
Log.Information("Starting ship api web host");
var webHost = CreateHostBuilder(args).Build();
//Build后 Run之前
//此時DI構建完成 但是中間件管道還未完成構建
//Run之后中間件管道才開始構建 IStartupFilter才會被執行
//獲取需要自動解密參數的方法
var autoDecryptCacheConfigs = GetAssemblyAutoDecryptModelMethods();
//加入緩存服務
var cache = webHost.Services.GetRequiredService<IMemoryCache>();
cache.Set(MemoryCacheConstants.AutoDecryptMethodTemplates, autoDecryptCacheConfigs);
//啟動Host
webHost.Run();
獲取方法:
/// <summary>
/// 獲取程序集中需要自動解密的方法
/// </summary>
/// <returns></returns>
private static List<AutoDecryptCacheConfig> GetAssemblyAutoDecryptModelMethods()
{
List<AutoDecryptCacheConfig> configs = new();
var assembly = typeof(Startup).Assembly.GetTypes().AsEnumerable()
.Where(type => typeof(BaseController).IsAssignableFrom(type) && type != typeof(BaseController)).ToList();
assembly.ForEach(r =>
{
var methods = r.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
foreach (var methodInfo in methods)
{
if (methodInfo.IsDefined(typeof(AutoDecryptModelAttribute), true))
{
if (methodInfo.IsDefined(typeof(RouteAttribute), true))
{
//獲取路由模板
RouteAttribute route = methodInfo.GetCustomAttribute<RouteAttribute>(true);
//在特性中添加自定義信息 寫入緩存 可以實現方法指定加密解密方式、指定密匙以及是否可以兼容不處理
AutoDecryptModelAttribute autoDecryptAttribute = methodInfo.GetCustomAttribute<AutoDecryptModelAttribute>(true);
if (route != null && autoDecryptAttribute != null)
{
var template = route.Template;
configs.Add(new AutoDecryptCacheConfig()
{
RouteTemplate = template,
SecuritySignCryptType = autoDecryptAttribute.SecuritySignCryptType,
SecurityParameterDecryptType = autoDecryptAttribute.SecurityParameterDecryptType,
Key = autoDecryptAttribute.Key,
//Mandatory = autoDecryptAttribute.Mandatory
});
Console.WriteLine($"method:{r.Namespace}-{r.Name}-{methodInfo.Name};route:{template}");
}
}
else
{
var err =
@$"方法使用特性[{nameof(AutoDecryptModelAttribute)}]時,必須與特性[{nameof(RouteAttribute)}]一起使用,
method:{r.Namespace}-{r.Name}-{methodInfo.Name}";
Log.Error(err);
}
}
}
});
return configs;
}
運行起來看看:
可以看出在標記有我們AutoDecryptModel特性的Action被正確識別打印了出來,接下來只要配合我們的中間件,從內存緩存項中匹配當前請求是否需要進行解密操作,即可實現我們最初的需求。
本來到這里應該結束了,一想到常用的ActionFilter不能滿足這次的要求,便又去看了看.Net Core的幾個Filter,結果這一看。
猛的注意到了以前忽略的ModelBinding,且平時常用其他四個Filter,經常忽略ResourceFilter,似乎意識到了什么,趕緊去查了查這個Filter的定義,通常是需要對Model加工處理才用。瞬間汗顏......馬上去添加了新的解決方案。
方案二,自定義ResourceFilter
實現IAsyncResourceFilter
將我們的AutoDecryptModel特性實現IAsyncResourceFilter接口,如果當前請求的Action上存在AutoDecryptModel特性,那么框架會自動幫你調用解密方法,只需要一個類即可完成整個流程。
全部代碼如下:
/// <summary>
/// 自動解密請求參數
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AutoDecryptModelAttribute : Attribute, IAsyncResourceFilter
{
/// <summary>
/// 默認密匙
/// </summary>
private readonly string _defaultKey =
AppConfigurationUtil.Configuration["Application:Security:DefaultSecurityKey"];
/// <summary>
/// 簽名加密方式
/// </summary>
public SecuritySignCryptTypeEnum SecuritySignCryptType { get; }
/// <summary>
/// 參數解密方式
/// </summary>
public SecurityParameterDecryptTypeEnum SecurityParameterDecryptType { get; }
/// <summary>
/// 密匙
/// </summary>
public string Key { get; }
/// <summary>
/// 自動解密 默認MD5加密簽名 DES加密參數
/// </summary>
public AutoDecryptModelAttribute() :
this(SecuritySignCryptTypeEnum.Md5Encrypt, SecurityParameterDecryptTypeEnum.DesDecrypt)
{
Key = _defaultKey;
}
/// <summary>
/// 自動解密
/// </summary>
/// <param name="securitySignCryptType">簽名加密方式</param>
public AutoDecryptModelAttribute(SecuritySignCryptTypeEnum securitySignCryptType) : this(securitySignCryptType,
SecurityParameterDecryptTypeEnum.DesDecrypt)
{
Key = _defaultKey;
}
/// <summary>
/// 自動解密
/// </summary>
/// <param name="securityParameterDecryptType">參數解密方式</param>
public AutoDecryptModelAttribute(SecurityParameterDecryptTypeEnum securityParameterDecryptType) : this(
SecuritySignCryptTypeEnum.Md5Encrypt, securityParameterDecryptType)
{
Key = _defaultKey;
}
/// <summary>
/// 自動解密
/// </summary>
/// <param name="key">密匙</param>
/// <param name="securityCryptType">簽名加密方式</param>
/// <param name="securityParameterDecryptType">參數解密方式</param>
public AutoDecryptModelAttribute(string key,
SecuritySignCryptTypeEnum securityCryptType = SecuritySignCryptTypeEnum.Md5Encrypt,
SecurityParameterDecryptTypeEnum securityParameterDecryptType =
SecurityParameterDecryptTypeEnum.DesDecrypt) : this(securityCryptType, securityParameterDecryptType,
key)
{
}
/// <summary>
/// 自動解密
/// </summary>
/// <param name="securityCryptType">簽名加密方式</param>
/// <param name="securityParameterDecryptType">參數解密方式</param>
/// <param name="key">密匙</param>
public AutoDecryptModelAttribute(SecuritySignCryptTypeEnum securityCryptType,
SecurityParameterDecryptTypeEnum securityParameterDecryptType,
string key = null)
{
Key = key;
SecuritySignCryptType = securityCryptType;
SecurityParameterDecryptType = securityParameterDecryptType;
}
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
HttpRequest request = context.HttpContext.Request;
string path = request.Path.Value ?? "";
//讀取body
request.Body.Position = 0;
using StreamReader sr = new StreamReader(request.Body);
string requestBody = await sr.ReadToEndAsync();
request.Body.Position = 0;
Console.WriteLine(
$"當前路徑:{path},請求參數:{requestBody},解密配置:{{ {GetDecryptConfigDescription()} }}");
//解析參數格式是否符合自動解密 不符合則直接傳遞
var encryptParameter = (JsonConvert.DeserializeObject<EncryptParameterRequest>(requestBody)) ??
new EncryptParameterRequest();
if (!string.IsNullOrWhiteSpace(encryptParameter.Sign) &&
!string.IsNullOrWhiteSpace(encryptParameter.Parameter))
{
//驗證簽名
if (VerifySign(encryptParameter))
{
//簽名對比通過 開始解密
var decryptParameter = DecryptParameter(encryptParameter.Parameter);
if (!string.IsNullOrWhiteSpace(decryptParameter))
{
//_logger.LogInformation
Console.WriteLine($"當前路徑:{path},請求參數:{requestBody},解密結果:{decryptParameter}");
//寫入解密參數
MemoryStream stream = new MemoryStream();
StreamWriter writer = new StreamWriter(stream);
await writer.WriteAsync(decryptParameter);
await writer.FlushAsync();
//寫入
request.Body = stream;
//重置指針
request.Body.Position = 0;
}
}
else
{
throw new Exception("422-簽名對比失敗,請求不通過");
}
}
else
{
throw new Exception("422-簽名或加密參數不能為空");
}
await next();
}
/// <summary>
/// 驗證簽名
/// </summary>
/// <param name="parameter">請求參數</param>
private bool VerifySign(EncryptParameterRequest parameter)
{
//如果有加密方式不需要Key,則需要修改case
RequiredKey(Key);
string toVerifyStr = parameter.Parameter + Key;
switch (SecuritySignCryptType)
{
case SecuritySignCryptTypeEnum.Md5Encrypt:
return (SecurityMd5Crypt.Md5Encrypt(toVerifyStr)).Equals(parameter.Sign);
case SecuritySignCryptTypeEnum.Md5Encrypt16:
return (SecurityMd5Crypt.Md5Encrypt16(toVerifyStr)).Equals(parameter.Sign);
case SecuritySignCryptTypeEnum.Md5Encrypt32:
return (SecurityMd5Crypt.Md5Encrypt32(toVerifyStr)).Equals(parameter.Sign);
case SecuritySignCryptTypeEnum.Sha1Encrypt:
return (SecurityShaCrypt.Sha1Encrypt(toVerifyStr)).Equals(parameter.Sign);
case SecuritySignCryptTypeEnum.Sha256Encrypt:
return (SecurityShaCrypt.Sha256Encrypt(toVerifyStr)).Equals(parameter.Sign);
}
return false;
}
/// <summary>
/// 解密參數
/// </summary>
/// <param name="encryptParameterStr">加密參數參數</param>
/// <returns></returns>
private string DecryptParameter(string encryptParameterStr)
{
if (!string.IsNullOrWhiteSpace(encryptParameterStr))
{
switch (SecurityParameterDecryptType)
{
case SecurityParameterDecryptTypeEnum.DesDecrypt:
RequiredKey(Key);
return SecurityDesCrypt.DesDecrypt(encryptParameterStr, Key);
case SecurityParameterDecryptTypeEnum.Base64Decrypt:
return SecurityBase64Crypt.Base64Decrypt(encryptParameterStr);
}
}
return null;
}
private static void RequiredKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new Exception("422-密匙不能為空");
}
}
/// <summary>
/// 獲取解密配置相關描述信息
/// </summary>
/// <returns></returns>
private string GetDecryptConfigDescription()
{
return
$"簽名加密方式:{SecuritySignCryptType.ToDescriptionString()},參數解密方式:{SecurityParameterDecryptType.ToDescriptionString()}";
}
}
使用方式和之前一樣,在需要進行自動解密的Action上打上AutoDecryptModel特性標識即可,解密方式也可以靈活配置,由於此時ModelBinding還未執行,解密后的參數模型也可以應用框架的模型驗證,完美達到了我們的目的。相比方案一精簡干練了很多,省去了中間件,省去了反射配置等等。運行起來驗證測試,一切如預期一樣進行~,至此完結,撒花。
總結
多多了解框架的設計,思考與實踐。