在 上一篇 中講到了在NetCore專案中如何進行全域性的請求模型驗證,只要在請求模型中加了驗證特性,介面使用時只用將資料拿來使用,而不用去關係資料是否符合業務需求。

  這篇中將講些個人對於JWT的看法和使用,在網上也能找到很多相關資料和如何使用,基本都是直接嵌到  Startup 類中來單獨使用。而博主是將jwt當做一個驗證方法來使用。使用起來更加方便,並且在做驗證時也更加的靈活。

 1.什麼是JWT?

  Json web token (JWT), 是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準((RFC 7519)。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證。

傳統的session認證

我們知道,http協議本身是一種無狀態的協議,而這就意味著如果使用者向我們的應用提供了使用者名稱和密碼來進行使用者認證,那麼下一次請求時,使用者還要再一次進行使用者認證才行,因為根據http協議,我們並不能知道是哪個使用者發出的請求,所以為了讓我們的應用能識別是哪個使用者發出的請求,我們只能在伺服器儲存一份使用者登入的資訊,這份登入資訊會在響應時傳遞給瀏覽器,告訴其儲存為cookie,以便下次請求時傳送給我們的應用,這樣我們的應用就能識別請求來自哪個使用者了,這就是傳統的基於session認證。

但是這種基於session的認證使應用本身很難得到擴充套件,隨著不同客戶端使用者的增加,獨立的伺服器已無法承載更多的使用者,而這時候基於session認證應用的問題就會暴露出來.

基於session認證所顯露的問題

Session: 每個使用者經過我們的應用認證之後,我們的應用都要在服務端做一次記錄,以方便使用者下次請求的鑑別,通常而言session都是儲存在記憶體中,而隨著認證使用者的增多,服務端的開銷會明顯增大。

擴充套件性: 使用者認證之後,服務端做認證記錄,如果認證的記錄被儲存在記憶體中的話,這意味著使用者下次請求還必須要請求在這臺伺服器上,這樣才能拿到授權的資源,這樣在分散式的應用上,相應的限制了負載均衡器的能力。這也意味著限制了應用的擴充套件能力。

CSRF: 因為是基於cookie來進行使用者識別的, cookie如果被截獲,使用者就會很容易受到跨站請求偽造的攻擊。

基於token的鑑權機制

基於token的鑑權機制類似於http協議也是無狀態的,它不需要在服務端去保留使用者的認證資訊或者會話資訊。這就意味著基於token認證機制的應用不需要去考慮使用者在哪一臺伺服器登入了,這就為應用的擴充套件提供了便利。

流程上是這樣的:

    • 使用者使用使用者名稱密碼來請求伺服器
    • 伺服器進行驗證使用者的資訊
    • 伺服器通過驗證傳送給使用者一個token
    • 客戶端儲存token,並在每次請求時附送上這個token值
    • 服務端驗證token值,並返回資料

JWT的構成

第一部分我們稱它為頭部(header),第二部分我們稱其為載荷(payload, 類似於飛機上承載的物品),第三部分是簽證(signature).

header

jwt的頭部承載兩部分資訊:

    • 宣告型別,這裡是jwt
    • 宣告加密的演算法 通常直接使用 HMAC SHA256

完整的頭部就像下面這樣的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然後將頭部進行base64加密(該加密是可以對稱解密的),構成了第一部分.

playload

載荷就是存放有效資訊的地方。這個名字像是特指飛機上承載的貨品,這些有效資訊包含三個部分

    • 標準中註冊的宣告
    • 公共的宣告
    • 私有的宣告

標準中註冊的宣告 (建議但不強制使用) :

    • iss: jwt簽發者
    • sub: jwt所面向的使用者
    • aud: 接收jwt的一方
    • exp: jwt的過期時間,這個過期時間必須要大於簽發時間
    • nbf: 定義在什麼時間之前,該jwt都是不可用的.
    • iat: jwt的簽發時間
    • jti: jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊。

公共的宣告 :
公共的宣告可以新增任何的資訊,一般新增使用者的相關資訊或其他業務需要的必要資訊.但不建議新增敏感資訊,因為該部分可以直接base64解碼,可以看到裡面的資訊

signature

jwt的第三部分是一個簽證資訊,這個簽證資訊由三部分組成:

    • header (base64後的)
    • payload (base64後的)
    • secret

這個部分需要base64加密後的header和base64加密後的payload使用.連線組成的字串,然後通過header中宣告的加密方式進行加鹽secret組合加密,然後就構成了jwt的第三部分。

將這三部分用 . 連線成一個完整的字串,構成了最終的jwt。

注意:secret是儲存在伺服器端的,jwt的簽發生成也是在伺服器端的,secret就是用來進行jwt的簽發和jwt的驗證,所以,它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret, 那就意味著客戶端是可以自我簽發jwt了。

2.如何將JWT 脫離出來生成與驗證?

  在任意類庫(建議放在公用類中)的NuGet包管理中新增: System.IdentityModel.Tokens.Jwt  然後新增  TokenManager 類

    /// <summary>
    /// token管理類
    /// </summary>
    public class TokenManager
    {
        //私有欄位建議放到配置檔案中
        /// <summary>
        /// 祕鑰  4的倍數  長度大於等於24
        /// </summary>
        private static string _secret = "levy0102030405060708asdf";
        /// <summary>
        /// 釋出者
        /// </summary>
        private static string _issuer = "levy";

        /// <summary>
        /// 生成token
        /// </summary>
        /// <param name="tokenStr">需要簽名的資料  </param>
        /// <param name="expireHour">預設3天過期</param>
        /// <returns>返回token字串</returns>
        public static string GenerateToken(string tokenStr, int expireHour = 3 * 24) //3天過期
        {
            var key1 = new SymmetricSecurityKey(Convert.FromBase64String(_secret));
            var cred = new SigningCredentials(key1, SecurityAlgorithms.HmacSha256);
            var claims = new[]
            {
                new Claim("sid",tokenStr),
                //new Claim(ClaimTypes.Name,name), //示例  可使用ClaimTypes中的型別
            };
            var token = new JwtSecurityToken(
                issuer: _issuer,//簽發者
                notBefore: DateTime.Now,//token不能早於這個時間使用
                expires: DateTime.Now.AddHours(expireHour),//新增過期時間
                claims: claims,//簽名資料
                signingCredentials: cred//簽名
                );
            //解決一個不知什麼問題的PII什麼異常
            IdentityModelEventSource.ShowPII = true;
            return new JwtSecurityTokenHandler().WriteToken(token);
        }

        /// <summary>
        /// 得到Token中的驗證訊息
        /// </summary>
        /// <param name="token"></param>
        /// <param name="dateTime"></param>
        /// <returns></returns>
        public static string ValidateToken(string token, out DateTime dateTime)
        {
            dateTime = DateTime.Now;
            var principal = GetPrincipal(token, out dateTime);

            if (principal == null)
                return default(string);

            ClaimsIdentity identity = null;
            try
            {
                identity = (ClaimsIdentity)principal.Identity;
            }
            catch (NullReferenceException)
            {
                return null;
            }
            //identity.FindFirst(ClaimTypes.Name).Value;
            return identity.FindFirst("sid").Value;
        }

        /// <summary>
        /// 從Token中得到ClaimsPrincipal物件
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        private static ClaimsPrincipal GetPrincipal(string token, out DateTime dateTime)
        {
            try
            {
                dateTime = DateTime.Now;
                var tokenHandler = new JwtSecurityTokenHandler();
                var jwtToken = (JwtSecurityToken)tokenHandler.ReadToken(token);

                if (jwtToken == null)
                    return null;

                var key = Convert.FromBase64String(_secret);

                var parameters = new TokenValidationParameters()
                {
                    RequireExpirationTime = true,
                    ValidateIssuer = true,//驗證建立該令牌的釋出者
                    ValidateLifetime = true,//檢查令牌是否未過期,以及發行者的簽名金鑰是否有效
                    ValidateAudience = false,//確保令牌的接收者有權接收它
                    IssuerSigningKey = new SymmetricSecurityKey(key),
                    ValidIssuer = _issuer//驗證建立該令牌的釋出者
                };
                //驗證token 
                var principal = tokenHandler.ValidateToken(token, parameters, out var securityToken);
                //若開始時間大於當前時間 或結束時間小於當前時間 則返回空
                if (securityToken.ValidFrom.ToLocalTime() > DateTime.Now || securityToken.ValidTo.ToLocalTime() < DateTime.Now)
                {
                    dateTime = DateTime.Now;
                    return null;
                }
                dateTime = securityToken.ValidTo.ToLocalTime();//返回Token結束時間
                return principal;
            }
            catch (Exception e)
            {
                dateTime = DateTime.Now;
                LogHelper.Logger.Fatal(e, "Token驗證失敗");
                return null;
            }
        }
    }

  再到控制器中新增測試方法

        [HttpGet]
        [Route("testtoken")]
        public ActionResult TestToken()
        {
            var token = TokenManager.GenerateToken("測試token的生成");
            Response.Headers["token"] = token;
            Response.Headers["Access-Control-Expose-Headers"] = "token";//一定要新增這一句  不然前端是取不到token欄位的值的!更別提存store了。
            return Succeed(token);
        }

在這裡必須得提的地方是  若是前後端分離的專案,由於存在跨域問題,必須得在返回header中多新增一個欄位 Access-Control-Expose-Headers 該欄位對應的值為前端需要取得欄位的集合,以英文逗號分隔。

原因:在跨域訪問時,XMLHttpRequest物件的getResponseHeader()方法只能拿到一些最基本的響應頭,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要訪問其他頭,則需要伺服器設定本響應頭。Access-Control-Expose-Headers 頭讓伺服器把允許瀏覽器訪問的頭放入白名單。不然容易出現前後端開發人員撕逼哦~

測試結果截圖:

能看到資料能返回出來,在除錯中也能看到。接著拿這個去訪問介面。博主這裡只是示例,具體業務視情況而定。

接下來我們拿生成的token去訪問驗證下是否能成功,在驗證token的時候我們可以順帶看下token是否即將過期,若快要過期了就取一個新的token。當然這裡有一個問題就是之前的token還可以使用。這裡可以用其它手段來規避。如快取過期token判斷等。

        [HttpPost]
        [Route("validtoken")]
        public ActionResult ValidToken([FromHeader]string token)
        {
            var str = TokenManager.ValidateToken(token, out DateTime date);
            if (!string.IsNullOrEmpty(str) || date > DateTime.Now)
            {
                //當token過期時間小於五小時,更新token並重新返回新的token
                if (date.AddHours(-5) > DateTime.Now) return Succeed($"Token字串:{str},過期時間:{date}");
                var nToken = TokenManager.GenerateToken(str);
                Response.Headers["token"] = nToken;
                token = nToken;
                Response.Headers["Access-Control-Expose-Headers"] = "token";
            }
            else
            {
                return Fail(101, "未取得授權資訊");
            }
            return Succeed($"Token字串:{str},過期時間:{DateTime.Now.AddHours(3 * 24)}");
        }

  測試結果:

 

 3.問題與討論~ 

  JWT也存在很多疑問的地方,比如 1.被盜取了怎麼辦?2.使用者處於失控狀態下?等等問題。

  建議:1.不在payload部分存放敏感資訊,且儘可能使用https方式,防止被盜的可能性,且提醒使用者有風險,不要在公共地方登陸。提供給使用者token儲存時間選擇,若未選擇長期儲存則只存sessionStorage ,選了則存localStorage。

       2.後端使用者資訊一般存於快取之中,一般使用者使用時間不會太長,所以後端快取設定時間短(如2小時),當後端快取過期了就根據payload部分資料來取使用者資訊存快取, 使用者資訊新增穩定狀態值來判斷是否可用。

       3.為解決2要使用payload部分的資料,為防止洩露,可進行AES 進行加密處理,當需要使用時取出在解密使用。

  以上屬個人想法。有什麼問題歡迎提出,共同討論。

 

  部分文字描述參考於:

  https://www.jianshu.com/p/576dbf44b2ae

  

  

  在下一篇中將介紹如何在NetCore中如何使用 MemoryCache 和 Redis 來做快取不常變動資料,提高響應速度~~

 

  有需要原始碼的在下方評論或私信~給我的SVN訪客賬戶密碼下載,程式碼未放在GitHub