一.前言
權限驗證在開發中是經常遇到的,通常也是封裝好的模塊,如果我們是使用者,通常指需要一個標記特性或者配置一下就可以完成,但實際里面還是有許多東西值得我們去探究。有時候我們也會用一些開源的權限驗證框架,不過能自己實現一遍就更好,自己開發的東西成就感(逼格)會更高一些。進入主題,本篇主要是介紹接口端的權限驗證,這個部分每個項目都會用到,所以最好就是也把它插件化,放在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。
