ASP.NET Coreで「最も安全な」APIインターフェースを構築する

ASP.NET Coreで「最も安全な」APIインターフェースを構築する

会社からAPIインターフェースを書くように依頼されたら、どのように設計すればよいでしょうか?

最終更新 2021/10/21 10:34
薛家明
読了目安 12 分
カテゴリ
ASP.NET Core
タグ
.NET C# ASP.NET Core セキュリティ API

もし会社からAPIインターフェースを書くタスクを任されたら、どう設計すれば「ハイレベルで」、「羨望と崇拝の対象」に見え、しかも通常のAPIインターフェースと違和感なく使え、さらにaspnetcoreの認証認可体系に完璧に組み込めるでしょうか?カスタム署名を使ってカスタムフィルターを実装する方法もありますが(それでも可能ですが、完璧とは言えません)。初心者が一目見て「この人はベテランだ」と分かるような設計とは?

今回は、あなたが知らないカスタム認証体系についてお話しします。

これはおそらく、aspnetcoreを扱う上で避けて通れない壁であり、多くのベテランもあまり知らない未知の領域です(「動けばいい」という人は、右上または左上のボタンをどうぞ)。

最も安全なAPIインターフェースを作るには

技術選定

パフォーマンスを考慮しない場合、非対称暗号化としてSM暗号またはRSA暗号を選択できます。ここではRSA2048ビット、PKCS8形式の鍵を使用します。HTTP通信はリクエストとレスポンスの2つのやり取りがあります。

HTTPSを使用しない場合の安全な通信方法は、平文情報を暗号化して署名し、相手に渡す。相手はそれを受信して復号し、応答の平文情報を暗号化して署名し返す。これでデータの安全性を確保できます。

非対称暗号には通常2つの鍵があります。公開鍵(公钥)と秘密鍵(私钥)です。公開鍵は公開可能で、インターネット上に置いても問題ありません。秘密鍵は自分で保存し、基本的に自分の秘密鍵を使うことはありません。

秘密鍵で署名した結果は、対応する公開鍵でのみ検証できます。公開鍵で暗号化したデータは、対応する秘密鍵でのみ復号できます。

実装原理

2つのシステム間のやり取りを考えます。システムAとシステムBです。システムAにはRSA鍵ペアがあり、公開鍵APubKey、秘密鍵APriKeyと呼びます。システムBには公開鍵BPubKey、秘密鍵BPriKeyがあります。

秘密鍵は各システムが内部で生成し保存します。秘密鍵の役割は「受信者に対して、送信者が確かに自分であること」を証明することです。公開鍵の役割は「受信者に対して、送信者が自分かどうか」を検証することです。この2つの定理に基づいて設計します。

まず、システムAがシステムBのApi1インターフェースを呼び出し、「hello」を送信し、システムBが「world」を返すとします。どのように安全を確保するか?システムAがメッセージを送信するとき、システムBはそれをシステムAが送ったものだと分からなければなりません。中間者攻撃ではないことを保証する必要があります。ここで署名を使います。システムAはAPriKeyで「hello」を暗号化して署名します。送信データは署名xと内容helloです。システムBは受信したデータに対し、署名を検証します。署名が秘密鍵で暗号化されていれば、どの公開鍵で検証するかによって、どのシステムからの送信かが分かります。APriKeyで署名されたデータはAPubKeyでのみ検証できます。そのためシステムBは、確かにシステムAからの送信であり、他のシステムではないと確認できます。ただし、まだ平文情報を送信しているだけなので、データを暗号化する必要があります。暗号化には通常、受信側の公開鍵を使います。受信側の公開鍵で暗号化したデータは、受信側の秘密鍵でのみ復号できるからです。

プロジェクト作成

まず、シンプルなaspnetcore webapiプロジェクトを作成します。

秘密鍵と公開鍵を保存する構成オプションを作成します。

public class RsaOptions
{
    public string PrivateKey { get; set; }
}

Schemeオプションクラスを作成します。

public class AuthSecurityRsaOptions: AuthenticationSchemeOptions
{
}

定数を定義します。

public class AuthSecurityRsaDefaults
{
    public const string AuthenticationScheme = "SecurityRsaAuth";
}

認証ハンドラー AuthSecurityRsaAuthenticationHandler を作成します。

