Web API,是一個能讓先後端分離、解放先後端生產力的好東西。不過大部分公司應該都沒能作到徹底的先後端分離。API的實現方式有很html
中臺和後臺各一個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站點,是任何一我的均可以訪問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.參數是否被篡改
3.請求的應用是否合法
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 }
爲何要實現這個拓展方法呢?我的認爲
Options、Middleware、Extensions、ServicesExtensions這四個是實現一箇中間件的標配(除去簡單到不行的那些中間件)
Options給咱們的中間件提供了一些可選的處理,提升了中間件的靈活性;
Middleware是咱們中間件最最重要的實現;
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}×tamp={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}×tamp={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!