LOADING

cookie,JWT,session

2024/3/9

cookie

客户端和服务器的传输使用的是 http 协议,http 协议是无状态的,什么叫无状态,就是服务器不知道这一次请求的人,跟之前登录请求成功的人是不是同一个人

由于 http 协议的无状态,服务器忘记了之前的所有请求,它无法确定这一次请求的客户端,就是之前登录成功的那个客户端。

服务器会按照下面的流程来认证客户端的身份

  1. 客户端登录成功后,服务器会给客户端一个出入证
  2. 后续客户端的每次请求,都必须要附带这个出入证

cookie 的作用

  1. 能够存放多个出入证。这些出入证来自不同的网站,也可能是一个网站有多个出入证,分别用于出入不同的地方
  2. 能够自动出示出入证。客户端在访问不同的网站时,能够自动的把对应的出入证附带请求发送出去。
  3. 正确的出示出入证。客户端不能将肯德基的出入证发送给麦当劳。
  4. 管理出入证的有效期。客户端要能够自动的发现那些已经过期的出入证,并把它从卡包内移除。

cookie 是浏览器中特有的一个概念,它就像浏览器的专属卡包,管理着各个网站的身份信息。

每个 cookie 就相当于是属于某个网站的一个卡片,它记录了下面的信息:

  • key:键,比如「身份编号」
  • value:值,比如袁小进的身份编号「14563D1550F2F76D69ECBF4DD54ABC95」,这有点像卡片的条形码,当然,它可以是任何信息
  • domain:域,表达这个 cookie 是属于哪个网站的,比如yuanjin.tech,表示这个 cookie 是属于yuanjin.tech这个网站的
  • path:路径,表达这个 cookie 是属于该网站的哪个基路径的,就好比是同一家公司不同部门会颁发不同的出入证。比如/news,表示这个 cookie 属于/news这个路径的。
  • secure:是否使用安全传输
  • expire:过期时间,表示该 cookie 在什么时候过期
  • httpOnly:只用于浏览器和服务器的交互,浏览器不能读取和修改

如果一个 cookie同时满足以下条件,则这个 cookie 会被附带到请求中

  • cookie 没有过期
  • cookie 中的域和这次请求的域是匹配的
    • 比如 cookie 中的域是yuanjin.tech,则可以匹配的请求域是yuanjin.techwww.yuanjin.techblogs.yuanjin.tech等等
    • 比如 cookie 中的域是www.yuanjin.tech,则只能匹配www.yuanjin.tech这样的请求域
    • cookie 是不在乎端口的,只要域匹配即可
  • cookie 中的 path 和这次请求的 path 是匹配的
    • 比如 cookie 中的 path 是/news,则可以匹配的请求路径可以是/news/news/detail/news/a/b/c等等,但不能匹配/blogs
    • 如果 cookie 的 path 是/,可以想象,能够匹配所有的路径
  • 验证 cookie 的安全传输
    • 如果 cookie 的 secure 属性是 true,则请求协议必须是https,否则不会发送该 cookie
    • 如果 cookie 的 secure 属性是 false,则请求协议可以是http,也可以是https

如果一个 cookie 满足了上述的所有条件,则浏览器会把它自动加入到这次请求中

具体加入的方式是,浏览器会将符合条件的 cookie,自动放置到请求头中,例如,当我在浏览器中访问百度的时候,它在请求头中附带了下面的 cookie:

看到打马赛克的地方了吗?这部分就是通过请求头cookie发送到服务器的,它的格式是键=值; 键=值; 键=值; ...,每一个键值对就是一个符合条件的 cookie。

cookie 中包含了重要的身份信息,永远不要把你的 cookie 泄露给别人!!!否则,他人就拿到了你的证件,有了证件,就具备了为所欲为的可能性。

由于 cookie 是保存在浏览器端的,同时,很多证件又是服务器颁发的

