HTTP 协议”无状态”的特性使得 Web 应用程序的服务端无法确定请求是来自哪个客户端, 也就是说, 对于服务端而言, 每个收到的请求都似乎来自一个”新的”客户端. 为了让服务端别那么健忘, 能认识”老朋友”, 能知道某个请求是来自曾经的那个他, 从而提供”定制化”的服务, 我们不得不有所作为. 另外”单点登录”是什么鬼? 也来聊一聊~

1. Web 应用程序的鉴权

对于”登录操作”相信大家一定不陌生. 是的, 就是你想的那样, 输入用户名、密码, 然后点登录按钮.

那么, 你是否想过, 为什么需要”登录”?

小机灵鬼们肯定会说, 如果不登录, 网站怎么会知道我是谁? 万一哪个”坏人”进到我的淘宝账户里, 悄摸声的就帮我把购物车清空了, 那该怎么办呀? 好纠结! 要不要以身相许呀…

无论你在上面的小故事里读到了什么, 作为一名技术宅, 看到的却是”网站怎么会知道我是谁?”

是的, 网站为了知道你是谁, 所以需要登录, 进行身份认证, 以确认你是它的合法用户, 另外也才知道哪个购物车是你的, 该给你呈现哪些内容, 该允许你做些什么操作?

那么, “登录”功能怎么实现呢? 一般常见的做法就是使用 用户名 + 密码 的方式进行身份认证.

小机灵鬼们肯定想像得到: 注册的时候记下登录信息, 然后在用户下次来的时候让他再次输入登录信息, 比对一致就认为你是好人, 放行! 当然, 你要玩得高级些, 扫脸, 扫指纹也是可以的, 原理差不多.

你以为这就完了吗? 嘿嘿, 关键的来了…

因为 Web 应用程序传输数据的 HTTP 协议是一个”无状态”协议, 简单说, 就是原始的 HTTP 协议数据包中并不包含”状态”信息, 所以 Web 服务端无法从 HTTP 数据包中得知此数据包是来自谁?

呵呵, 似乎还是说得不够简单, 通俗讲, 就是如果开发人员不做更多的技术处理, 那么即使你已经登录过了, 当你向服务端发送请求时( 例如: 查询购物车商品列表 ), 服务端仍然不知道你是谁, 还会让你重新登录, 然后你重新登录后美滋滋地再次发送请求时, 服务端又把你忘了…

嘿嘿, 这回你知道问题的严重性了吧! 这可如何是好… 来吧, 下面我们谈谈该怎么办?

其实, 上面的问题解决起来原理也不复杂, 可以由服务端给你分配一个令牌(token), 并在回应中将 token 带回给你, 以后每次发送请求时你都将此 token 带上, 服务端收到请求后验证 token 就能确定你就是他认识的人. 这个过程跟特务对暗号是一样一样滴~

你以为这就完了吗? 呵呵, 并没有~

1.1 基于 Session 的鉴权

在各种 Web 应用程序开发技术中, 通常都实现了 Session 机制, 大致的工作步骤如下:

  • 用户登录时, 服务端程序验证从客户端发来的用户登录信息正确后, 在服务端内存中分配一个称作 Session 的存储空间, 将用户 ID 之类的用户信息存储到 Session 中, 并以 SessionId (一串唯一的字符串) 标识此 Session. 在服务端向客户端返回的回应数据中将 SessionId 带回, 由客户端(浏览器)自动保存.

  • 用户此后每次发送请求时( 如: 查询购物车数据 ) 浏览器自动带上 SessionId, 服务端程序根据 SessionId 读出 Session 中的用户信息, 然后进行后续操作 ( 如: 根据用户 ID 查询属于他的购物车数据返回客户端 )

  • 如果客户端在 Session 超时时间内都没有向服务端发送任何请求, 服务端则认为此用户已经离开, 从而令对应的 Session 失效, 连同与此 Session 关联的用户信息一起清除. 当然, 也可通过服务端代码主动结束会话, 令 Session 失效. 一旦 Session 失效, 用户的登录状态自然也就失效了, 用户即会被要求重新登录. 在实际的应用场景中, 第 1 种情形通常被称作”登录超时”, 而第 2 种情形即是用户点击”退出”按钮, 主动退出系统.

