需求描述:
專案裡的幾個Webapi介面需要進行鑑權,同介面可被小程式或網頁呼叫,小程式裡沒有使用者登入的概念,網頁裡有使用者登入的概念,對於呼叫方來源是小程式的情況下進行放權,其他情況下需要有身份驗證。也就是說給所有小程式請求進行放行,給網頁請求進行jwt身份驗證。由於我的小程式沒有使用者登入的功能,所以要針對小程式和網頁設計出兩套完全不同的鑑權方式。
鑑權流程設計:
查閱相關資料,最終決定的鑑權方式:
- 小程式採用sign簽名檢驗
- 網頁採用目前比較流行的JWT的token校驗
通過AOP的思想使用.Net的Attribute進行攔截請求
程式碼實現
主要是服務端寫一個Attribute,判斷是小程式還是網頁,然後採用不同的兩種不同的鑑權方式。
- Attribute程式碼:
public class WxAllowFilterAttribute : BaseActionFilter { private static readonly int _errorCode = 401; public override void OnActionExecuting(HttpActionContext filterContext) { var iswx = filterContext.iswx();//判斷是否是小程式發來的請求 if (iswx) {
//小程式的簽名校驗 if (!filterContext.checkwx()) { filterContext.Response = Error("小程式簽名驗證失敗", _errorCode); }; } else {
//JWT的token校驗 string token = filterContext.GetToken(); if (string.IsNullOrEmpty(token)) { filterContext.Response = Error("缺少token", _errorCode); return; } if (!JWTHelper.CheckToken(token, JWTHelper.JWTSecret)) { filterContext.Response = Error("token校驗失敗!", _errorCode); return; } var payload = JWTHelper.GetPayload<JWTPayload>(token); if (payload.Expire < DateTime.Now) { filterContext.Response = Error("token過期!", _errorCode); return; } base.OnActionExecuting(filterContext); } } }
- 擴充套件類
public static class HttpRequest { public static readonly string wx_secret = ConfigurationManager.AppSettings["wx_secret"]; /// <summary> /// 獲取Token /// </summary> /// <param name="req">請求</param> /// <returns></returns> public static string GetToken(this HttpActionContext req) { string tokenHeader = req.Request.Headers.Authorization == null ? "" : req.Request.Headers.Authorization.Parameter; if (string.IsNullOrEmpty(tokenHeader)) return null; string pattern = "^Bearer (.*?)$"; if (!Regex.IsMatch(tokenHeader, pattern)) throw new Exception("token格式不對!格式為:Bearer {token}"); string token = Regex.Match(tokenHeader, pattern).Groups[1].ToString(); if (string.IsNullOrEmpty(token)) throw new Exception("token不能為空!"); return token; } /// <summary> /// 判斷是否微信 /// </summary> /// <param name="req"></param> /// <returns></returns> public static bool iswx(this HttpActionContext req) { var queryList = req.Request.RequestUri.Query.Split('&').ToList<string>(); Dictionary<String, String> pList = new Dictionary<String, String>(); if (queryList.Count < 2) { return false; } else { queryList.ForEach(x => { var a = x.Split('='); if (a.Count() >= 2) { pList.Add(a[0], a[1]); } }); var iswx = pList.Any(x => x.Key == "app_key" && x.Value == "wx");//判斷是否有微信標識的欄位 return iswx; } } /// <summary> /// 檢驗微信sign是否合法 /// </summary> /// <param name="req"></param> /// <returns></returns> public static bool checkwx(this HttpActionContext req) { var queryList = req.Request.RequestUri.Query.Split('&').ToList<string>(); Dictionary<String, String> pList = new Dictionary<String, String>(); queryList.ForEach(x => { var a = x.Split('='); if (a.Count() >= 2) { pList.Add(a[0], a[1]); } }); var app_key = pList["app_key"]; var app_secret = wx_secret; var timetamp = pList["timestamp"]; var sign = pList["sign"]; if (!string.IsNullOrEmpty(timetamp)) { var tamp=Convert.ToInt64(timetamp); var nowtamp = ToTimestamp(DateTime.Now); var a = nowtamp-tamp; if (a >= 15) { return false; } } StringBuilder sb = new StringBuilder(); sb.Append(app_key); sb.Append(app_secret); sb.Append(timetamp); var newsign = GetMD5(sb.ToString()); return newsign == sign; } public static string GetMD5(string sDataIn) { MD5CryptoServiceProvider provider = new MD5CryptoServiceProvider(); byte[] bytes = Encoding.UTF8.GetBytes(sDataIn); byte[] buffer2 = provider.ComputeHash(bytes); provider.Clear(); string str = ""; for (int i = 0; i < buffer2.Length; i++) { str = str + buffer2[i].ToString("X").PadLeft(2, '0'); } return str.ToLower(); } public static long ToTimestamp(this DateTime target) { return (target.ToUniversalTime().Ticks - 621355968000000000) / 10000000; } }
- Filter基類
public class BaseActionFilter : ActionFilterAttribute { //public virtual void OnActionExecuting(HttpActionContext filterContext) //{ //} //public virtual void OnActionExecuted(HttpActionContext filterContext) //{ //} /// <summary> /// 返回JSON /// </summary> /// <param name="json">json字串</param> /// <returns></returns> public HttpResponseMessage JsonContent(string json) { var content = new StringContent(json, Encoding.UTF8, "application/json"); return new HttpResponseMessage { Content = content, StatusCode = HttpStatusCode.OK }; } public HttpResponseMessage IsSuccess() { AjaxResult res = new AjaxResult { IsSuccess = true, Msg = "請求成功!" }; return JsonContent(JsonHelper.SerializeObject(res)); } /// <summary> /// 返回成功 /// </summary> /// <param name="msg">訊息</param> /// <returns></returns> public HttpResponseMessage IsSuccess(string msg) { AjaxResult res = new AjaxResult { IsSuccess = true, Msg = msg }; return JsonContent(JsonHelper.SerializeObject(res)); } /// <summary> /// 返回成功 /// </summary> /// <param name="data">返回的資料</param> /// <returns></returns> public HttpResponseMessage IsSuccess<T>(T data) { AjaxResult<T> res = new AjaxResult<T> { IsSuccess = true, Msg = "請求成功!", Data = data }; return JsonContent(JsonHelper.SerializeObject(res)); } /// <summary> /// 返回錯誤 /// </summary> /// <returns></returns> public HttpResponseMessage Error() { AjaxResult res = new AjaxResult { IsSuccess = false, Msg = "請求失敗!" }; return JsonContent(JsonHelper.SerializeObject(res)); } /// <summary> /// 返回錯誤 /// </summary> /// <param name="msg">錯誤提示</param> /// <returns></returns> public HttpResponseMessage Error(string msg) { AjaxResult res = new AjaxResult { IsSuccess = false, Msg = msg, }; return JsonContent(JsonHelper.SerializeObject(res)); } /// <summary> /// 返回錯誤 /// </summary> /// <param name="msg">錯誤提示</param> /// <param name="errorCode">錯誤程式碼</param> /// <returns></returns> public HttpResponseMessage Error(string msg, int errorCode) { AjaxResult res = new AjaxResult { IsSuccess = false, Msg = msg, StatusCode = errorCode }; return JsonContent(JsonHelper.SerializeObject(res)); } }
- JWT擴充套件類
public class JWTHelper { private static readonly string _headerBase64Url = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".Base64UrlEncode(); public static readonly string JWTSecret = ConfigurationManager.AppSettings["JWTSecret"]; /// <summary> /// 生成Token /// </summary> /// <param name="payloadJsonStr">資料JSON字串</param> /// <param name="secret">金鑰</param> /// <returns></returns> public static string GetToken(string payloadJsonStr, string secret) { string payloadBase64Url = payloadJsonStr.Base64UrlEncode(); StringBuilder sb = new StringBuilder(); StringBuilder sb1 = new StringBuilder(); sb.AppendFormat("{0}", _headerBase64Url); sb.Append("."); sb.AppendFormat("{0}", payloadBase64Url); sb1 = sb; string sign = sb.ToString().ToHMACSHA256String(secret); string token = sb1.AppendFormat(".{0}", sign).ToString(); return token; } /// <summary> /// 獲取Token中的資料 /// </summary> /// <typeparam name="T">泛型</typeparam> /// <param name="token">token</param> /// <returns></returns> public static T GetPayload<T>(string token) { if (string.IsNullOrEmpty(token)) { return default(T); } return token.Split('.')[1].Base64UrlDecode().ToObject<T>(); } /// <summary> /// 校驗Token /// </summary> /// <param name="token">token</param> /// <param name="secret">金鑰</param> /// <returns></returns> public static bool CheckToken(string token, string secret) { var items = token.Split('.'); var oldSign = items[2]; StringBuilder sb = new StringBuilder(); sb.AppendFormat("{0}", items[0]); sb.AppendFormat(".{0}", items[1]); string newSign = sb.ToString().ToHMACSHA256String(secret); return oldSign == newSign; } }
檢驗使用者名稱密碼是否正確的業務介面程式碼這裡不貼了..
網頁客戶端的程式碼還沒寫完,主要思路就是判斷快取裡是否有token,沒有就去把使用者名稱密碼去呼叫服務端的登入介面拿到token後存到快取裡,之後的所有請求都在頭部帶上這個token。
小程式客戶端程式碼 :
在app.js中定義一個公共的promise請求方法,並帶上請求的引數(app_key,時間戳,md5加密後的sign等),這裡要注意區分get和post請求的區別,get是放在url後的,post是放在body裡的,要對傳參的格式要稍加處理
request(params) { reqTime++; //載入彈框 wx.showLoading({ title: '載入中...', mask: true }); //返回 return new Promise((resolve, reject) => { var data = { app_key: this.globalData.app_key, timestamp: Math.round(new Date() / 1000), sign: '' } data.sign = utilMd5.hexMD5(`${this.globalData.app_key}${this.globalData.app_secret}${data.timestamp}`) if (params.method.toUpperCase() == 'POST') { if (!params.url.includes('?')) { params.url += '?' } var url = `&app_key=${this.globalData.app_key}×tamp=${data.timestamp}&sign=${data.sign}` params = { ...params, url: params.url + url } data = params.data } else { data = { ...params.data, ...data } } params = { ...params, data: { ...data } } wx.request({ //解構params獲取請求引數 ...params, success: (result) => { resolve(result); }, fail: (err) => { reject(err); }, complete: () => { reqTime--; //停止載入 if (!reqTime) wx.hideLoading(); } }); }); }
這邊說明下,我的app_key和app_secret都是寫在app.js裡的公共變數中的,app_key在url裡是暴露的,但是app_secret是絕不能被暴露的。光知道app_key是無法生成正確的sign的,必須app_key,app_secret和timestap三者的加密才能生成正確的sign。我把app_secret寫在app.js中可能不是安全的做法,但是通過請求伺服器去獲取app.secret又要面臨網路請求的安全問題,最多對字串進行加密解密,但也不能說絕對安全了。app_secret怎麼處理最安全我目前也沒想到很好的辦法。。
好了,以上就是小程式的鑑權方法,小程式客戶端在請求時只需要呼叫這個公共方法就行。
鑑權測試結果
- 給控制器或者方法前面加上鑑權的特性[WxAllowFilter]
- PostMan直接呼叫不帶任何sign等引數
- 偽造小程式引數簽名驗證失敗或者時間戳超過10秒
- 小程式內呼叫