public class AuthSecurityRsaAuthenticationHandler: AuthenticationHandler<AuthSecurityRsaOptions>
{
//正式替换成redis
    private readonly ConcurrentDictionary<string, object> _repeatRequestMap =
        new ConcurrentDictionary<string, object>();

    public AuthSecurityRsaAuthenticationHandler(IOptionsMonitor<AuthSecurityRsaOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        try
        {
            string authorization = Request.Headers["AuthSecurity-Authorization"];
            // If no authorization header found, nothing to process further
            if (string.IsNullOrWhiteSpace(authorization))
                return AuthenticateResult.NoResult();

            var authorizationSplit = authorization.Split('.');
            if (authorizationSplit.Length != 4)
                return await AuthenticateResultFailAsync("签名参数不正确");
            var reg = new Regex(@"[0-9a-zA-Z]{1,40}");


            var requestId = authorizationSplit[0];
            if (string.IsNullOrWhiteSpace(requestId) || !reg.IsMatch(requestId))
                return await AuthenticateResultFailAsync("请求Id不正确");


            var appid = authorizationSplit[1];
            if (string.IsNullOrWhiteSpace(appid) || !reg.IsMatch(appid))
                return await AuthenticateResultFailAsync("应用Id不正确");


            var timeStamp = authorizationSplit[2];
            if (string.IsNullOrWhiteSpace(timeStamp) || !long.TryParse(timeStamp, out var timestamp))
                return await AuthenticateResultFailAsync("请求时间不正确");
            //请求时间大于30分钟的就抛弃
            if (Math.Abs(UtcTime.CurrentTimeMillis() - timestamp) > 30 * 60 * 1000)
                return await AuthenticateResultFailAsync("请求已过期");


            var sign = authorizationSplit[3];
            if (string.IsNullOrWhiteSpace(sign))
                return await AuthenticateResultFailAsync("签名参数不正确");
            //数据库获取
            //Request.HttpContext.RequestServices.GetService<DbContext>()
            var app = AppCallerStorage.ApiCallers.FirstOrDefault(o=>o.Id==appid);
            if (app == null)
                return AuthenticateResult.Fail("未找到对应的应用信息");
            //获取请求体
            var body = await Request.RequestBodyAsync();

            //验证签名
            if (!RsaFunc.ValidateSignature(app.AppPublickKey, $"{requestId}{appid}{timeStamp}{body}", sign))
                return await AuthenticateResultFailAsync("签名失败");
            var repeatKey = $"AuthSecurityRequestDistinct:{appid}:{requestId}";
            //自行替换成缓存或者redis本项目不带删除key功能没有过期时间原则上需要设置1小时过期,前后30分钟服务器时间差
            if (_repeatRequestMap.ContainsKey(repeatKey) || !_repeatRequestMap.TryAdd(repeatKey,null))
            {
                return await AuthenticateResultFailAsync("请勿重复提交");
            }


            //给Identity赋值
            var identity = new ClaimsIdentity(AuthSecurityRsaDefaults.AuthenticationScheme);
            identity.AddClaim(new Claim("appid", appid));
            identity.AddClaim(new Claim("appname", app.Name));
            identity.AddClaim(new Claim("role", "app"));
            //......

            var principal = new ClaimsPrincipal(identity);
            return HandleRequestResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name));
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "RSA签名失败");
            return await AuthenticateResultFailAsync("认证失败");
        }
    }

    private async Task<AuthenticateResult> AuthenticateResultFailAsync(string message)
    {
        Response.StatusCode = 401;
        await Response.WriteAsync(message);
        return AuthenticateResult.Fail(message);
    }
}

第三步に拡張メソッドを追加します。

public static class AuthSecurityRsaExtension
{
    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder)
        => builder.AddAuthSecurityRsa(AuthSecurityRsaDefaults.AuthenticationScheme, _ => { });

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, Action<AuthSecurityRsaOptions> configureOptions)
        => builder.AddAuthSecurityRsa(AuthSecurityRsaDefaults.AuthenticationScheme, configureOptions);

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, string authenticationScheme, Action<AuthSecurityRsaOptions> configureOptions)
        => builder.AddAuthSecurityRsa(authenticationScheme, displayName: null, configureOptions: configureOptions);

    public static AuthenticationBuilder AddAuthSecurityRsa(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<AuthSecurityRsaOptions> configureOptions)
    {
        return builder.AddScheme<AuthSecurityRsaOptions, AuthSecurityRsaAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
    }
}

返り値の暗号化・復号を行う SafeResponseMiddleware を追加します。

