谈谈 cookie & session & jwt

        谈谈 cookie & session & jwt - 掘金

        在前端开发登录页面时,经常会遇到cookiesessionjwt这几个词,虽然经常使用,但是对它们的理解都比较浅,本文试图理清它们的本质与之间的联系。

cookie

        我们知道,HTTP协议是无状态的,因此需要前端传递一个状态信息告诉后台是谁发起了这次请求,cookie就是这个状态信息,它是保存在浏览器中的一小块数据(不超过4K),每次发起HTTP请求时浏览器会自动加到请求头中发送给后台。

cookie各个属性

Set-Cookie: sessionId=123; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly; SameSite=Strict

Domain & path

控制cookie的生效范围

  • domain即域名,path即路径,cookie只能在指定的domainpath下使用。

  • domain不指定时,默认是当前的域名(不包含子域名);当path不指定时,默认是域名下所有的路径'/'。

  • 在上面的例子中,第一个cookie:sessionId,没有指定domain且指定了Path=/accounts,所以只有当请求a.com/accounts下的资源时,才会携带该cookie

Expires & Max-Age

控制cookie的有效时间

  • ExpiresMax-Age 用来指定cookie的超时时间,前者用来指定具体的cookie超时时间,后者用来指定cookie的超时间隔;
  • 如果不指定,则在用户关闭浏览器时会删除cookie

Secure & HttpOnly & SameSite

控制cookie的安全策略

  • Secure指定cookie必须在https请求中才能携带;
  • HttpOnly禁止前端使用js操作cookiedocument.cookie将不能获取到cookie值;
  • SameSite跨域时决定浏览器是否自动携带cookie
    • Strict 最为严格。如果 SameSite 的值是 Strict,那么浏览器会完全禁止第三方 Cookie。简言之,如果你从极客时间的页面中访问 InfoQ 的资源,而 InfoQ 的 Cookie 设置了 SameSite = Strict 的话,那么这些 Cookie 是不会被发送到 InfoQ 的服务器上的。只有你从 InfoQ 的站点去请求 InfoQ 的资源时,才会带上这些 Cookie。

    • Lax 相对宽松一点。在跨站点的情况下,从第三方站点的链接打开和从第三方站点提交 Get 方式的表单这两种方式都会携带 Cookie。但如果在第三方站点中使用 Post 方法,或者通过 img、iframe 等标签加载的 URL,这些场景都不会携带 Cookie

    • 而如果使用 None 的话,在任何情况下都会发送 Cookie 数据。

cookie的原理

要弄清cookie的原理,首先要搞清楚谁才能设置cookie?如下图:

image.png

        从图中可知:当用户访问a.com时,后台返回的响应头中有一个字段set-cookie,浏览器读取响应头发现有这个字段后,会自动在浏览器中保存这个字段所带的信息,在后面的请求中浏览器会自动的携带这个cookie数据发送给服务端。

        所以,cookie是服务端设置的,浏览器自动保存这个字段信息到用户的电脑上

    cookie仅会在相同的domain请求下会自动的被携带,当访问与该cookie不同domain下的资源时,浏览器不会携带此domaincookie

        如果一个用户先请求a.com的页面,浏览器存储了a.com下的cookie。然后用户又接着打开了b.com下的页面,同时浏览器也存储了b.com下的cookie如果用户这时在b.com的页面向a.com发送了请求,那么浏览器会携带那个domain下的cookie呢?

        答案是a.com,也就是在请求中是携带那个域名下的cookie与当前打开的网站无关,而与请求的url有关。因为这个特性会导致CSRF攻击

cookie的用途

image.png

