ASP.NET Core Web Api之JWT VS Session VS Cookie(二)

Jeffcky發表於2019-07-29

前言

本文我們來探討下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,感謝閱讀。

相關文章