public class SafeResponseMiddleware
{
    private readonly RequestDelegate _next;

    public SafeResponseMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {

        //AuthSecurity-Authorization
        if ( context.Request.Headers.TryGetValue("AuthSecurity-Authorization", out var authorization) && !string.IsNullOrWhiteSpace(authorization))
        {
            //获取Response.Body内容
            var originalBodyStream = context.Response.Body;
            await using (var newResponse = new MemoryStream())
            {
                //替换response流
                context.Response.Body = newResponse;
                await _next(context);
                string responseString = null;
                var identityIsAuthenticated = context.User?.Identity?.IsAuthenticated;
                if (identityIsAuthenticated.HasValue && identityIsAuthenticated.Value)
                {
                    var authorizationSplit = authorization.ToString().Split('.');
                    var requestId = authorizationSplit[0];
                    var appid = authorizationSplit[1];

                    using (var reader = new StreamReader(newResponse))
                    {
                        newResponse.Position = 0;
                        responseString = (await reader.ReadToEndAsync())??string.Empty;
                            var responseStr = JsonConvert.SerializeObject(responseString);
                            var app = AppCallerStorage.ApiCallers.FirstOrDefault(o => o.Id == appid);
                            var encryptBody = RsaFunc.Encrypt(app.AppPublickKey, responseStr);
                            var signature = RsaFunc.CreateSignature(app.MyPrivateKey, $"{requestId}{appid}{encryptBody}");
                            context.Response.Headers.Add("AuthSecurity-Signature", signature);
                            responseString = encryptBody;
                    }

                    await using (var writer = new StreamWriter(originalBodyStream))
                    {
                        await writer.WriteAsync(responseString);
                        await writer.FlushAsync();
                    }
                }
            }
        }
        else
        {
            await _next(context);
        }
    }
}

認証を実現する基本基底クラスを追加します。

[Authorize(AuthenticationSchemes =AuthSecurityRsaDefaults.AuthenticationScheme )]
public class RsaBaseController : ControllerBase
{
}

この時点でインターフェースはほぼ完成していますが、まだ happy coding できません。次にモデルの解析と検証を実装します。

モデル解析

まず、ASP.NET Core がリクエストボディの文字列をモデルにバインドする方法を確認します。ソースコードを解析すると、IModelBinder を使用していることがわかります。

まずモデルバインダーを実装します。

public class EncryptBodyModelBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var httpContext = bindingContext.HttpContext;
        //if (bindingContext.ModelType != typeof(string))
        //    return;
        string authorization = httpContext.Request.Headers["AuthSecurity-Authorization"];
        if (!string.IsNullOrWhiteSpace(authorization))
        {
            //有参数接收就反序列化并且进行校验
            if (bindingContext.ModelType != null)
            {
                //获取请求体
                var encryptBody = await httpContext.Request.RequestBodyAsync();
                if (string.IsNullOrWhiteSpace(encryptBody))
                    return;
                //解密
                var rsaOptions = httpContext.RequestServices.GetService<RsaOptions>();
                var body = RsaFunc.Decrypt(rsaOptions.PrivateKey, encryptBody);
                var request = JsonConvert.DeserializeObject(body, bindingContext.ModelType);
                if (request == null)
                {
                    return;
                }
                bindingContext.Result = ModelBindingResult.Success(request);

            }
        }
    }
}

attributeでモデル解析を指定するための属性を追加します。

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class RsaModelParseAttribute : Attribute, IBinderTypeProviderMetadata, IBindingSourceMetadata, IModelNameProvider
{
    private readonly ModelBinderAttribute modelBinderAttribute = new ModelBinderAttribute() { BinderType = typeof(EncryptBodyModelBinder) };

    public BindingSource BindingSource => modelBinderAttribute.BindingSource;

    public string Name => modelBinderAttribute.Name;

    public Type BinderType => modelBinderAttribute.BinderType;
}

テスト用DTOを追加します。

[RsaModelParse]
public class TestModel
{
    [Display(Name = "id"),Required(ErrorMessage = "{0}不能为空")]
    public string Id { get; set; }
}

モデルコントローラを作成します。

