去年研究過一陣子 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 2Web 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

Post a comment