Session 通常有 2 层意思:

(1) 会话, 即从 “会话开始” 直至 “会话结束” (超时/主动结束会话) 的整个期间.

(2) 服务端为各个客户端独立分配的存储空间, 使用 SessionId 标识.

配个图说明一下吧~

Session鉴权原理图

这种基于 Session 的鉴权做法存在如下弊端:

  • 用户信息保存于服务端内存 ( Session ) 中, 出于服务器端存储压力的考量, 不能在 Session 中存储过多信息, 否则服务器端内存开销会很大. 通常只会在 Session 中存储点用户 ID 之类的简要信息, 如果需要完整的用户信息, 再根据 ID 查询数据库获得(增加数据库负载). 当然, 如果你的网站生意特别好, 那即使只在内存中保存用户 ID 也一样会耗尽内存, 呵呵~

  • 如果业务量较大, 通常需要将服务端程序部署到集群系统中 ( 即: 将服务器端程序同时运行于多台服务器) , 并做负载均衡. 那么问题来了, 如果用户登录时联系的是服务器 A , 服务端验名正身后倒是放他进去了, 而此后他别的业务请求却被负载均衡分配到别的服务器上, 那别的服务器上运行的程序又会认为他没登录, 从而要求其登录.

    有时为了提升系统稳定性, 又只有得起一台服务器的情况下, 也可能会在同一台服务器上启动多个服务端程序进程, 进行”单机”集群部署.

    基于 Session 鉴权的 Web 应用程序如果进行集群部署, 无论是单机还是多机, 都需要进行 Session 广播之类的操作, 简单说就是无论用户在哪一个服务进程登录, 所有服务进程均应知晓他的登录状态.

  • Session 是基于 cookie 来存储 SessionId 的, 如果 cookie 被截获, 用户很容易受到跨站请求伪造的攻击 ( CSRF )

  • 如果要实现多个系统的”单点登录”, Session 无法在多个不同的系统间直接共享信息.

    例如: 学校里有很多信息系统, 教务系统, 图书馆系统, 财务系统…. 想要实现用户在任何一个系统登录后, 无论他跳转到哪个别的系统都无需再登录, 这就叫”单点登录”.

    现在一个单位有 N 多个业务系统已不是什么新鲜事, 客户爸爸自然希望各个业务系统之间可以”丝滑切换”, 甚至不同单位之间的各个业务系统之间也需要”无缝跳转”, 单点登录似乎已经逐渐成为”刚需”.

看了上面这一堆吐槽, 是否对 Session 机制大失所望? 呵呵, 其实对于一般的小项目, 又没有诸如群集部署、单点登录之类的需求, Session 机制还是蛮好用的. 所以各种服务器端技术都支持这种机制, 很多项目中也在使用这种机制.

只是… 本文的另一目的是向大家介绍另一种鉴权机制. 所以, 继续往下看吧~

1.2 基于 Token 的鉴权

本质上来说, 基于 Session 的机制也同样用到了 token ( SessionId ), 但它只是把 token 当作一个标识来使用而已.

本节想介绍的是把所需的用户信息直接编码于 token 中, 在服务端和客户端之间进行传递的做法.

是的, 就是将用户信息直接编码于 token 中, 而不是存储于服务器内存中. 这样的话, 首先可降低服务端内存开销, 此外, 因为用户信息都在 token 中, 所以面对群集部署 或 多系统单点登录需求时都可轻松应对.

当然, 需要能鉴别 token 的有效性(是否是经服务端签署的 token, 且未被篡改). 同时, token 还应有时效性(有效期), 否则万一坏人截获了我们的 token, 一直悄悄拿着我们的 token 冒充我们干坏事就不好了….

配个图, 先感性认识一下…

Token鉴权原理图

2. JWT

Json web token ( JWT ) 是一种基于 JSON 的开放标准 ( RFC 7519 ), 它定义了一种紧凑的、自包含的方式在各方之间传递信息. 这些信息可被校验和信任, 因为它是经数字签名的. 可以看看Introduction to JSON Web Tokens

JWT 可用于 1.2 节中所述的基于 token 的鉴权.

JWT 由 Header, Payload 和 Signature 三个部分组成

  • Header - 其通常包含签名算法的名称和 Token 的类型
  • Payload - Token 的荷载, 即 JWT 中包含的程序员自定义的业务数据
  • Signature - 将 header 和 playload 内容使用 header 中指定的签名算法和给定密钥进行签名后的产物.

下图展示了 JWT 的各个组成. ( 左侧五颜六色的那个字符串即为 JWT, 右侧是解码后的内容 )

JWT

呵呵, 有点复杂哈~

简单说, JWT 有如下特点:

  • 可以直接将用户信息这样的业务数据打包到 JWT 的 payload 部分, 在各方之间传递
  • JWT 带有数字签名, 可以被校验是否被篡改. 但注意 JWT 中的荷载信息仅是转换为 Base64Url 编码,并非加密。

因为 JWT 直接将业务数据打包带着, 安全、可靠地在各方之间跑来跑去, 所以服务端不像 Session 机制哪样可能因存储过多的业务数据而带来额外的内存开销, 同时, 无论群集部署或是单点登录, 服务端程序之间无需额外的协商.

2.1 JWT 实战

下面我们以 Node.js 环境为例来说说 JWT 怎么玩…

如果还不知道怎么基于 Node.js 环境开发 Web 应用程序, 可以看看这个: 使用Node.js开发Web应用程序

2.1.1 起步

老规矩, 我们从零开始 ~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 新建文件夹 jwt-example, 并进入
mkdir jwt-example && cd jw-example

# 初始化 Node.js 项目
npm init -y

# 安装 express 和 jsonwoetoken
npm i express jsonwebtoken -S

# 根目录下新建几个文件: server.js, authServer.js, secret.js
touch server.js
touch authServer.js
touch secret.js

# 新建文件夹 public, 并在其中放两个文件: index.html, client.js
mkdir public
touch public/index.html
touch public/client.js

# VSCode打开项目
code .

以上为 linux 命令, 但即使你使用 windows, 只要最终的文件结构如下图即可

目录结构

先简单解释一下各文件的用途:

  • server.js : Web 服务端程序, 处理具体的业务逻辑
  • authServer.js : 授权服务端程序, 处理登入/登出等鉴权逻辑. 它与处理具体业务的 web 服务端程序分离, 单独运行.
  • secret.js : 存储密钥的文件
  • public 文件夹中为前端页面和它的 js 文件

接下来, 编辑上述各个文件…

/secret.js

1
2
3
4
5
6
module.exports = {
// 用于签名accessToken的密钥
// 以下是使用 require('crypto').randomBytes(16).toString('hex') 随机生成的16位的密钥
// 你也可以随便乱编一个
ACCESS_TOKEN_SECRET : '1ddaeea6a35a191e19fcdd15a93ef2cf'
}

/server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const express = require('express')
const app = express()

// 引入第3方库(jsonwebtoken)
const jwt = require('jsonwebtoken')

// 引入密钥文件
const secret = require('./secret.js')

// 使用 json 中间件处理前端上行的 json 数据
app.use(express.json())

// 指定 public 文件夹为前端静态资源
app.use(express.static('public'))

// 这里注册了一个用于鉴权的中间件
// 对前端发来的所有请求都会经此中间件处理
app.use(function(req, res, next) {
// 从请求头(headers)中获取授权信息(格式为 'Bearer token' 的字符串), 参见后文/public/client.js
const authHeader = req.headers['authorization']
// 取得授权信息中的token部分
const token = authHeader && authHeader.split(' ')[1]

// 若无法获得token直接返回401
if (!token) return res.sendStatus(401)

// 检验token的有效性
// 若token无效则返回403, 否则将从token中取得的用户信息放入request以供后续使用
// 此处使用了secret.js中存储的 ACCESS_TOKEN_SECRET
jwt.verify(token, secret.ACCESS_TOKEN_SECRET, (err, payload) => {
if (err) return res.sendStatus(403)
req.user = payload.user
next()
})
})

// 监听前端 /getMessage 请求
app.post('/getMessage', (req, res) => {
// req.user为token中解码得到的用户信息, 参见第32行
res.end(`Hi, ${ req.user.name }, ${ new Date().toTimeString() }`)
})

// 开始监听 3000 端口
app.listen(3000, () => {
console.log('Server start on port 3000')
})

上述代码 ( server.js ) 模拟了具体业务系统的逻辑:

第 38 ~ 41 行注册的路由用于监听前端获取信息的请求(getMessage), 若客户端已登录, 持有有效的 token 则反馈 ‘Hi, xxx, 当前服务器时间’

第 18 ~ 35 行注册的”中间件”校验前端带来的 accessToken ( JWT ) 是否有效, 若有效则解码出用户信息供后续业务使用 ( 第 40 行 ).

第 18 ~ 35 行自定义的中间件类似 JavaEE 中的过滤器(filter), 将对前端发来的所有请求进行预处理后再转交其它路由处理.

来吧, 接下来是前端代码…

/public/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JWT</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script defer src="./client.js"></script>
</head>
<body>
<textarea id="output-area" rows="10" cols="80"></textarea>
<div>
<button id="btn-get-message">Get Message</button>
<button id="btn-login">Login</button>
<button id="btn-refresh-token">Refresh Token</button>
<button id="btn-logout">Logout</button>
</div>
</body>
</html>

页面中放置了一个文本框 ( output-area ) 用于显示从服务器端获取的消息, 4 个按钮分别用于触发获取消息、登录、刷新 Token 和 登出 代码很简单, 自己脑补一下画面, 就不上图了.

这里直接引用了 axios 的在线版本, 以实现客户端和服务端之间的 Ajax 通信.

注意第 9 行的 defer

4 个按钮是怎么玩? 这里先不急, 看完本文你就明白了…

接下来, 我们先编辑 client.js 文件, 加入点击”Get Message”按钮应触发的逻辑…

/public/client.js

1
2
3
4
5
6
7
8
9
10
11
12
// 获取服务端信息
document.getElementById('btn-get-message').onclick = async function () {
try {
// 调用服务端 /getMessage 接口
const resp = await axios.post('/getMessage')
// 若成功取得消息, 则呈现在文本区(output-area)中
document.getElementById('output-area').value = resp.data
} catch (e) {
// 若有异常, 则直接弹出异常信息
alert(e.message)
}
}

好了, 终于可以运行测试一下了…

node server.js 启动程序, 然后浏览器打开 http://localhost:3000

直接点击页面中的”Get Message”按钮, 获取服务端消息…

呵呵, 是不是弹出了消息框, 显示 “Request failed with status code 401”… 这就对啦~

不难发现, 这是 server.js 代码中第 25 行返回的错误码. 因为我们 client.js 代码中第 5 行发送请求时未携带任何授权信息.

修改 client.js (第 5 行) 如下:

/public/client.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获取服务端信息
document.getElementById('btn-get-message').onclick = async function () {
try {
// 调用服务端 /getMessage 接口
// 注意这回在请求的header中携带了accessToken: 1234
// 按惯例使用Bearer说明Authorization携带的是"发件人"的信息
const resp = await axios.post('/getMessage', null, {
headers: { Authorization: 'Bearer 1234' }
})
// 若成功取得消息, 则呈现在文本区(output-area)中
document.getElementById('output-area').value = resp.data
} catch (e) {
alert(e.message)
}
}