[Route("api/[controller]/[action]")]
[ApiController]
public class TestController: RsaBaseController
{
    [AllowAnonymous]
    public IActionResult Test()
    {
        return Ok();
    }

//正常测试
    public IActionResult Test1()
    {
        var appid = Request.HttpContext.User.Claims.FirstOrDefault(o=>o.Type== "appid").Value;
        var appname = Request.HttpContext.User.Claims.FirstOrDefault(o=>o.Type== "appname").Value;

        return Ok($"appid:{appid},appname:{appname}");
    }
///模型校验
    public IActionResult Test2(TestModel request)
    {
        return Ok(JsonConvert.SerializeObject(request));
    }
//异常错误校验
    public IActionResult Test3(TestModel request)
    {
        var x = 0;
        var a = 1 / x;
        return Ok("ok");
    }
}

グローバル例外ハンドラを追加します。

public class HttpGlobalExceptionFilter : IExceptionFilter
{
    private readonly ILogger<HttpGlobalExceptionFilter> _logger;

    public HttpGlobalExceptionFilter(ILogger<HttpGlobalExceptionFilter> logger)
    {
        _logger = logger;
    }

    public void OnException(ExceptionContext context)
    {
        _logger.LogError(new EventId(context.Exception.HResult),
            context.Exception,
            context.Exception.Message);
        context.Result = new OkObjectResult("未知异常");
        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
        context.ExceptionHandled = true;
    }
}

モデル検証フィルターを追加します。

public class ValidateModelStateFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ModelState.IsValid)
        {
            return;
        }

        var validationErrors = context.ModelState
            .Keys
            .SelectMany(k => context.ModelState[k].Errors)
            .Select(e => e.ErrorMessage)
            .ToArray();

        context.Result = new OkObjectResult(string.Join(",", validationErrors));
        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
    }

}

Startup構成。

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<ApiBehaviorOptions>(options =>
    {
        //忽略系统自带校验你[ApiController]
        options.SuppressModelStateInvalidFilter = true;
    });
    services.AddControllers(options =>
    {
        options.Filters.Add<HttpGlobalExceptionFilter>();
        options.Filters.Add<ValidateModelStateFilter>();
    });
    services.AddControllers();

    services.AddAuthentication().AddAuthSecurityRsa();
        services.AddSingleton(sp =>
        {
            return new RsaOptions()
            {
                PrivateKey = Configuration.GetSection("RsaConfig")["PrivateKey"],
            };
        });
}


public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseMiddleware<SafeResponseMiddleware>();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

これでサーバー側のすべてのAPIインターフェースと構成が完了しました。次にクライアントを作成し、RSA鍵ペアを生成してAPIを使用開始します。

RSA鍵の生成方法

まずOpenSSLをダウンロードします。

ダウンロード先 openssl

ダブルクリック

作成コマンドを入力します。

打开bin下openssl.exe
生成RSA私钥
openssl>genrsa -out rsa_private_key.pem 2048

生成RSA公钥
openssl>rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

将RSA私钥转换成PKCS8格式
openssl>pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM -nocrypt -out rsa_pkcs8_private_key.pem

公開鍵と秘密鍵はXML形式ではないため、C#でRSAを使用するにはXML形式の鍵が必要です。そのため、まず鍵を変換します。

まずNuGetで公開鍵・秘密鍵変換ツールをダウンロードします。

Install-Package BouncyCastle.NET Core -Version 1.8.8
public class RsaKeyConvert
{
    private RsaKeyConvert()
    {

    }
    public static string RsaPrivateKeyJava2DotNet(string privateKey)
    {
        RsaPrivateCrtKeyParameters privateKeyParam = (RsaPrivateCrtKeyParameters)PrivateKeyFactory.CreateKey(Convert.FromBase64String(TrimPrivatePrefixSuffix(privateKey)));

        return string.Format("<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent><P>{2}</P><Q>{3}</Q><DP>{4}</DP><DQ>{5}</DQ><InverseQ>{6}</InverseQ><D>{7}</D></RSAKeyValue>",
            Convert.ToBase64String(privateKeyParam.Modulus.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.PublicExponent.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.P.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.Q.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.DP.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.DQ.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.QInv.ToByteArrayUnsigned()),
            Convert.ToBase64String(privateKeyParam.Exponent.ToByteArrayUnsigned()));
    }

