zoukankan      html  css  js  c++  java
  • [转]用Middleware给ASP.NET Core Web API添加自己的授权验证

    本文转自:http://www.cnblogs.com/catcher1994/p/6021046.html

      Web API,是一个能让前后端分离、解放前后端生产力的好东西。不过大部分公司应该都没能做到完全的前后端分离。API的实现方式有很

    多,可以用ASP.NET Core、也可以用ASP.NET Web API、ASP.NET MVC、NancyFx等。说到Web API,不同的人有不同的做法,可能前台、

    中台和后台各一个api站点,也有可能一个模块一个api站点,也有可能各个系统共用一个api站点,当然这和业务有必然的联系。

      安全顺其自然的成为Web API关注的重点之一。现在流行的OAuth 2.0是个很不错的东西,不过本文是暂时没有涉及到的,只是按照最最最

    原始的思路做的一个授权验证。在之前的MVC中,我们可能是通过过滤器来处理这个身份的验证,在Core中,我自然就是选择Middleware来处

    理这个验证。

      下面开始本文的正题:

      先编写一个能正常运行的api,不进行任何的权限过滤。

    复制代码
     1 using Dapper;
     2 using Microsoft.AspNetCore.Mvc;
     3 using System.Data;
     4 using System.Linq;
     5 using System.Threading.Tasks;
     6 using WebApi.CommandText;
     7 using WebApi.Common;
     8 using Common;
     9 
    10 namespace WebApi.Controllers
    11 {
    12     [Route("api/[controller]")]
    13     public class BookController : Controller
    14     {
    15 
    16         private DapperHelper _helper;
    17         public BookController(DapperHelper helper)
    18         {
    19             this._helper = helper;
    20         }
    21 
    22         // GET: api/book
    23         [HttpGet]
    24         public async Task<IActionResult> Get()
    25         {
    26             var res = await _helper.QueryAsync(BookCommandText.GetBooks);
    27             CommonResult<Book> json = new CommonResult<Book>
    28             {
    29                 Code = "000",
    30                 Message = "ok",
    31                 Data = res
    32             };
    33             return Ok(json);
    34         }
    35 
    36         // GET api/book/5
    37         [HttpGet("{id}")]
    38         public IActionResult Get(int id)
    39         {
    40             DynamicParameters dp = new DynamicParameters();
    41             dp.Add("@Id", id, DbType.Int32, ParameterDirection.Input);
    42             var res = _helper.Query<Book>(BookCommandText.GetBookById, dp, null, true, null, CommandType.StoredProcedure).FirstOrDefault();
    43             CommonResult<Book> json = new CommonResult<Book>
    44             {
    45                 Code = "000",
    46                 Message = "ok",
    47                 Data = res
    48             };
    49             return Ok(json);
    50         }
    51 
    52         // POST api/book        
    53         [HttpPost]
    54         public IActionResult Post([FromForm]PostForm form)
    55         {
    56             DynamicParameters dp = new DynamicParameters();
    57             dp.Add("@Id", form.Id, DbType.Int32, ParameterDirection.Input);
    58             var res = _helper.Query<Book>(BookCommandText.GetBookById, dp, null, true, null, CommandType.StoredProcedure).FirstOrDefault();
    59             CommonResult<Book> json = new CommonResult<Book>
    60             {
    61                 Code = "000",
    62                 Message = "ok",
    63                 Data = res
    64             };
    65             return Ok(json);
    66         }
    67 
    68     }
    69 
    70     public class PostForm
    71     {
    72         public string Id { get; set; }
    73     }
    74 
    75 }
    复制代码
      api这边应该没什么好说的,都是一些常规的操作,会MVC的应该都可以懂。主要是根据id获取图书信息的方法(GET和POST)。这是我们后

    面进行单元测试的两个主要方法。这样部署得到的一个API站点,是任何一个人都可以访问http://yourapidomain.com/api/book 来得到相关

    的数据。现在我们要对这个api进行一定的处理,让只有权限的站点才能访问它。

      下面就是编写自定义的授权验证中间件了。

      Middleware这个东西大家应该都不会陌生了,OWIN出来的时候就有中间件这样的概念了,这里就不展开说明,在ASP.NET Core中是如何

    实现这个中间件的可以参考官方文档 Middleware。 

      我们先定义一个我们要用到的option,ApiAuthorizedOptions

    复制代码
     1 namespace WebApi.Middlewares
     2 {
     3     public class ApiAuthorizedOptions
     4     {
     5         //public string Name { get; set; }
     6 
     7         public string EncryptKey { get; set; }
     8         
     9         public int ExpiredSecond { get; set; }
    10     }
    11 }
    复制代码

      option内容比较简单,一个是EncryptKey ,用于对我们的请求参数进行签名,另一个是ExpiredSecond ,用于检验我们的请求是否超时。

    与之对应的是在appsettings.json中设置的ApiKey节点

    复制代码
    1   "ApiKey": {
    2     //"username": "123",
    3     //"password": "123",
    4     "EncryptKey": "@*api#%^@",
    5     "ExpiredSecond": "300"
    6   }
    复制代码

      有了option,下面就可以编写middleware的内容了

      我们的api中就实现了get和post的方法,所以这里也就对get和post做了处理,其他http method,有需要的可以自己补充。

      这里的验证主要是下面的几个方面:

      1.参数是否被篡改

      2.请求是否已经过期

      3.请求的应用是否合法

      主检查方法:Check
    复制代码
     1          /// <summary>
     2         /// the main check method
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <param name="requestInfo"></param>
     6         /// <returns></returns>
     7         private async Task Check(HttpContext context, RequestInfo requestInfo)
     8         {
     9             string computeSinature = HMACMD5Helper.GetEncryptResult($"{requestInfo.ApplicationId}-{requestInfo.Timestamp}-{requestInfo.Nonce}", _options.EncryptKey);
    10             double tmpTimestamp;
    11             if (computeSinature.Equals(requestInfo.Sinature) &&
    12                 double.TryParse(requestInfo.Timestamp, out tmpTimestamp))
    13             {
    14                 if (CheckExpiredTime(tmpTimestamp, _options.ExpiredSecond))
    15                 {
    16                     await ReturnTimeOut(context);
    17                 }
    18                 else
    19                 {
    20                     await CheckApplication(context, requestInfo.ApplicationId, requestInfo.ApplicationPassword);
    21                 }
    22             }
    23             else
    24             {
    25                 await ReturnNoAuthorized(context);
    26             }
    27         }
    复制代码

      Check方法带了2个参数,一个是当前的httpcontext对象和请求的内容信息,当签名一致,并且时间戳能转化成double时才去校验是否超时

    和Applicatioin的相关信息。这里的签名用了比较简单的HMACMD5加密,同样是可以换成SHA等加密来进行这一步的处理,加密的参数和规则是

    随便定的,要有一个约定的过程,缺少灵活性(就像跟银行对接那样,银行说你就要这样传参数给我,不这样就不行,只好乖乖从命)。

      Check方法还用到了下面的4个处理

      1.子检查方法--超时判断CheckExpiredTime

    复制代码
     1          /// <summary>
     2         /// check the expired time
     3         /// </summary>
     4         /// <param name="timestamp"></param>
     5         /// <param name="expiredSecond"></param>
     6         /// <returns></returns>
     7         private bool CheckExpiredTime(double timestamp, double expiredSecond)
     8         {
     9             double now_timestamp = (DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalSeconds;
    10             return (now_timestamp - timestamp) > expiredSecond;
    11         }
    复制代码

      这里取了当前时间与1970年1月1日的间隔与请求参数中传过来的时间戳进行比较,是否超过我们在appsettings中设置的那个值,超过就是

    超时了,没超过就可以继续下一个步骤。

      2.子检查方法--应用程序判断CheckApplication

      应用程序要验证什么呢?我们会给每个应用程序创建一个ID和一个访问api的密码,所以我们要验证这个应用程序的真实身份,是否是那些

    有权限的应用程序。

    复制代码
     1         /// <summary>
     2         /// check the application
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <param name="applicationId"></param>
     6         /// <param name="applicationPassword"></param>
     7         /// <returns></returns>
     8         private async Task CheckApplication(HttpContext context, string applicationId, string applicationPassword)
     9         {
    10             var application = GetAllApplications().Where(x => x.ApplicationId == applicationId).FirstOrDefault();
    11             if (application != null)
    12             {
    13                 if (application.ApplicationPassword != applicationPassword)
    14                 {
    15                     await ReturnNoAuthorized(context);
    16                 }
    17             }
    18             else
    19             {
    20                 await ReturnNoAuthorized(context);
    21             }
    22         }
    复制代码

      先根据请求参数中的应用程序id去找到相应的应用程序,不能找到就说明不是合法的应用程序,能找到再去验证其密码是否正确,最后才确

    定其能否取得api中的数据。

      下面两方法是处理没有授权和超时处理的实现:

      没有授权的返回方法ReturnNoAuthorized

    复制代码
     1         /// <summary>
     2         /// not authorized request
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <returns></returns>
     6         private async Task ReturnNoAuthorized(HttpContext context)
     7         {
     8             BaseResponseResult response = new BaseResponseResult
     9             {
    10                 Code = "401",
    11                 Message = "You are not authorized!"
    12             };
    13             context.Response.StatusCode = 401;
    14             await context.Response.WriteAsync(JsonConvert.SerializeObject(response));
    15         }
    复制代码

      这里做的处理是将响应的状态码设置成401(Unauthorized)。

      超时的返回方法ReturnTimeOut

    复制代码
     1         /// <summary>
     2         /// timeout request 
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <returns></returns>
     6         private async Task ReturnTimeOut(HttpContext context)
     7         {
     8             BaseResponseResult response = new BaseResponseResult
     9             {
    10                 Code = "408",
    11                 Message = "Time Out!"
    12             };
    13             context.Response.StatusCode = 408;
    14             await context.Response.WriteAsync(JsonConvert.SerializeObject(response));
    15         }
    复制代码

      这里做的处理是将响应的状态码设置成408(Time Out)。

      下面就要处理Http的GET请求和POST请求了。

      HTTP GET请求的处理方法GetInvoke

    复制代码
     1         /// <summary>
     2         /// http get invoke
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <returns></returns>
     6         private async Task GetInvoke(HttpContext context)
     7         {
     8             var queryStrings = context.Request.Query;
     9             RequestInfo requestInfo = new RequestInfo
    10             {
    11                 ApplicationId = queryStrings["applicationId"].ToString(),
    12                 ApplicationPassword = queryStrings["applicationPassword"].ToString(),
    13                 Timestamp = queryStrings["timestamp"].ToString(),
    14                 Nonce = queryStrings["nonce"].ToString(),
    15                 Sinature = queryStrings["signature"].ToString()
    16             };
    17             await Check(context, requestInfo);
    18         }
    复制代码

      处理比较简单,将请求的参数赋值给RequestInfo,然后将当前的httpcontext和这个requestinfo交由我们的主检查方法Check去校验

    这个请求的合法性。

      同理,HTTP POST请求的处理方法PostInvoke,也是同样的处理。

    复制代码
     1         /// <summary>
     2         /// http post invoke
     3         /// </summary>
     4         /// <param name="context"></param>
     5         /// <returns></returns>
     6         private async Task PostInvoke(HttpContext context)
     7         {
     8             var formCollection = context.Request.Form;
     9             RequestInfo requestInfo = new RequestInfo
    10             {
    11                 ApplicationId = formCollection["applicationId"].ToString(),
    12                 ApplicationPassword = formCollection["applicationPassword"].ToString(),
    13                 Timestamp = formCollection["timestamp"].ToString(),
    14                 Nonce = formCollection["nonce"].ToString(),
    15                 Sinature = formCollection["signature"].ToString()
    16             };
    17             await Check(context, requestInfo);
    18         }
    复制代码

      最后是Middleware的构造函数和Invoke方法。

    复制代码
     1        public ApiAuthorizedMiddleware(RequestDelegate next, IOptions<ApiAuthorizedOptions> options)
     2         {
     3             this._next = next;
     4             this._options = options.Value;
     5         }
     6 
     7         public async Task Invoke(HttpContext context)
     8         {
     9             switch (context.Request.Method.ToUpper())
    10             {
    11                 case "POST":
    12                     if (context.Request.HasFormContentType)
    13                     {
    14                         await PostInvoke(context);
    15                     }
    16                     else
    17                     {
    18                         await ReturnNoAuthorized(context);
    19                     }
    20                     break;
    21                 case "GET":
    22                     await GetInvoke(context);
    23                     break;
    24                 default:
    25                     await GetInvoke(context);
    26                     break;
    27             }
    28             await _next.Invoke(context);
    29         }
    复制代码

      到这里,Middleware是已经编写好了,要在Startup中使用,还要添加一个拓展方法ApiAuthorizedExtensions

    复制代码
     1 using Microsoft.AspNetCore.Builder;
     2 using Microsoft.Extensions.Options;
     3 using System;
     4 
     5 namespace WebApi.Middlewares
     6 {
     7     public static class ApiAuthorizedExtensions
     8     {
     9         public static IApplicationBuilder UseApiAuthorized(this IApplicationBuilder builder)
    10         {
    11             if (builder == null)
    12             {
    13                 throw new ArgumentNullException(nameof(builder));
    14             }
    15 
    16             return builder.UseMiddleware<ApiAuthorizedMiddleware>();
    17         }
    18 
    19         public static IApplicationBuilder UseApiAuthorized(this IApplicationBuilder builder, ApiAuthorizedOptions options)
    20         {
    21             if (builder == null)
    22             {
    23                 throw new ArgumentNullException(nameof(builder));
    24             }
    25 
    26             if (options == null)
    27             {
    28                 throw new ArgumentNullException(nameof(options));
    29             }
    30             
    31             return builder.UseMiddleware<ApiAuthorizedMiddleware>(Options.Create(options));
    32         }
    33     }
    34 }
    复制代码

      到这里我们已经可以在Startup的Configure和ConfigureServices方法中配置这个中间件了

      这里还有一个不一定非要实现的拓展方法ApiAuthorizedServicesExtensions,但我个人还是倾向于实现这个ServicesExtensions。

     1 using Microsoft.Extensions.DependencyInjection;
     2 using System;
     3 
     4 namespace WebApi.Middlewares
     5 {
     6     public static class ApiAuthorizedServicesExtensions
     7     {
     8 
     9         /// <summary>
    10         /// Add response compression services.
    11         /// </summary>
    12         /// <param name="services">The <see cref="IServiceCollection"/> for adding services.</param>
    13         /// <returns></returns>
    14         public static IServiceCollection AddApiAuthorized(this IServiceCollection services)
    15         {
    16             if (services == null)
    17             {
    18                 throw new ArgumentNullException(nameof(services));
    19             }
    20 
    21             return services;
    22         }
    23 
    24         /// <summary>
    25         /// Add response compression services and configure the related options.
    26         /// </summary>
    27         /// <param name="services">The <see cref="IServiceCollection"/> for adding services.</param>
    28         /// <param name="configureOptions">A delegate to configure the <see cref="ResponseCompressionOptions"/>.</param>
    29         /// <returns></returns>
    30         public static IServiceCollection AddApiAuthorized(this IServiceCollection services, Action<ApiAuthorizedOptions> configureOptions)
    31         {
    32             if (services == null)
    33             {
    34                 throw new ArgumentNullException(nameof(services));
    35             }
    36             if (configureOptions == null)
    37             {
    38                 throw new ArgumentNullException(nameof(configureOptions));
    39             }
    40 
    41             services.Configure(configureOptions);
    42             return services;
    43         }
    44     }
    45 }
    ApiAuthorizedServicesExtensions

      为什么要实现这个拓展方法呢?个人认为

      Options、Middleware、Extensions、ServicesExtensions这四个是实现一个中间件的标配(除去简单到不行的那些中间件)

      Options给我们的中间件提供了一些可选的处理,提高了中间件的灵活性;

      Middleware是我们中间件最最重要的实现;

      Extensions是我们要在Startup的Configure去表明我们要使用这个中间件;

      ServicesExtensions是我们要在Startup的ConfigureServices去表明我们把这个中间件添加到容器中。

      下面是完整的Startup

    复制代码
     1 using Microsoft.AspNetCore.Builder;
     2 using Microsoft.AspNetCore.Hosting;
     3 using Microsoft.Extensions.Configuration;
     4 using Microsoft.Extensions.DependencyInjection;
     5 using Microsoft.Extensions.Logging;
     6 using System;
     7 using WebApi.Common;
     8 using WebApi.Middlewares;
     9 
    10 namespace WebApi
    11 {
    12     public class Startup
    13     {
    14         public Startup(IHostingEnvironment env)
    15         {
    16             var builder = new ConfigurationBuilder()
    17                 .SetBasePath(env.ContentRootPath)
    18                 .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
    19                 .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true);
    20 
    21             if (env.IsEnvironment("Development"))
    22             {
    23                 // This will push telemetry data through Application Insights pipeline faster, allowing you to view results immediately.
    24                 builder.AddApplicationInsightsSettings(developerMode: true);
    25             }
    26 
    27             builder.AddEnvironmentVariables();
    28             Configuration = builder.Build();
    29         }
    30 
    31         public IConfigurationRoot Configuration { get; }
    32 
    33         // This method gets called by the runtime. Use this method to add services to the container
    34         public void ConfigureServices(IServiceCollection services)
    35         {
    36             // Add framework services.
    37             services.AddApplicationInsightsTelemetry(Configuration);
    38             services.Configure<IISOptions>(options =>
    39             {
    40 
    41             });
    42 
    43             services.Configure<DapperOptions>(options =>
    44             {
    45                 options.ConnectionString = Configuration.GetConnectionString("DapperConnection");
    46             });
    47 
    48             //api authorized middleware
    49             services.AddApiAuthorized(options =>
    50             {
    51                 options.EncryptKey = Configuration.GetSection("ApiKey")["EncryptKey"];
    52                 options.ExpiredSecond = Convert.ToInt32(Configuration.GetSection("ApiKey")["ExpiredSecond"]);
    53             });
    54 
    55 
    56             services.AddMvc();
    57 
    58             services.AddSingleton<DapperHelper>();
    59         }
    60 
    61         // This method gets called by the runtime. Use this method to configure the HTTP request pipeline
    62         public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    63         {
    64 
    65             loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    66             loggerFactory.AddDebug();
    67 
    68             app.UseDapper();
    69 
    70             //api authorized middleware
    71             app.UseApiAuthorized();
    72 
    73             app.UseApplicationInsightsRequestTelemetry();
    74 
    75             app.UseApplicationInsightsExceptionTelemetry();
    76 
    77             app.UseMvc();
    78         }
    79     }
    80 }
    复制代码

      万事具备,只欠测试!!

      建个类库项目,写个单元测试看看。

    复制代码
     1 using Common;
     2 using Newtonsoft.Json;
     3 using System;
     4 using System.Collections.Generic;
     5 using System.Net.Http;
     6 using System.Threading.Tasks;
     7 using Xunit;
     8 
     9 namespace WebApiTest
    10 {
    11     public class BookApiTest
    12     {
    13         private HttpClient _client;
    14         private string applicationId = "1";
    15         private string applicationPassword = "123";
    16         private string timestamp = (DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalSeconds.ToString();
    17         private string nonce = new Random().Next(1000, 9999).ToString();
    18         private string signature = string.Empty;
    19 
    20         public BookApiTest()
    21         {
    22             _client = new HttpClient();
    23             _client.BaseAddress = new Uri("http://localhost:8091/");
    24             _client.DefaultRequestHeaders.Clear();
    25             signature = HMACMD5Helper.GetEncryptResult($"{applicationId}-{timestamp}-{nonce}", "@*api#%^@");
    26         }
    27 
    28         [Fact]
    29         public async Task book_api_get_by_id_should_success()
    30         {
    31             string queryString = $"applicationId={applicationId}&timestamp={timestamp}&nonce={nonce}&signature={signature}&applicationPassword={applicationPassword}";
    32             
    33             HttpResponseMessage message = await _client.GetAsync($"api/book/4939?{queryString}");
    34             var result = JsonConvert.DeserializeObject<CommonResult<Book>>(message.Content.ReadAsStringAsync().Result);
    35 
    36             Assert.Equal("000", result.Code);
    37             Assert.Equal(4939, result.Data.Id);
    38             Assert.True(message.IsSuccessStatusCode);
    39         }
    40 
    41         [Fact]
    42         public async Task book_api_get_by_id_should_failure()
    43         {
    44             string inValidSignature = Guid.NewGuid().ToString();
    45             string queryString = $"applicationId={applicationId}&timestamp={timestamp}&nonce={nonce}&signature={inValidSignature}&applicationPassword={applicationPassword}";
    46 
    47             HttpResponseMessage message = await _client.GetAsync($"api/book/4939?{queryString}");
    48             var result = JsonConvert.DeserializeObject<CommonResult<Book>>(message.Content.ReadAsStringAsync().Result);
    49 
    50             Assert.Equal("401", result.Code);
    51             Assert.Equal(System.Net.HttpStatusCode.Unauthorized, message.StatusCode);            
    52         }
    53 
    54         [Fact]
    55         public async Task book_api_post_by_id_should_success()
    56         {              
    57             var data = new Dictionary<string, string>();
    58             data.Add("applicationId", applicationId);
    59             data.Add("applicationPassword", applicationPassword);
    60             data.Add("timestamp", timestamp);
    61             data.Add("nonce", nonce);
    62             data.Add("signature", signature);
    63             data.Add("Id", "4939");
    64             HttpContent ct = new FormUrlEncodedContent(data);
    65 
    66             HttpResponseMessage message = await _client.PostAsync("api/book", ct);
    67             var result = JsonConvert.DeserializeObject<CommonResult<Book>>(message.Content.ReadAsStringAsync().Result);
    68 
    69             Assert.Equal("000", result.Code);
    70             Assert.Equal(4939, result.Data.Id);
    71             Assert.True(message.IsSuccessStatusCode);
    72 
    73         }
    74 
    75         [Fact]
    76         public async Task book_api_post_by_id_should_failure()
    77         {
    78             string inValidSignature = Guid.NewGuid().ToString();
    79             var data = new Dictionary<string, string>();
    80             data.Add("applicationId", applicationId);
    81             data.Add("applicationPassword", applicationPassword);
    82             data.Add("timestamp", timestamp);
    83             data.Add("nonce", nonce);
    84             data.Add("signature", inValidSignature);
    85             data.Add("Id", "4939");
    86             HttpContent ct = new FormUrlEncodedContent(data);
    87 
    88             HttpResponseMessage message = await _client.PostAsync("api/book", ct);
    89             var result = JsonConvert.DeserializeObject<CommonResult<Book>>(message.Content.ReadAsStringAsync().Result);
    90 
    91             Assert.Equal("401", result.Code);
    92             Assert.Equal(System.Net.HttpStatusCode.Unauthorized, message.StatusCode);
    93         }
    94     }   
    95 }
    复制代码

      测试用的是XUnit。这里写了get和post的测试用例。

      下面来看看测试的效果。

     

       测试通过。这里是直接用VS自带的测试窗口来运行测试,比较直观。

      当然也可以通过我们的dotnet test命令来运行测试。

      本文的Demo已经上传到Github:

      https://github.com/hwqdt/Demos/tree/master/src/ASPNETCoreAPIAuthorizedDemo

      Thanks for your reading!

    如果您认为这篇文章还不错或者有所收获,可以点击右下角的【推荐】按钮,因为你的支持是我继续写作,分享的最大动力!    
    声明: 本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。如果您发现博客中出现了错误,或者有更好的建议、想法,请及时与我联系!!如果想找我私下交流,可以私信或者加我QQ。
     
    标签: ASP.NET Core
  • 相关阅读:
    递归与尾递归(C语言)
    超酷算法:Levenshtein自动机
    编程面试的10大算法概念汇总
    C 语言中的指针和内存泄漏
    计算机实际上是如何工作的
    超酷算法:同型哈希
    4个Linux服务器监控工具
    2015-3-23
    2015-3-20
    2015-3-19
  • 原文地址:https://www.cnblogs.com/freeliver54/p/6244201.html
Copyright © 2011-2022 走看看