通过一个案例理解 JWT

10,279 阅读15分钟


阅读原文


JWT 简述

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


JWT 的应用场景

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

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

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


JWT 对比 cookie/session

cookie/session 的过程:

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

cookie/session 的问题:

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

JWT 的过程:

当用户发送请求,将用户信息带给服务器的时候,服务器不再像过去一样存储在 session 中,而是将浏览器发来的内容通过内部的密钥加上这些信息,使用 sha256RSA 等加密算法生成一个 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 的签发时间。

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

(2) 公共声明

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

(3) 私有声明

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

3、Signature

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

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

3、路由配置

// 文件位置:&#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 发送注册请求生成一个账户以备后面验证使用。

4、登录组件 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,并跳转路由到订单页,请求错误时弹出错误信息。

5、订单组件 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

6、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 的实现原理。

1、创建模块

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

/**
* 其他方法
*/

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

我们知道 jwt-simple 我们使用的有两个方法 encodedecode,所以最后导出的对象上有这两个方法,使用加盐算法进行签名需要使用 crypto,所以我们提前引入。

2、字符串和 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。

3、生成签名的方法

// 文件位置:&#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,去使用设置的算法生成签名。

4、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

5、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 并不安全,可以被破解,所以不能存放敏感信息。