mirror of
https://gitee.com/fudiwei/DotNetCore.SKIT.FlurlHttpClient.Wechat.git
synced 2025-12-29 18:04:42 +08:00
feat(wxapi): 适配 API 安全鉴权模式
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Constants
|
||||
{
|
||||
public static class SecurityApiAsymmetricAlgorithms
|
||||
{
|
||||
public const string RSA = "RSA";
|
||||
|
||||
public const string SM2 = "SM2";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Constants
|
||||
{
|
||||
public static class SecurityApiSymmetricAlgorithms
|
||||
{
|
||||
public const string AES = "AES";
|
||||
|
||||
public const string SM4 = "SM4";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Flurl.Http;
|
||||
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Interceptors
|
||||
{
|
||||
using SKIT.FlurlHttpClient.Internal;
|
||||
using SKIT.FlurlHttpClient.Wechat.Api.Constants;
|
||||
|
||||
internal class WechatApiSecurityApiInterceptor : HttpInterceptor
|
||||
{
|
||||
/**
|
||||
* REF:
|
||||
* https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/getting_started/api_signature.html
|
||||
* https://developers.weixin.qq.com/community/develop/article/doc/00028ca675c708b23f100b8e161013
|
||||
* https://developers.weixin.qq.com/community/develop/article/doc/000e68b8038ed8796f00f6c2f68c13
|
||||
*/
|
||||
private static readonly ISet<string> SIGN_REQUIRED_URLS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/cgi-bin/clear_quota",
|
||||
"/cgi-bin/openapi/quota/get",
|
||||
"/cgi-bin/openapi/rid/get",
|
||||
"/wxa/getpluginopenpid",
|
||||
"/wxa/business/checkencryptedmsg",
|
||||
"/wxa/business/getuserencryptkey",
|
||||
"/wxa/business/getuserphonenumber",
|
||||
"/wxa/getwxacode",
|
||||
"/wxa/getwxacodeunlimit",
|
||||
"/cgi-bin/wxaapp/createwxaqrcode",
|
||||
"/cgi-bin/message/custom/send",
|
||||
"/cgi-bin/message/wxopen/updatablemsg/send",
|
||||
"/wxaapi/newtmpl/deltemplate",
|
||||
"/cgi-bin/message/subscribe/send",
|
||||
"/wxaapi/newtmpl/addtemplate",
|
||||
"/wxa/msg_sec_check",
|
||||
"/wxa/media_check_async",
|
||||
"/wxa/getuserriskrank",
|
||||
"/datacube/getweanalysisappidweeklyretaininfo",
|
||||
"/datacube/getweanalysisappidmonthlyretaininfo",
|
||||
"/datacube/getweanalysisappiddailyretaininfo",
|
||||
"/datacube/getweanalysisappidmonthlyvisittrend",
|
||||
"/datacube/getweanalysisappiddailyvisittrend",
|
||||
"/datacube/getweanalysisappidweeklyvisittrend",
|
||||
"/datacube/getweanalysisappiddailysummarytrend",
|
||||
"/datacube/getweanalysisappidvisitpage",
|
||||
"/datacube/getweanalysisappiduserportrait",
|
||||
"/wxa/business/performance/boot",
|
||||
"/datacube/getweanalysisappidvisitdistribution",
|
||||
"/wxa/getwxadevinfo",
|
||||
"/wxaapi/log/get_performance",
|
||||
"/wxaapi/log/jserr_detail",
|
||||
"/wxaapi/log/jserr_list",
|
||||
"/wxa/devplugin",
|
||||
"/wxa/plugin",
|
||||
"/cgi-bin/express/business/account/getall",
|
||||
"/cgi-bin/express/business/delivery/getall",
|
||||
"/cgi-bin/express/business/printer/getall",
|
||||
"/wxa/servicemarket",
|
||||
"/cgi-bin/soter/verify_signature",
|
||||
"/cgi-bin/express/intracity/apply",
|
||||
"/cgi-bin/express/intracity/createstore",
|
||||
"/cgi-bin/express/intracity/querystore",
|
||||
"/cgi-bin/express/intracity/updatestore",
|
||||
"/cgi-bin/express/intracity/storecharge",
|
||||
"/cgi-bin/express/intracity/storerefund",
|
||||
"/cgi-bin/express/intracity/queryflow",
|
||||
"/cgi-bin/express/intracity/balancequery",
|
||||
"/cgi-bin/express/intracity/preaddorder",
|
||||
"/cgi-bin/express/intracity/addorder",
|
||||
"/cgi-bin/express/intracity/queryorder",
|
||||
"/cgi-bin/express/intracity/cancelorder",
|
||||
"/cgi-bin/express/intracity/setpaymode",
|
||||
"/cgi-bin/express/intracity/getpaymode",
|
||||
"/cgi-bin/express/intracity/getcity",
|
||||
"/cgi-bin/express/intracity/mocknotify"
|
||||
};
|
||||
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _appId;
|
||||
private readonly string _symmetricAlg;
|
||||
private readonly string _symmetricNum;
|
||||
private readonly string _symmetricKey;
|
||||
private readonly string _asymmetricAlg;
|
||||
private readonly string _asymmetricNum;
|
||||
private readonly string _asymmetricPrivateKey;
|
||||
private readonly Func<string, bool>? _customRequestPathMatcher;
|
||||
|
||||
public WechatApiSecurityApiInterceptor(string baseUrl, string appId, string symmetricAlg, string symmetricNum, string symmetricKey, string asymmetricAlg, string asymmetricNum, string asymmetricPrivateKey, Func<string, bool>? customRequestPathMatcher)
|
||||
{
|
||||
_baseUrl = baseUrl;
|
||||
_appId = appId;
|
||||
_symmetricAlg = symmetricAlg;
|
||||
_symmetricNum = symmetricNum;
|
||||
_symmetricKey = symmetricKey;
|
||||
_asymmetricAlg = asymmetricAlg;
|
||||
_asymmetricNum = asymmetricNum;
|
||||
_asymmetricPrivateKey = asymmetricPrivateKey;
|
||||
_customRequestPathMatcher = customRequestPathMatcher;
|
||||
}
|
||||
|
||||
public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
if (context.FlurlCall.Completed) throw new WechatApiException("Failed to encrypt and sign request. This interceptor must be called before request completed.");
|
||||
|
||||
if (context.FlurlCall.HttpRequestMessage.Method != HttpMethod.Post)
|
||||
return;
|
||||
if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
|
||||
return;
|
||||
if (!IsRequestUrlRequireEncryption(context.FlurlCall.HttpRequestMessage.RequestUri))
|
||||
return;
|
||||
|
||||
string urlpath = GetRequestUrl(context.FlurlCall.HttpRequestMessage.RequestUri);
|
||||
string timestamp = DateTimeOffset.Now.ToLocalTime().ToUnixTimeSeconds().ToString();
|
||||
string postData = "{}";
|
||||
string postDataEncrypted;
|
||||
if (context.FlurlCall.HttpRequestMessage?.Content is not null)
|
||||
{
|
||||
if (context.FlurlCall.HttpRequestMessage.Content is MultipartFormDataContent)
|
||||
return;
|
||||
|
||||
HttpContent httpContent = context.FlurlCall.HttpRequestMessage.Content;
|
||||
postData = await
|
||||
#if NET5_0_OR_GREATER
|
||||
httpContent.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
_AsyncEx.RunTaskWithCancellationTokenAsync(httpContent.ReadAsStringAsync(), cancellationToken).ConfigureAwait(false);
|
||||
#endif
|
||||
if (string.IsNullOrEmpty(postData))
|
||||
postData = "{}";
|
||||
}
|
||||
|
||||
// 对称加密
|
||||
if (string.IsNullOrEmpty(_symmetricKey))
|
||||
{
|
||||
throw new WechatApiException("Failed to encrypt request, because the AES/SM4 key is not set.");
|
||||
}
|
||||
else
|
||||
{
|
||||
string nonce = GenerateSymmetricEncryptionNonce(12);
|
||||
string associatedData = GenerateSymmetricEncryptionAssociatedData(urlpath, _appId, timestamp);
|
||||
string plainData;
|
||||
try
|
||||
{
|
||||
JsonObject jsonObj = JsonObject.Parse(postData)!.AsObject();
|
||||
jsonObj["_n"] = GenerateSymmetricEncryptionNonce(16);
|
||||
jsonObj["_appid"] = _appId;
|
||||
jsonObj["_timestamp"] = timestamp;
|
||||
|
||||
NameValueCollection queryParams = HttpUtility.ParseQueryString(context.FlurlCall.HttpRequestMessage!.RequestUri.Query);
|
||||
foreach (string? key in queryParams.AllKeys)
|
||||
{
|
||||
if (key is null) continue;
|
||||
if (key == "access_token") continue;
|
||||
jsonObj[key] = queryParams[key];
|
||||
}
|
||||
|
||||
plainData = jsonObj.ToJsonString(SystemTextJsonSerializer.GetDefaultSerializerOptions());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to encrypt request. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
|
||||
string sData, sIV, sAuthTag;
|
||||
switch (_symmetricAlg)
|
||||
{
|
||||
case SecurityApiSymmetricAlgorithms.AES:
|
||||
{
|
||||
try
|
||||
{
|
||||
const int TAG_LENGTH_BYTE = 16;
|
||||
byte[] cipherBytes = Utilities.AESUtility.EncryptWithGCM(
|
||||
keyBytes: Convert.FromBase64String(_symmetricKey),
|
||||
nonceBytes: Convert.FromBase64String(nonce),
|
||||
associatedDataBytes: Encoding.UTF8.GetBytes(associatedData),
|
||||
plainBytes: Encoding.UTF8.GetBytes(plainData)
|
||||
)!;
|
||||
byte[] encdataBytes = new byte[cipherBytes.Length - TAG_LENGTH_BYTE];
|
||||
byte[] authtagBytes = new byte[TAG_LENGTH_BYTE];
|
||||
Buffer.BlockCopy(cipherBytes, 0, encdataBytes, 0, encdataBytes.Length);
|
||||
Buffer.BlockCopy(cipherBytes, encdataBytes.Length, authtagBytes, 0, authtagBytes.Length);
|
||||
|
||||
sIV = nonce;
|
||||
sData = Convert.ToBase64String(encdataBytes);
|
||||
sAuthTag = Convert.ToBase64String(authtagBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to encrypt request. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SecurityApiSymmetricAlgorithms.SM4:
|
||||
{
|
||||
try
|
||||
{
|
||||
const int TAG_LENGTH_BYTE = 16;
|
||||
byte[] cipherBytes = Utilities.SM4Utility.EncryptWithGCM(
|
||||
keyBytes: Convert.FromBase64String(_symmetricKey),
|
||||
nonceBytes: Convert.FromBase64String(nonce),
|
||||
associatedDataBytes: Encoding.UTF8.GetBytes(associatedData),
|
||||
plainBytes: Encoding.UTF8.GetBytes(plainData)
|
||||
)!;
|
||||
byte[] encdataBytes = new byte[cipherBytes.Length - TAG_LENGTH_BYTE];
|
||||
byte[] authtagBytes = new byte[TAG_LENGTH_BYTE];
|
||||
Buffer.BlockCopy(cipherBytes, 0, encdataBytes, 0, encdataBytes.Length);
|
||||
Buffer.BlockCopy(cipherBytes, encdataBytes.Length, authtagBytes, 0, authtagBytes.Length);
|
||||
|
||||
sIV = nonce;
|
||||
sData = Convert.ToBase64String(encdataBytes);
|
||||
sAuthTag = Convert.ToBase64String(authtagBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to encrypt request. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new WechatApiException($"Failed to encrypt request. Unsupported encryption algorithm: \"{_symmetricAlg}\".");
|
||||
}
|
||||
|
||||
IDictionary<string, string> temp = new Dictionary<string, string>(capacity: 4)
|
||||
{
|
||||
{ "iv", sIV },
|
||||
{ "data", sData },
|
||||
{ "authtag", sAuthTag }
|
||||
};
|
||||
postDataEncrypted = JsonSerializer.Serialize(temp, SystemTextJsonSerializer.GetDefaultSerializerOptions());
|
||||
context.FlurlCall.HttpRequestMessage!.Content?.Dispose();
|
||||
context.FlurlCall.HttpRequestMessage!.Content = new StringContent(postDataEncrypted, Encoding.UTF8, MimeTypes.Json);
|
||||
}
|
||||
|
||||
// 非对称签名
|
||||
if (string.IsNullOrEmpty(_asymmetricPrivateKey))
|
||||
{
|
||||
throw new WechatApiException("Failed to sign request, because the RSA/SM2 private key is not set.");
|
||||
}
|
||||
else
|
||||
{
|
||||
string signData = $"{urlpath}\n{_appId}\n{timestamp}\n{postDataEncrypted}";
|
||||
string sign;
|
||||
|
||||
switch (_asymmetricAlg)
|
||||
{
|
||||
case SecurityApiAsymmetricAlgorithms.RSA:
|
||||
{
|
||||
try
|
||||
{
|
||||
sign = Utilities.RSAUtility.SignWithSHA256PSS(_asymmetricPrivateKey, signData).Value!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to sign request. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SecurityApiAsymmetricAlgorithms.SM2:
|
||||
{
|
||||
try
|
||||
{
|
||||
sign = Utilities.SM2Utility.SignWithSM3(_asymmetricPrivateKey, _asymmetricNum, signData).Value!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to sign request. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new WechatApiException($"Failed to sign request. Unsupported signing algorithm: \"{_asymmetricAlg}\".");
|
||||
}
|
||||
|
||||
context.FlurlCall.Request.WithHeader("Wechatmp-Appid", _appId);
|
||||
context.FlurlCall.Request.WithHeader("Wechatmp-TimeStamp", timestamp);
|
||||
context.FlurlCall.Request.WithHeader("Wechatmp-Signature", sign);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task AfterCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
if (!context.FlurlCall.Completed) throw new WechatApiException("Failed to decrypt and verify response. This interceptor must be called after request completed.");
|
||||
|
||||
if (context.FlurlCall.HttpRequestMessage.Method != HttpMethod.Post)
|
||||
return;
|
||||
if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
|
||||
return;
|
||||
if (!IsRequestUrlRequireEncryption(context.FlurlCall.HttpRequestMessage.RequestUri))
|
||||
return;
|
||||
if (context.FlurlCall.HttpResponseMessage is null)
|
||||
return;
|
||||
|
||||
string urlpath = GetRequestUrl(context.FlurlCall.HttpRequestMessage.RequestUri);
|
||||
byte[] respBytes = Array.Empty<byte>();
|
||||
if (context.FlurlCall.HttpResponseMessage.Content is not null)
|
||||
{
|
||||
HttpContent httpContent = context.FlurlCall.HttpResponseMessage.Content;
|
||||
respBytes = await
|
||||
#if NET5_0_OR_GREATER
|
||||
httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
_AsyncEx.RunTaskWithCancellationTokenAsync(httpContent.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
// 对称解密
|
||||
if (string.IsNullOrEmpty(_symmetricKey))
|
||||
{
|
||||
throw new WechatApiException("Failed to decrypt response, because the AES/SM4 key is not set.");
|
||||
}
|
||||
else if (_StringSyntaxAssert.MaybeJson(respBytes))
|
||||
{
|
||||
bool requireDecrypt = false;
|
||||
string? sData = null, sIV = null, sAuthTag = null;
|
||||
|
||||
try
|
||||
{
|
||||
JsonObject jsonObj = JsonObject.Parse(respBytes)!.AsObject();
|
||||
requireDecrypt = context.FlurlResponse!.Headers.Contains("Wechatmp-Signature")
|
||||
&& jsonObj.ContainsKey("data")
|
||||
&& jsonObj.ContainsKey("iv")
|
||||
&& jsonObj.ContainsKey("authtag");
|
||||
|
||||
if (requireDecrypt)
|
||||
{
|
||||
sData = jsonObj["data"]!.GetValue<string>();
|
||||
sIV = jsonObj["iv"]!.GetValue<string>();
|
||||
sAuthTag = jsonObj["authtag"]!.GetValue<string>();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to decrypt response. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
|
||||
if (requireDecrypt)
|
||||
{
|
||||
byte[] respBytesDecrypted;
|
||||
string appId = context.FlurlResponse!.Headers.GetAll("Wechatmp-Appid")?.FirstOrDefault() ?? string.Empty;
|
||||
string timestamp = context.FlurlResponse!.Headers.GetAll("Wechatmp-TimeStamp")?.FirstOrDefault() ?? string.Empty;
|
||||
string associatedData = GenerateSymmetricEncryptionAssociatedData(urlpath, appId, timestamp);
|
||||
|
||||
switch (_symmetricAlg)
|
||||
{
|
||||
case SecurityApiSymmetricAlgorithms.AES:
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] encdataBytes = Convert.FromBase64String(sData!);
|
||||
byte[] authtagBytes = Convert.FromBase64String(sAuthTag!);
|
||||
byte[] cipherBytes = new byte[encdataBytes.Length + authtagBytes.Length];
|
||||
Buffer.BlockCopy(encdataBytes, 0, cipherBytes, 0, encdataBytes.Length);
|
||||
Buffer.BlockCopy(authtagBytes, 0, cipherBytes, encdataBytes.Length, authtagBytes.Length);
|
||||
|
||||
respBytesDecrypted = Utilities.AESUtility.DecryptWithGCM(
|
||||
keyBytes: Convert.FromBase64String(_symmetricKey),
|
||||
nonceBytes: Convert.FromBase64String(sIV!),
|
||||
associatedDataBytes: Encoding.UTF8.GetBytes(associatedData),
|
||||
cipherBytes: cipherBytes
|
||||
)!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to decrypt response. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SecurityApiSymmetricAlgorithms.SM4:
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] encdataBytes = Convert.FromBase64String(sData!);
|
||||
byte[] authtagBytes = Convert.FromBase64String(sAuthTag!);
|
||||
byte[] cipherBytes = new byte[encdataBytes.Length + authtagBytes.Length];
|
||||
Buffer.BlockCopy(encdataBytes, 0, cipherBytes, 0, encdataBytes.Length);
|
||||
Buffer.BlockCopy(authtagBytes, 0, cipherBytes, encdataBytes.Length, authtagBytes.Length);
|
||||
|
||||
respBytesDecrypted = Utilities.SM4Utility.DecryptWithGCM(
|
||||
keyBytes: Convert.FromBase64String(_symmetricKey),
|
||||
nonceBytes: Convert.FromBase64String(sIV!),
|
||||
associatedDataBytes: Encoding.UTF8.GetBytes(associatedData),
|
||||
cipherBytes: cipherBytes
|
||||
)!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WechatApiException("Failed to decrypt response. Please see the inner exception for more details.", ex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new WechatApiException($"Failed to decrypt response. Unsupported encryption algorithm: \"{_symmetricAlg}\".");
|
||||
}
|
||||
|
||||
context.FlurlCall.HttpResponseMessage!.Content?.Dispose();
|
||||
context.FlurlCall.HttpResponseMessage!.Content = new ByteArrayContent(respBytesDecrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateSymmetricEncryptionNonce(int byteLength)
|
||||
{
|
||||
byte[] bytes = new byte[byteLength];
|
||||
new Random().NextBytes(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private string GenerateSymmetricEncryptionAssociatedData(string urlpath, string appId, string timestamp)
|
||||
{
|
||||
return $"{urlpath}|{appId}|{timestamp}|{_symmetricNum}";
|
||||
}
|
||||
|
||||
private string GetRequestUrl(Uri uri)
|
||||
{
|
||||
return uri.AbsoluteUri.Substring(0, uri.AbsoluteUri.Length - uri.Query.Length);
|
||||
}
|
||||
|
||||
private bool IsRequestUrlRequireEncryption(Uri uri)
|
||||
{
|
||||
string absoluteUrl = GetRequestUrl(uri);
|
||||
if (!absoluteUrl.StartsWith(_baseUrl))
|
||||
return false;
|
||||
|
||||
string relativeUrl = absoluteUrl.Substring(_baseUrl.TrimEnd('/').Length);
|
||||
if (!SIGN_REQUIRED_URLS.Contains(relativeUrl))
|
||||
{
|
||||
if (_customRequestPathMatcher is not null)
|
||||
return _customRequestPathMatcher(relativeUrl);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Models
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>表示 [POST] /wxa/getuserriskrank 接口的响应。</para>
|
||||
|
||||
@@ -3,9 +3,13 @@ using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Org.BouncyCastle.X509;
|
||||
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
{
|
||||
@@ -16,11 +20,6 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
/// </summary>
|
||||
public static class RSAUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// 签名算法:SHA-256withRSA。
|
||||
/// </summary>
|
||||
private const string DIGEST_ALGORITHM_SHA256 = "SHA-256withRSA";
|
||||
|
||||
private static byte[] ConvertPrivateKeyPemToByteArray(string privateKeyPem)
|
||||
{
|
||||
const string PKCS8_HEADER = "-----BEGIN PRIVATE KEY-----";
|
||||
@@ -103,6 +102,15 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
return Convert.FromBase64String(publicKeyPem);
|
||||
}
|
||||
|
||||
private static X509Certificate ParseCertificatePemToX509(string certificatePem)
|
||||
{
|
||||
using (TextReader sreader = new StringReader(certificatePem))
|
||||
{
|
||||
PemReader pemReader = new PemReader(sreader);
|
||||
return (X509Certificate)pemReader.ReadObject();
|
||||
}
|
||||
}
|
||||
|
||||
#if NET5_0_OR_GREATER
|
||||
#else
|
||||
private static RsaKeyParameters ParsePrivateKeyToParameters(byte[] privateKeyBytes)
|
||||
@@ -115,17 +123,17 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
return (RsaKeyParameters)PublicKeyFactory.CreateKey(publicKeyBytes);
|
||||
}
|
||||
|
||||
private static byte[] Sign(RsaKeyParameters rsaPrivateKeyParams, byte[] messageBytes, string digestAlgorithm)
|
||||
private static byte[] SignWithSHA256PSS(RsaKeyParameters rsaPrivateKeyParams, byte[] messageBytes)
|
||||
{
|
||||
ISigner signer = SignerUtilities.GetSigner(digestAlgorithm);
|
||||
ISigner signer = new PssSigner(new RsaEngine(), new Sha256Digest());
|
||||
signer.Init(true, rsaPrivateKeyParams);
|
||||
signer.BlockUpdate(messageBytes, 0, messageBytes.Length);
|
||||
return signer.GenerateSignature();
|
||||
}
|
||||
|
||||
private static bool Verify(RsaKeyParameters rsaPublicKeyParams, byte[] messageBytes, byte[] signBytes, string digestAlgorithm)
|
||||
private static bool VerifyWithSHA256PSS(RsaKeyParameters rsaPublicKeyParams, byte[] messageBytes, byte[] signBytes)
|
||||
{
|
||||
ISigner signer = SignerUtilities.GetSigner(digestAlgorithm);
|
||||
ISigner signer = new PssSigner(new RsaEngine(), new Sha256Digest());
|
||||
signer.Init(false, rsaPublicKeyParams);
|
||||
signer.BlockUpdate(messageBytes, 0, messageBytes.Length);
|
||||
return signer.VerifySignature(signBytes);
|
||||
@@ -133,13 +141,12 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 使用私钥基于 SHA-256 算法生成签名。
|
||||
/// 使用私钥基于 SHA-256/PSS 算法生成签名。
|
||||
/// </summary>
|
||||
/// <param name="privateKeyBytes">PKCS#8 私钥字节数组。</param>
|
||||
/// <param name="messageBytes">待签名的数据字节数组。</param>
|
||||
/// <param name="digestAlgorithm">签名算法。(默认值:<see cref="DIGEST_ALGORITHM_SHA256"/>)</param>
|
||||
/// <returns>签名字节数组。</returns>
|
||||
public static byte[] SignWithSHA256(byte[] privateKeyBytes, byte[] messageBytes)
|
||||
public static byte[] SignWithSHA256PSS(byte[] privateKeyBytes, byte[] messageBytes)
|
||||
{
|
||||
if (privateKeyBytes is null) throw new ArgumentNullException(nameof(privateKeyBytes));
|
||||
if (messageBytes is null) throw new ArgumentNullException(nameof(messageBytes));
|
||||
@@ -148,67 +155,64 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
using (RSA rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _);
|
||||
return rsa.SignData(messageBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return rsa.SignData(messageBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
#else
|
||||
RsaKeyParameters rsaPrivateKeyParams = ParsePrivateKeyToParameters(privateKeyBytes);
|
||||
return Sign(rsaPrivateKeyParams, messageBytes, DIGEST_ALGORITHM_SHA256);
|
||||
return SignWithSHA256PSS(rsaPrivateKeyParams, messageBytes);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用私钥基于 SHA-256 算法生成签名。
|
||||
/// 使用私钥基于 SHA-256/PSS 算法生成签名。
|
||||
/// </summary>
|
||||
/// <param name="privateKeyPem">PKCS#1/PKCS#8 私钥(PEM 格式)。</param>
|
||||
/// <param name="messageData">待签名的数据。</param>
|
||||
/// <param name="digestAlgorithm">签名算法。(默认值:<see cref="DIGEST_ALGORITHM_SHA256"/>)</param>
|
||||
/// <returns>经过 Base64 编码的签名。</returns>
|
||||
public static EncodedString SignWithSHA256(string privateKeyPem, string messageData)
|
||||
public static EncodedString SignWithSHA256PSS(string privateKeyPem, string messageData)
|
||||
{
|
||||
if (privateKeyPem is null) throw new ArgumentNullException(nameof(privateKeyPem));
|
||||
if (messageData is null) throw new ArgumentNullException(nameof(messageData));
|
||||
|
||||
byte[] privateKeyBytes = ConvertPrivateKeyPemToByteArray(privateKeyPem);
|
||||
byte[] messageBytes = EncodedString.FromLiteralString(messageData);
|
||||
byte[] signBytes = SignWithSHA256(privateKeyBytes, messageBytes);
|
||||
byte[] signBytes = SignWithSHA256PSS(privateKeyBytes, messageBytes);
|
||||
return EncodedString.ToBase64String(signBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用公钥基于 SHA-256 算法验证签名。
|
||||
/// 使用公钥基于 SHA-256/PSS 算法验证签名。
|
||||
/// </summary>
|
||||
/// <param name="publicKeyBytes">PKCS#8 公钥字节数组。</param>
|
||||
/// <param name="messageBytes">待验证的数据字节数组。</param>
|
||||
/// <param name="signBytes">签名字节数组。</param>
|
||||
/// <param name="digestAlgorithm">签名算法。(默认值:<see cref="DIGEST_ALGORITHM_SHA256"/>)</param>
|
||||
/// <returns>验证结果。</returns>
|
||||
public static bool VerifyWithSHA256(byte[] publicKeyBytes, byte[] messageBytes, byte[] signBytes)
|
||||
public static bool VerifyWithSHA256PSS(byte[] publicKeyBytes, byte[] messageBytes, byte[] signBytes)
|
||||
{
|
||||
if (publicKeyBytes is null) throw new ArgumentNullException(nameof(publicKeyBytes));
|
||||
if (messageBytes is null) throw new ArgumentNullException(nameof(messageBytes));
|
||||
if (signBytes is null) throw new ArgumentNullException(nameof(signBytes));
|
||||
|
||||
#if NET5_0_OR_GREATER
|
||||
#if NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER
|
||||
using (RSA rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
return rsa.VerifyData(messageBytes, signBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return rsa.VerifyData(messageBytes, signBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
#else
|
||||
RsaKeyParameters rsaPublicKeyParams = ParsePublicKeyToParameters(publicKeyBytes);
|
||||
return Verify(rsaPublicKeyParams, messageBytes, signBytes, DIGEST_ALGORITHM_SHA256);
|
||||
return VerifyWithSHA256PSS(rsaPublicKeyParams, messageBytes, signBytes);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用公钥基于 SHA-256 算法验证签名。
|
||||
/// 使用公钥基于 SHA-256/PSS 算法验证签名。
|
||||
/// </summary>
|
||||
/// <param name="publicKeyPem">PKCS#1/PKCS#8 公钥(PEM 格式)。</param>
|
||||
/// <param name="messageData">待验证的数据。</param>
|
||||
/// <param name="encodingSignature">经过编码后的(通常为 Base64)签名。</param>
|
||||
/// <param name="digestAlgorithm">签名算法。(默认值:<see cref="DIGEST_ALGORITHM_SHA256"/>)</param>
|
||||
/// <returns>验证结果。</returns>
|
||||
public static bool VerifyWithSHA256(string publicKeyPem, string messageData, EncodedString encodingSignature)
|
||||
public static bool VerifyWithSHA256PSS(string publicKeyPem, string messageData, EncodedString encodingSignature)
|
||||
{
|
||||
if (publicKeyPem is null) throw new ArgumentNullException(nameof(publicKeyPem));
|
||||
if (messageData is null) throw new ArgumentNullException(nameof(messageData));
|
||||
@@ -217,7 +221,46 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
byte[] publicKeyBytes = ConvertPublicKeyPemToByteArray(publicKeyPem);
|
||||
byte[] messageBytes = EncodedString.FromLiteralString(messageData);
|
||||
byte[] signBytes = EncodedString.FromString(encodingSignature, fallbackEncodingKind: EncodingKinds.Base64);
|
||||
return VerifyWithSHA256(publicKeyBytes, messageBytes, signBytes);
|
||||
return VerifyWithSHA256PSS(publicKeyBytes, messageBytes, signBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用证书基于 SHA-256/PSS 算法验证签名。
|
||||
/// </summary>
|
||||
/// <param name="certificatePem">证书内容(PEM 格式)。</param>
|
||||
/// <param name="messageData">待验证的数据。</param>
|
||||
/// <param name="encodingSignature">经过编码后的(通常为 Base64)签名。</param>
|
||||
/// <returns>验证结果。</returns>
|
||||
public static bool VerifyWithSHA256PSSByCertificate(string certificatePem, string messageData, EncodedString encodingSignature)
|
||||
{
|
||||
if (certificatePem is null) throw new ArgumentNullException(nameof(certificatePem));
|
||||
|
||||
string publicKeyPem = ExportPublicKeyFromCertificate(certificatePem);
|
||||
return VerifyWithSHA256PSS(publicKeyPem, messageData, encodingSignature);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>从 CRT/CER 证书中导出 PKCS#8 公钥。</para>
|
||||
/// <para>
|
||||
/// 即从 -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----
|
||||
/// 转为 -----BEGIN PUBLIC KEY----- ..... -----END PUBLIC KEY-----
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="certificate">证书内容(PEM 格式)。</param>
|
||||
/// <returns>PKCS#8 公钥(PEM 格式)。</returns>
|
||||
public static string ExportPublicKeyFromCertificate(string certificate)
|
||||
{
|
||||
if (certificate is null) throw new ArgumentNullException(nameof(certificate));
|
||||
|
||||
using (TextWriter swriter = new StringWriter())
|
||||
{
|
||||
X509Certificate x509cert = ParseCertificatePemToX509(certificate);
|
||||
RsaKeyParameters rsaPublicKeyParams = (RsaKeyParameters)x509cert.GetPublicKey();
|
||||
PemWriter pemWriter = new PemWriter(swriter);
|
||||
pemWriter.WriteObject(rsaPublicKeyParams);
|
||||
pemWriter.Writer.Flush();
|
||||
return swriter.ToString()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Org.BouncyCastle.Asn1;
|
||||
@@ -7,9 +8,11 @@ using Org.BouncyCastle.Asn1.X9;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Math;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Org.BouncyCastle.Utilities;
|
||||
using Org.BouncyCastle.Utilities.Encoders;
|
||||
using Org.BouncyCastle.X509;
|
||||
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
{
|
||||
@@ -44,6 +47,15 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
return Convert.FromBase64String(publicKeyPem);
|
||||
}
|
||||
|
||||
private static X509Certificate ParseCertificatePemToX509(string certificatePem)
|
||||
{
|
||||
using (TextReader sreader = new StringReader(certificatePem))
|
||||
{
|
||||
PemReader pemReader = new PemReader(sreader);
|
||||
return (X509Certificate)pemReader.ReadObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static ECPrivateKeyParameters ParsePrivateKeyToParameters(byte[] privateKeyBytes)
|
||||
{
|
||||
return (ECPrivateKeyParameters)PrivateKeyFactory.CreateKey(privateKeyBytes);
|
||||
@@ -218,6 +230,28 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
return EncodedString.ToBase64String(signBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用私钥基于 SM3 算法生成签名。
|
||||
/// </summary>
|
||||
/// <param name="privateKeyPem">PKCS#8 私钥(PEM 格式)。</param>
|
||||
/// <param name="uidData">用户标识符。</param>
|
||||
/// <param name="messageData">待签名的数据。</param>
|
||||
/// <param name="asn1Encoding">指示签名结果是否为 ASN.1 编码的形式。(默认值:true)</param>
|
||||
/// <returns>经过 Base64 编码的签名。</returns>
|
||||
public static EncodedString SignWithSM3(string privateKeyPem, string uidData, string messageData, bool asn1Encoding = true)
|
||||
{
|
||||
if (privateKeyPem is null) throw new ArgumentNullException(nameof(privateKeyPem));
|
||||
if (messageData is null) throw new ArgumentNullException(nameof(messageData));
|
||||
|
||||
byte[] signBytes = SignWithSM3(
|
||||
privateKeyBytes: ConvertPrivateKeyPemToByteArray(privateKeyPem),
|
||||
uidBytes: EncodedString.FromLiteralString(uidData),
|
||||
messageBytes: EncodedString.FromLiteralString(messageData),
|
||||
asn1Encoding: asn1Encoding
|
||||
);
|
||||
return EncodedString.ToBase64String(signBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 EC 私钥基于 SM3 算法生成签名。
|
||||
/// </summary>
|
||||
@@ -353,6 +387,27 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用证书基于 SM3 算法验证签名。
|
||||
/// </summary>
|
||||
/// <param name="certificatePem">证书内容(PEM 格式)。</param>
|
||||
/// <param name="messageData">待验证的数据。</param>
|
||||
/// <param name="encodingSignature">经过编码后的(通常为 Base64)签名。</param>
|
||||
/// <param name="asn1Encoding">指示签名结果是否为 ASN.1 编码的形式。(默认值:true)</param>
|
||||
/// <returns>验证结果。</returns>
|
||||
public static bool VerifyWithSM3ByCertificate(string certificatePem, string messageData, EncodedString encodingSignature, bool asn1Encoding = true)
|
||||
{
|
||||
if (certificatePem is null) throw new ArgumentNullException(nameof(certificatePem));
|
||||
|
||||
string publicKeyPem = ExportPublicKeyFromCertificate(certificatePem);
|
||||
return VerifyWithSM3(
|
||||
publicKeyPem: publicKeyPem,
|
||||
messageData: messageData,
|
||||
encodingSignature: encodingSignature,
|
||||
asn1Encoding: asn1Encoding
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 EC 公钥基于 SM3 算法生成签名。
|
||||
/// </summary>
|
||||
@@ -433,5 +488,29 @@ namespace SKIT.FlurlHttpClient.Wechat.Api.Utilities
|
||||
asn1Encoding: asn1Encoding
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>从 CRT/CER 证书中导出 PKCS#8 公钥。</para>
|
||||
/// <para>
|
||||
/// 即从 -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----
|
||||
/// 转为 -----BEGIN PUBLIC KEY----- ..... -----END PUBLIC KEY-----
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="certificatePem">证书内容(PEM 格式)。</param>
|
||||
/// <returns>PKCS#8 公钥(PEM 格式)。</returns>
|
||||
public static string ExportPublicKeyFromCertificate(string certificatePem)
|
||||
{
|
||||
if (certificatePem is null) throw new ArgumentNullException(nameof(certificatePem));
|
||||
|
||||
using (TextWriter swriter = new StringWriter())
|
||||
{
|
||||
X509Certificate x509cert = ParseCertificatePemToX509(certificatePem);
|
||||
ECPublicKeyParameters exPublicKeyParams = (ECPublicKeyParameters)x509cert.GetPublicKey();
|
||||
PemWriter pemWriter = new PemWriter(swriter);
|
||||
pemWriter.WriteObject(exPublicKeyParams);
|
||||
pemWriter.Writer.Flush();
|
||||
return swriter.ToString()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,21 @@ namespace SKIT.FlurlHttpClient.Wechat.Api
|
||||
|
||||
FlurlClient.BaseUrl = options.Endpoint ?? WechatApiEndpoints.DEFAULT;
|
||||
FlurlClient.WithTimeout(options.Timeout <= 0 ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(options.Timeout));
|
||||
|
||||
if (options.SecurityApiEnabled)
|
||||
{
|
||||
Interceptors.Add(new Interceptors.WechatApiSecurityApiInterceptor(
|
||||
baseUrl: FlurlClient.BaseUrl,
|
||||
appId: string.IsNullOrEmpty(options.SecurityApiAppId) ? options.AppId : options.SecurityApiAppId,
|
||||
symmetricAlg: options.SecurityApiSymmetricAlgorithm!,
|
||||
symmetricNum: options.SecurityApiSymmetricNumber!,
|
||||
symmetricKey: options.SecurityApiSymmetricKey!,
|
||||
asymmetricAlg: options.SecurityApiAsymmetricAlgorithm!,
|
||||
asymmetricNum: options.SecurityApiAsymmetricNumber!,
|
||||
asymmetricPrivateKey: options.SecurityApiAsymmetricPrivateKey!,
|
||||
customRequestPathMatcher: options.SecurityApiCustomRequestPathMatcher
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace SKIT.FlurlHttpClient.Wechat.Api
|
||||
{
|
||||
/// <summary>
|
||||
@@ -75,5 +77,56 @@ namespace SKIT.FlurlHttpClient.Wechat.Api
|
||||
/// 获取或设置米大师平台 AppKey(用于小游戏虚拟支付 2.0 相关接口)。
|
||||
/// </summary>
|
||||
public string? MidasAppKeyV2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式是否开启。
|
||||
/// </summary>
|
||||
public bool SecurityApiEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式所使用的 AppId。如果不指定将使用 <see cref="AppId"/>。
|
||||
/// </summary>
|
||||
public string? SecurityApiAppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式对称加密算法。
|
||||
/// <para>
|
||||
/// 默认值:<see cref="Constants.SecurityApiSymmetricAlgorithms.AES"/>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string SecurityApiSymmetricAlgorithm { get; set; } = Constants.SecurityApiSymmetricAlgorithms.AES;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式对称加密密钥编号。
|
||||
/// </summary>
|
||||
public string? SecurityApiSymmetricNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式对称加密密钥。
|
||||
/// </summary>
|
||||
public string? SecurityApiSymmetricKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式非对称加密算法。
|
||||
/// <para>
|
||||
/// 默认值:<see cref="Constants.SecurityApiAsymmetricAlgorithms.RSA"/>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string SecurityApiAsymmetricAlgorithm { get; set; } = Constants.SecurityApiAsymmetricAlgorithms.RSA;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式非对称加密私钥编号。
|
||||
/// </summary>
|
||||
public string? SecurityApiAsymmetricNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式非对称加密私钥。
|
||||
/// </summary>
|
||||
public string? SecurityApiAsymmetricPrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 API 安全鉴权模式自定义请求路径匹配器。如果不指定将只匹配关键 API。
|
||||
/// </summary>
|
||||
public Func<string, bool>? SecurityApiCustomRequestPathMatcher { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user