前言
本文我们来探讨下 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://i.cnblogs.com/
但是如果我们有这样的业务场景: 用于跳转至登录页时, 在 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=/Account/GetCurrentTime?id=a309f451-e2ff-4496-bf18-65ba5c3ace9f
总结
来源: https://www.cnblogs.com/CreateMyself/p/11197497.html