天天看點

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

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

前言

本文我們來探讨下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, "[email protected]"),
            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();

接下來我們寫一個在頁面上點選【修改資訊】的方法,并在記憶體中設定撤銷指定使用者,如下:

[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();

最終我們跳轉到登入頁将會看到我們添加的額外參數id也将呈現在url上,如下:

http://localhost:5001/Account/Login?ReturnUrl=%2FAccount%2FGetCurrentTime%3Fid%3Da309f451-e2ff-4496-bf18-65ba5c3ace9f

總結

本節我們講解了Session和JWT的優缺點以及Cookie認證中可能我們需要用到的地方,下一節也是JWT最後一節内容,我們講講并探讨如何實作重新整理Token,感謝閱讀。

原文位址

https://www.cnblogs.com/CreateMyself/p/11197497.html