所以,cookie 的设置有两种模式:

  • 服务器响应:这种模式是非常普遍的,当服务器决定给客户端颁发一个证件时,它会在响应的消息中包含 cookie,浏览器会自动的把 cookie 保存到卡包中
  • 客户端自行设置:这种模式少见一些,不过也有可能会发生,比如用户关闭了某个广告,并选择了「以后不要再弹出」,此时就可以把这种小信息直接通过浏览器的 JS 代码保存到 cookie 中。后续请求服务器时,服务器会看到客户端不想要再次弹出广告的 cookie,于是就不会再发送广告过来了。

服务器可以通过设置响应头,来告诉浏览器应该如何设置 cookie

响应头按照下面的格式设置:

set-cookie: cookie1
set-cookie: cookie2
set-cookie: cookie3
...

通过这种模式,就可以在一次响应中设置多个 cookie 了,具体设置多少个 cookie,设置什么 cookie,根据你的需要自行处理

其中,每个 cookie 的格式如下:

键=值; path=?; domain=?; expire=?; max-age=?; secure; httponly

每个 cookie 除了键值对是必须要设置的,其他的属性都是可选的,并且顺序不限

当这样的响应头到达客户端后,浏览器会自动的将 cookie 保存到卡包中,如果卡包中已经存在一模一样的卡片(其他 path、domain 相同),则会自动的覆盖之前的设置

下面,依次说明每个属性值:

  • path:设置 cookie 的路径。如果不设置,浏览器会将其自动设置为当前请求的路径。比如,浏览器请求的地址是/login,服务器响应了一个set-cookie: a=1,浏览器会将该 cookie 的 path 设置为请求的路径/login
  • domain:设置 cookie 的域。如果不设置,浏览器会自动将其设置为当前的请求域,比如,浏览器请求的地址是
http://www.yuanjin.tech

,服务器响应了一个

set-cookie: a=1

浏览器会将该 cookie 的 domain 设置为请求的域

www.yuanjin.tech

这里值得注意的是,如果服务器响应了一个无效的域,浏览器是不认的

什么是无效的域?就是响应的域连根域都不一样。比如,浏览器请求的域是yuanjin.tech,服务器响应的 cookie 是set-cookie:a=1; domain=baidu.com,这样的域浏览器是不认的。

如果浏览器连这样的情况都允许,就意味着张三的服务器,有权利给用户一个 cookie,用于访问李四的服务器,这会造成很多安全性的问题

  • expire:设置 cookie 的过期时间。这里必须是一个有效的 GMT 时间,即格林威治标准时间字符串,比如Fri, 17 Apr 2020 09:35:59 GMT,表示格林威治时间的2020-04-17 09:35:59,即北京时间的2020-04-17 17:35:59。当客户端的时间达到这个时间点后,会自动销毁该 cookie。
  • max-age:设置 cookie 的相对有效期。expire 和 max-age 通常仅设置一个即可。比如设置max-age1000,浏览器在添加 cookie 时,会自动设置它的expire为当前时间加上 1000 秒,作为过期时间。
    • 如果不设置 expire,又没有设置 max-age,则表示会话结束后过期。
    • 对于大部分浏览器而言,关闭所有浏览器窗口意味着会话结束。
  • secure:设置 cookie 是否是安全连接。如果设置了该值,则表示该 cookie 后续只能随着https请求发送。如果不设置,则表示该 cookie 会随着所有请求发送。
  • httponly:设置 cookie 是否仅能用于传输。如果设置了该值,表示该 cookie 仅能用于传输,而不允许在客户端通过 JS 获取,这对防止跨站脚本攻击(XSS)会很有用。

下面来一个例子,客户端通过post请求服务器http://yuanjin.tech/login,并在消息体中给予了账号和密码,服务器验证登录成功后,在响应头中加入了以下内容:

set-cookie: token=123456; path=/; max-age=3600; httponly

当该响应到达浏览器后,浏览器会创建下面的 cookie:

key: token
value: 123456
domain: yuanjin.tech
path: /
expire: 2020-04-17 18:55:00 #假设当前时间是2020-04-17 17:55:00
secure: false #任何请求都可以附带这个cookie,只要满足其他要求
httponly: true #不允许JS获取该cookie

于是,随着浏览器后续对服务器的请求,只要满足要求,这个 cookie 就会被附带到请求头中传给服务器:

cookie: token=123456; 其他cookie...

如果要删除浏览器的 cookie,只需要让服务器响应一个同样的域、同样的路径、同样的 key,只是时间过期的 cookie 即可

所以,删除 cookie 其实就是修改 cookie

下面的响应会让浏览器删除token

set-cookie: token=; domain=yuanjin.tech; path=/; max-age=-1

浏览器按照要求修改了 cookie 后,会发现 cookie 已经过期,于是自然就会删除了。

无论是修改还是删除,都要注意 cookie 的域和路径,因为完全可能存在域或路径不同,但 key 相同的 cookie

因此无法仅通过 key 确定是哪一个 cookie

既然 cookie 是存放在浏览器端的,所以浏览器向 JS 公开了接口,让其可以设置 cookie

document.cookie = "键=值; path=?; domain=?; expire=?; max-age=?; secure";

可以看出,在客户端设置 cookie,和服务器设置 cookie 的格式一样,只是有下面的不同

  • 没有 httponly。因为 httponly 本来就是为了限制在客户端访问的,既然你是在客户端配置,自然失去了限制的意义。
  • path 的默认值。在服务器端设置 cookie 时,如果没有写 path,使用的是请求的 path。而在客户端设置 cookie 时,也许根本没有请求发生。因此,path 在客户端设置时的默认值是当前网页的 path
  • domain 的默认值。和 path 同理,客户端设置时的默认值是当前网页的 domain
  • 其他:一样
  • 删除 cookie:和服务器也一样,修改 cookie 的过期时间即可

总结

登录请求

  1. 浏览器发送请求到服务器,附带账号密码
  2. 服务器验证账号密码是否正确,如果不正确,响应错误,如果正确,在响应头中设置 cookie,附带登录认证信息(至于登录认证信息是设么样的,如何设计,要考虑哪些问题,就是另一个话题了,可以百度 jwt)
  3. 客户端收到 cookie,浏览器自动记录下来

后续请求

  1. 浏览器发送请求到服务器,希望添加一个管理员,并将 cookie 自动附带到请求中
  2. 服务器先获取 cookie,验证 cookie 中的信息是否正确,如果不正确,不予以操作,如果正确,完成正常的业务流程

JWT

JWT 全称Json Web Token,本质就是一个字符串

它要解决的问题,就是在互联网环境中,提供统一的、安全的令牌格式

如果直接存储用户信息的 JSON 串,这会造成下面的几个问题:

  • 非浏览器环境,如何在令牌中记录过期时间
  • 如何防止令牌被伪造

JWT 就是为了解决这些问题出现的。

登录流程

jwt 只是一个令牌格式而已,你可以把它存储到 cookie,也可以存储到 localstorage,没有任何限制!

同样的,对于传输,你可以使用任何传输方式来传输 jwt,一般来说,我们会使用消息头来传输它

比如,当登录成功后,服务器可以给客户端响应一个 jwt:

HTTP/1.1 200 OK
...
set-cookie:token=jwt令牌
authentication:jwt令牌
...

{..., token:jwt令牌}

可以看到,jwt 令牌可以出现在响应的任何一个地方,客户端和服务器自行约定即可。

当然,它也可以出现在响应的多个地方,比如为了充分利用浏览器的 cookie,同时为了照顾其他设备,也可以让 jwt 出现在set-cookieauthorization或body中,尽管这会增加额外的传输量。

当客户端拿到令牌后,它要做的只有一件事:存储它。

