JWT(json web token)是为了在网络应用环境之间传递声明而基于 json
的开放标准,JWT 的声明通常被采用在身份提供者和服务器提供者间传递被认证的身份信息,以便于从资源服务器获取资源。html
JWT 通常用于用户登陆上,身份认证在这种场景下,一旦用户登陆完成,在接下来的每一个涉及用户权限的请求中都包含 JWT,能够对用户身份、路由、服务和资源的访问权限进行验证。前端
举一个例子,假如一个电商网站,在用户登陆之后,须要验证用户的地方其实有不少,好比购物车,订单页,我的中心等等,访问这些页面正常的逻辑是先验证用户权限和登陆状态,若是验证经过,则进入访问的页面,不然重定向到登陆页。vue
而在 JWT 以前,这样的验证咱们大多都是经过 cookie
和 session
去实现的,咱们接下来就来对比如下这两种方式的不一样。node
cookie/session 的过程:ios
因为浏览器的请求是无状态的,cookie
的存在就是为了带给服务器一些状态信息,服务器在接收到请求时会对其进行验证(实际上是在登陆时,服务器发给浏览器的),若是验证经过则正常返回结果,若是验证不经过则重定向到登陆页,而服务器是根据 session
中存储的结果和收到的信息进行对比决定是否验证经过,固然这里只是简述过程。git
cookie/session 的问题:web
从上面能够看出服务器种植 cookie
后每次请求都会带上 cookie
,浪费带宽,并且 cookie
不支持跨域,不方便与其余的系统之间进行跨域访问,而服务器会用 session
来存储这些用户验证的信息,这样浪费了服务器的内存,当多个服务器想要共享 session
须要都拷贝过去。算法
JWT 的过程:vue-router
当用户发送请求,将用户信息带给服务器的时候,服务器再也不像过去同样存储在 session
中,而是将浏览器发来的内容经过内部的密钥加上这些信息,使用 sha256
和 RSA
等加密算法生成一个 token
令牌和用户信息一块儿返回给浏览器,当涉及验证用户的全部请求只须要将这个 token
和用户信息发送给服务器,而服务器将用户信息和本身的密钥经过既定好的算法进行签名,而后将发来的签名和生成的签名比较,严格相等则说明用户信息没被篡改和伪造,验证经过。mongodb
JWT 的过程当中,服务器再也不须要额外的内存存储用户信息,和多个服务器之间只须要共享密钥就可让多个服务器都有验证能力,同时也解决了 cookie
不能跨域的问题。
JWT 之因此能被做为一种声明传递的标准是由于它有本身的结构,并非随便的发个 token
就能够的,JWT 用于生成 token
的结构有三个部分,使用 .
隔开。
Header
头部中主要包含两部分,token
类型和加密算法,如 {typ: "jwt", alg: "HS256"}
,HS256
就是指 sha256
算法,会将这个对象转成 base64
。
Payload
负载就是存放有效信息的地方,有效信息被分为标准中注册的声明、公共的声明和私有的声明。
下面是标准中注册的声明,建议但不强制使用。
jwt
签发者;jwt
所面向的用户;jwt
的一方;jwt
的过时时间,这个过时时间必需要大于签发时间,这是一个秒数;jwt
都是不可用的;jwt
的签发时间。上面的标准中注册的声明中经常使用的有 exp
和 nbf
。
公共的声明能够添加任何的信息,通常添加用户的相关信息或其余业务须要的必要信息,但不建议添加敏感信息,由于该部分在客户端可解密,如 {"id", username: "panda", adress: "Beijing"}
,会将这个对象转成 base64
。
私有声明是提供者和消费者所共同定义的声明,通常不建议存放敏感信息,由于 base64
是对称解密的,意味着该部分信息能够归类为明文信息。
Signature
这一部分指将 Header
和 Payload
经过密钥 secret
和加盐算法进行加密后生成的签名,secret
,密钥保存在服务端,不会发送给任何人,因此 JWT 的传输方式是很安全的。
最后将三部分使用 .
链接成字符串,就是要返回给浏览器的 token
浏览器通常会将这个 token
存储在 localStorge
以备其余须要验证用户的请求使用。
通过上面对 JWT 的叙述可能仍是没有彻底的理解什么是 JWT,具体怎么操做的,咱们接下来实现一个小的案例,为了方便,服务端使用 express
框架,数据库使用 mongo
来存储用户信息,前端使用 Vue
来实现,作一个登陆页登陆后进入订单页验证 token
的功能。
<pre/>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
// 文件位置:~jwt-apply/jwt-server/config.js module.exports = { "db_url": "mongodb://localhost:27017/jwt", // 操做 mongo 自动生成这个数据库 "secret": "pandashen" // 密钥 };
上面配置文件中,db_url
存储的是 mango
数据库的地址,操做数据库自动建立,secret
是用来生成 token
的密钥。
// 文件位置:~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
当中,将数据模型导出方便在服务器的代码中进行查找操做。
// 文件位置:~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
跨域的中间件。
// 文件位置:~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
处理跨域问题。
// 文件位置:~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
数据库,返回值为存入的数据,若是存入成功,则返回注册成功的信息,不然返回注册失败的信息。
// 文件位置:~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
,若是不存在则登陆失败。
// 文件位置:~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/cliyarn add axios
// 文件位置:~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
自动生成的,咱们并无作改动,可是为了方便查看咱们会将主要文件的代码一一贴出来。
<!-- 文件位置:~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
两个路由。
// 文件位置:~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
发送注册请求生成一个帐户以备后面验证使用。
<!-- 文件位置:~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
,并跳转路由到订单页,请求错误时弹出错误信息。
<!-- 文件位置:~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
。
// 文件位置:~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 如何生成的,token
的格式是怎样的,如何跟前端交互去验证 token
,咱们在这些基础上再深刻的研究一下 token
的整个生成过程和验证过程,咱们使用的 jwt-simple
模块的 encode
方法如何生成 token
,使用 decode
方法如何验证 token
,下面就看看一看 jwt-simple
的实现原理。
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js const crypto = require("crypto"); /** * 其余方法 */ // 建立对象 module.exports = { encode, decode };
咱们知道 jwt-simple
咱们使用的有两个方法 encode
和 decode
,因此最后导出的对象上有这两个方法,使用加盐算法进行签名须要使用 crypto
,因此咱们提早引入。
// 文件位置:~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。
// 文件位置:~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
,去使用设置的算法生成签名。
// 文件位置:~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
。
// 文件位置:~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
并不安全,能够被破解,因此不能存放敏感信息。