通過一個案例理解 JWT

在這裏插入圖片描述

原文出自:https://www.pandashen.com

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 的功能。

文件目錄

<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

1、配置文件

// 文件位置:&#126;jwt-apply/jwt-server/config.js
module.exports = {
    "db_url": "mongodb://localhost:27017/jwt", // 操作 mongo 自動生成這個數據庫
    "secret": "pandashen" // 密鑰
};

上面配置文件中,db_url 存儲的是 mango 數據庫的地址,操作數據庫自動創建,secret 是用來生成 token 的密鑰。

2、創建數據庫模型

// 文件位置:&#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 當中,將數據模型導出方便在服務器的代碼中進行查找操作。

3、實現基本服務

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

4、添加中間件

// 文件位置:&#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 處理跨域問題。

5、註冊接口的實現

// 文件位置:&#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 數據庫,返回值爲存入的數據,如果存入成功,則返回註冊成功的信息,否則返回註冊失敗的信息。

6、登錄接口的實現

// 文件位置:&#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,如果不存在則登錄失敗。

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

1、入口文件

// 文件位置:&#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 自動生成的,我們並沒有做改動,但是爲了方便查看我們會將主要文件的代碼一一貼出來。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章