前端鉴权
这里先阐述一些前置概念。
- Authentication:身份鉴别,以下简称认证
- Authorisation:授权
- Authorization Server / Identity Provider(IdP):认证服务器
- Service Provider(SP) / Resource Server:业务服务器,负责提供资源(API 调用)
认证的作用在于认可你有权限访问系统,用于鉴别访问者是否是合法用户;而授权用于决定你有访问哪些资源的权限。大多数人站在用户的立场上不会区分这两者的区别。而作为系统的设计者来说,这两者是有差别的,分属于不同的工作职责,我们可以只需要认证功能,而不需要授权功能,甚至不需要自己实现认证功能,而借助 Google 的认证系统,即用户可以用 Google 的账号进行登陆。
API 鉴权有如下四种方式:
- cookie + session:和平常 web 登陆一样的鉴权方式,很常见。
- HTTP Basic:将账号和密码拼接然后 base64 编码加到 header 头中。很显然,因为账号和密码几乎是『明文』传输的,而且每次请求都传,安全性很低。
- HTTP Digest:将账号和密码加上其他一些信息拼接然后取摘要加到 header 头中。这个安全性比上面要好一点,因为如果是取摘要的话,即使信息段被截取,也无法轻易破解出来(当然也是有破解的可能)。不过其实最大的问题还是:每次请求都要对账号、密码取一次摘要,也就是说每次请求都要有账号和密码,账号和密码要么缓存一下,要么就每次请求要求用户输一次密码,这样显然不合适。同样,上面的 Basic 也存在这样的问题。
- Token:token 通过一次登录验证,得到一个鉴权字符串,然后以后带着这个鉴权字符串进行后续操作,这样就可以解决每次请求都要带账号密码的问题,而且也不需要反复使用账号和密码。
(一)Token
token 是一串字符串,通常作为访问资源的凭据,最常使用的场景是 API 鉴权。例如当你调用 Google API,需要带上有效 token 来表明你请求的合法性。这个 token 是 Google 提供的,代表 Google 的授权使你能够访问 API 背后的资源。
1. Token 概述
① Token 优势
Token 相对于 Cookie + Session 的优点,主要有下面两个:
避免 CSRF 攻击:构成这个攻击的原因,就在于 Cookie + Session 的鉴权方式中,鉴权数据(cookie 中的 session_id)是由浏览器自动携带发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。而 token 是通过客户端本身逻辑作为动态参数加到请求中的,token 也不会轻易泄露出去,因此 token 在 CSRF 防御方面存在天然优势。
适合移动应用:移动端不支持 cookie,而 token 只要客户端可以进行存储就能使用,因此 token 在移动端上也具有优势。
Token 完全由应用管理,所以可以避开同源策略
② Token 种类
一般来说 token 主要分为以下三种:
- 自定义 token:开发者根据业务逻辑自定义的 token
- JWT:JSON Web Token,定义在 RFC 7519 中的一种 token 规范
- Oauth2.0:定义在 RFC 6750 中的一种授权规范,但这其实并不是一种 token,只是其中也有用到 token
2. Token 工作原理
① Token 有效期
Token 是在服务端产生的。假设前端使用【用户名/密码】向服务端请求认证,服务端认证成功后会返回 Token 给前端。于是前端会在每次请求时带上 Token 证明自己的合法地位。如果这个 Token 在服务端持久化(比如存入数据库),那它就是一个永久的身份令牌。但无论从安全角度或者吊销角度考虑,Token 都应该设置有效期。
为了解决在操作过程不让用户发现 Token 失效的问题,一种方案是在服务器端保存 Token 状态,用户每次操作都会自动刷新(推迟)Token 的过期时间。Session 就是类似如此来保持用户登录状态的。然而在前后端分离、SPA 这些情况下,每秒种都可能发起多次请求,每次都去刷新会产生很大代价。如果 Token 的过期时间被持久化到数据库或文件,代价就更大了。所以通常为了提升效率,减少消耗,会把 Token 的过期时间保存在缓存或内存中。
另一种方案是使用 Refresh Token,它可以避免频繁读写操作。这种方案中,服务端不需要刷新 Token 的过期时间,一旦 Token 过期,就反馈给前端,前端使用 Refresh Token 申请一个全新 Token 继续使用。这种方案中,服务端只需要在客户端请求更新 Token 的时候对 Refresh Token 的有效性进行一次检查,大大减少了更新有效期的操作,也就避免了频繁读写。当然 Refresh Token 也存在有效期,但这个有效期可以设置长一些,比如,以天为单位的时间。而当 Refresh Token 过期时,用户则需要重新登录授权。
使用 Token 和 Refresh Token 的时序图如下:
1)登录

2)业务请求

3)Token 过期,刷新 Token

问:为什么要区分 refresh token 和 access token?如果合并成一个 token 然后把过期时间调整得更长,并且每次失效后用户重新登陆授权就好了?
答:这样的处理是为了职责的分离:refresh token 负责身份认证,token 负责请求资源。虽然 refresh token 和 token 都由 IdP 发出,但是 token 还要和 SP 进行数据交换,如果公用的话会存在身份泄露的可能。并且 IdP 和 SP 可能是由完全不同的服务提供的。Token 过期有可能是服务器故意为之,而不是自然过期,比如更改权限或禁用用户等,这样 Refresh 时就不会通过,换句话说,Refresh 不是为了保证持续有效,而是为了保证失效。

