AspNetCore打造一個“最安全”的api接口


Authentication,Authorization

如果公司交給你一個任務讓你寫一個api接口,那么我們應該如何設計這個api接口來保證這個接口是對外看起來“高大上”,“羡慕崇拜”,並且使用起來和普通api接口無感,並且可以完美接入aspnetcore的認證授權體系呢,而不是自定義簽名來進行自定義過濾器實現呢(雖然也可以但是並不是最完美的),如何讓小白羡慕一眼就知道你是老鳥。
接下來我將給大家分享你不知道的自定義認證授體系。
我相信這可能是你面對aspnetcore下一個無論如何都要跨過去的坎,也是很多老鳥不熟悉的未知領域(很多人說能用就行,那么你可以直接右上角或者左上角)

如何打造一個最最最安全的api接口

技術選型

在不考慮性能的影響下我們選擇非對稱加密可以選擇sm或者rsa加密,這邊我們選擇rsa2048位pkcs8密鑰來進行,http傳輸可以分為兩個一個是request一個是response兩個交互模式。
安全的交互方式在不使用https的前提下那么就是我把明文信息加密並且簽名后給你,你收到后自己解密然后把你響應給我的明文信息加密后簽名在回給我,這樣就可以保證數據交互的安全性,
非對稱加密一般擁有兩個密鑰,一個被稱作為公鑰,一個被稱作為私鑰,公鑰是可以公開的哪怕放到互聯網上也是沒關系的,私鑰是自己保存的,一般而言永遠不會用到自己的私鑰。

私鑰簽名的結果只能被對應的公鑰校驗成功,公鑰加密的數據只能被對應的私鑰解密

實現原理

假設我們現在是兩個系統間的交互,系統A,系統B。系統A有一對rsa密鑰對我們稱之為公鑰APubKey,私鑰APriKey,系統B有一對rsa密鑰我們稱之為公鑰BPubKey,私鑰BPriKey。
私鑰是每個系統生成后自己內部保存的,私鑰的作用就是告訴發送方收到的人一定是我,公鑰的作用就是告訴接收到是不是我發送的,基於這兩條定理我們來設計程序,
首先我們系統A調用系統B的Api1接口假設我們傳遞一個hello,然后系統B會回復一個world。那么我們如何設計才可以保證安全呢。首先系統A發送消息如何讓系統B知道是系統A發過來的而不是別的中間人共計呢。這里我們需要用到簽名,就是說系統A用APriKey進行對hello的加密后那么如果發過去的數據如果簽名是x內容是hello,系統B收到了就會對hello進行簽名的校驗,如果校驗出來的結果是用私鑰加密的那么你用哪個公鑰進行的前面校驗就可以保證系統是由哪個系統發送的。用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,接下來我們要實現模型的解析和校驗

模型解析

首先我們要確保微軟是如何通過request body的字符串到model的綁定的,通過源碼解析我們可以發現aspnetcore是通過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.NetCore -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", "");
        }
    }

編寫好client后開始調用



依次啟動兩個項目就可以看到我們調用成功了,

本項目采用rsa雙向簽名和加密來接入aspnetcore的權限系統並且可以獲取到系統調用方用戶

完美接入aspnetcore認證系統和權限系統(后續會出一篇如何設計權限)

系統交互采用雙向加密和簽名認證

完美接入模型校驗

完美處理響應結果

注意本項目僅僅只是是一個學習demo,而且根據實踐得出的結論rsa加密僅僅是滿足了最最最安全的api這個條件,但是性能上而言會隨着body的變大性能急劇下降,所以並不是一個很好的抉擇當然可以用在雙方交互的時候設置秘鑰提供api接口,實際情況下可以選擇使用對稱加密比如:AES或者DES進行body體的加密解密,但是在簽名方面完全沒問題可以選擇rsa,本次使用的是rsa2(rsa 2048位的秘鑰)秘鑰位數越大加密等級越高但是解密性能越低
當然你可以直接上https,本文章也不是說一定要雙向處理更多的是分享如何接入aspnetcore的認證體系中和模型校驗,而不用帖一大堆的attribute

demo

AspNetCoreSafeApi

最后

分享本人開發的efcore分表分庫讀寫分離組件,希望為.net生態做一份共享,如果喜歡或者覺得有用請點下star或者贊讓更多的人看到

Gitee Star 助力dotnet 生態 Github Star


博客

QQ群:771630778

個人QQ:326308290(歡迎技術支持提供您寶貴的意見)

個人郵箱:326308290@qq.com


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM