經過一個案例理解 JWT


閱讀原文


JWT 簡述

JWT(json web token)是爲了在網絡應用環境之間傳遞聲明而基於 json 的開放標準,JWT 的聲明通常被採用在身份提供者和服務器提供者間傳遞被認證的身份信息,以便於從資源服務器獲取資源。javascript


JWT 的應用場景

JWT 通常用於用戶登陸上,身份認證在這種場景下,一旦用戶登陸完成,在接下來的每一個涉及用戶權限的請求中都包含 JWT,能夠對用戶身份、路由、服務和資源的訪問權限進行驗證。html

舉一個例子,假如一個電商網站,在用戶登陸之後,須要驗證用戶的地方其實有不少,好比購物車,訂單頁,我的中心等等,訪問這些頁面正常的邏輯是先驗證用戶權限和登陸狀態,若是驗證經過,則進入訪問的頁面,不然重定向到登陸頁。前端

而在 JWT 以前,這樣的驗證咱們大多都是經過 cookiesession 去實現的,咱們接下來就來對比如下這兩種方式的不一樣。vue


JWT 對比 cookie/session

cookie/session 的過程:java

因爲瀏覽器的請求是無狀態的,cookie 的存在就是爲了帶給服務器一些狀態信息,服務器在接收到請求時會對其進行驗證(實際上是在登陸時,服務器發給瀏覽器的),若是驗證經過則正常返回結果,若是驗證不經過則重定向到登陸頁,而服務器是根據 session 中存儲的結果和收到的信息進行對比決定是否驗證經過,固然這裏只是簡述過程。node

cookie/session 的問題:ios

從上面能夠看出服務器種植 cookie 後每次請求都會帶上 cookie,浪費帶寬,並且 cookie 不支持跨域,不方便與其餘的系統之間進行跨域訪問,而服務器會用 session 來存儲這些用戶驗證的信息,這樣浪費了服務器的內存,當多個服務器想要共享 session 須要都拷貝過去。git

JWT 的過程:web

當用戶發送請求,將用戶信息帶給服務器的時候,服務器再也不像過去同樣存儲在 session 中,而是將瀏覽器發來的內容經過內部的密鑰加上這些信息,使用 sha256RSA 等加密算法生成一個 token 令牌和用戶信息一塊兒返回給瀏覽器,當涉及驗證用戶的全部請求只須要將這個 token 和用戶信息發送給服務器,而服務器將用戶信息和本身的密鑰經過既定好的算法進行簽名,而後將發來的簽名和生成的簽名比較,嚴格相等則說明用戶信息沒被篡改和僞造,驗證經過。算法

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


文件目錄

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,若是不存在則登陸失敗。

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

一、入口文件

// 文件位置:~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 並不安全,能夠被破解,因此不能存放敏感信息。

相關文章
相關標籤/搜索