ASP.NET WebAPI 2 + NSwag - 實作簡單 ApiKey Header + IP 權限管控
1 |
去年研究過一陣子 NSwag + ASP.NET WebAPI 2 整合,算是找到「寫 WebAPI 只需專心寫邏輯,測試介面、文件、客戶端都由系統打理」的美妙開發模式:
最近遇上大量現有函式翻寫 WebAPI 的需求,先前的研究成果又派上用場了。(多愧 2019 的 Jeffrey 用心整理,拯救了 2020 的 Jeffrey,寫部落格最大的意義,其實是自己救自己呀! XD)
去年整理的架構已稱完整,算算只缺少存取權限管控,這篇就來試試如何為 ASP.NET WebAPI 2 加上簡單的 HTTP Header API Key + IP 管控。
沿用去年的範例程式,原始碼我放上 Github 了,所以文章只挑重點講,完整程式碼請大家自行下載參考。
我的計劃是呼叫 WebAPI 時夾帶一個 X-Api-Key HTTP Header,伺服器端則寫一個 IAuthenticationFilter,由 Requset 提取 X-Api-Key Header 及來源 IP,負責跟設定檔或資料庫中的清單比較,若為有效 API Key 且 IP 也相符,即產生一個 ClaimsPrincipal 設定給 HttpAuthenticationContext.Principal。配合在 Controller 標註 [Authorize],簡單的存取管控就完成了。(參考: Authentication Filters in ASP.NET Web API 2、 Web API 2 security extensibility points part 2: custom authentication filter)
ApiKeyAuthAttribute.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Principal;
using System.ServiceModel.Channels;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Http.Filters;
namespace WebApiDemo.Models
{
/// <inheritdoc />
public class ApiKeyAuthAttribute : Attribute, IAuthenticationFilter
{
/// <inheritdoc />
public bool AllowMultiple => false;
private const string API_KEY_HEADER_NAME = "X-Api-Key";
private ClaimsPrincipal CreateClaimPrinciple(string apiClientId)
{
//TODO 實際應用時由資料庫或設定檔檢核API Key及限定IP
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.Name, apiClientId));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "MyApiAuth"));
}
//REF: http://www.herlitz.nu/2013/06/27/getting-the-client-ip-via-asp-net-web-api/
private string GetIP(HttpRequestMessage request)
{
if (request.Properties.ContainsKey("MS_HttpContext"))
{
return ((HttpContextWrapper)request.Properties["MS_HttpContext"]).Request.UserHostAddress;
}
else if (request.Properties.ContainsKey(RemoteEndpointMessageProperty.Name))
{
var prop = (RemoteEndpointMessageProperty)request.Properties[RemoteEndpointMessageProperty.Name];
return prop.Address;
}
else if (HttpContext.Current != null)
{
return HttpContext.Current.Request.UserHostAddress;
}
else
{
return null;
}
}
/// <inheritdoc />
public async Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var ip = GetIP(request);
ClaimsPrincipal principle = null;
if (request.Headers.Contains(API_KEY_HEADER_NAME))
{
var xApiKey = request.Headers.GetValues(API_KEY_HEADER_NAME).FirstOrDefault();
var apiClientId = AccessControlManager.GetApiClientId(xApiKey, ip);
if (!string.IsNullOrEmpty(apiClientId))
{
principle = CreateClaimPrinciple(apiClientId);
context.Principal = principle;
}
}
if (principle == null)
context.ErrorResult = new AuthenticationFailureResult("Invalid API key.", request);
}
/// <inheritdoc />
public async Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken)
{
return;
}
}
public class AuthenticationFailureResult : IHttpActionResult
{
public AuthenticationFailureResult(string reasonPhrase, HttpRequestMessage request)
{
ReasonPhrase = reasonPhrase;
Request = request;
}
public string ReasonPhrase { get; private set; }
public HttpRequestMessage Request { get; private set; }
public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
{
return Task.FromResult(Execute());
}
private HttpResponseMessage Execute()
{
HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
response.RequestMessage = Request;
response.ReasonPhrase = ReasonPhrase;
return response;
}
}
}
比對 X-Api-Key 與 IP 部分我是用 web.config appSetting <add key="apikey:Local" value="::1,127.0.0.1" />
簡單示範,實際應用時可考慮存在資料庫,我預留了定期更新 Cache 機制減少資料庫負擔。
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Runtime.Caching;
using System.Web;
namespace WebApiDemo.Models
{
/// <summary>
/// 存取控制管理員
/// </summary>
public static class AccessControlManager
{
static Dictionary<string, string[]> ApiKeys =
ConfigurationManager.AppSettings.AllKeys
.Where(o => o.StartsWith("apikey:"))
.ToDictionary(
o => o.Split(':').Last(),
o => (ConfigurationManager.AppSettings[o] ?? string.Empty).Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries).ToArray()
);
/// <summary>
/// 檢查ApiKey及IP是否有效?有效時傳回ApiClient識別碼,否則傳回null
/// </summary>
/// <param name="apiKey">ApiKey</param>
/// <param name="ipAddress">IP位址</param>
/// <returns>ApiClient識別碼或null</returns>
public static string GetApiClientId(string apiKey, string ipAddress)
{
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(ipAddress))
return null;
//利用閒置五分鐘Cache機制減少反覆查詢
//缺點是增刪修改ApiKey設定後可能要等五分鐘
string cacheKey = $"{apiKey}\t{ipAddress}";
if (MemoryCache.Default.Contains(cacheKey))
return (string)MemoryCache.Default[cacheKey];
//此處使用 apSetting 儲存 Api Key,亦可考量改存於資料庫
string apiClientId =
(ApiKeys.ContainsKey(apiKey) && ApiKeys[apiKey].Contains(ipAddress)) ? apiKey : null;
//加入Cache減少查詢次數
MemoryCache.Default.Add(cacheKey, apiClientId, new CacheItemPolicy
{
AbsoluteExpiration = DateTime.Now.NextSlotStartTime(5, 10)
});
return apiClientId;
}
//REF: https://blog.darkthread.net/blog/better-abs-time-expire-cache/
static Random rnd = new Random();
/// <summary>
/// 下個時間格隔起點
/// </summary>
/// <param name="time">現在時間或推算基準</param>
/// <param name="slotMins">時間格大小(以分鐘表示)</param>
/// <param name="randomDelaySecs">隨機延遲</param>
/// <returns>下個時間格的起算時間</returns>
public static DateTime NextSlotStartTime(this DateTime time, int slotMins, int randomDelaySecs = 30)
{
var slotSecs = slotMins * 60;
var remainingSecs = slotSecs - ((time - time.Date).TotalSeconds % slotSecs);
//加上 Delay
if (randomDelaySecs > 0)
remainingSecs += rnd.Next(randomDelaySecs);
return time.AddSeconds(remainingSecs);
}
}
}
準備好之後,在 Controller 加上 [ApiKeyAuth]、[Authorize] 即可啟用檢查:
/// <summary>
/// 加解密功能
/// </summary>
[MvcStyleBinding]
[ApiKeyAuth]
[Authorize]
public class CodecController : ApiController
修改後 Swagger 介面無法測試,會傳回 HTTP 401 Error: Invalid API Key. 錯誤:
這是因為我們還沒有告訴 NSwag 我們的 Controller 需要身分認證,需在 Startup.cs UseSwaggerUi3() 加入 GeneratorSettings.DocumentProcessors 及 GeneratorSettings.OperationProcessors 設定:(註: NSwag 12.x 跟 13.x 有點差異,套件名稱、命名空間及部分型別名稱需調整,範例為 13.x 的寫法,12.x 寫法請參考 Github 較早的 Commit)
public class Startup
{
public void Configuration(IAppBuilder app)
{
var config = new HttpConfiguration();
app.UseSwaggerUi3(typeof(Startup).Assembly, settings =>
{
//針對RPC-Style WebAPI,指定路由包含Action名稱
settings.GeneratorSettings.DefaultUrlTemplate =
"api/{controller}/{action}/{id?}";
//可加入客製化調整邏輯
settings.PostProcess = document =>
{
document.Info.Title = "WebAPI 範例";
};
//加入Api Key定義
settings.GeneratorSettings.DocumentProcessors.Add(new SecurityDefinitionAppender("ApiKey", new OpenApiSecurityScheme()
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "X-Api-Key",
Description = "請填入配發之 API Key",
In = OpenApiSecurityApiKeyLocation.Header
}));
//REF: https://github.com/RicoSuter/NSwag/issues/1304
settings.GeneratorSettings.OperationProcessors.Add(new OperationSecurityScopeProcessor("ApiKey"));
});
app.UseWebApi(config);
config.MapHttpAttributeRoutes();
config.EnsureInitialized();
}
}
加入上述設定後,Swagger UI 會多出一個 Authorize 按鈕,按下後可輸入 X-Api-Key 的 Header 值,與 web.config appSetting 的設定對應,簡單的 Web API 呼叫權限管控就完成囉。
Example of how to add API key header and IP address access control to ASP.NET Web API 2 with NSwag.
Comments
# by YCh
TENET