JWT入门与实战

前面的话

实现用户登录认证的方式常见的有两种:一种是基于 cookie 的认证,另外一种是基于 token 的认证 。本文以基于cookie的认证为参照,详细介绍JWT标准,并实现基于该标签的用户认证接口

cookie认证

传统的基于 cookie 的认证方式基本有下面几个步骤:

  1、用户输入用户名和密码,发送给服务器

  2、服务器验证用户名和密码,正确的话就创建一个会话( session ),同时会把这个会话的 ID 保存到客户端浏览器中,因为保存的地方是浏览器的 cookie ,所以这种认证方式叫做基于 cookie 的认证方式

  3、后续的请求中,浏览器会发送会话 ID 到服务器,服务器上如果能找到对应 ID 的会话,那么服务器就会返回需要的数据给浏览器

  4、当用户退出登录,会话会同时在客户端和服务器端被销毁

这种认证方式的不足之处有两点

  1、服务器端要为每个用户保留 session信息,连接用户多了,服务器内存压力巨大

  2、适合单一域名,不适合第三方请求cookie认证的后端典型代码如下所示:

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
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: false }));

const session = require('express-session')
const pug = require('pug');

app.set('view engine', 'pug');

app.use(session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: true
}))


app.get('/', function(req, res){
let currentUser = req.session.username;
res.render('index', {currentUser});
})

app.get('/login', function(req, res){
res.sendFile('login.html', {root: 'public'});
})

app.post('/login', function(req, res){
let username = req.body.username;
req.session.username = username;
res.redirect('/');
})

app.get('/logout', function(req, res){
req.session.destroy();
res.redirect('/');
})

app.listen(3006, function(){
console.log('running on port 3006...');
})

token认证

下面来介绍token认证。详细认证过程如下

  1、用户输入用户名密码,发送给服务器

  2、服务器验证用户名和密码,正确的话就返回一个签名过的 token( token 可以认为就是个长长的字符串),客户端浏览器拿到这个 token

  3、后续每次请求中,浏览器会把 token 作为 http header 发送给服务器,服务器可以验证一下签名是否有效,如果有效那么认证就成功了,可以返回客户端需要的数据

  4、一旦用户退出登录,只需要客户端销毁一下 token 即可,服务器端不需要有任何操作

  这种方式的特点就是客户端的 token 中自己保留有大量信息,服务器没有存储这些信息,而只负责验证,不必进行数据库查询,执行效率大大提高.   

对比

  • 传统的 session 流程
    • 浏览器发起请求登陆
    • 服务端验证身份,生成身份验证信息,存储在服务端,并且告诉浏览器写入 Cookie
    • 浏览器发起请求获取用户资料,此时 Cookie 内容也跟随这发送到服务器
    • 服务器发现 Cookie 中有身份信息,验明正身
    • 服务器返回该用户的用户资料
  • JWT 流程

    • 浏览器发起请求登陆
    • 服务端验证身份,根据算法,将用户标识符打包生成 token, 并且返回给浏览器
    • 浏览器发起请求获取用户资料,把刚刚拿到的 token 一起发送给服务器
    • 服务器发现数据中有 token,验明正身
    • 服务器返回该用户的用户资料
  • session 存储在服务端占用服务器资源,而 JWT 存储在客户端

  • session 存储在 Cookie 中,存在伪造跨站请求伪造攻击的风险
  • session 只存在一台服务器上,那么下次请求就必须请求这台服务器,不利于分布式应用
  • 存储在客户端的 JWT 比存储在服务端的 session 更具有扩展性

如果加强 JWT 的安全性?

  • 缩短 token 有效时间
  • 使用安全系数高的加密算法
  • token 不要放在 Cookie 中,有 CSRF 风险
  • 使用 HTTPS 加密协议
  • 对标准字段 iss、sub、aud、nbf、exp 进行校验
  • 使用成熟的开源库,不要手贱造轮子
  • 特殊场景下可以把用户的 UA、IP 放进 payload 进行校验(不推荐)

JWT 简述

JWT(json web token)是为了在网络应用环境之间传递声明而基于 json 的开放标准,JWT 的声明一般被采用在身份提供者和服务器提供者间传递被认证的身份信息,以便于从资源服务器获取资源。

JWT 的应用场景

JWT 一般用于用户登录上,身份认证在这种场景下,一旦用户登录完成,在接下来的每个涉及用户权限的请求中都包含 JWT,可以对用户身份、路由、服务和资源的访问权限进行验证。

举一个例子,假如一个电商网站,在用户登录以后,需要验证用户的地方其实有很多,比如购物车,订单页,个人中心等等,访问这些页面正常的逻辑是先验证用户权限和登录状态,如果验证通过,则进入访问的页面,否则重定向到登录页。

而在 JWT 之前,这样的验证我们大多都是通过 cookie 和 session 去实现的,我们接下来就来对比以下这两种方式的不同。

JWT 特点

  • 体积小,因而传输速度快
  • 传输方式多样,可以通过URL/POST参数/HTTP头部等方式传输
  • 严格的结构化。它自身(在 payload 中)就包含了所有与用户相关的验证消息,如用户可访问路由、访问有效期等信息,服务器无需再去连接数据库验证信息的有效性,并且 payload 支持为你的应用而定制化。
  • 支持跨域验证,可以应用于单点登录。

由于浏览器的请求是无状态的,cookie 的存在就是为了带给服务器一些状态信息,服务器在接收到请求时会对其进行验证(其实是在登录时,服务器发给浏览器的),如果验证通过则正常返回结果,如果验证不通过则重定向到登录页,而服务器是根据 session 中存储的结果和收到的信息进行对比决定是否验证通过,当然这里只是简述过程。

从上面可以看出服务器种植 cookie 后每次请求都会带上 cookie,浪费带宽,而且 cookie 不支持跨域,不方便与其他的系统之间进行跨域访问,而服务器会用 session 来存储这些用户验证的信息,这样浪费了服务器的内存,当多个服务器想要共享 session 需要都拷贝过去。

JWT 的过程:

当用户发送请求,将用户信息带给服务器的时候,服务器不再像过去一样存储在 session 中,而是将浏览器发来的内容通过内部的密钥加上这些信息,使用 sha256 和 RSA 等加密算法生成一个 token 令牌和用户信息一起返回给浏览器,当涉及验证用户的所有请求只需要将这个 token 和用户信息发送给服务器,而服务器将用户信息和自己的密钥通过既定好的算法进行签名,然后将发来的签名和生成的签名比较,严格相等则说明用户信息没被篡改和伪造,验证通过。

JWT 的过程中,服务器不再需要额外的内存存储用户信息,和多个服务器之间只需要共享密钥就可以让多个服务器都有验证能力,同时也解决了 cookie 不能跨域的问题。

JWT 的结构 https://jwt.io/


JWT 之所以能被作为一种声明传递的标准是因为它有自己的结构,并不是随便的发个 token 就可以的,JWT 用于生成 token 的结构有三个部分,使用 . 隔开。

1、Header [头部]

Header 头部中主要包含两部分,token 类型和加密算法,如

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

jwt的头部包含两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分。
HS256 就是指 sha256 算法,会将这个对象转成 base64。

2、Payload(载荷)

由于这里用的是可逆的base64 编码,所以第二部分的数据实际上是明文的。我们应该避免在这里存放不能公开的隐私信息。

Payload 负载就是存放有效信息的地方,有效信息被分为标准中注册的声明、公共的声明和私有的声明.

载荷就是存放有效信息的地方。这些有效信息包含三个部分:

标准中注册声明

  • 公共的声名
  • 私有的声明
  • 公共的声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密。
  • 私有的声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

下面是一个例子

1
2
3
4
5
6
{
"username": "libin",
"id": "5c170dfb5966f9060d290fe8",
"iat": 1545014835,
"exp": 1545101235
}

(1) 标准中注册的声明
下面是标准中注册的声明,建议但不强制使用。

1
2
3
4
5
6
7
8
9
10
11
12
// 包括需要传递的用户信息

{
"iss": "Online JWT TEST",
"iat": 1416797419,
"exp": 1448333419,
"aud": "www.jwt.io",
"sub": "uid",
"nickname": "jwttest",
"username": "jwttest",
"scopes": [ "admin", "user" ]
}

  • iss:jwt 签发者;
  • sub:jwt 所面向的用户;
  • aud:接收 jwt 的一方;
  • exp:jwt 的过期时间,这个过期时间必须要大于签发时间,这是一个秒数;
  • nbf:定义在什么时间之前,该 jwt 都是不可用的;
  • iat:jwt 的签发时间。
    上面的标准中注册的声明中常用的有 exp 和 nbf。

其他还有:

  • nbf (Not Before):如果当前时间在nbf里的时间之前,则Token不被接受;一般都会留一些余地,比如几分钟;,是否使用是可选的;
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

(2) 公共声明

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密,如 {“id”, username: “panda”, adress: “Beijing”},会将这个对象转成 base64。

(3) 私有声明

私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64 是对称解密的,意味着该部分信息可以归类为明文信息。

3、Signature [签名]

1
2
3
4
5
6
7
8
// 根据alg算法与私有秘钥进行加密得到的签名字串;
// 这一段是最重要的敏感信息,只能在服务端解密;

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret

Signature 这一部分指将 Header 和 Payload 通过密钥 secret 和加盐算法进行加密后生成的签名,secret,密钥保存在服务端,不会发送给任何人,所以 JWT 的传输方式是很安全的。

最后将三部分使用 . 连接成字符串,就是要返回给浏览器的 token 浏览器一般会将这个 token 存储在 localStorge 以备其他需要验证用户的请求使用。

将上面的编码后的字符串都用句号.连接在一起(头部在前),就形成了:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImxpYmluIiwiaWQiOiI1YzE3MGRmYjU5NjZmOTA2MGQyOTBmZTgiLCJpYXQiOjE1NDUwMTQ4MzUsImV4cCI6MTU0NTEwMTIzNX0.FDWkBfas2b-jvXWuzedlNlhZmT4vQBey1w9q8vu2B8Q

JWT 使用场景

JWT的主要优势在于使用无状态、可扩展的方式处理应用中的用户会话。服务端可以通过内嵌的声明信息,很容易地获取用户的会话信息,而不需要去访问用户或会话的数据库。在一个分布式的面向服务的框架中,这一点非常有用。

但是,如果系统中需要使用黑名单实现长期有效的token刷新机制,这种无状态的优势就不明显了。

优点:快速开发,json格式简单,不需要cookie JSON在移动端的广泛应用 不依赖于社交登录 相对简单的概念理解,同session相比,性能更好一些,省去了处理分布session的问题;

  • 支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
  • 无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
  • 更适用CDN: 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.
  • 去耦: 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可.
  • 更适用于移动应用: 当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
  • CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。- 性能: 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多.
  • 不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
  • 基于标准化:你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).

缺点:Token有长度限制 Token不能撤销 需要token有失效时间限制(exp)

经过上面对 JWT 的叙述可能还是没有完全的理解什么是 JWT,具体怎么操作的,我们接下来实现一个小的案例,为了方便,服务端使用 express 框架,数据库使用 mongo 来存储用户信息,前端使用 Vue 来实现,做一个登录页登录后进入订单页验证 token 的功能。

Vue+Koa2+JWT 完整代码

jsonwebtoken

jwt.sign(payload, secretOrPrivateKey, [options, callback])
  • (异步)如果提供回调,则使用err或JWT 调用回调。
  • (同步)将JsonWebToken返回为字符串。

payload必须是一个object, buffer或者string。请注意, exp只有当payload是object字面量时才可以设置。
secretOrPrivateKey 是包含HMAC算法的密钥或RSA和ECDSA的PEM编码私钥的string或buffer。

options:

1
2
3
4
5
6
7
8
9
algorithm:加密算法(默认值:HS256)
expiresIn:以秒表示或描述时间跨度zeit / ms的字符串。如60,"2 days","10h","7d",Expiration time,过期时间
notBefore:以秒表示或描述时间跨度zeit / ms的字符串。如:60,"2days","10h","7d"
audience:Audience,观众
issuer:Issuer,发行者
jwtid:JWT ID
subject:Subject,主题
noTimestamp
header

如果payload不是buffer或string,它将被强制转换为使用的字符串JSON.stringify()。
在expiresIn,notBefore,audience,subject,issuer没有默认值时。也可以直接在payload中用exp,nbf,aud,sub和iss分别表示,但是你不能在这两个地方同时设置。
请记住exp,nbf,iat是NumericDate类型。
生成的jwts通常会包含一个iat值除非指定了noTimestamp。如果iat插入payload中,则将使用它来代替实际的时间戳来计算其他事情,诸如options.expiresIn给定一个exp这样的时间间隔。

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
// sign with default (HMAC SHA256)
var jwt = require('jsonwebtoken');
var token = jwt.sign({ foo: 'bar' }, 'shhhhh');
//backdate a jwt 30 seconds
var older_token = jwt.sign({ foo: 'bar', iat: Math.floor(Date.now() / 1000) - 30 }, 'shhhhh');

// sign with RSA SHA256
var cert = fs.readFileSync('private.key'); // get private key
var token = jwt.sign({ foo: 'bar' }, cert, { algorithm: 'RS256'});