你可以存储到任何位置,比如手机文件、PC 文件、localstorage、cookie

当后续请求发生时,你只需要将它作为请求的一部分发送到服务器即可。

虽然 jwt 没有明确要求应该如何附带到请求中,但通常我们会使用如下的格式:

GET /api/resources HTTP/1.1
...
authorization: bearer jwt令牌
...

这样一来,服务器就能够收到这个令牌了,通过对令牌的验证,即可知道该令牌是否有效。

它们的完整交互流程是非常简单清晰的

令牌的组成

为了保证令牌的安全性,jwt 令牌由三个部分组成,分别是:

  1. header:令牌头部,记录了整个令牌的类型和签名算法
  2. payload:令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里
  3. signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改

它们组合而成的完整格式是:header.payload.signature

比如,一个完整的 jwt 令牌如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc

它各个部分的值分别是:

  • header:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • payload:eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9
  • signature: BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc

它是令牌头部,记录了整个令牌的类型和签名算法

它的格式是一个json对象,如下:

{
  "alg": "HS256",
  "typ": "JWT"
}

该对象记录了:

  • alg:signature 部分使用的签名算法,通常可以取两个值
    • HS256:一种对称加密算法,使用同一个秘钥对 signature 加密解密
    • RS256:一种非对称加密算法,使用私钥签名,公钥验证
  • typ:整个令牌的类型,固定写JWT即可

设置好了header之后,就可以生成header部分了

具体的生成方式及其简单,就是把header部分使用base64 url编码即可

base64 url不是一个加密算法,而是一种编码方式,它是在base64算法的基础上对+=/三个字符做出特殊处理的算法

base64是使用 64 个可打印字符来表示一个二进制数据,具体的做法

浏览器提供了btoa函数,可以完成这个操作:

window.btoa(
  JSON.stringify({
    alg: "HS256",
    typ: "JWT",
  })
);
// 得到字符串:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

同样的,浏览器也提供了atob函数,可以对其进行解码:

window.atob("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
// 得到字符串:{"alg":"HS256","typ":"JWT"}

nodejs 中没有提供这两个函数,可以安装第三方库atobbota搞定

payload

这部分是 jwt 的主体信息,它仍然是一个 JSON 对象,它可以包含以下内容:

{
  "ss":"发行者",
  "iat":"发布时间",
  "exp":"到期时间",
  "sub":"主题",
  "aud":"听众",
  "nbf":"在此之前不可用",
  "jti":"JWT ID"
}

以上属性可以全写,也可以一个都不写,它只是一个规范,就算写了,也需要你在将来验证这个 jwt 令牌时手动处理才能发挥作用

上述属性表达的含义分别是:

  • ss:发行该 jwt 的是谁,可以写公司名字,也可以写服务名称
  • iat:该 jwt 的发放时间,通常写当前时间的时间戳
  • exp:该 jwt 的到期时间,通常写时间戳
  • sub:该 jwt 是用于干嘛的
  • aud:该 jwt 是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点
  • nbf:一个时间点,在该时间点到达之前,这个令牌是不可用的
  • jti:jwt 的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)

当用户登陆成功之后,我可能需要把用户的一些信息写入到 jwt 令牌中,比如用户 id、账号等等(密码就算了 😳)

其实很简单,payload 这一部分只是一个 json 对象而已,你可以向对象中加入任何想要加入的信息

比如,下面的 json 对象仍然是一个有效的 payload

上述的 payload 中可以相关 jwt 中的描述可以一个都不写,只写你需要存储的相关信息(user_id 等)

{
  "foo": "bar",
  "iat": 1587548215
}

foo: bar是我们自定义的信息,iat: 1587548215是 jwt 规范中的信息

最终,payload 部分和 header 一样,需要通过base64 url编码得到:

window.btoa(
  JSON.stringify({
    foo: "bar",
    iat: 1587548215,
  })
);
// 得到字符串:eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9

signature