刷新页面后, 再次点击”Get Message”按钮…

呵呵, 这回换了一个错误消息: Request failed with status code 403

这是 server.js 代码中第 31 行返回的错误码. 虽然客户端请求中携带了accessToken, 但服务端校验失败. 因为这个授权信息就是我们瞎编的嘛, 正确的打开方式应该是先登录…

接下来, 我们来实现 “登录” 功能…

首先, 编辑 authServer.js ,代码如下:

/authServer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const express = require('express')
const app = express()
const jwt = require('jsonwebtoken')
const secret = require('./secret.js')

app.use(express.json())

// 允许跨域请求
app.use((req, res, next) => {
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
})
next()
})

// 登录
app.post('/login', (req, res) => {
// 取得前端请求中上行的用户名信息
const username = req.body.username
// 组装用户信息并生成 accessToken 返回客户端
// 这里只是举例将用户信息存入 JWT 的 payload, 实际的应用场景中可任意扩展.
const user = { name: username }
const accessToken = generateAccessToken({ user })
res.json({ accessToken })
})

// 使用 ACCESS_TOKEN_SECRET 对用户信息进行数字签名, 返回JWT(AccessToken)
// 为方便测试, accessToken 10秒后失效. 实际场景根据需要设定. 类似session的超时时间
function generateAccessToken(payload) {
return jwt.sign(payload, secret.ACCESS_TOKEN_SECRET, { expiresIn: '10s' })
}

app.listen(9000, () => {
console.log('Auth Server start on port 9000')
})

除了上述代码的注释的内容外, 特别注意 3 个地方:

  • authServer.js 是另外一个独立于 server.js ( 具体业务 ) 的程序, 监听的是另外的端口(9000). 这模拟了集群部署或单点登录场景下使用一个独立的程序处理鉴权业务的情形.
  • 也正因为 authServer.js 是独立于 server.js 的程序, 所以必须允许跨域请求( 第 9 ~ 16 行 ), 否则从 server.js 管理的前端程序发来的请求将被阻止.
  • accessToken 应有一个”有效期”, 不能一直有效. 否则, 万一 accessToken 被坏人窃取, 他就可以一直假借合法用户的名义向服务端发送请求.

出于安全的考虑, 服务端程序默认都不允许跨域的 POST 请求. 对于本例而言, 简单说, 即 authServer.js 这个监听 http://localhost:9000 的服务端程序会阻止我们由 http://localhost:3000 这个域发来的 POST 请求.

如果还不清楚, 建议看下CORS

此外, 第 24 行处的代码只是举例将用户信息存入 JWT 的 payload, 实际的应用场景中可任意扩展.

修改 client.js 中的代码, 添加登录功能…

/public/client.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 简单起见, 此处使用变量存储token信息
// 实际项目中可存储于浏览器端本地存储, 如localStorage, sessionStorage, cookie
let token = {}

// 获取服务端信息
document.getElementById('btn-get-message').onclick = async function () {
try {
// 这里将token放入header带到服务端
// 按惯例授权信息放入header的Authorization字段, 并用Bearer标识后面的token携带的是发送人的信息
const resp = await axios.post('/getMessage', null, {
headers: { Authorization: 'Bearer ' + token.accessToken }
})
document.getElementById('output-area').value = resp.data
} catch (e) {
alert(e.message)
}
}

// 登录
document.getElementById('btn-login').onclick = async function () {
try {
// 将用户名'zhangsan'作为登录信息上行至服务端进行登录
// 简单起见, 本例未做用户登录界面, 使用第26行的数据模拟登录信息
// 注意这里的post请求是发送向鉴权服务(authServer的9000端口)
const resp = await axios.post('http://localhost:9000/login', {
username: 'zhangsan', password: '1234'
})
// 暂存服务器端返回的token, 以供后续业务使用(第11行)
token = resp.data
alert('登录成功, 现在获取信息试试...')
} catch (e) {
alert(e)
}
}

好了, 再打开一个控制台窗口, node authServer.js 启动鉴权服务.

回到浏览器, 再次刷新页面…

  • 直接点击”Get Message”按钮, 应会提示错误消息: Request failed with status code 403.

  • 点击”Login”按钮后, 再点击”Get Message”按钮, 这回应该可以正常获得服务端返回的消息, 并显示于文本框中.

  • 登录 10 秒后, 登录超时, 需要重新”Login”

故事讲到这里, 基本已经可以结束了, 我们其实已经实现了基于 JWT 的鉴权机制. 但你就不好奇页面上的 “Refresh Token” 和 “Logout” 按钮是怎么玩的吗? 我们似乎还真没实现”退出登录”的功能. 呵呵~

继续往下看之前, 建议先停一会, 整理思路, 想明白我们上面的代码是如何通过 JWT 实现鉴权的?

2.1.2 进阶

前面我们使用 JWT 实现了鉴权, 我们得让 accessToken 在一定时间内失效( 登录超时 ), 以免坏人可一直使用窃取的 accessToken 发请求.

实际应用场景中, 为了更安全, accessToken 超时时间可以设置短一些. 但总提示用户”登录超时”, 让重新登录, 人家也烦呀!

我们可以在 accessToken 失效后, 设计一个机制可以刷新 accessToken, 以向客户端提供一个可用的 accessToken, 免得用户老是被要求重新登录.

因此我们再引入一个 refreshToken, 用于刷新 accessToken…

首先, 在 secret.js 中添加一个用于签名 refreshToken 的密钥, 如下( 第 5 行 )

/secret.js

1
2
3
4
5
6
module.exports = {
// 用于签名accessToken的密钥
ACCESS_TOKEN_SECRET : '1ddaeea6a35a191e19fcdd15a93ef2cf',
// 用于签名refreshToken的密钥
REFRESH_TOKEN_SECRET : 'c3537a544c4b2e49cd5a13b1b8410845'
}

然后, 修改 authServer.js 如下:

/authServer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const express = require('express')
const app = express()
const jwt = require('jsonwebtoken')
const secret = require('./secret.js')

app.use(express.json())

// 记录refreshToken
let refreshTokens = []

// 允许跨域请求
app.use((req, res, next) => {
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS'
})
next()
})

// 登录
app.post('/login', (req, res) => {
// 取得前端请求中上行的用户名信息
const username = req.body.username
// 组装用户信息并生成 accessToken 返回客户端
// 这里的 user 对象中存储了当前用户相关的信息, 可根据实际业务扩展.
const user = { name: username }
const accessToken = generateAccessToken({ user })

// 刷新accessToken使用的refreshToken
const refreshToken = jwt.sign({ user }, secret.REFRESH_TOKEN_SECRET)
refreshTokens.push(refreshToken)

res.json({ accessToken, refreshToken })
})

// 使用 ACCESS_TOKEN_SECRET 对用户信息进行签名, 返回JWT(AccessToken)
// accessToken 10秒后失效, 实际场景根据需要设定. 类似session的超时时间
function generateAccessToken(payload) {
return jwt.sign(payload, secret.ACCESS_TOKEN_SECRET, { expiresIn: '10s' })
}

// 刷新accessToken
app.post('/token', function (req, res) {
const refreshToken = req.body.token
if (!refreshToken) return res.sendStatus(401)
if (!refreshTokens.includes(refreshToken)) return res.sendStatus(403)
jwt.verify(refreshToken, secret.REFRESH_TOKEN_SECRET, (err, payload) => {
if (err) return res.sendStatus(403)
const accessToken = generateAccessToken({ user: payload.user })
res.json({ accessToken })
})
})

// 退出登录
app.delete('/logout', function (req, res) {
refreshTokens = refreshTokens.filter(token => token !== req.body.token)
res.sendStatus(204)
})