    public static string RsaPrivateKeyDotNet2Java(string privateKey)
    {
        XmlDocument doc = new XmlDocument();
        doc.LoadXml(TrimPrivatePrefixSuffix(privateKey));
        BigInteger m = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Modulus")[0].InnerText));
        BigInteger exp = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Exponent")[0].InnerText));
        BigInteger d = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("D")[0].InnerText));
        BigInteger p = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("P")[0].InnerText));
        BigInteger q = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Q")[0].InnerText));
        BigInteger dp = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("DP")[0].InnerText));
        BigInteger dq = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("DQ")[0].InnerText));
        BigInteger qinv = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("InverseQ")[0].InnerText));

        RsaPrivateCrtKeyParameters privateKeyParam = new RsaPrivateCrtKeyParameters(m, exp, d, p, q, dp, dq, qinv);

        PrivateKeyInfo privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKeyParam);
        byte[] serializedPrivateBytes = privateKeyInfo.ToAsn1Object().GetEncoded();
        return Convert.ToBase64String(serializedPrivateBytes);
    }

    public static string RsaPublicKeyJava2DotNet(string publicKey)
    {
        RsaKeyParameters publicKeyParam = (RsaKeyParameters)PublicKeyFactory.CreateKey(Convert.FromBase64String(TrimPublicPrefixSuffix(publicKey)));
        return string.Format("<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent></RSAKeyValue>",
            Convert.ToBase64String(publicKeyParam.Modulus.ToByteArrayUnsigned()),
            Convert.ToBase64String(publicKeyParam.Exponent.ToByteArrayUnsigned()));
    }

    public static string RsaPublicKeyDotNet2Java(string publicKey)
    {
        XmlDocument doc = new XmlDocument();
        doc.LoadXml(TrimPublicPrefixSuffix(publicKey));
        BigInteger m = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Modulus")[0].InnerText));
        BigInteger p = new BigInteger(1, Convert.FromBase64String(doc.DocumentElement.GetElementsByTagName("Exponent")[0].InnerText));
        RsaKeyParameters pub = new RsaKeyParameters(false, m, p);

        SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(pub);
        byte[] serializedPublicBytes = publicKeyInfo.ToAsn1Object().GetDerEncoded();
        return Convert.ToBase64String(serializedPublicBytes);
    }

    public static string TrimPublicPrefixSuffix(string publicKey)
    {
        return publicKey
            .Replace("-----BEGIN PUBLIC KEY-----", string.Empty)
            .Replace("-----END PUBLIC KEY-----", string.Empty)
            .Replace("\r\n", "");
    }
    public static string TrimPrivatePrefixSuffix(string privateKey)
    {
        return privateKey
            .Replace("-----BEGIN PRIVATE KEY-----", string.Empty)
            .Replace("-----END PRIVATE KEY-----", string.Empty)
            .Replace("\r\n", "");
    }
}

クライアントを作成して呼び出し

両方のプロジェクトを順次起動すると、呼び出しが成功したことが確認できます。

本プロジェクトはRSAによる双方向署名と暗号化を用いて、aspnetcoreの権限システムに統合し、システム呼び出し元のユーザーを取得できるようにしています。

aspnetcoreの認証システムと権限システムに完璧に統合(権限設計については別の記事で公開予定)

双方向の暗号化と署名認証によるシステム間連携

モデル検証への完璧な統合

応答結果の完全な処理

注意:本プロジェクトは学習用のデモに過ぎません。実践から得られた結論として、RSA暗号化は「最も安全なAPI」という条件を満たすものの、パフォーマンス面ではボディサイズが大きくなるにつれて急激に低下します。そのため、良い選択とは言えません。ただし、双方のやり取りで鍵を設定してAPIを提供する場合には利用できます。実際の状況では、ボディの暗号化・復号には対称暗号(AESやDESなど)を選択し、署名のみにRSAを使用することは全く問題ありません。今回はRSA2(RSA 2048ビット鍵)を使用しました。鍵ビット数が大きいほど暗号化レベルは高いですが、復号性能は低下します。

もちろんHTTPSを直接使用しても構いません。この記事は必ずしも双方向処理を推奨するものではなく、むしろaspnetcoreの認証体系やモデル検証にどう統合するか、そして大量のattributeを貼り付ける必要がない方法を共有することを目的としています。

デモ:AspNetCoreSafeApi

最後に

私が開発したefcoreのシャーディング・リードライト分離コンポーネントを紹介します。.NETエコシステムに貢献できれば幸いです。気に入った場合や役立つと思った場合は、Starまたはいいねをお願いします。より多くの人に見てもらえますように。

Gitee Star 助力 dotnet 生态 GitHub Star

さらに探索

関連読書

その他の記事