通过一个案例理解 JWT
阅读原文
JWT 简述
JWT(json web token)是为了在网络应用环境之间传递声明而基于 json
的开放标准,JWT 的声明一般被采用在身份提供者和服务器提供者间传递被认证的身份信息,以便于从资源服务器获取资源。
JWT 的应用场景
JWT 一般用于用户登录上,身份认证在这种场景下,一旦用户登录完成,在接下来的每个涉及用户权限的请求中都包含 JWT,可以对用户身份、路由、服务和资源的访问权限进行验证。
举一个例子,假如一个电商网站,在用户登录以后,需要验证用户的地方其实有很多,比如购物车,订单页,个人中心等等,访问这些页面正常的逻辑是先验证用户权限和登录状态,如果验证通过,则进入访问的页面,否则重定向到登录页。
而在 JWT 之前,这样的验证我们大多都是通过 cookie
和 session
去实现的,我们接下来就来对比以下这两种方式的不同。
JWT 对比 cookie/session
cookie/session 的过程:
由于浏览器的请求是无状态的,cookie
的存在就是为了带给服务器一些状态信息,服务器在接收到请求时会对其进行验证(其实是在登录时,服务器发给浏览器的),如果验证通过则正常返回结果,如果验证不通过则重定向到登录页,而服务器是根据 session
中存储的结果和收到的信息进行对比决定是否验证通过,当然这里只是简述过程。
cookie/session 的问题:
从上面可以看出服务器种植 cookie
后每次请求都会带上 cookie
,浪费带宽,而且 cookie
不支持跨域,不方便与其他的系统之间进行跨域访问,而服务器会用 session
来存储这些用户验证的信息,这样浪费了服务器的内存,当多个服务器想要共享 session
需要都拷贝过去。
JWT 的过程:
当用户发送请求,将用户信息带给服务器的时候,服务器不再像过去一样存储在 session
中,而是将浏览器发来的内容通过内部的密钥加上这些信息,使用 sha256
和 RSA
等加密算法生成一个 token
令牌和用户信息一起返回给浏览器,当涉及验证用户的所有请求只需要将这个 token
和用户信息发送给服务器,而服务器将用户信息和自己的密钥通过既定好的算法进行签名,然后将发来的签名和生成的签名比较,严格相等则说明用户信息没被篡改和伪造,验证通过。
JWT 的过程中,服务器不再需要额外的内存存储用户信息,和多个服务器之间只需要共享密钥就可以让多个服务器都有验证能力,同时也解决了 cookie
不能跨域的问题。
JWT 的结构
JWT 之所以能被作为一种声明传递的标准是因为它有自己的结构,并不是随便的发个 token
就可以的,JWT 用于生成 token
的结构有三个部分,使用 .
隔开。
1、Header
Header
头部中主要包含两部分,token
类型和加密算法,如 {typ: "jwt", alg: "HS256"}
,HS256
就是指 sha256
算法,会将这个对象转成 base64
。
2、Payload
Payload
负载就是存放有效信息的地方,有效信息被分为标准中注册的声明、公共的声明和私有的声明。
(1) 标准中注册的声明
下面是标准中注册的声明,建议但不强制使用。
- iss:
jwt
签发者; - sub:
jwt
所面向的用户; - aud:接收
jwt
的一方; - exp:
jwt
的过期时间,这个过期时间必须要大于签发时间,这是一个秒数; - nbf:定义在什么时间之前,该
jwt
都是不可用的; - iat:
jwt
的签发时间。
上面的标准中注册的声明中常用的有 exp
和 nbf
。
(2) 公共声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密,如 {"id", username: "panda", adress: "Beijing"}
,会将这个对象转成 base64
。
(3) 私有声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64
是对称解密的,意味着该部分信息可以归类为明文信息。
3、Signature
Signature
这一部分指将 Header
和 Payload
通过密钥 secret
和加盐算法进行加密后生成的签名,secret
,密钥保存在服务端,不会发送给任何人,所以 JWT 的传输方式是很安全的。
最后将三部分使用 .
连接成字符串,就是要返回给浏览器的 token
浏览器一般会将这个 token
存储在 localStorge
以备其他需要验证用户的请求使用。
经过上面对 JWT 的叙述可能还是没有完全的理解什么是 JWT,具体怎么操作的,我们接下来实现一个小的案例,为了方便,服务端使用 express
框架,数据库使用 mongo
来存储用户信息,前端使用 Vue
来实现,做一个登录页登录后进入订单页验证 token
的功能。
文件目录
jwt-apply |- jwt-client | |- src | | |- views | | | |- Login.vue | | | |- Order.vue | | |- App.vue | | |- axios.js | | |- main.js | | |- router.js | |- .gitignore | |- babel.config | |- package.json |- jwt-server | |- model | | |- user.js | |- app.js | |- config.js | |- jwt-simple.js | |- package.json
服务端的实现
在搭建服务端之前需要安装我们使用的依赖,这里我们使用 yarn
来安装,命令如下。
yarn add express body-parse mongoose jwt-simple
1、配置文件
// 文件位置:~jwt-apply/jwt-server/config.js
module.exports = {
"db_url": "mongodb://localhost:27017/jwt", // 操作 mongo 自动生成这个数据库
"secret": "pandashen" // 密钥
};
复制代码
上面配置文件中,db_url
存储的是 mango
数据库的地址,操作数据库自动创建,secret
是用来生成 token
的密钥。
2、创建数据库模型
// 文件位置:~jwt-apply/jwt-server/model/user.js
// 操作数据库的逻辑
const mongoose = require("mongoose");
let { db_url } = require("../config");
// 连接数据库,端口默认 27017
mongoose.connect(db_url, {
useNewUrlParser: true // 去掉警告
});
// 创建一个骨架 Schema,数据会按照这个骨架格式存储
let UserSchema = new mongoose.Schema({
username: String,
password: String
});
// 创建一个模型
module.exports = mongoose.model("User", UserSchema);
复制代码
我们将连接数据库、定义数据库字段和值类型以及创建数据模型的代码统一放在了 model
文件夹下的 user.js
当中,将数据模型导出方便在服务器的代码中进行查找操作。
3、实现基本服务
// 文件位置:~jwt-apply/jwt-server/app.js
const express = require("express");
const bodyParser = require('body-parser');
const jwt = require("jwt-simple");
const User = require("./model/user");
let { secret } = require("./config");
// 创建服务器
const app = express();
/**
* 设置中间件
*/
/**
* 注册接口
*/
/**
* 登录接口
*/
/**
* 验证 token 接口
*/
// 监听端口号
app.listen(3000);
复制代码
上面是一个基本的服务器,引入了相关的依赖,能保证启动,接下来添加处理 post
请求的中间件和实现 cors
跨域的中间件。
4、添加中间件
// 文件位置:~jwt-apply/jwt-server/app.js
// 设置跨域中间件
app.use((req, res, next) => {
// 允许跨域的头
res.setHeader("Access-Control-Allow-Origin", "*");
// 允许浏览器发送的头
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
// 允许哪些请求方法
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
// 如果当前请求是 OPTIONS 直接结束,否则继续执行
req.method === "OPTIONS" ? res.end() : next();
});
// 设置处理 post 请求参数的中间件
app.use(bodyParser.json());
复制代码
之所以设置处理 post
请求参数中间件是因为注册和登录都需要使用 post
请求,之所以设置跨域中间件是因为我们项目虽小也是前后端分离的,需要用前端的 8080
端口访问服务器的 3000
端口,所以需要服务端使用 cors
处理跨域问题。
5、注册接口的实现
// 文件位置:~jwt-apply/jwt-server/app.js
// 注册接口的实现
app.post("/reg", async (req, res, next) => {
// 获取 post 请求的数据
let user = req.body;
// 错误验证
try {
// 存入数据库,添加成功后返回的就是添加后的结果
user = await User.create(user);
// 返回注册成功的信息
res.json({
code: 0,
data: {
user: {
id: user._id,
username: user.username
}
}
});
} catch (e) {
// 返回注册失败的信息
res.json({ code: 1, data: "注册失败" });
}
});
复制代码
上面将用户注册的信息存入了 mongo
数据库,返回值为存入的数据,如果存入成功,则返回注册成功的信息,否则返回注册失败的信息。
6、登录接口的实现
// 文件位置:~jwt-apply/jwt-server/app.js
// 用户能登录
app.post("/login", async (req, res, next) => {
let user = req.body;
try {
// 查找用户是否存在
user = await User.findOne(user);
if (user) {
// 生成 token
let token = jwt.encode({
id: user._id,
username: user.username,
exp: Date.now() + 1000 * 10
}, secret);
res.json({
code: 0,
data: { token }
});
} else {
res.json({ code: 1, data: "用户不存在" });
}
} catch (e) {
res.json({ code: 1, data: "登录失败" });
}
});
复制代码
登录的过程中会先拿用户的账号和密码进数据库中进行严重和查找,如果存在,则登录成功并返回 token
,如果不存在则登录失败。
7、token 校验接口
// 文件位置:~jwt-apply/jwt-server/app.js
// 只针对 token 校验接口的中间件
let auth = (req, res, next) => {
// 获取请求头 authorization
let authorization = req.headers["authorization"];
// 如果存在,则获取 token
if (authorization) {
let token = authorization.split(" ")[1];
try {
// 对 token 进行校验
req.user = jwt.decode(token, secret);
next();
} catch (e) {
res.status(401).send("Not Allowed");
}
} else {
res.status(401).send("Not Allowed");
}
}
// 用户可以校验是否登录过,通过请求头 authorization: Bearer token
app.get("/order", auth, (req, res, next) => {
res.json({
code: 0,
data: {
user: req.user
}
});
});
复制代码
在校验过程中,每次浏览器都会将 token
通过请求头 authorization
带给服务器,请求头的值为 Bearer token
,这是 JWT 规定的,服务器取出 token
使用 decode
方法进行解码,并使用 try...catch
进行捕获,如果解码失败则会触发 try...catch
,说明 token
过期、被篡改、或被伪造,返回 401
响应。
前端的实现
我们使用 3.0
版本的 vue-cli
脚手架生成 Vue
项目,并安装 axios
发送请求。
yarn add global @vue/cli
yarn add axios
1、入口文件
// 文件位置:~jwt-apply/jwt-client/src/main.js
import Vue from "vue"
import App from "./App.vue"
import router from "./router"
// 是否为生产模式
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount("#app")
复制代码
上面这个文件是 vue-cli
自动生成的,我们并没有做改动,但是为了方便查看我们会将主要文件的代码一一贴出来。
2、主组件 App
<!-- 文件位置:~jwt-apply/jwt-client/src/App.vue -->
<template>
<div id="app">
<div id="nav">
<router-link to="/login">登录</router-link> |
<router-link to="/order">订单</router-link>
</div>
<router-view/>
</div>
</template>
复制代码
在主组件中我们将 router-link
分别对应了 /login
和 /order
两个路由。
3、路由配置
// 文件位置:~jwt-apply/jwt-client/src/router.js
import Vue from "vue"
import Router from "vue-router"
import Login from "./views/Login.vue"
import Order from "./views/Order.vue"
Vue.use(Router)
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/login",
name: "login",
component: Login
},
{
path: "/order",
name: "order",
component: Order
}
]
})
复制代码
我们定义了两个路由,一个对应登录页,一个对应订单页,并引入了组件 Login
和 Order
,前端并没有写注册模块,可以使用 postman
发送注册请求生成一个账户以备后面验证使用。
4、登录组件 Login
<!-- 文件位置:~jwt-apply/jwt-client/src/views/Login.vue -->
<template>
<div class="login">
用户名
<input type="text" v-model="user.username">
密码
<input type="text" v-model="user.password">
<button @click="login">提交</button>
</div>
</template>
<script>
import axios from "../axios"
export default {
data() {
return {
user: {
username: "",
password: ""
}
}
},
methods: {
login() {
// 发送请求访问服务器的登录接口
axios.post('/login', this.user).then(res => {
// 将返回的 token 存入 localStorage,并跳转订单页
localStorage.setItem("token", res.data.token);
this.$router.push("/order");
}).catch(err => {
// 弹出错误
alert(err.data);
});
}
}
}
</script>
复制代码
Login
组件中将两个输入框的值同步到 data
中,用来存放账号和密码,当点击提交按钮时,触发点击事件 login
发送请求,请求成功后将返回的 token
存入 localStorage
,并跳转路由到订单页,请求错误时弹出错误信息。
5、订单组件 Order
<!-- 文件位置:~jwt-apply/jwt-client/src/views/Order.vue -->
<template>
<div class="order">
{{username}} 的订单
</div>
</template>
<script>
import axios from "../axios"
export default {
data() {
return {
username: ""
}
},
mounted() {
axios.get("/order").then(res =>{
this.username = res.data.user.username;
}).catch(err => {
alert(err);
});
},
}
</script>
复制代码
Order
页面显示的内容是 “XXX 的订单”,在加载 Order
组件被挂载时发送请求获取用户名,即访问服务器的验证 token
接口,因为订单页就是一个涉及到验证用户的页面,当请求成功时,将用户名同步到 data
,否则弹出错误信息。
在 Login
和 Order
两个组件中对请求的回调内似乎写的太简单了,其实是因为 axios
的返回值会在服务器返回的返回值外面包了一层,存放一些 http
响应的相关信息,两个接口访问时请求地址也是同一个服务器,而且在服务器响应时的错误处理都是对状态吗 401
的处理,在涉及验证用户信息的请求中需要设置请求头 Authorization
发送 token
。
这些逻辑我们似乎在组件请求相关的代码中都没有看到,是因为我们使用 axios
的 API 设置了 baseURL
请求拦截和响应拦截,细心可以发现其实引入的 axios
并不是直接从 node_modules
引入,而是引入了我们自己的导出的 axios
。
6、axios 配置
// 文件位置:~jwt-apply/jwt-client/src/axios.js
import axios from "axios";
import router from "./router";
// 设置默认访问地址
axios.defaults.baseURL = "http://localhost:3000";
// 响应拦截
axios.interceptors.response.use(res => {
// 报错执行 axios then 方法错误的回调,成功返回正确的数据
return res.data.code !== 0 ? Promise.reject(res.data) : res.data;
}, res => {
// 如果 token 验证失败则跳回登陆页,并执行 axios then 方法错误的回调
if (res.response.status === 401) {
router.history.push("/login");
}
return Promise.reject("Not Allowed");
});
// 请求拦截,用于将请求统一带上 token
axios.interceptors.request.use(config => {
// 在 localStorage 获取 token
let token = localStorage.getItem("token");
// 如果存在则设置请求头
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default axios;
复制代码
访问服务器时会将 axios
中的第一个参数拼接在 axios.defaults.baseURL
的后面作为请求地址。
axios.interceptors.response.use
为响应拦截,axios
发送请求后所有的响应都会先执行这个方法内部的逻辑,返回值为数据,作为参数传递给 axios
返回值的 then
方法。
axios.interceptors.request.use
为请求拦截,axios
发送的所有请求都会先执行这个方法的逻辑,然后发送给服务器,一般用来设置请求头。
jwt-simple 模块的实现原理
相信通过上面的过程已经非常清楚 JWT 如何生成的,token
的格式是怎样的,如何跟前端交互去验证 token
,我们在这些基础上再深入的研究一下 token
的整个生成过程和验证过程,我们使用的 jwt-simple
模块的 encode
方法如何生成 token
,使用 decode
方法如何验证 token
,下面就看看一看 jwt-simple
的实现原理。
1、创建模块
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
const crypto = require("crypto");
/**
* 其他方法
*/
// 创建对象
module.exports = {
encode,
decode
};
复制代码
我们知道 jwt-simple
我们使用的有两个方法 encode
和 decode
,所以最后导出的对象上有这两个方法,使用加盐算法进行签名需要使用 crypto
,所以我们提前引入。
2、字符串和 Base64 互相转换
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
// 将子子符串转换成 Base64
function stringToBase64(str) {
return Buffer.from(str).toString("base64");
}
// 将 Base64 转换成字符串
function base64ToString(base64) {
return Buffer.from(base64, "base64").toString("utf8");
}
复制代码
从方法的名字相信很容易看出用途和参数,所以就一起放在这了,其实本质是在两种编码之间进行转换,所以转换之前都应该先转换成 Buffer。
3、生成签名的方法
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
function createSign(str, secret) {
// 使用加盐算法进行加密
return crypto.createHmac("sha256", secret).update(str).digest("base64");
}
复制代码
这一步就是通过加盐算法使用 sha256
和密钥 secret
进行生成签名,但是为了方便我们把使用的加密算法给写死了,正常情况下是应该根据 Header
中 alg
字段的值去检索 alg
的值与加密算法名称对应的 map
,去使用设置的算法生成签名。
4、encode
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
function encode(payload, secret) {
// 头部
let header = stringToBase64(JSON.stringify({
typ: "JWT",
alg: "HS256"
}));
// 负载
let content = stringToBase64(JSON.stringify(payload));
// 签名
let sign = createSign([header, content].join("."), secret);
// 生成签名
return [header, content, sign].join(".");
}
复制代码
在 encode
中将 Header
、Payload
转换成 base64
,通过 .
连接在一起,然后使用 secret
密钥生成签名,最后将 Header
和 Payload
的 base64
通过 .
和生成的签名连接在一起,这就形成了 “明文” + “明文” + “暗文” 三段格式的 token
。
5、decode
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
function decode(token, secret) {
let [header, content, sign] = token.split(".");
// 将接收到的 token 的前两部分(base64)重新签名并验证,验证不通过抛出错误
if (sign !== createSign([header, content].join("."), secret)) {
throw new Error("Not Allow");
}
// 将 content 转成对象
content = JSON.parse(base64ToString(content));
// 检测过期时间,如果过去抛出错误
if (content.exp && content.exp < Date.now()) {
throw new Error("Not Allow");
}
return content;
}
复制代码
在验证方法 decode
中,首先将 token
的三段分别取出,并用前两段重新生成签名,并与第三段 sign
对比,相同通过验证,不同说明篡改过并抛出错误,将 Payload
的内容重新转换成对象,也就是将 content
转换成对象,取出 exp
字段与当前时间对比来验证是否过期,如果过期抛出错误。
总结
在 JWT 生成的 token
中,前两段明文可解,这样别人拦截后知道了我们的加密算法和规则,也知道我们传输的信息,也可以使用 jwt-simple
加密一段暗文拼接成 token
的格式给服务器去验证,为什么 JWT 还这么安全呢,这就说到了最最重点的地方,无论别人知道多少我们在传输的信息,篡改和伪造后都不能通过服务器的验证,是因为无法获取服务器的密钥 secret
,真正能保证安全的就是 secret
,同时证明了 Header
和 Payload
并不安全,可以被破解,所以不能存放敏感信息。