app.listen(9000, () => {
console.log('Auth Server start on port 9000')
})
  • 第 31 ~ 34 行, 在用户登录时生成refreshToken并记录下来, 同时在 34 行将 refreshToken 一并带回给客户端

  • 第 44 ~ 53 行, 增加了一个刷新 accessToken 的路由接口/token.

    其中的代码应该很容易看明白: 从前端请求中取得 refreshToken, 验证有效后重新签名一个新的 accessToken 回送给前端

  • 第 56 ~ 59 行, 附赠了一个”退出登录”的接口, 从 refreshTokens 数组中移除指定的 refreshToken, 以使此 refreshToken失效 ( 参看第 46 行 )

OK, 对应修改一下前端代码, 以实现刷新 accessToken 和 退出登录 功能…

/public/client.js ( 在原有代码的基础上增加如下代码 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 刷新accessToken
document.getElementById('btn-refresh-token').onclick = async function () {
try {
const resp = await axios.post('http://localhost:9000/token', {
token: token.refreshToken
})
token.accessToken = resp.data.accessToken
alert('AccessToken已刷新')
} catch (e) {
alert(e)
}
}

// 退出登录
document.getElementById('btn-logout').onclick = async function () {
try {
await axios.delete('http://localhost:9000/logout', {
data: { token: token.refreshToken }
})
alert('已退出登录')
} catch (e) {
alert(e)
}
}

好了, 现在来完整的跑一遍吧~

重新启动服务端的两个程序server.jsauthServer.js, 刷新浏览器中的页面, 你可以看到:

  • 必须先”Login”才可以”Get Message”
  • 登录后 10 秒内可以无限次 Get Message, 但 10 秒后 accessToken 失效, 无法再 Get Message
  • accessToken 失效后点击”Refresh Token”可重新获得有效的 accessToken, 从而又可以正常 Get Message 了. 实际项目中, 可由前端程序在发现 accessToken 失效后自动触发 refresh, 然后再次发送请求
  • 点击”Logout”后 refreshToken 也随之失效, 必须再次”Login”…

2.1.3 后记

细心的小伙伴可能已经发现, 即使你点击了”Logout”, 但如果立马”Get Message”, 只要 accessToken 还未失效, 仍然可以正常获取服务端的消息. ( 有 BUG ! )

道理很简单, 退出登录时我们并没有在服务端主动让 accessToken 失效, JWT 的机制也做不到. 我们只是从 refreshTokens 数组中清除了”有效的” refreshToken, 以避免有坏人窃取到 refreshToken 后无限地刷新 accessToken, 为所欲为. 但事实上当前有效的 accessToken 还仍未失效.

这可咋办呢?

把 accessToken 的有效期设短些, 提高安全性. 再配合 refreshToken, 基本上可以做到让用户无感.

嘿嘿, 前面不就是这么做的吗? 这不还是没解决什么问题吗? 是的, 本质上 accessToken 失效前, 用户即使”退出登录”了, 其实还是不安全, 但这所谓的不安全时间不是很短吗? ( 无耻地笑了~ )

如果还是不爽, 心里有个结, 那就这样…

搞一个黑名单数组( 如: invalidAccessTokens ), 在退出登录时将当前退出的用户的 accessToken 放进黑名单, 服务端鉴权时如果发现前端带来的 accessToken 在黑名单中, 直接阻止 ( 在 server.js 第 25 行代码后面加入黑名单判定 ) . 有必要的话, 再定期清一清 invalidAccessTokens 数组中的 token ( 发现已经过期则从数组中放心地清除 )

不要试图用任何方法在服务端记录有效的 accessToken, 这完全违背了 JWT 设计的初衷, 这不又回到 Session 的老路上了吗?

最后总结一句, 基于 token 的鉴权机制摒弃了传统 session 机制会加剧服务端内存开销的缺点, 更重要的是它能更好地适应分式应用和单点登录的需求. JWT 可以很好地帮我们实现基于 token 的鉴权机制, 是个好东西!

最后附上完整项目的代码, jwt-example.zip . 收到后想要运行先执行npm i安装依赖.