通過一個案例理解 JWT

PandaShen發表於2018-09-20

通過一個案例理解 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 的功能。


檔案目錄

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


相關文章