// sign asynchronously
jwt.sign({ foo: 'bar' }, cert, { algorithm: 'RS256' }, function(err, token) {
console.log(token);
});

//签署1小时期限的token:
jwt.sign({
exp: Math.floor(Date.now() / 1000) + (60 * 60),
data: 'foobar'
}, 'secret');

//使用此库生成令牌的另一种方法是:
jwt.sign({
data: 'foobar'
}, 'secret', { expiresIn: 60 * 60 });

//or even better:

jwt.sign({
data: 'foobar'
}, 'secret', { expiresIn: '1h' });

jwt.verify(token,secretOrPublicKey,[options,callback])

验证token的合法性

wt.decode(token [,options])

(同步)返回解码没有验证签名是否有效的payload。
警告:这不会验证签名是否有效。你应该不为不可信的消息使用此。你最有可能要使用jwt.verify()。

Server

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
const user = require('../models/UserModels')
const jwt = require('jsonwebtoken')
const config = require('../config/config')
const md5 = require('md5')
// 密码使用md5加密存储

//注册
let addUser = async (ctx, next) => {
let {username, password} = ctx.request.body
password = md5(password) // md5加密处理
await user.addUser(username,password) // 异步处理,因为ctx.body不支持异步回调
.then((data)=> {
ctx.body = {
data,
type: 1
}
})
.catch((data)=> {
ctx.body = {
data,
type: 0 // 有毛病 type字段我定于或者不定义 都是type:1 莫名其妙
}
})
}

//登录
let verifyUser = async (ctx, next) => {
let {username, password} = ctx.request.body
password = md5(password)

await user.verifyUser(username, password)
.then((data)=> {
let {text, id} = data
// 处理token
let token = jwt.sign({
username,
id
}, config.secretOrPublicKey, {
expiresIn: 60 * 60 * 24 // 24小时过期
})
ctx.body = {
text,
token,
type: 1
}
})
.catch((data) => {
ctx.body = {
data,
type: 0
}
})
}

// 验证
let verification = async (ctx, next) => {
let { token } = ctx.request.body
try {
await jwt.verify(token, config.secretOrPublicKey, (err, decoded)=> {
if (err) {
ctx.body = {
data: '登录信息失效',
type: 0
}
return
}
if (decoded) {
ctx.body = {
type: 1
}
}
})
} catch (error) {
ctx.body = {
data: '登录信息出错',
type: 0
}
}
}



module.exports = {
addUser,
verifyUser,
verification
}

生成Token

1
2
3
4
5
6
jwt.sign({
username,
id
}, config.secretOrPublicKey, {
expiresIn: 60 * 60 * 24 // 24小时过期
})

解析Token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
jwt.verify(token, config.secretOrPublicKey, (err, decoded)=> {
if (err) {
ctx.body = {
data: '登录信息失效',
type: 0
}
return
}
if (decoded) {
ctx.body = {
type: 1
}
}
})

Client

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
router.beforeEach((to, from, next) => {
let token = localStorage.getItem('token') // 获取token
if (to.name !== 'login' && to.name !== 'todoList') {
next()
}
if (to.name == 'login') { // 假如登录 判断token是不是存在 存在让他跳转到主页面
verification(token, next)
.then((data) => {
if (data.data.type) { // type 为1 直接跳过登录
Message({
showClose: true,
message: '欢迎回来'
});
next('/todolist')
} else {
next()
}
})
}

if (to.name == 'todoList') {
verification(token, next)
.then((data) => {
if (data.data.type) {
// type 为1说明token没有失效
// 跳转到主页面
next()
} else {
// token失效 强制定位到登录页面
if (token === null) { // 说明从来没有登陆过
Message({
showClose: true,
message: '您还没有登录',
type: 'warning'
})
next('/login')
} else {
Message.error('登录信息失效')
next('/login')
localStorage.removeItem('token')
}
}
})
.catch((data) => {
console.log(data);
})
}

if (to.meta.title) {
document.title = to.meta.title
}
})



// 验证
let verification = (token, next) => {
return axios.post('/api/verification', { token })
}

前端解析Token

1
2
3
4
5
6
7
8
9
10
11
import jwt from 'jsonwebtoken'
export default {
mounted() {
let token = localStorage.getItem('token')
let Payload = jwt.decode(token)
this.username = Payload.username // 解密用户名
this._id = Payload.id
this.getData() // 获取待完成时间
this.getfulfilData()
},
...