一.前言
權限驗證在開發中是經常遇到的,通常也是封裝好的模塊,如果我們是使用者,通常指需要一個標記特性或者配置一下就可以完成,但實際裡面還是有許多東西值得我們去探究。有時候我們也會用一些開源的權限驗證框架,不過能自己實現一遍就更好,自己開發的東西成就感(逼格)會更高一些。進入主題,本篇主要是介紹接口端的權限驗證,這個部分每個項目都會用到,所以最好就是也把它插件化,放在Common中,新的項目就可以直接使用了。基於web的驗證之前也寫過這篇,有興趣的看一下ASP.NET MVC Form驗證。
二.簡介
對於我們系統來說,提供給外部訪問的方式有多種,例如通過網頁訪問,通過接口訪問等。對於不同的操作,訪問的權限也不同,如:
1. 可直接訪問。對於一些獲取數據操作不影響系統正常運行的和數據的,多余的驗證是沒有必要的,這個時候可以直接訪問,例如獲取當天的天氣預報信息,獲取網站的統計信息等。
2. 基於表單的web驗證。對於網站來說,有些網頁需要我們登錄才可以操作,http請求是無狀態,用戶每次操作都登錄一遍也是不可能的,這個時候就需要將用戶的登錄狀態記錄在某個地方。基於表單的驗證通常是把登錄信息記錄在Cookie中,Cookie每次會隨請求發送到服務端,以此來進行驗證。例如博客園,會把登錄信息記錄在一個名稱為.CNBlogsCookie的Cookie中(F12可去掉cookie觀察效果),這是一個經過加密的字符串,服務端會進行解密來獲取相關信息。當然雖然進行加密了,但請求在網絡上傳輸,依據可能被竊取,應對這一點,通常是使用https,它會對請求進行非對稱加密,就算被竊取,也無法直接獲得我們的請求信息,大大提高了安全性。可以看到博客園也是基於https的。
3. 基於簽名的api驗證。對於接口來說,訪問源可能有很多,網站、移動端和桌面程序都有可能,這個時候就不能通過cookie來實現了。基於簽名的驗證方式理論很簡單,它有幾個重要的參數:appkey, random,timestamp,secretkey。secretkey不隨請求傳輸,服務端會維護一個 appkey-secretkey 的集合。例如要查詢用戶余額時,請求會是類似:/api/user/querybalance?userid=1&appkey=a86790776dbe45ca9032fc59bbc351cb&random=191×tamp=14826791236569260&sign=09d72f207ba8ca9c0fd0e5f8523340f5
參數解析:
1.appkey用於給服務端找到對應的secretkey。有時候我們會分配多對appkey-secretkey,例如安卓分一對,ios分一對。
2.random、timestamp是為了防止重放攻擊的(Repaly Attacks),這是為了避免請求被竊取後,攻擊者通過分析後破解後,再次發起惡意請求。參數timestamp時間戳是必須的,所謂時間戳是指從1970-1-1至當前的總秒數。我們規定一個時間,例如20分鐘,超過20分鐘就算過期,如果當前時間與這個時間戳的間隔超過20分鐘,就拒絕。random不是必須的,但有了它也可以更好防止重放攻擊,理論上來說,timestamp+random應該是唯一的,這個時候我們可以將其作為key緩存在redis,如果通過請求的timestamp+random能在規定時間獲取到,就拒絕。這裡還有個問題,客戶端與服務端時間不同步怎麼辦?這個可以要求客戶端校正時間,或者把過期時間調大,例如30分鐘才算過期,再或者可以使用網絡時間。防止重放攻擊也是很常見的,例如你可以把手機時間調到較早前一個時間,再使用手機銀行,這個時候就會收到error了。
3.sign簽名是通過一定規則生成,在這裡我用sign=md5(httpmethod+url+timestamp+參數字符串+secretkey)生成。服務端接收到請求後,先通過appkey找到secretkey,進行同樣拼接後進行hash,再與請求的sign進行比較,不一致則拒絕。這裡需要注意的是,雖然我們做了很多工作,但依然不能阻止請求被竊取;我把timestamp參與到sign的生成,因為timestamp在請求中是可見的,請求被竊取後它完全可以被修改並再次提交,如果我們把它參與到sign的生成,一旦修改,sign也就不一樣了,提高了安全性。參數字符串是通過請求參數拼接生成的字符串,目的也是類似的,防止參數被篡改。例如有三個參數a=1,b=3,c=2,那麼參數字符串=a1b3c2,也可以通過將參數按值進行排序再拼接生成參數字符串。
使用例子,最近剛好在使用友盟的消息推送服務,可以看到它的簽名生成規則如下,與我們介紹是類似的。
三.編碼實現
這裡還是通過Action Filter來實現的,具體可以看通過源碼了解ASP.NET MVC 幾種Filter的執行過程介紹。通過上面的簡介,這裡的代碼雖多,但很容易理解了。ApiAuthorizeAttribute 是標記在Action或者Controller上的,定義如下
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class ApiAuthorizeAttribute : ApiBaseAuthorizeAttribute { private static string[] _keys = new string[] { "appkey", "timestamp", "random", "sign" }; public override void OnAuthorization(AuthorizationContext context) { //是否允許匿名訪問 if (context.ActionDescriptor.IsDefined(typeof(AllowAnonymousAttribute), false)) { return; } HttpRequestBase request = context.HttpContext.Request; string appkey = request[_keys[0]]; string timestamp = request[_keys[1]]; string random = request[_keys[2]]; string sign = request[_keys[3]]; ApiStanderConfig config = ApiStanderConfigProvider.Config; if(string.IsNullOrEmpty(appkey)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.MissAppKey); return; } if (string.IsNullOrEmpty(timestamp)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.MissTimeStamp); return; } if (string.IsNullOrEmpty(random)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.MissRamdon); return; } if(string.IsNullOrEmpty(sign)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.MissSign); return; } //驗證key string secretKey = string.Empty; if(!SecretKeyContainer.Container.TryGetValue(appkey, out secretKey)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.KeyNotFound); return; } //驗證時間戳(時間戳是指1970-1-1到現在的總秒數) long lt = 0; if (!long.TryParse(timestamp, out lt)) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.TimeStampTypeError); return; } long now = DateTime.Now.Subtract(new DateTime(1970, 1, 1)).Ticks; if (now - lt > new TimeSpan(0, config.Minutes, 0).Ticks) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.PastRequet); return; } //驗證簽名 //httpmethod + url + 參數字符串 + timestamp + secreptkey MD5Hasher md5 = new MD5Hasher(); string parameterStr = GenerateParameterString(request); string url = request.Url.ToString(); url = url.Substring(0, url.IndexOf('?')); string serverSign = md5.Hash(request.HttpMethod + url + parameterStr + timestamp + secretKey); if(sign != serverSign) { SetUnAuthorizedResult(context, ApiUnAuthorizeType.ErrorSign); return; } } private string GenerateParameterString(HttpRequestBase request) { string parameterStr = string.Empty; var collection = request.HttpMethod == "GET" ? request.QueryString : request.Form; foreach(var key in collection.AllKeys.Except(_keys)) { parameterStr += key + collection[key] ?? string.Empty; } return parameterStr; } }
下面會對這段核心代碼進行解析。ApiStanderConfig包裝了一些配置信息,例如上面我們說到的過期時間是20分鐘,但我們希望可以在模塊外部進行自定義。所以通過一個ApiStanderConfig來包裝,通過ApiStanderConfigProvider來注冊和獲取。ApiStanderConfig和ApiStanderConfigProvider的定義如下
public class ApiStanderConfig { public int Minutes { get; set; } }
public class ApiStanderConfigProvider { public static ApiStanderConfig Config { get; private set; } static ApiStanderConfigProvider() { Config = new ApiStanderConfig() { Minutes = 20 }; } public static void Register(ApiStanderConfig config) { Config = config; } }
前面介紹到服務端會維護一個appkey-secretkey的集合,這裡通過一個SecretKeyContainer實現,它的Container就是一個字典集合,定義如下
public class SecretKeyContainer { public static Dictionary<string, string> Container { get; private set; } static SecretKeyContainer() { Container = new Dictionary<string, string>(); } public static void Register(string appkey, string secretKey) { Container.Add(appkey, secretKey); } public static void Register(Dictionary<string, string> set) { foreach(var key in set) { Container.Add(key.Key, key.Value); } } }
可以看到,上面有很多的條件判斷,並且錯誤會有不同的描述。所以我定義了一個ApiUnAuthorizeType錯誤類型枚舉和DescriptionAttribute標記,如下:
public enum ApiUnAuthorizeType { [Description("時間戳類型錯誤")] TimeStampTypeError = 1000, [Description("appkey缺失")] MissAppKey = 1001, [Description("時間戳缺失")] MissTimeStamp = 1002, [Description("隨機數缺失")] MissRamdon = 1003, [Description("簽名缺失")] MissSign = 1004, [Description("appkey不存在")] KeyNotFound = 1005, [Description("過期請求")] PastRequet = 1006, [Description("錯誤的簽名")] ErrorSign = 1007 }
public class DescriptionAttribute : Attribute { public string Description { get; set; } public DescriptionAttribute(string description) { Description = description; } }
當驗證不通過時,會調用SetUnAuthorizedResult,並且請求不需再進行下去了。這個方法是在基類中實現的,如下
public class ApiBaseAuthorizeAttribute : AuthorizeAttribute { protected virtual void SetUnAuthorizedResult(AuthorizationContext context, ApiUnAuthorizeType type) { UnAuthorizeHandlerProvider.ApiHandler(context, type); HandleUnauthorizedRequest(context); } protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) { if (filterContext.Result != null) { return; } base.HandleUnauthorizedRequest(filterContext); } }
可以看到,它通過一個委托根據錯誤類型處理結果,UnAuthorizeHandlerProvider定義如下
public class UnAuthorizeHandlerProvider { public static Action<AuthorizationContext, ApiUnAuthorizeType> ApiHandler { get; private set; } static UnAuthorizeHandlerProvider() { ApiHandler = ApiUnAuthorizeHandler.Handler; } public static void Register(Action<AuthorizationContext, ApiUnAuthorizeType> action) { ApiHandler = action; } }
它默認通過ApiUnAuthorizeHandler.Handler來處理結果,但也可以在模塊外部進行注冊。默認的處理為ApiUnAuthorizeHandler.Handler,如下
public class ApiUnAuthorizeHandler { public readonly static Action<AuthorizationContext, ApiUnAuthorizeType> Handler = (context, type) => { context.Result = new StanderJsonResult() { Result = FastStatnderResult.Fail(type.GetDescription(), (int)type) }; }; }
它的操作就是返回一個json結果。type.GetDescription是一個擴展方法,目的就是獲取DescriptionAttribute的描述信息,如下
public static class EnumExt { public static string GetDescription(this Enum e) { Type type = e.GetType(); var attributes = type.GetField(e.ToString()).GetCustomAttributes(typeof(DescriptionAttribute), false) as DescriptionAttribute[]; if(attributes.IsNullOrEmpty()) { return null; } return attributes[0].Description; } }
這裡還涉及到幾個json相關對象,但它們應該不影響閱讀。StanderResult, FastStanderResult, StanderJsonResult,有興趣也可以看一下,在實際項目中有很多地方都可以用到它們,可以標准和簡化許多操作。如下
public class StanderResult { public bool IsSuccess { get; set; } public object Data { get; set; } public string Description { get; set; } public int Code { get; set; } } public static class FastStatnderResult { private static StanderResult _success = new StanderResult() { IsSuccess = true }; public static StanderResult Success() { return _success; } public static StanderResult Success(object data, int code = 0) { return new StanderResult() { IsSuccess = true, Data = data, Code = code }; } public static StanderResult Fail() { return new StanderResult() { IsSuccess = false }; } public static StanderResult Fail(string description, int code = 0) { return new StanderResult() { IsSuccess = false, Description = description, Code = code }; } }
public class StanderJsonResult : ActionResult { public StanderResult Result { get; set; } public string ContentType { get; set; } public Encoding Encoding { get; set; } public override void ExecuteResult(ControllerContext context) { HttpResponseBase response = context.HttpContext.Response; response.ContentType = string.IsNullOrEmpty(ContentType) ? "application/json" : ContentType; if (Encoding != null) { response.ContentEncoding = Encoding; } string json = JsonConvert.SerializeObject(Result); response.Write(json); } }
四.例子
我們在程序初始化時注冊appkey-secretkey,如
//注冊appkey-secretkey string[] appkey1 = ConfigurationReader.GetStringValue("appkey1").Split(','); SecretKeyContainer.Container.Add(appkey1[0], appkey1[1]);
下面的使用就很簡單了,標記需要驗證的接口。如
[ApiAuthorize] public ActionResult QueryBalance(int userId) { return Json("查詢成功"); }
我們在網頁輸入鏈接測試:如
1.輸入過期時間會提示{"IsSuccess":false,"Data":null,"Description":"過期請求","Code":1006}
2.輸入錯誤簽名會提示{"IsSuccess":false,"Data":null,"Description":"錯誤的簽名","Code":1007}
只有所有驗證都成功時才可以訪問。
當然實際項目的驗證可能會更復雜一些,條件也會更多一些,不過都可以在此基礎上進行擴展。如上面所說,這種算法可以保證請求是合法的,而且參數不被篡改,但還是無法保證請求不被竊取,要實現更高的安全性還是需要使用https。