用Middleware給ASP.NET Core Web API添加本身的受權驗證

  Web API,是一個能讓先後端分離、解放先後端生產力的好東西。不過大部分公司應該都沒能作到徹底的先後端分離。API的實現方式有很html

多,能夠用ASP.NET Core、也能夠用ASP.NET Web API、ASP.NET MVC、NancyFx等。說到Web API,不一樣的人有不一樣的作法,可能前臺、

中臺和後臺各一個api站點,也有可能一個模塊一個api站點,也有可能各個系統共用一個api站點,固然這和業務有必然的聯繫。git

  安全順其天然的成爲Web API關注的重點之一。如今流行的OAuth 2.0是個很不錯的東西,不過本文是暫時沒有涉及到的,只是按照最最最github

原始的思路作的一個受權驗證。在以前的MVC中,咱們多是經過過濾器來處理這個身份的驗證,在Core中,我天然就是選擇Middleware來處json

理這個驗證。後端

  下面開始本文的正題:api

  先編寫一個能正常運行的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 來獲得相關app

的數據。如今咱們要對這個api進行必定的處理,讓只有權限的站點才能訪問它。asp.net

  下面就是編寫自定義的受權驗證中間件了。

  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!

相關文章
相關標籤/搜索