1. 会话管理

  • 用户首次访问购物网站(1-4),网站server为用户生成了一个sessionId,并在响应中携带Set-Cookie: sessionId=123; Expires=Tue, 15 Jan 2021 21:47:38 GMT;

  • 浏览器收到服务端的响应,从响应中获取到Set-Cookie,将sessionId=123存储浏览器cookie中。由于Set-Cookie中携带了Expires属性,浏览器同时为该cookie设置过期时间(如果没有Expires属性,浏览器会把该cookie作为session cookie处理,即当用户关闭浏览器时,该cookie会被删除);

  • 用户将一个商品加入购物车,浏览器会将此购物车操作发送给server,并且在该请求中的cookie中自动携带上sessionId=123server拿到sessionId=123所对应用户(至于怎么通过sessionId=123找到对应的用户后面会介绍),然后在他的购物车中添加了一个商品;

  • 用户然后关闭了该购物网站;

  • 数小时后,用户再次打开此购物网站并访问购物车,网站从后端请求购物车数据,浏览器查找本地cookie,发现保存了此网站sessionId=123的有效cookie,浏览器在网站请求头中附带自动附带sessionId=123的cookie;

  • 服务端收到购物车查询请求,并从请求头中获取到sessionId=123,服务器查找内存中的id=123session,发现有此用户的购物车商品数据,服务器将此数据返回给前端。

  • 用户在购物车中看到了自己上次访问网站时添加的商品,选中此商品完成结算。

        以上就完成了会话的管理。

2. 用户跟踪(广告投放)

  • 购物网站接入了广告平台sdk(百度广告sdk),并在广告平台上付费开通了广告投放;

  • 用户访问购物网站,会自动请求广告平台的资源(一般是请求一张广告平台的gif)(图中的[2]),广告平台sdk为用户生成了一个唯一id: user123,并在资源响应头中携带cookie信息(Set-Cookie:HMACCOUNT=user123; Path=/; Domain=hm.baidu.com; Expires=Sun, 18 Jan 2038 00:00:00 GMT);

  • 浏览器会将Set-Cookie中的信息存储在cookie中,注意此cookiedomain属性是广告平台的域名,而不是购物网站的域名,我们将这种cookie称为第三方cookie(third cookie)

  • 用户在购物网站浏览了一些商品后关闭了网站;

  • 一段时间后,用户浏览了一个视频网站youku.com,此视频网站在广告平台上开通了承接广告业务,并接入了广告平台sdk。同样该网站会自动请求广告平台下的资源(图中的[7][8]),浏览器会自动携带之前存储的三方cookie(因为此cookie的domain是广告平台资源的域名)。

  • 广告平台收到来自youku.com的资源请求,并发现请求头中带上cookie:HMACCOUNT=user123。以此,广告平台识别出此用户是之前访问过购物网站的用户user123,由于购物网站开通了付费广告,此用户被识别为精准用户,当youku.com从广告平台拉取广告素材时,会拉到购物网站投放的广告,并展示给用户。

  • 用户在youku.com观看视频,视频开头插入了一段30秒的广告,广告内容是关于购物网站的优惠活动。

  • 用户对此广告推送的活动比较感兴趣,并且本身也是购物网站的用户,有信任基础,于是用户点开广告,进入购物网站领取优惠并购买商品,因此youku.com收到了广告平台发放的广告收入。

        这样就通过cookie完成了用户的跟踪。

session

到底什么是session

        作为前端开发,在很长一段时间都不理解什么是session,直到慢慢学习后台开发后才彻底弄懂了什么是session

        从前面一节的内容可以知道,cookie是保存在浏览器中的,即客户端,那session保存在哪呢?

        答案是服务端

        当浏览器发送cookie到服务端之后,服务端怎么知道是哪个用户呢?也就是服务端是如何用cookie做登录验证的?

        客户端发送登录请求,服务端验证密码和用户名,如果都正确,服务端在响应头中会设置set-cookiekey-value 为username=zhangsan:

const getCookieExpires = () => {
  const d = new Date()
  d.setTime(d.getTime() + (24 * 60 * 60 * 1000))
  return d.toGMTString()
}

res.setHeader('Set-Cookie', `username=zhangsan; path=/; httpOnly; expires=${getCookieExpires()}`)

        浏览器获得响应头之后就会在浏览器中存储cookie信息,下次请求的时候就会带上存储的cookie,服务端获得请求头就开始解析cookie信息:

// 解析请求头中的cookie信息
req.cookie = {}
const cookieStr = req.headers.cookie || ''
cookieStr.split(';').forEach(item => {
  if (!item) {
    return
  }
  const arr = item.split('=')
  req.cookie[arr[0]] = arr[1]
})
console.log('cookie', req.cookie)

        解析完之后,就进行用户验证,验证很简单,就是看cookie中是否有username,如果有说明就已经登录过。

        这就是cookie是如何完成用户验证的过程。

        非常简单吧!

        上面cookie中有个致命的问题:暴露username等关键信息是很危险的。

        解决方法是cookie中存储userid,服务端存储对应的username,那么服务端存储的username等信息就是session

        现在,你应该知道什么是session了吧。

怎么存储session

        那session保存在什么地方呢?

        首先想到的是保存在内存中(即在全局维护一个对象,用这个对象来保存用户信息),下面是利用内存来实现一个存储session功能的步骤:

  • 在全局维护一个存储session的对象SESSION_DATA,这样在整个进程中都能访问;
  • 如果请求头没有带userid,说明是一次访问,就让用户去登录;
  • 如果请求头带有userid,那么就去SESSION_DATA获取对应的用户信息,如果没有获取到让用户去登录;
  • 登录成功后把这个用户的信息写入到session对象SESSION_DATA中;
// session数据 在全局维护一个session_data
const SESSION_DATA = {}

// 当用户发送请求的时候,解析session
// 解析session
let needSetCookie = false
let userId = req.cookie.userId
if (userId) {
  if (!SESSION_DATA[userId]) {
    SESSION_DATA[userId] = {}
  }
} else {
  userId = `${Date.now()}_${Math.random()}`
  SESSION_DATA[userId] = {}
  // 如果没有userid,说明是第一次登录,那么就去设置cookie
  needSetCookie = true
}
// 把session挂载在req上
req.session = SESSION_DATA[userId]

// 在登录接口,登录成功后往session_data里面写数据
if (method === 'POST' && req.path === '/api/user/login') {
  const { username, password } = req.body
  const result = login(username, password)
  return result.then(data => {
    if (data.username) {
      // 设置session数据
      req.session.username = data.username
      req.session.realname = data.realname

      return new SuccessModel('登录成功')
    }
    return new ErrorModel('登录失败')
  })
}

如果把session存储在内存当中,会遇到两个问题:

  • 既然是全局变量,就是在内存中的,但是对每个进程分配的内存大小是有限制的,当用户很多时,就会存不下
    • 操作系统会为每个进程分配一个内存空间,如上图比如说是从0x1000开始到0x8000结束,上面是栈内存,下面是堆内存,我们的session就放在堆内存中Heap,如果当用户很多的时候那么Heap就会越来越多,把整个内存都占满了,那么整个进程就崩溃了。

WechatIMG106.png

  • 在线上是多进程部署的,进程和进程之间是无法访问的,所以B进程是无法访问到A进程里面的session的。

WechatIMG107.png

        现在的计算机都是多核的,为了充分利用计算机的资源,一个应用往往会启用多个进程。如果每个进程都有session的话,进程之间的session是不能共享的。当你第一次进来,命中了第一个进程中的session,但是第二次进来命中了第二个进程中的session,但是这个session没有你的信息,就登录不了了。这是因为负载均衡导致的,它看哪个进程比较闲就分配哪个进程。

        既然把session保存在内存中不行,那么保存在数据库中可不可以呢?如mysql

        这其实也有问题,每个请求过来我都要验证用户的合法性,如果验证成功,那么就去操作后面的步骤去数据库取数据,这相当于导致查询了两次数据库,这导致请求时长变长

        为了解决这个问题,一般我们把session放在redis这个内存数据库中,访问内存的速度比访问硬盘的速度要高的多。

        因此,现在的web服务常见模型如图所示:

image.png

session为何适用redis

1. session 访问频繁,对性能要求极高

    session访问频繁,因为我们在每个请求的时候都要验证是否登录,是一个访问的前置操作,所以就需要访问非常快,如果session都访问很慢后面的操作也就变慢,从而导致请求时间较长,因此对性能要求极高。

