经过一个案例理解 JWT

在这里插入图片描述


阅读原文


JWT 简述

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


JWT 的应用场景

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

举一个例子,假如一个电商网站,在用户登陆之后,须要验证用户的地方其实有不少,好比购物车,订单页,我的中心等等,访问这些页面正常的逻辑是先验证用户权限和登陆状态,若是验证经过,则进入访问的页面,不然重定向到登陆页。vue

而在 JWT 以前,这样的验证咱们大多都是经过 cookiesession 去实现的,咱们接下来就来对比如下这两种方式的不一样。node


JWT 对比 cookie/session

cookie/session 的过程:ios

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

cookie/session 的问题:web

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

JWT 的过程:vue-router

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

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


JWT 的结构

JWT 之因此能被做为一种声明传递的标准是由于它有本身的结构,并非随便的发个 token 就能够的,JWT 用于生成 token 的结构有三个部分,使用 . 隔开。

一、Header

Header 头部中主要包含两部分,token 类型和加密算法,如 {typ: "jwt", alg: "HS256"}HS256 就是指 sha256 算法,会将这个对象转成 base64

二、Payload

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

(1) 标准中注册的声明

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

  • iss:jwt 签发者;
  • sub:jwt 所面向的用户;
  • aud:接收 jwt 的一方;
  • exp:jwt 的过时时间,这个过时时间必需要大于签发时间,这是一个秒数;
  • nbf:定义在什么时间以前,该 jwt 都是不可用的;
  • iat:jwt 的签发时间。

上面的标准中注册的声明中经常使用的有 expnbf

(2) 公共声明

公共的声明能够添加任何的信息,通常添加用户的相关信息或其余业务须要的必要信息,但不建议添加敏感信息,由于该部分在客户端可解密,如 {"id", username: "panda", adress: "Beijing"},会将这个对象转成 base64

(3) 私有声明

私有声明是提供者和消费者所共同定义的声明,通常不建议存放敏感信息,由于 base64 是对称解密的,意味着该部分信息能够归类为明文信息。

三、Signature

Signature 这一部分指将 HeaderPayload 经过密钥 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

一、配置文件

// 文件位置:&#126;jwt-apply/jwt-server/config.js
module.exports = {
    "db_url": "mongodb://localhost:27017/jwt", // 操做 mongo 自动生成这个数据库
    "secret": "pandashen" // 密钥
};

上面配置文件中,db_url 存储的是 mango 数据库的地址,操做数据库自动建立,secret 是用来生成 token 的密钥。

二、建立数据库模型

// 文件位置:&#126;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 当中,将数据模型导出方便在服务器的代码中进行查找操做。

三、实现基本服务

// 文件位置:&#126;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 跨域的中间件。

四、添加中间件

// 文件位置:&#126;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 处理跨域问题。

五、注册接口的实现

// 文件位置:&#126;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 数据库,返回值为存入的数据,若是存入成功,则返回注册成功的信息,不然返回注册失败的信息。

六、登陆接口的实现

// 文件位置:&#126;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,若是不存在则登陆失败。

七、token 校验接口

// 文件位置:&#126;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

一、入口文件

// 文件位置:&#126;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 自动生成的,咱们并无作改动,可是为了方便查看咱们会将主要文件的代码一一贴出来。

二、主组件 App

<!-- 文件位置:&#126;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 两个路由。

三、路由配置

// 文件位置:&#126;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
        }
    ]
})

咱们定义了两个路由,一个对应登陆页,一个对应订单页,并引入了组件 LoginOrder,前端并无写注册模块,可使用 postman 发送注册请求生成一个帐户以备后面验证使用。

四、登陆组件 Login

<!-- 文件位置:&#126;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,并跳转路由到订单页,请求错误时弹出错误信息。

五、订单组件 Order

<!-- 文件位置:&#126;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,不然弹出错误信息。

LoginOrder 两个组件中对请求的回调内彷佛写的太简单了,实际上是由于 axios 的返回值会在服务器返回的返回值外面包了一层,存放一些 http 响应的相关信息,两个接口访问时请求地址也是同一个服务器,并且在服务器响应时的错误处理都是对状态吗 401 的处理,在涉及验证用户信息的请求中须要设置请求头 Authorization 发送 token

这些逻辑咱们彷佛在组件请求相关的代码中都没有看到,是由于咱们使用 axios 的 API 设置了 baseURL 请求拦截和响应拦截,细心能够发现其实引入的 axios 并非直接从 node_modules 引入,而是引入了咱们本身的导出的 axios

六、axios 配置

// 文件位置:&#126;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 的实现原理。

一、建立模块

// 文件位置:&#126;jwt-apply/jwt-server/jwt-simple.js
const crypto = require("crypto");

/**
* 其余方法
*/

// 建立对象
module.exports = {
    encode,
    decode
};

咱们知道 jwt-simple 咱们使用的有两个方法 encodedecode,因此最后导出的对象上有这两个方法,使用加盐算法进行签名须要使用 crypto,因此咱们提早引入。

二、字符串和 Base64 互相转换

// 文件位置:&#126;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。

三、生成签名的方法

// 文件位置:&#126;jwt-apply/jwt-server/jwt-simple.js
function createSign(str, secret) {
    // 使用加盐算法进行加密
    return crypto.createHmac("sha256", secret).update(str).digest("base64");
}

这一步就是经过加盐算法使用 sha256 和密钥 secret 进行生成签名,可是为了方便咱们把使用的加密算法给写死了,正常状况下是应该根据 Headeralg 字段的值去检索 alg 的值与加密算法名称对应的 map,去使用设置的算法生成签名。

四、encode

// 文件位置:&#126;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 中将 HeaderPayload 转换成 base64,经过 . 链接在一块儿,而后使用 secret 密钥生成签名,最后将 HeaderPayloadbase64 经过 . 和生成的签名链接在一块儿,这就造成了 “明文” + “明文” + “暗文” 三段格式的 token

五、decode

// 文件位置:&#126;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,同时证实了 HeaderPayload 并不安全,能够被破解,因此不能存放敏感信息。

相关文章
相关标签/搜索