到目前为止,Token 都是有状态的,即在服务端需要保存并记录相关属性。那说好的无状态呢,怎么实现?
② 无状态 token
如果把所有状态信息都放在 Token 上,服务器就可以不保存,只需要认证 Token 有效:服务端通过签名确认自己有无签发该 Token,以及其信息是否改动过。通常签名都是一方签发,另一方验证,所以要使用非对称加密算法。可在这里,签发和验证都是同一方,所以对称加密算法就能达到要求,而且对称算法比非对称算法要快得多(可达数十倍差距)。但对称加密算法会还原加密内容,这一功能在对 Token 签名时并无必要。既然不需要解密,摘要(散列)算法就会更快。HMAC 是可以指定密码的散列算法。JWT 对此已经定义了详细的规范,而且有各种语言的若干实现。
无状态 Token 减轻了服务端的压力,但服务端还需要保存未到期便被注销的 Token,以便下次收到这个仍在有效期内的 Token 时判其无效。在前端可控的情况下(比如前端和服务端在同一项目组内),可以协商:前端一旦注销成功,就丢掉本地保存(如内存、LocalStorage 等)的 Token 和 Refresh Token,这样服务器就能假设收到的 Token 没被注销(因为注销后前端便不会再使用);而当前端不可控时,仍可以进行上面的假设,但应尽量缩短 Token 的有效期。在用户主动注销时,实际上在较短的一段时间内并没有注销。如果这点漏洞在应用设计中没有造成什么损失,那采用这种策略就是可行的。
在使用无状态 Token 的时候,有两点需要注意:
- Refresh Token 有效时间较长,所以它应该保存在服务器端,以增强安全性,确保用户注销时可控
- 应该考虑使用二次认证来增强敏感操作的安全性
上面说的只是 IdP 和 SP 集成在一起的情况,如果是分离的情况呢?
③ 分离认证服务
无状态 Token 使得单点登录容易实现。前端拿到一个有效的 Token 后,便能在同一体系中任一服务上认证通过——只要它们使用同样的密钥和算法来认证 Token 的有效性。

如果 Token 过期,前端仍然需要去认证服务 IdP 更新 Token:

尽管分离了认证和业务,但实际并无多大差异,因为这是建立在 IdP 信任 SP 的前提下,即 IdP 产生 Token 的密钥和 SP 认证 Token 的密钥相同(业务服务器同样可以创建有效的 Token)。
④ 不受信的业务服务器
遇到不受信的 SP 时,可以考虑非对称加密:IdP 使用私钥对 Token 签名,公开公钥。信任该 IdP 的 SP 保存公钥,用于验证签名。
当 SP 不受信任时,多个 SP 之间使用相同的 Token 对用户来说是不安全的。因为任何一个 SP 拿到 Token 都可以仿冒用户去另一个 SP 处理业务。为了防止这种情况发生,IdP 需要在产生的 Token 中加上使用该 Token 的 SP 信息,这样当另一个 SP 拿到该 Token 时,就能发现它不是自己需要验证的 Token,于是直接拒绝。
虽然 IdP 不信任 SP,SP 之间也相互不信任,但前端是信任这些服务器的:如果前端不信任,就不会拿 Token 去请求验证。但前端信任不代表用户信任。如果 Token 没有携带用户隐私,那么用户不必关心信任问题。但如果 Token 含有用户隐私,这时 IdP 就得在用户请求 Token 时询问用户是否要授权给某 SP。可是用户和 IdP 都不能确认该 SP 是否为本尊,因为公钥已经公开了,任何一个 SP 都可以声明自己是本尊。
为了得到用户的信任,IdP 需要帮助用户来甄别 SP。于是,IdP 决定不公开公钥,而是要求 SP 先申请注册并通过审核。只有通过审核的 SP 才能得到 IdP 创建的仅供它使用的公钥。如果该 SP 泄漏公钥,风险由该 SP 自行承担。现在 IdP 可以清楚告诉用户 SP 的身份。如果用户不够放心,IdP 甚至能询问用户细致到:当该 SP 需要请求 A、B、C 三项个人数据,其中 A 是必须的,不然它不工作,是否允许授权?如果授权,授权的几项数据将加密放在 Token 中。
3. Token 使用
① token 携带方式
存储在 localStorage、sessionStorage 中:每次调用接口的时候发送给服务端(每次调用接口放在 HTTP 请求头的 Authorization 字段里)
- 优点:可以跨域
- 缺点:没有任何安全防御,很容易受到 xss 攻击(简单理解在输入框输入 js 代码)导致 token 被盗取,因此要做好 xss 防御
存储在 cookie 中:可以自动发送给服务端
- 优点:可以指定
httponly
,来防止 xss,也可以指定 secure,来保证 token 只能在 HTTPS 下传输 - 缺点:不能跨域,而且不符合 Restful 最佳实践,易受 CSRF 攻击(简单来说攻击者盗用已经认证过的用户信息,以用户的名义进行一些操作(发邮件、转账等)CSRF 不能拿到用户信息,他只是盗用用户的凭证去进行操作)。
请求 API 时携带 token 的方式也有很多种,通过 HTTP Header 或者 url 参数或者 google 提供的类库都可以:
1 | // HTTP Header: |
以调用 Google API 为例,回顾第一次获取 token 的流程如下:
- 首先向 Google API 注册你的应用程序,注册完毕后你会拿到认证信息(credentials)包括 ID 和 secret。不是所有的程序类型都有 secret;
- 接下来要向 Google 请求 access token。这里先忽略一些细节,例如请求参数(secret 等)以及不同类型的程序的请求方式等。重要的是,如果你想访问的是用户资源,这里就会提醒用户进行授权;
- 如果用户授权完毕。Google 就会返回 access token。又或者是返回授权代码(authorization code),你再通过代码取得 access token;
- token 获取到后,就能够带上 token 访问 API 了。
② 无痛刷新 token
需求:前端登录后,后端返回 token 和 refresh_token,当 token 过期时用旧 refresh_token 去获取新的 token,前端需要做到无痛刷新 token,即请求刷新 token 时要做到用户无感知。当用户发起一个请求时,判断 token 是否已过期,若已过期则先调 refreshToken 接口,拿到新的 token 后再继续执行之前的请求。
难点:同时发起多个请求,而刷新 token 的接口还没返回,此时其他请求该如何处理?
实现:这里会使用 axios 来实现,以上方法是请求后拦截,所以会使用 axios.interceptors.response.use()
方法。首先说明一下,项目中的 token 是存在 localStorage 中的。如何防止多次刷新 token 如果 refreshToken 接口还没返回,此时再有一个过期的请求进来,上面的代码就会再一次执行 refreshToken,这就会导致多次执行刷新 token 的接口,因此需要防止这个问题。我们可以在 request.js 中用一个 flag 来标记当前是否正在刷新 token 的状态,如果正在刷新则不再调用刷新 token 的接口。
这样就可以避免在刷新 token 时再进入方法了。但这种做法是相当于把其他失败的接口给舍弃了,假如同时发起两个请求,且几乎同时返回,第一个请求肯定是进入了 refreshToken 后再重试,而第二个请求则被丢弃了,仍是返回失败,所以接下来还得解决其他接口的重试问题。
当两个接口几乎同时发起和返回,第一个接口会进入刷新 token 后重试的流程,而第二个接口需要先存起来,然后等刷新 token 后再重试。同样,如果同时发起三个请求,此时需要缓存后两个接口,等刷新 token 后再重试。由于接口都是异步的,处理起来会有点麻烦。
当第二个过期的请求进来,token 正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新 token 后再逐个重试清空请求队列。那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助 Promise。将请求存进队列中后,同时返回一个 Promise,让这个 Promise 一直处于 Pending 状态(即不调用 resolve),此时这个请求就会一直等待,只要不执行 resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用 resolve,逐个重试。
最终代码:
1 | import axios from 'axios' |
4. JWT
JWT(Json Web Token)也称 Json 令牌,是一种规范化的 token。它是 RFC 7519 中定义的用于安全地将信息作为 Json 对象进行传输的一种形式。JWT 中存储的信息是经过数字签名的,因此可以被信任和理解。可以使用 HMAC 算法或 RSA/ECDSA 的公用/专用密钥对 JWT 进行签名。使用 JWT 主要用于下面两点。
- 认证(Authorization):这是使用 JWT 最常见的一种情况,一旦用户登录,后面每个请求都会包含 JWT,从而允许用户访问该令牌所允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小。
- 信息交换(Information Exchange):JWT 是能够安全传输信息的一种方式。通过使用公钥/私钥对 JWT 进行签名认证。此外,由于签名是使用
head
和payload
计算的,因此还可以验证内容是否遭到篡改。
① JWT 工作流程
本质上来说 JWT 也是 token,是一种访问资源的凭证。与 OAuth 中没有 Client 参与的流程类似,Google 的一些 API 诸如 Prediction API 或者 Google Cloud Storage,是不需要访问用户的个人数据的,因而不需要经过用户的授权这一步骤,应用程序可以直接访问。这就要借助 JWT 完成访问了。
- 首先你需要在 Google API 上创建一个服务账号(service account)
- 获取服务账号的认证信息(credential),包括邮箱地址,client ID,以及一对公钥/私钥
- 使用 client ID 和私钥创一个签名的 JWT,然后将这个 JWT 发送给 Google 交换 access token
- Google 返回 access token
- 程序通过 access token 访问 API
甚至可以不需要向 Google 索要 access token,而是直接携带 JWT 作为 HTTP header 里的 bearer <token>
对 API 进行访问。注意,JWT 不支持 refresh token,JWT 过期后需要执行登录授权的完整流程。
② JWT 格式
JWT 主要由三部分组成,每个部分用 .
进行分割,各个部分分别是
- Header(头部)
- Payload(载荷)
- Signature(签名)
Header
Header 是 JWT 的标头,它通常由两部分组成:令牌的类型(即 JWT)和使用的签名算法(如 HMAC SHA256 或 RSA)。
例如
1 | { |
指定类型和签名算法后,JSON 块被 Base64Url
编码成 JWT 的第一部分。
Payload
载荷中放置了 token
的一些基本信息,以帮助接受它的服务器来理解这个 token
。同时还可以包含一些自定义的信息,用户信息交换。载荷的属性分为如下三类:
- registered(预定义):包含一组建议使用的预定义声明,主要包括
字段 | 含义 |
---|---|
iss (issuer) | 签发人 |
exp (expiration time) | 过期时间 |
sub (subject) | 主题 |
aud (audience) | 受众 |
nbf (Not Before) | 生效时间 |
iat (Issued At) | 签发时间 |
jti (JWT ID) | 编号 |
1 | { |
- public(公有):公共的声明,可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
- private(私有):自定义声明,旨在在同意使用它们的各方之间共享信息,既不是注册声明也不是公共声明。
1 | { |
然后 payload Json 块会被 Base64Url
编码成 JWT 的第二部分。
Signature
JWT 的第三部分是一个签名信息,用于验证消息在此过程中有无更改,并且对于使用私钥进行签名的令牌,它还可以验证 JWT 的发送者的真实身份。这个签名信息由三部分组成
- header (base64 后的)
- payload (base64 后的)
- secret
如果以 HMAC SHA256
算法进行签名加密,则签名信息如下:
1 | HMACSHA256( |
加密后再进行 base64url
编码,最后得到的字符串就是 JWT 的第三部分。
HMAC(Hash-based Message Authentication Code)算法是不可逆算法,类似 MD5 和 hash,但多一个密钥,密钥(即上面的 secret)由服务端持有,客户端把 token 发给服务端后,服务端可以把其中的头部和载荷再加上事先共享的 secret 再进行一次 HMAC 加密,得到的结果和 token 的第三段进行对比,如果一样则表明数据没有被篡改。
现在把上面的三个由点分隔的 Base64-URL 字符串部分组成在一起,这个字符串可以在 HTML 和 HTTP 环境中轻松传递。下面是一个完整的 JWT 示例,它对 header 和 payload 进行编码,然后使用 signature 进行签名
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ |
注意:Token 中的数据是明文保存的(Base64 编码并不是加密),所以不能在其中保存敏感信息。
如果想自己测试编写的话,可以访问 JWT 官网 jwt.io/#debugger-i…
③ JWT 使用
和 Token 一样,JWT 的使用有如下两种方式,推荐使用后者,因为在 https 情况下更安全。
- 放在 URL 的参数部分:
?token=<token>
- 放在 header 中:
Authorization: Bearer <token>
JWT 在客户端的存储有三种方式,推荐第三种,因为前两种存在跨域读取限制,而 Cookie 使用不同的跨域策略。
- LocalStorage
- SessionStorage
- Cookie(不能设置 HTTPonly,否则不能通过 JS 获取。因为没开 HTTPonly,所以要注意防范 XSS 漏洞)
Cookie 的跨域策略:子可以读父,但是父不可以读子,兄弟之间不能互相访问。如:a.xxx.com 和 b.xxx.com 可以读 xxx.com,但是 a.xxx.com 和 b.xxx.com 不能互相读取,xxx.com 也不能读 a.xxx.com 和 b.xxx.com 的。
你可能会想:存 Cookie 那我不是又变得和 cookie + session 一样了吗?其实不然,因为存 cookie 在这只是用到了其存储机制,而没有利用其去鉴权。也就只是简单存一下,并没有期望浏览器带上 token 去鉴权,将 token 加入请求这部分操作还是我手动进行的。
④ JWT 优势
JWT 除了防止 CSRF 和适合移动应用等 Token 共有的特性外,还具备以下独特的优点:
无状态:一般的 Token 是存储在数据库中的,在服务器端进行数据库查询,并比对 Token 是否合法。而 JWT 是让 Header 和 Payload 加密后存储在用户端,服务端只需要解密即可,不需要维护状态。无状态能带来如下好处:
Ⅰ. 节省服务器的资源:因为服务端无需维护一个状态,因此能够节省服务端原先保存这些状态所花费的资源
Ⅱ. 适合分布式:因为服务端无需维护状态,因此如果服务端是多台服务器组成的分布式集群,那么无需像『有状态』一样互相同步各自的状态。
Ⅲ. 时间换空间:因为 token 的校验是通过签名校验来进行的,签名校验消耗的是 CPU 时间,而『有状态』是需要通过客户端提供的凭据对服务端现有的状态进行一次查询,消耗的是 I/O 和内存、磁盘空间。通常对于一个 Web 服务来说,其属于 I/O 密集型,因此通过时间换空间这一操作,可以提高整体的硬件使用率。
编码数据:因为 JWT 能够在载荷中编码部分信息,所以把常用数据编码进去,能够大大减少数据库的查询次数,不过载荷信息是明文编码的,所以不能编码敏感信息在里面,如果要编码可以先加密再编码进去。另外,token 在每次请求时都会进行传输,所以载荷中不能编码过多的信息,否则会降低传输效率
注:对于只需要登录用户并访问存储在站点数据库中的一些信息的中小型网站来说,Session Cookies 通常就能满足。如果你有企业级站点,应用程序或附近的站点,并且需要处理大量的请求,尤其是第三方或很多第三方(包括位于不同域的 API),则 JWT 显然更适合。
⑤ JWT 安全问题
既然主要使用场景是鉴权,那么安全问题就是不得不考虑的问题了。下面对 JWT 可能需要的安全问题都进行一次深入的探讨并寻求最佳的解决方案。
Ⅰ. 重放攻击
重放攻击是通过把原先的包进行一次重放来进行攻击的手段。需要明确的是 cookie + session 也存在重放攻击问题。常用的防范重放攻击的措施主要有以下几种:
- timestamp:在请求中夹带一个时间戳,设置较短的有效期,如果一个新来的请求的请求时间超过了请求中的有效期,则认为无效。但是这种策略也存在问题,即如果一个黑客『眼疾手快』在有效期以内将你的包进行了重放,那就来攻击成功。这种策略对应到 JWT 中就是给 token 设置一个较短的有效期。
- nonce:在请求中夹带一个随机字符串,这个字符串传送到客户端后即存入客户端的黑名单中,如果一个新来的请求其中存在的随机字符串已经在黑名单中则认为无效。但是显然,这个策略存在巨大的问题:服务端需要维护一个黑名单库,这个库的大小会随着业务运行的时间而变得无比巨大,从而严重影响效率。这种策略对应到 JWT 中就是给 token 设置一个黑名单,但是不设置有效期。
- timestamp + nonce:在请求中夹带一个随机字符串和一个时间戳,如果一个新来的请求,其随机字符串已经在黑名单中则认为无效,或者一个请求的的请求时间超过了其有效期,则也认为其无效。这样黑名单的范围只需设置为时间戳策略的有效期范围即可。这种策略对应到 JWT 中就是给 token 既设置一个黑名单,又设置一个有效期。
- 挑战 - 应答:这个其实和 timestamp + nonce 策略一样,只是随机字符串是有服务端生成给客户端的,客户端携带服务端所给的随机串来请求。这样有什么好处呢?服务端可以通过一个加密算法来生成这个串,使其和时间戳相关,同时客户端又无法伪造。这样就不需要维护黑名单了。同样也是时间换空间的策略。但是显然每次或几次请求就要进行一次与预请求以得到随机串,并不是特别方便,造成的额外消耗也有待考量。
- 序列号:通过在请求中嵌入一个序列号,每次请求依次加一,如果一个请求的序列号早已用过,则认为无效。但是这个要用逻辑额外一个全局序列号,并不是特别方便。
- HTTPS:终极解决方案了,HTTPS 在握手过程中会自动维护一个隐式序列号,解决了上面要自己维护序列号的问题。
Ⅱ. token 被盗
因为 token 中包含了登陆状态,因此一旦 token 被盗,那么就会被人盗用身份。那么 token 针对被盗的防范措施整理如下:
- 使用 HTTPS 传输:从传输层的角度解决问题
- HTTPOnly:从存储层的角度解决问题,防止 XSS 攻击窃取 cookie,但是这种方案其实存在问题,因为这样 js 就无法读取 token 并把它加到 header 头中了。所以不开启 HTTPOnly 的话必须要额外注意防范 XSS 攻击。
- 在 token 中嵌入客户端指纹:通过客户端指纹,即使黑客盗取了你的 cookie,他也无法用你的 cookie 进行请求。
- 设置较短的 token 有效期:这样如果 token 被盗,只要超过一定时限就无法使用。
⑥ JWT 其他问题
除了安全问题,JWT 还有一些其他需要考虑的问题。
Ⅰ. 注销问题
因为 JWT 是无状态的,所以它的有效期完全由其本身决定,即服务端无法让一个 token 失效。对此解决方案如下:
a. 客户端主动注销
- 客户端直接删除存储 token 的 cookie:这种方案最为简单,操作的结果是无论客户端还是服务端都没有这个 token,可实际上这个 token 并非不可用,而是处于一个游离态。
- 黑名单策略:客户端携带要注销的 token 访问一个注销接口,服务端把 token 加入一个黑名单。因为黑名单只需维护目前尚未过期但又要使其无效的 token,所以黑名单不会过于庞大。
b. 服务端主动注销 / 用户修改密码
- 把 token 和 uuid 用 key-value 对存储在 redis:这种方案看似可行,但实际上相当于自己实现了一次 cookie + session,JWT 就失去了无状态的特性。
- 让每个用户都有一个 secret:前面讲到签发 token 的时候用到了 secret,这种策略的思想就是让每个用户都有一个 secret,注销一个用户的时候修改其 secret,即可使其前面签发的 token 无法通过校验而失效。这种策略上听上去不需要维护一个状态,但是实际上存在更大的问题。试想一下,第一种方案是通过 uuid 在已登录用户的 token 表中找到要注销的 token 注销。cookie + session 是通过 session_id 在已登录的用户的 session 表中找到其对应的 session 并删除来注销。而此方案是通过 uuid 在所有用户(而非已登录用户)中找到对于的 secret 修改来注销。这样看来会发现效率更低,因为查找范围更大了。
- 预黑名单:把要注销的用户的 uuid 和当前时间(TIME)组成 key-value 对加入预黑名单,下次请求来时,若其 uuid 和黑名单中的对应,并且签发时间在 TIME 之前,则将其注销。这样查找范围就是未过期但又要注销的用户。并且在实现逻辑上这个预黑名单可以和签名的黑名单做到一起。
关于黑名单策略的补充:
有人可能会觉得黑名单也是一种状态,用这种策略实现的 JWT 并不能算纯正的无状态。这种说法没错,但是考虑每次要检索的数据范围可以得到下面一个关系:
未过期但要提前注销的用户或 token 数 < 所有已登录用户数 < 所有用户数
此处的『 < 』基本可以看成『远远小于』,所以黑名单策略虽然也算有状态,但是其维护的状态数也是特别小的。
可见 『黑名单』策略能够有效解决 JWT 的注销问题。
(二)SSO
通常公司内部会有很多工具平台供员工使用,比如人力资源,代码管理,日志监控,预算申请等。如果每个平台都实现自己的用户体系,无疑会带来浪费,所以公司内部会有一套公用的用户体系,用户只要登陆后,就能够访问所有的系统。这就是单点登录(SSO,Single Sign-On)
SSO 是一类解决方案的统称,而在具体实施层面有两种策略可供选择:
- SAML 2.0
- OAuth 2.0
1. SMAL 2.0
SMAL 2.0 的流程如下:

- 未登陆的用户打开浏览器访问你的网站(业务服务器,SP),SP 会提供服务但并不负责用户认证;
- 于是 SP 向 IdP(认证服务器)发送 SAML 认证请求,同时 SP 将用户浏览器重定向到 IdP;
- IdP 在验证该请求无误后,在浏览器中呈现登陆表单让用户填写用户名和密码进行登陆;
- 一旦用户登陆成功,IdP 会生成一个包含用户信息的 SAML token(也称 SAML Assertion,本质是 XML 节点),并将用户重定向到 SP 站点,期间会返回 SAML token 给 SP。重定向通常有如下两种途径:
- HTTP 重定向(HTTP Redirect):不推荐,因为重定向的 URL 长度有限,无法携带更长的信息,比如 SMAL Token
- HTTP POST 请求:常规做法,当用户登陆完毕之后渲染出一个表单,用户点击后向 SP 提交 POST 请求。或者可以使用 Javascript 向 SP 发出一个 POST 请求
- SP 对拿到的 token 进行验证,并从中解析出用户信息(如身份和对应权限等)。解析成功后就能根据这些信息允许用户访问 SP 的内容。
如果你的应用是基于 web,那么以上方案没有任何问题。但如果是一个 iOS 或者 Android 的手机应用,那么问题就来了:用户在 iPhone 上打开应用,此时用户需要通过 IdP 进行认证,于是应用跳转至 Safari 浏览器,在登陆认证完毕后,需要通过 HTTP POST 的形式将 token 返回至手机应用。
虽然 POST 的 url 可以拉起应用,但是手机应用无法解析 POST 的内容,因而导致无法读取 SAML Token。当然还是有办法的:比如在 IdP 授权阶段不跳转至系统的 Safari 浏览器,而是在内嵌的 webview 中解决,想方设法从 webview 中提取 token,或者利用代理服务器。但无论如何,SAML 2.0 并不适用于当下跨平台的场景,这也许与它产生的年代也有关系,它诞生于 2005 年,在那个时刻 HTTP POST 确实是最好的选择方案
2. OAuth 2.0
SSO 下的 OAuth 2.0 的流程如下:

- 用户通过客户端(浏览器或手机应用)想要访问 SP 上的资源,但 SP 告诉用户需要进行认证,并将用户重定向至 IdP
- IdP 向用户询问是否允许 SP 访问用户信息,如果用户同意,IdP 向客户端返回 access code
- 客户端拿 code 向 IdP 换 access token,并拿着 access token 向 SP 请求资源
- SP 收到请求后拿着附带 token 向 IdP 验证用户的身份
那么 OAuth 是如何避免 SAML 流程下无法解析 POST 内容的信息的呢?用户通过 URL 重定向从 IdP 返回到客户端,这里的 URL 允许自定义 schema,所以即使在手机上也能拉起应用;另一方面因为 IdP 向客户端传递的是 code,而不是 XML 信息,所以 code 可以很轻易地附着在重定向 URL 上进行传递。
但以上的 SSO 流程体现不出 OAuth 的本意。OAuth 的本意是一个应用允许另一个应用在用户授权的情况下访问自己的数据,OAuth 的设计本意更倾向于授权而非认证(当然授权用户信息就间接实现了认证),虽然 Google 的 OAuth 2.0 API 同时支持授权和认证。所以用户在使用 Facebook 或者 Gmail 账号登陆第三方站点时,会弹出授权对话框告知第三方站点可以访问用户的哪些信息,并征集用户是否同意访问:
在上面 SSO 的 OAuth 流程中涉及三方角色:SP、IdP 以及 Client。但在实际工作中 Client 可以是不存在的,例如你编写了一个后端程序定时的通过 Google API 从 Youtube 拉取最新的节目数据,那么你的后端程序需要得到 Youtube 的 OAuth 授权即可。
OAuth VS OpenId
在某些站点会看到允许以 OpenID 的方式登陆,即以 Facebook 账号或者 Google 账号登陆站点:
这听上去似乎和 OAuth 很像。但本质上来看它们截然不同:
- OpenID 只用于身份认证(Authentication),允许你以同一个账户在多个网站登陆。它仅仅是为你的合法身份背书,当你以 Facebook 账号登陆某个站点之后,该站点无权访问你的在 Facebook 上的数据
- OAuth 用于授权(Authorisation),允许被授权方访问授权方的用户数据
(三)cookie & session
HTTP 是无状态的协议,对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息。每个请求都是完全独立的,服务端无法确认当前访问者的身份信息。所以服务器与浏览器为了进行会话跟踪,就必须主动去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态可以通过 cookie 和 session 实现。cookie 是客户端本地管理用户状态的工具,session 是服务端管理状态的工具。
1. session
客户端请求服务端时,服务端会为这次请求开辟一块内存空间用来存放 Session 对象,存储结构为 ConcurrentHashMap
。Session 弥补了 HTTP 无状态性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。

用户第一次请求服务器时,服务器根据用户提交的相关信息(如用户名和密码),开辟了一块 Session 空间,同时生成其唯一标识信息 SessionID,返回带有头部Set-Cookie:JSESSIONID=XXXXXXX
的响应头给浏览器。浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名。
当用户继续访问该服务器时,请求会自动判断此域名下是否存在 Cookie 信息,如果存在则会将 Cookie 信息一并发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息。如果没有找到,则说明用户未登录或登录失效;如果找到 Session,证明用户已经登录可以执行后续操作。
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。
Session 机制有个缺点,比如 A 服务器存储了 Session,当做了负载均衡后,假如一段时间内 A 的访问量激增,则会转发到 B 进行访问,但 B 服务器并没有存储 A 的 Session,于是导致 Session 的失效。
2. cookie
- cookie 存储在客户端: cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。
- cookie 是不可跨域的: 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)。
(1) cookie 属性
name=value:键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码
domain:指定 cookie 所属域名,即哪些主机可以接受 Cookie。如果不指定,默认为当前主机 (不包含子域名)。如果指定了
Domain
,则一般包含子域名。例如,如果设置
Domain=mozilla.org
,则 Cookie 也包含在子域名中(如developer.mozilla.org
)。path:指定 cookie 在哪个路径下生效,默认是 ‘/‘。如果设置为
/abc
,则只有/abc
下的路由可以访问到该 cookie,如:/abc/read
maxAge:cookie 的有效时间,单位为秒。如果为正整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie,关闭浏览器时就失效,浏览器也不会以任何形式保存该 cookie。如果为 0,表示删除该 cookie。默认为 -1
expires:过期时间,在设置的某个时间点后该 cookie 就会失效。一般浏览器的 cookie 都是默认储存的,当关闭浏览器结束这个会话的时候,这个 cookie 也就会被删除
secure:该 cookie 是否仅被使用安全协议 HTTPS 传输,即在网络上传输数据之前是否先将数据加密。默认为 false。当 secure 值为 true 时,cookie 在 HTTP 中无效,在 HTTPS 中才有效
httpOnly:如果给某个 cookie 设置了 httpOnly 属性,则无法通过 JS 脚本读取到该 cookie 的信息,但仍能在 Application 中手动修改 cookie,所以只在一定程度上避免 XSS 攻击,并非绝对安全
(2) cookie 分类
有两种类型的 Cookies,一种是 Session Cookies,一种是 Persistent Cookies,如果 Cookie 不包含到期日期,则将其视为会话 Cookie。会话 Cookie 存储在内存中,永远不会写入磁盘,当浏览器关闭时,此后 Cookie 将永久丢失。如果 Cookie 包含有效期,则将其视为持久性 Cookie。当到达指定的日期,Cookie 将从磁盘中删除。
会话 Cookies
当客户端关闭时,会话 Cookie 会被删除,因为它没有指定 Expires
或 Max-Age
指令。但 Web 浏览器可能会使用会话还原,这会使大多数会话 Cookie 保持永久状态,就像从未关闭过浏览器一样。
永久性 Cookies
永久性 Cookie 不会在客户端关闭时过期,而是在特定日期(Expires)或特定时间长度(Max-Age)外过期。例如
1 | Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2021 07:28:00 GMT; |
(3) cookie 使用
1 | // 保存 cookie 值 |
(4) cookie 优化
为了安全,cookie 一般不允许存放敏感信息(切忌明文存储用户名、密码),如果非要强行存储,一定要在 cookie 中设置httpOnly
,另外可以考虑 rsa 等非对称加密(因为浏览器本地容易被攻克,并不安全)。
由于在同域名的资源请求时,浏览器会默认带上本地的 cookie,这种特性在某些场景中会影响效率。譬如,客户端在域名 A 下有 cookie(登陆时由服务端写入),然后在域名 A 下有一个页面,页面中有很多依赖的静态资源(都是域名 A 的,譬如有 20 个静态资源)。页面加载请求这些静态资源时,浏览器会默认带上 cookie,也就是说,这 20 个静态资源的 http 请求,每一个都得带上 cookie,而实际上静态资源并不需要 cookie 验证。这造成了浪费,同时降低了访问速度(因为内容更多了)。
针对这种场景,可以使用多域名拆分进行优化:将静态资源分组,放到不同的域名下(如static.base.com
),而在page.base.com
(页面所在域名)下请求时,便不会带上static.base.com
域名的 cookie,因此避免了浪费。
多域名拆分可能存在的问题:在移动端,如果请求的域名数过多,会降低请求速度(因为域名整套解析流程是很耗费时间的,而且移动端一般带宽都比不上 pc)。此时需要用到一种优化方案:dns-prefetch
(让浏览器空闲时提前解析 dns 域名)
3. 区别
Session vs Cookie
- 安全性: Session 比 Cookie 安全,Session 存储在服务器端,Cookie 存储在客户端。
- 存取值的类型不同:Cookie 只支持存字符串数据,想要设置成其他类型,需要将其转换成字符串,而 Session 可以存任意数据类型。
- 有效期不同: Cookie 可设置为长时间保持,比如经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭(默认情况下)或者 Session 超时都会失效。
- 存储大小不同: 单个 Cookie 保存的数据不能超过 4K,Session 可存储数据远高于 Cookie,但当访问量过多,会占用过多的服务器资源。
Session/Cookie vs Token
- session 的使用方式是客户端 cookie 里存 id,服务端 session 存用户数据,客户端访问服务端的时候,根据 id 找用户数据。
- token 的使用方式是客户端里存 id(也就是 token)、用户信息以及密文,而服务端不保存任何状态,只有一段加密代码,用来判断之前加密的密文是否和客户端传递过来的密文一致,如果不一致,就是客户端的用户数据被篡改了,如果一致,就代表客户端的用户数据正常且正确。
4. cookie 禁用,session 还能用吗?
默认 SESSION 配置
在默认的JSP、PHP 配置中,SessionID 是需要存储在 Cookie 中的,默认 Cookie 名为:
- PHPSESSIONID
- JSESSIONID
以下以 PHP 为例:
- 你第一次访问网站时,
- 服务端脚本中开启了 Session
session_start();
, - 服务器会生成一个不重复的 SESSIONID 的文件
session_id();
,比如在/var/lib/php/session
目录 - 并将返回 (Response) 如下的 HTTP 头
Set-Cookie:PHPSESSIONID=xxxxxxx
- 客户端接收到
Set-Cookie
的头,将PHPSESSIONID
写入 cookie - 当你第二次访问页面时,所有 Cookie 会附带的请求头 (Request) 发送给服务器端
- 服务器识别
PHPSESSIONID
这个 cookie,然后去 session 目录查找对应 session 文件, - 找到这个 session 文件后,检查是否过期,如果没有过期,去读取 Session 文件中的配置;如果已经过期,清空其中的配置
如果客户端禁用了 Cookie,那PHPSESSIONID
都无法写入客户端,Session 还能用?
答案显而易见:不能
并且服务端因为没有得到
PHPSESSIONID
的 cookie,会不停的生成session_id
文件
取巧传递 session_id
但是这难不倒服务端程序,聪明的程序员想到,如果一个 Cookie 都没接收到,基本上可以预判客户端禁用了 Cookie,那将 session_id 附带在每个网址后面 (包括 POST),
比如:
1 | GET http://www.xx.com/index.php?session_id=xxxxx |
然后在每个页面的开头使用session_id($_GET['session_id'])
,来强制指定当前session_id
这样,答案就变成了:能
聪明的你肯定想到,那将这个网站发送给别人,那么他将会以你的身份登录并做所有的事情
(目前很多订阅公众号就将 openid 附带在网址后面,这是同样的漏洞)。其实不仅仅如此,cookie 也可以被盗用,比如 XSS 注入,通过 XSS 漏洞获取大量的 Cookie,也就是控制了大量的用户,腾讯有专门的 XSS 漏洞扫描机制,因为大量的 QQ 盗用,发广告就是因为 XSS 漏洞
所以 Laravel 等框架中,内部实现了 Session 的所有逻辑,并将
PHPSESSIONID
设置为httponly
并加密,这样,前端 JS 就无法读取和修改这些敏感信息,降低了被盗用的风险。
Cookie 在现代
禁用 Cookie 是 IE6 那个年代的事情,现在的网站都非常的依赖 Cookie,禁用 Cookie 会造成大量的麻烦。
在 Flash 还流行的年代,Flash 在提交数据会经常出现用户无法找到的情况,其实是因为 Flash 在 IE 下是独立的程序,无法得到 IE 下的 Cookie。
所以在 Flash 的flash_var
中,一般都会指定当前的session_id
,让 Flash 提交数据的时候,将这个session_id
附带着提交过去
Chrome 中使用 Flash 沙箱 已经解决了 cookie 的问题,但是为了兼容 IE,比如swfupload
等 flash 程序都要求开发者附带一个session_id
(四)localStorage & sessionStorage
前端本地存储的方式除了 cookie,还有 localstorage 和 sessionStorage。
1. 使用方法
localStorage 和 sessionStorage 所使用的方法是一样的,下面以 sessionStorage 为例。
1 | var name = 'sessionData'; |
2. 区别
生命周期
cookie:可设置失效时间,没有设置的话,默认是关闭浏览器后失效;
localStorage:除非被手动清除,否则将会永久保存;
sessionStorage:仅在当前网页会话下有效,关闭页面或浏览器后就会被清除。
存放数据大小
cookie:4KB 左右
localStorage 和 sessionStorage:可以保存 5MB 的信息
http 请求
cookie:每次都会携带在 HTTP 头中,如果使用 cookie 保存过多数据会带来性能问题
localStorage 和 sessionStorage:仅在客户端(即浏览器)中保存,不参与和服务器的通信
易用性
cookie:需要程序员自己封装,源生的 Cookie 接口不友好
localStorage 和 sessionStorage:源生接口可以接受,亦可再次封装来对 Object 和 Array 有更好的支持
应用场景
从安全性来说,因为每次 http 请求都会携带 cookie 信息,这样无形中浪费了带宽,所以 cookie 应该尽可能少地使用,另外 cookie 还需要指定作用域,不可以跨域调用,限制比较多。但是从识别用户登录来说,cookie 还是比 storage 更好用。其他情况下,建议使用 storage。
localStorage 和 sessionStorage 唯一的差别一个是永久保存在浏览器里面,一个是关闭网页就清除了信息。localStorage 可以用来跨页面传递参数,sessionStorage 用来保存一些临时数据,防止用户刷新页面后丢失了一些参数。
浏览器支持情况
localStorage 和 sessionStorage 是 html5 才应用的新特性,可能有些浏览器并不支持,这里要注意。
cookie 的浏览器支持没有找到,可以通过下面这段代码来判断所使用的浏览器是否支持 cookie:
1 | if(navigator.cookieEnabled) { |