这一部分是 jwt 的签名,正是它的存在,保证了整个 jwt 不被篡改

这部分的生成,是对前面两个部分的编码结果,按照头部指定的方式进行加密

比如:头部指定的加密方法是HS256,前面两部分的编码结果是eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9

则第三部分就是用对称加密算法HS256对字符串eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9进行加密,当然你得指定一个秘钥,比如shhhhh

HS256(
  `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9`,
  "shhhhh"
);
// 得到:BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc

最终,将三部分组合在一起,就得到了完整的 jwt

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc

由于签名使用的秘钥保存在服务器,这样一来,客户端就无法伪造出签名,因为它拿不到秘钥。

换句话说,之所以说无法伪造 jwt,就是因为第三部分的存在。

而前面两部分并没有加密,只是一个编码结果而已,可以认为几乎是明文传输

这不会造成太大的问题,因为既然用户登陆成功了,它当然有权力查看自己的用户信息

甚至在某些网站,用户的基本信息可以被任何人查看

你要保证的,是不要把敏感的信息存放到 jwt 中,比如密码

jwt 的signature可以保证令牌不被伪造,那如何保证令牌不被篡改呢?

比如,某个用户登陆成功了,获得了 jwt,但他人为的篡改了payload,比如把自己的账户余额修改为原来的两倍,然后重新编码出payload发送到服务器,服务器如何得知这些信息被篡改过了呢?

这就要说到令牌的验证了

令牌的验证

令牌在服务器组装完成后,会以任意的方式发送到客户端

客户端会把令牌保存起来,后续的请求会将令牌发送给服务器

而服务器需要验证令牌是否正确,如何验证呢?

首先,服务器要验证这个令牌是否被篡改过,验证方式非常简单,就是对**header+payload**用同样的秘钥和加密算法进行重新加密

然后把加密的结果和传入 jwt 的**signature**进行对比,如果完全相同,则表示前面两部分没有动过,就是自己颁发的,如果不同,肯定是被篡改过了。

传入的header.传入的payload.传入的signature
新的signature = header中的加密算法(传入的header.传入的payload, 秘钥)
验证:新的signature == 传入的signature

当令牌验证为没有被篡改后,服务器可以进行其他验证:比如是否过期等等,这些就视情况而定了

总结

最后,总结一下 jwt 的特点:

  • jwt 本质上是一种令牌格式。它和终端设备无关,同样和服务器无关,甚至与如何传输无关,它只是规范了令牌的格式而已
  • jwt 由三部分组成:header、payload、signature。主体信息在 payload
  • jwt 难以被篡改和伪造。这是因为有第三部分的签名存在。

session

cookie 是保存在客户端的,虽然为服务器减少了很多压力,但某些情况下,会出现麻烦。

比如,验证码

如果这样做,客户端可以随便填写一个别人的手机号,然后从 cookie 中获取到验证码,从而绕开整个验证。因此,有些敏感数据是万万不能发送给客户端的

session 使用

服务器的 session 数据可以保存在数据库,文件,内存中

敏感数据存储在服务器里,只给用户发送一个 sid,然后通过 sid 服务器来获取到敏感数据,比如手机验证码,然后服务器从 session 中取出数据来和用户传过来的数据进行比对

session 也是键值对,它保存在服务器端,通过 sessionid 和客户端关联

总结

cookie 和 session 的区别是什么?

  1. cookie 的数据保存在浏览器端;session 的数据保存在服务器
  2. cookie 的存储空间有限;session 的存储空间不限
  3. cookie 只能保存字符串;session 可以保存任何类型的数据
  4. cookie 中的数据容易被获取;session 中的数据难以获取

如何消除 session

  1. 过期时间
    当客户端长时间没有传递 sessionid 过来时,服务器可以在过期时间之后自动清除 session
  2. 客户端主动通知
    可以使用 JS 监听客户端页面关闭或其他退出操作,然后通知服务器清除 session