2. session 可不考虑断电数据丢失的问题(内存的硬伤)

    session 如果断电数据丢失了,你再登录一次(再登录一次就是把用户相关的信息写进去,用户下次访问的时候会先去redis中获取数据,拿到数据后,再进行接下来的业务逻辑)就可以了,登录后请求其他接口就可以通过useridredis里面查,如果有就说明已经登录过了。

3. session 数据量不会太大(相比于mysql中存储的数据)

jwt

什么是jwt

json web tokenjwt)是一种基于JSON的、用于在网络上声明某种主张的令牌(token),通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。

  • 头信息指定了该jwt使用的签名算法
header = {"alg":"HS256","typ":"JWT"}

HS256 表示使用了 HMAC-SHA256 来生成签名。

  • 消息体Payload,就是真实存储需要传递的信息的部分,例如正常我们会存储些用户 ID、用户名之类的。此外,还包含一些例如发布人、过期日期等的元数据。
payload = {"userName":"admin","iat":1422779638} //iat表示令牌生成的时间
  • 签名:对 HeaderPayload 进行签名

未签名的令牌由base64url编码的头信息和消息体拼接而成(使用"."分隔),签名则通过私有的key计算而成:

key = 'secretkey' // 秘钥保存在服务端,即使用户篡改了数据,因为不知道秘钥,生成的token也是无效的
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)  
signature = HMAC-SHA256(key, unsignedToken)

最后在未签名的令牌尾部拼接上base64url编码的签名(同样使用"."分隔)就是jwt了:

token = encodeBase64(header) + '.' + encodeBase64(payload) + '.' + encodeBase64(signature) 

# token看起来像这样: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHI

jwt 实现登录原理

  1. 首先,前端通过Web表单将自己的用户名和密码发送到后端。

  2. 后端核对用户名和密码成功后,将用户的id等其他信息作为jwt Payload(负载),将其与头部分别进行Base64编码拼接后签名,组成一个token,如aaa.bbb.ccc这样的字符串。

image.png

  1. 后端将token字符串作为登录成功的返回结果返回给前端,前端可以将返回的结果保存在localStoragesessionStorage上,退出登录时前端删除保存的token即可。

  2. 前端在每次请求时把token放在请求头中发送给后端,目前有两种方式:

    • 一是通过cookie的形式,即把token放在cookie中,每次浏览器会自动帮我们带过去,不需要我们自己设置。

    • 二是放在请求头header Authorization中,需要我们自己手动设置请求头。通常获取到token之后,会存放在sessionStorage或者localStorage中,这样当页面刷新之后保证token不会消失。

Authorization: Bearer eyJhbGci*...<snip>...*yu5CSpyHI
  1. 后端检查是否存在,如存在验证token的有效性。例如,检查签名是否正确,检查Token是否过期,等等。

jwt的优缺点

        从上面的介绍可知,服务端把用户信息都放在了token上,自己不需要redis来保存session了,也就是使用jwt最大的优点,干掉session

        但是这会带来一个很大的问题,因为token保存在客户端,服务端无法作废已颁布的令牌,即使你知道了某个token被盗取了,你也没有办法将其作废。在token过期之前(你绝对应该设置过期时间),你无能为力。因此,设置token的过期时间是非常有必要的

        另外,token尽管保存在Local Storage中,避免CSRF攻击,但是仍然无法避免XSS攻击,跨域脚本仍然可以盗取Local Storage中的数据。

总结

        由于http协议是无状态的,为了知道是哪个用户发起的请求,于是诞生了cookie-sessioncookie存储在客户端,每次发送请求时浏览器会自动带上发给后台,后台从请求头中获取到cookie后,从redis里面的session找到这个用户的个人信息,于是就完成了用户的确认。

        由于需要使用redis保存session比较麻烦,希望用户信息直接保存在客户端,不保存在服务端,于是诞生了jwt

        服务端把用户信息用加密算法加密后生成token发送给浏览器,浏览器存储在localstorage里面,每次请求时从localstorage取出加到请求头中发给后台,后台通过密钥验证token的正确性。

        不管是cookie-session,还是jwt都是为了完成用户的认证,各有优劣,在不同场景下可灵活使用。

猜你喜欢

转载自blog.csdn.net/liuqinhou/article/details/132009487