前言
本文我們來探討下JWT VS Session的問題,這個問題本沒有過多的去思考,看到評論討論太激烈,就花了一點時間去研究和總結,順便說一句,這就是寫部落格的好處,一篇部落格寫出有的可能是經驗積累,有的可能是學習分享,但都逃不過看到文章的你有更多或更好的想法,往返交流自身能收穫更多,何樂而不為呢?希望本文能解惑或者能得到更多的交流。我們可直接丟擲問題:使用客戶端儲存的JWT比服務端維持Session更好嗎?
基於JWT和Session認證共同點
既然要比較JWT VS Session,那我們就得知道為何需要JWT和Session,它們共同是為了解決什麼問題呢?那我們從一個場景說起,網上購物現已是再平常不過的事情了,當我們將某個商品加入購物車後,然後跳轉到其他商品頁面此時需要之前選擇的商品依然在購物車中,此時就需要維持會話,因為HTTP無狀態,所以JWT和Session共同點都是為了持久維持會話而存在,為了克服HTTP無狀態的情況,JWT和Session分別是如何處理的呢?
JWT VS Session認證
Session:當使用者在應用系統中登入後,此時服務端會建立一個Session(我們也稱作為會話),然後SessionId會儲存到使用者的Cookie中,只要使用者是登入狀態,對於每個請求,在Cookie中的SessionId都會傳送到服務端,然後服務端會將儲存在記憶體中的SessionId和Cookie中的SessionId進行比較來認證使用者的身份並響應。
JWT:當使用者在應用系統中登入後,此時服務端會建立一個JWT,並將JWT傳送到客戶端,客戶端儲存JWT(一般是在Local Storage中)同時在每個請求頭即Authorization中包含JWT,對於每個請求,服務端都會進行驗證JWT是否合法,直接在服務端本地進行驗證,比如頒發者,受理者等等,以致於無需發出網路請求或與資料庫互動,這種方式可能比使用Session更快,從而加快響應效能,降低伺服器和資料庫伺服器負載。
通過如上對JWT認證和Session認證簡短的描述,我們知道二者最大的不同在於Session是儲存在服務端,而JWT儲存在客戶端。服務端儲存會話無外乎兩種,一種是將會話識別符號儲存在資料庫,一種是儲存在記憶體中維持會話,我想大多數情況下都是基於記憶體來維持會話,但是這會帶來一定的問題,如果系統存在大流量,也就是說若有大量使用者訪問系統,此時使用基於記憶體維持的會話則限制了水平擴充套件,但對基於Token的認證則不存在這樣的問題,同時Cookie一般也只適用於單域或子域,如果對於跨域,假如是第三方Cookie,瀏覽器可能會禁用Cookie,所以也受瀏覽器限制,但對Token認證來說不是問題,因為其儲存在請求頭中。
如果我們將會話轉移到客戶端,也就是說使用Token認證,此時將解除會話對服務端的依賴,同時也可水平擴充套件,不受瀏覽器限制,但是與此同時也會帶來一定的問題,一是令牌的傳輸安全性,對於令牌傳輸安全性我們可使用HTTPS加密通道來解決,二是與儲存在Cookie中的SessionId相比,JWT顯然要大很多,因為JWT中還包含使用者資訊,所以為了解決這個問題,我們儘量確保JWT中只包含必要的資訊(大多數情況下只包含sub以及其他重要資訊),對於敏感資訊我們也應該省略掉從而防止XSS攻擊。JWT的核心在於宣告,宣告在JWT中是JSON資料,也就是說我們可以在JWT中嵌入使用者資訊,從而減少資料庫負載。所以綜上所述JWT解決了其他會話存在的問題或缺點:
更靈活
更安全
減少資料庫往返,從而實現水平可伸縮。
防篡改客戶端宣告
移動裝置上能更好工作
適用於阻止Cookie的使用者
綜上關於JWT在有效期內沒有強制使其無效的能力而完全否定JWT的好處顯然站不住腳,當然不可辯駁的是若是沒有如上諸多使用限制,實現其他型別的身份驗證完全也是合情合理且合法的,需綜合權衡,而非一家之言下死結論。到目前為止,我們一直討論的是JWT VS Session認證,而不是JWT VS Cookie認證,但是如標題我們將Cookie也納入了,只是想讓學習者別搞混了,因為JWT VS Cookie認證這種說法是錯誤的,Cookie只是一種儲存和傳輸資訊介質,只能說我們可以通過Cookie儲存和傳輸JWT。接下來我們來實現Cookie儲存和傳輸JWT令牌。
JWT AS Cookies Identity Claim
在Startup中我們可以新增如下Cookie認證中介軟體,此時我們有必要了解下配置Cookie的一些選項,通過對這些選項的配置來告知Cookie身份認證中介軟體在瀏覽器中的表現形式,我們看下幾個涉及到安全的選項。
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Account/Login"; options.LogoutPath = "/Account/Logout"; options.Cookie.Expiration = TimeSpan.FromMinutes(5); options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.None; options.Cookie.SameSite = SameSiteMode.Lax; });
配置HttpOnly標誌著Cookie是否僅供服務端使用,而不能通過前端直接訪問。
配置SecurePolicy將限制Cookie為HTTPS,在生產環境建議配置此引數同時支援HTTPS。
配置SameSite用來指示瀏覽器是否可以將Cookie與跨站點請求一同使用,若是對於OAuth身份認證,可設定為Lax,允許外部連結重定向發出比如POST請求而維持會話,若是Cookie認證,設定為Restrict,因為Cookie認證只適用於單站點,若是設定為None,則不會設定Cookie Header值。(注意:SameSite屬性在谷歌、火狐瀏覽器均已實現,對於IE11好像不支援,Safari從版本12.1開始支援該屬性)
在建立.NET Core預設Web應用程式時,在ConfigureServices方法中,通過中介軟體直接配置了全域性Cookie策略,如下:
services.Configure<CookiePolicyOptions>(options => { options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; });
當然預設配置了全域性Cookie策略,同時也在Configure方法中使用其策略如下:
app.UseCookiePolicy();
我們也可以直接在上述呼叫使用Cookie策略中介軟體的方法中來設定對應引數策略,如下:
若是我們在新增Cookie中介軟體的同時也配置全域性Cookie策略,我們會發現對於屬性HTTPOnly和SameSite都可配置,此時個人猜測會存在覆蓋的情況,如下:
對於需要認證的控制器我們需要新增上[Authroize]特性,對每一個控制器我們都得新增這樣一個特性,相信大部分童鞋都是這麼幹的。其實我們大可反向操作,對於無需認證的我們新增可匿名訪問特性即可,而需要認證的控制器我們進行全域性配置認證過濾器,如下:
services.AddMvc(options=> options.Filters.Add(new AuthorizeFilter())) .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
好了到了這裡,我們只是粗略的講解了下關於Cookie中介軟體引數配置和Cookie全域性配置策略的說明,沒有太深入去研究裡面的細枝末節,等遇到問題再具體分析吧。繼續回到話題,Cookie認證相比JWT對API訪問來講安全係數低,所以我們完全可以在Cookie認證中結合JWT來使用。具體我們可嘗試怎麼搞呢?將其放到身份資訊宣告中,我想應該是可行的方式,我們來模擬登陸和登出試試,大概程式碼如下:
public class AccountController : Controller { /// <summary> /// 登入 /// </summary> /// <returns></returns> [HttpPost] public async Task<IActionResult> Login() { var claims = new Claim[] { new Claim(ClaimTypes.Name, "Jeffcky"), new Claim(JwtRegisteredClaimNames.Email, "2752154844@qq.com"), new Claim(JwtRegisteredClaimNames.Sub, "D21D099B-B49B-4604-A247-71B0518A0B1C"), new Claim("access_token", GenerateAccessToken()), }; var claimsIdentity = new ClaimsIdentity( claims, CookieAuthenticationDefaults.AuthenticationScheme); var authticationProperties = new AuthenticationProperties(); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authticationProperties); return RedirectToAction(nameof(HomeController.Index), "Home"); } string GenerateAccessToken() { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("1234567890123456")); var token = new JwtSecurityToken( issuer: "http://localhost:5000", audience: "http://localhost:5001", notBefore: DateTime.Now, expires: DateTime.Now.AddHours(1), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256) ); return new JwtSecurityTokenHandler().WriteToken(token); } /// <summary> /// 退出 /// </summary> /// <returns></returns> [Authorize] [HttpPost] public async Task<IActionResult> Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return RedirectToAction(nameof(HomeController.Index), "Home"); } }
上述程式碼很簡單,無需我再多講,和Cookie認證無異,只是我們在宣告中新增了access_token來提高安全性,接下來我們自定義一個Action過濾器特性,並將此特性應用於Action方法,如下:
public class AccessTokenActionFilterAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { var principal = context.HttpContext.User as ClaimsPrincipal; var accessTokenClaim = principal?.Claims .FirstOrDefault(c => c.Type == "access_token"); if (accessTokenClaim is null || string.IsNullOrEmpty(accessTokenClaim.Value)) { context.HttpContext.Response.Redirect("/account/login", permanent: true); return; } var sharedKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("1234567890123456")); var validationParameters = new TokenValidationParameters { ValidateAudience = true, ValidIssuer = "http://localhost:5000", ValidAudiences = new[] { "http://localhost:5001" }, IssuerSigningKeys = new[] { sharedKey } }; var accessToken = accessTokenClaim.Value; var handler = new JwtSecurityTokenHandler(); var user = (ClaimsPrincipal)null; try { user = handler.ValidateToken(accessToken, validationParameters, out SecurityToken validatedToken); } catch (SecurityTokenValidationException exception) { throw new Exception($"Token failed validation: {exception.Message}"); } base.OnActionExecuting(context); } }
JWT Combine Cookie Authentication
如上是採用將JWT放到宣告的做法,我想這麼做也未嘗不可,至少我沒找到這麼做有什麼不妥當的地方。我們也可以將Cookie認證和JWT認證進行混合使用,只不過是在上一節的基礎上新增了Cookie中介軟體罷了,如下圖:
通過如上配置後我們就可以將Cookie和JWT認證來組合使用了,比如我們在使用者登入後,如下圖點選登入後顯示當前登入使用者名稱,然後點選退出,在退出Action方法上我們新增組合特性:
/// <summary> /// 退出 /// </summary> /// <returns></returns> [Authorize(AuthenticationSchemes = "Bearer,Cookies")] [HttpPost] public async Task<IActionResult> Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return RedirectToAction(nameof(HomeController.Index), "Home"); }
在上一節中,我們通過獲取AccessToken,從而訪問埠號為5001的客戶端來獲取當前時間,那現在我們針對獲取當前時間的方法新增上需要Cookie認證,如下:
[Authorize(CookieAuthenticationDefaults.AuthenticationScheme)] [HttpGet("api/[controller]")] public string GetCurrentTime() { var sub = User.FindFirst(d => d.Type == JwtRegisteredClaimNames.Sub)?.Value; return DateTime.Now.ToString("yyyy-MM-dd"); }
Cookie認證撤銷
在.NET Core 2.1版本通過Cookie進行認證中,當使用者與應用程式進行互動修改了資訊,需要在cookie的整個生命週期,也就說在登出或cookie過期之前看不到資訊的更改時,我們可通過cookie的身份認證事件【撤銷身份】來實現這樣的需求,下面我們來看看。
public class RevokeCookieAuthenticationEvents : CookieAuthenticationEvents { private readonly IDistributedCache _cache; public RevokeCookieAuthenticationEvents( IDistributedCache cache) { _cache = cache; } public override Task ValidatePrincipal( CookieValidatePrincipalContext context) { var userId = context.Principal?.Claims .First(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; if (!string.IsNullOrEmpty(_cache.GetString("revoke-" + userId))) { context.RejectPrincipal(); _cache.Remove("revoke-" + userId); } return Task.CompletedTask; } }
我們通過重寫CookieAuthenticationEvents事件中的ValidatePrincipal,然後判斷寫在記憶體中關於使用者表示是否存在,若存在則呼叫 context.RejectPrincipal() 撤銷使用者身份。然後我們在新增Cookie中介軟體裡配置該事件型別以及對其進行註冊:
services.AddScoped<RevokeCookieAuthenticationEvents>();
接下來我們寫一個在頁面上點選【修改資訊】的方法,並在記憶體中設定撤銷指定使用者,如下:
[HttpPost] public IActionResult ModifyInformation() { var principal = HttpContext?.User as ClaimsPrincipal; var userId = principal?.Claims .First(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; if (!string.IsNullOrEmpty(userId)) { _cache.SetString("revoke-" + userId, userId); } return RedirectToAction(nameof(HomeController.Index), "Home"); }
從如上動圖中我們可以看到,當點選修改資訊後,然後將撤銷的使用者標識寫入到記憶體中,然後跳轉到Index頁面,此時呼叫我們寫的撤銷事件,最終重定向到登入頁,且此時使用者cookie仍未過期,所以我們能夠在左上角看到使用者名稱,不清楚這種場景在什麼情況下才會用到。
重定向至登入攜帶或移除引數
當我們在某個頁面進行操作時,若此時Token或Cookie過期了,此時則會自動引導使用者且將使用者當前訪問的URL攜帶並重定向跳轉到登入頁進行登入,比如關於部落格園如下跳轉URL:
https://account.cnblogs.com/signin?returnUrl=http%3a%2f%2fi.cnblogs.com%2f
但是如果我們有這樣的業務場景:用於跳轉至登入頁時,在URL上需要攜帶額外的引數,我們需要獲取此業務引數才能進行對應業務處理,那麼此時我們應該如何做呢?我們依然是重寫CookieAuthenticationEvents事件中的RedrectToLogin方法,如下:
public class RedirectToLoginCookieAuthenticationEvents : CookieAuthenticationEvents { private IUrlHelperFactory _helper; private IActionContextAccessor _accessor; public RedirectToLoginCookieAuthenticationEvents(IUrlHelperFactory helper, IActionContextAccessor accessor) { _helper = helper; _accessor = accessor; } public override Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context) { //獲取路由資料 var routeData = context.Request.HttpContext.GetRouteData(); //獲取路由資料中的路由值 var routeValues = routeData.Values; var uri = new Uri(context.RedirectUri); //解析跳轉URL查詢引數 var returnUrl = HttpUtility.ParseQueryString(uri.Query)[context.Options.ReturnUrlParameter]; //add extra parameters for redirect to login var parameters = $"id={Guid.NewGuid().ToString()}"; //新增額外引數到路由值中 routeValues.Add(context.Options.ReturnUrlParameter, $"{returnUrl}{parameters}"); var urlHelper = _helper.GetUrlHelper(_accessor.ActionContext); context.RedirectUri = UrlHelperExtensions.Action(urlHelper, "Login", "Account", routeValues); return base.RedirectToLogin(context); } }
這裡需要注意的是因為上述我們用到了IActionContextAccessor,所以我們需要將其進行對應如下注冊:
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
最終我們跳轉到登入頁將會看到我們新增的額外引數id也將呈現在url上,如下:
http://localhost:5001/Account/Login?ReturnUrl=%2FAccount%2FGetCurrentTime%3Fid%3Da309f451-e2ff-4496-bf18-65ba5c3ace9f
總結
本節我們講解了Session和JWT的優缺點以及Cookie認證中可能我們需要用到的地方,下一節也是JWT最後一節內容,我們講講並探討如何實現重新整理Token,感謝閱讀。