在前端开发登录页面时,经常会遇到cookie
、session
、jwt
这几个词,虽然经常使用,但是对它们的理解都比较浅,本文试图理清它们的本质与之间的联系。
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
只能在指定的domain
和path
下使用。 -
当
domain
不指定时,默认是当前的域名(不包含子域名);当path
不指定时,默认是域名下所有的路径’/'。 -
在上面的例子中,第一个
cookie:sessionId
,没有指定domain
且指定了Path=/accounts
,所以只有当请求a.com/accounts
下的资源时,才会携带该cookie
。
Expires & Max-Age
控制cookie
的有效时间
Expires
和Max-Age
用来指定cookie
的超时时间,前者用来指定具体的cookie
超时时间,后者用来指定cookie
的超时间隔;- 如果不指定,则在用户关闭浏览器时会删除
cookie
;
Secure & HttpOnly & SameSite
控制cookie
的安全策略
Secure
指定cookie
必须在https
请求中才能携带;HttpOnly
禁止前端使用js
操作cookie
,document.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
?如下图:
从图中可知:当用户访问a.com
时,后台返回的响应头中有一个字段set-cookie
,浏览器读取响应头发现有这个字段后,会自动在浏览器中保存这个字段所带的信息,在后面的请求中浏览器会自动的携带这个cookie
数据发送给服务端。
所以,cookie
是服务端设置的,浏览器自动保存这个字段信息到用户的电脑上。
cookie
仅会在相同的domain
请求下会自动的被携带,当访问与该cookie
不同domain
下的资源时,浏览器不会携带此domain
下cookie
。
如果一个用户先请求a.com
的页面,浏览器存储了a.com
下的cookie
。然后用户又接着打开了b.com
下的页面,同时浏览器也存储了b.com
下的cookie
,如果用户这时在b.com
的页面向a.com
发送了请求,那么浏览器会携带那个domain
下的cookie
呢?
答案是a.com
,也就是在请求中是携带那个域名下的cookie
与当前打开的网站无关,而与请求的url
有关。因为这个特性会导致CSRF
攻击。
cookie
的用途
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=123
,server
拿到sessionId=123
所对应用户(至于怎么通过sessionId=123找到对应的用户后面会介绍),然后在他的购物车中添加了一个商品; -
用户然后关闭了该购物网站;
-
数小时后,用户再次打开此购物网站并访问购物车,网站从后端请求购物车数据,浏览器查找本地
cookie
,发现保存了此网站sessionId=123
的有效cookie
,浏览器在网站请求头中附带自动附带sessionId=123
的cookie; -
服务端收到购物车查询请求,并从请求头中获取到
sessionId=123
,服务器查找内存中的id=123
的session
,发现有此用户的购物车商品数据,服务器将此数据返回给前端。 -
用户在购物车中看到了自己上次访问网站时添加的商品,选中此商品完成结算。
以上就完成了会话的管理。
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
中,注意此cookie
的domain
属性是广告平台的域名,而不是购物网站的域名,我们将这种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-cookie
,key-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就会越来越多,把整个内存都占满了,那么整个进程就崩溃了。
- 在线上是多进程部署的,进程和进程之间是无法访问的,所以B进程是无法访问到A进程里面的
session
的。
现在的计算机都是多核的,为了充分利用计算机的资源,一个应用往往会启用多个进程。如果每个进程都有session
的话,进程之间的session
是不能共享的。当你第一次进来,命中了第一个进程中的session
,但是第二次进来命中了第二个进程中的session
,但是这个session
没有你的信息,就登录不了了。这是因为负载均衡导致的,它看哪个进程比较闲就分配哪个进程。
既然把session
保存在内存中不行,那么保存在数据库中可不可以呢?如mysql
。
这其实也有问题,每个请求过来我都要验证用户的合法性,如果验证成功,那么就去操作后面的步骤去数据库取数据,这相当于导致查询了两次数据库,这导致请求时长变长。
为了解决这个问题,一般我们把session
放在redis
这个内存数据库中,访问内存的速度比访问硬盘的速度要高的多。
因此,现在的web服务常见模型如图所示:
session
为何适用redis
?
1. session 访问频繁,对性能要求极高
session
访问频繁,因为我们在每个请求的时候都要验证是否登录,是一个访问的前置操作,所以就需要访问非常快,如果session
都访问很慢后面的操作也就变慢,从而导致请求时间较长,因此对性能要求极高。
2. session
可不考虑断电数据丢失的问题(内存的硬伤)
session
如果断电数据丢失了,你再登录一次(再登录一次就是把用户相关的信息写进去,用户下次访问的时候会先去redis中获取数据,拿到数据后,再进行接下来的业务逻辑)就可以了,登录后请求其他接口就可以通过userid
到redis
里面查,如果有就说明已经登录过了。
3. session 数据量不会太大(相比于mysql中存储的数据)
jwt
什么是jwt
json web token
(jwt
)是一种基于JSON的、用于在网络上声明某种主张的令牌(token),通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。
- 头信息指定了该
jwt
使用的签名算法
header = {"alg":"HS256","typ":"JWT"}
HS256
表示使用了 HMAC-SHA256
来生成签名。
- 消息体Payload,就是真实存储需要传递的信息的部分,例如正常我们会存储些用户 ID、用户名之类的。此外,还包含一些例如发布人、过期日期等的元数据。
payload = {"userName":"admin","iat":1422779638} //iat表示令牌生成的时间
- 签名:对
Header
和Payload
进行签名
未签名的令牌由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
实现登录原理
-
首先,前端通过Web表单将自己的用户名和密码发送到后端。
-
后端核对用户名和密码成功后,将用户的id等其他信息作为
jwt Payload
(负载),将其与头部分别进行Base64编码拼接后签名,组成一个token
,如aaa.bbb.ccc
这样的字符串。
-
后端将
token
字符串作为登录成功的返回结果返回给前端,前端可以将返回的结果保存在localStorage
或sessionStorage
上,退出登录时前端删除保存的token
即可。 -
前端在每次请求时把
token
放在请求头中发送给后端,目前有两种方式:-
一是通过
cookie
的形式,即把token
放在cookie
中,每次浏览器会自动帮我们带过去,不需要我们自己设置。 -
二是放在请求头
header Authorization
中,需要我们自己手动设置请求头。通常获取到token
之后,会存放在sessionStorage
或者localStorage
中,这样当页面刷新之后保证token
不会消失。
-
Authorization: Bearer eyJhbGci*...<snip>...*yu5CSpyHI
- 后端检查是否存在,如存在验证
token
的有效性。例如,检查签名是否正确,检查Token是否过期,等等。
jwt
的优缺点
从上面的介绍可知,服务端把用户信息都放在了token
上,自己不需要redis
来保存session
了,也就是使用jwt
最大的优点,干掉session
。
但是这会带来一个很大的问题,因为token
保存在客户端,服务端无法作废已颁布的令牌,即使你知道了某个token
被盗取了,你也没有办法将其作废。在token
过期之前(你绝对应该设置过期时间),你无能为力。因此,设置token
的过期时间是非常有必要的。
另外,token
尽管保存在Local Storage
中,避免CSRF
攻击,但是仍然无法避免XSS
攻击,跨域脚本仍然可以盗取Local Storage
中的数据。
总结
由于http
协议是无状态的,为了知道是哪个用户发起的请求,于是诞生了cookie-session
,cookie
存储在客户端,每次发送请求时浏览器会自动带上发给后台,后台从请求头中获取到cookie
后,从redis
里面的session
找到这个用户的个人信息,于是就完成了用户的确认。
由于需要使用redis
保存session
比较麻烦,希望用户信息直接保存在客户端,不保存在服务端,于是诞生了jwt
。
服务端把用户信息用加密算法加密后生成token
发送给浏览器,浏览器存储在localstorage
里面,每次请求时从localstorage
取出加到请求头中发给后台,后台通过密钥验证token
的正确性。
不管是cookie-session
,还是jwt
都是为了完成用户的认证,各有优劣,在不同场景下可灵活使用。