首页 > 代码库 > 用Middleware给ASP.NET Core Web API添加自己的授权验证

用Middleware给ASP.NET Core Web API添加自己的授权验证

  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.Controllers11 {12     [Route("api/[controller]")]13     public class BookController : Controller14     {15 16         private DapperHelper _helper;17         public BookController(DapperHelper helper)18         {19             this._helper = helper;20         }21 22         // GET: api/book23         [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 =http://www.mamicode.com/ res32             };33             return Ok(json);34         }35 36         // GET api/book/537         [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 =http://www.mamicode.com/ res48             };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 =http://www.mamicode.com/ res64             };65             return Ok(json);66         }67 68     }69 70     public class PostForm71     {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                 else19                 {20                     await CheckApplication(context, requestInfo.ApplicationId, requestInfo.ApplicationPassword);21                 }22             }23             else24             {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             else19             {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 RequestInfo10             {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 RequestInfo10             {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                     else17                     {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 WebApi11 {12     public class Startup13     {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 container34         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 middleware49             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 pipeline62         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 middleware71             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 WebApiTest10 {11     public class BookApiTest12     {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 = http://www.mamicode.com/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 = http://www.mamicode.com/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!

 

用Middleware给ASP.NET Core Web API添加自己的授权验证