在 上一篇 中講到了在NetCore項目中如何進行全局的請求模型驗證,只要在請求模型中加了驗證特性,介面使用時只用將數據拿來使用,而不用去關係數據是否符合業務需求。 這篇中將講些個人對於JWT的看法和使用,在網上也能找到很多相關資料和如何使用,基本都是直接嵌到 Startup 類中來單獨使用。而博主 ...
在 上一篇 中講到了在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 進行加密處理,當需要使用時取出在解密使用。
以上屬個人想法。有什麼問題歡迎提出,共同討論。
後續補充:
後端使用:只需要新建 BaseUserController 來繼承 BaseController 重寫 OnActionExecuting 方法,在該方法中添加驗證判斷。如果在控制器中有某個介面不需要驗證,但是又繼承了 BaseUserController 的話,
可以在介面方法上加上 AllowAnonymousAttribute 屬性來排除驗證。 沒有使用刷新和驗證token的區分,個人覺得這兩者都存在一樣的問題,何不就用一個呢?
BaseUserController 類代碼
/// <summary> /// 用戶許可權驗證控制器 /// </summary> public abstract class BaseUserController : BaseController { // private UserModel _user; // /// <summary> // /// 當前用戶 // /// </summary> // protected new UserModel User // { // get => _user ?? (_user = _userCache.Current);//從緩存中取 // set => _user = value; // } public override void OnActionExecuting(ActionExecutingContext filterContext) { base.OnActionExecuting(filterContext); if (filterContext.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor) { var isDefined = controllerActionDescriptor.MethodInfo.GetCustomAttributes(true) .Any(a => a.GetType() == typeof(AllowAnonymousAttribute)); if (isDefined) { return; } } var token = Request.Headers["token"]; if (string.IsNullOrEmpty(token)) { filterContext.Result = new CustomHttpStatusCodeResult(200, 401, "未授權"); return; } 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; var nToken = TokenManager.GenerateToken(str); Response.Headers["token"] = nToken; Response.Headers["Access-Control-Expose-Headers"] = "token"; return; } filterContext.Result = new CustomHttpStatusCodeResult(200, 401, "未授權"); } }
添加token測試代碼,將之前的測試代碼改變下
public class TokenTestController : BaseUserController { [HttpGet] [Route("testtoken")] [AllowAnonymous]//允許所有人訪問 public ActionResult TestToken() { var token = TokenManager.GenerateToken("測試token的生成"); Response.Headers["token"] = token; Response.Headers["Access-Control-Expose-Headers"] = "token";//一定要添加這一句 不然前端是取不到token欄位的值的!更別提存store了。 return Succeed(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)}"); //} [HttpPost] [Route("validtoken")] public ActionResult ValidToken() { //業務處理 token已在基類中驗證 return Succeed("成功"); } }
然後再允許測試看下效果。發現是不是特別棒~~~~
前端使用:使用Axios來管理執行請求操作。可以完美的使用請求、響應攔截器來處理token等信息。做業務時都無需關心token問題。
部分文字描述參考於:
https://www.jianshu.com/p/576dbf44b2ae
在下一篇中將介紹如何在NetCore中如何使用 MemoryCache 和 Redis 來做緩存不常變動數據,提高響應速度~~
有需要源碼的在下方評論或私信~給我的SVN訪客賬戶密碼下載,代碼未放在GitHub上。