JWT(json web token)是爲了在網絡應用環境之間傳遞聲明而基於 json
的開放標準,JWT 的聲明通常被採用在身份提供者和服務器提供者間傳遞被認證的身份信息,以便於從資源服務器獲取資源。javascript
JWT 通常用於用戶登陸上,身份認證在這種場景下,一旦用戶登陸完成,在接下來的每一個涉及用戶權限的請求中都包含 JWT,能夠對用戶身份、路由、服務和資源的訪問權限進行驗證。html
舉一個例子,假如一個電商網站,在用戶登陸之後,須要驗證用戶的地方其實有不少,好比購物車,訂單頁,我的中心等等,訪問這些頁面正常的邏輯是先驗證用戶權限和登陸狀態,若是驗證經過,則進入訪問的頁面,不然重定向到登陸頁。前端
而在 JWT 以前,這樣的驗證咱們大多都是經過 cookie
和 session
去實現的,咱們接下來就來對比如下這兩種方式的不一樣。vue
cookie/session 的過程:java
因爲瀏覽器的請求是無狀態的,cookie
的存在就是爲了帶給服務器一些狀態信息,服務器在接收到請求時會對其進行驗證(實際上是在登陸時,服務器發給瀏覽器的),若是驗證經過則正常返回結果,若是驗證不經過則重定向到登陸頁,而服務器是根據 session
中存儲的結果和收到的信息進行對比決定是否驗證經過,固然這裏只是簡述過程。node
cookie/session 的問題:ios
從上面能夠看出服務器種植 cookie
後每次請求都會帶上 cookie
,浪費帶寬,並且 cookie
不支持跨域,不方便與其餘的系統之間進行跨域訪問,而服務器會用 session
來存儲這些用戶驗證的信息,這樣浪費了服務器的內存,當多個服務器想要共享 session
須要都拷貝過去。git
JWT 的過程:web
當用戶發送請求,將用戶信息帶給服務器的時候,服務器再也不像過去同樣存儲在 session
中,而是將瀏覽器發來的內容經過內部的密鑰加上這些信息,使用 sha256
和 RSA
等加密算法生成一個 token
令牌和用戶信息一塊兒返回給瀏覽器,當涉及驗證用戶的全部請求只須要將這個 token
和用戶信息發送給服務器,而服務器將用戶信息和本身的密鑰經過既定好的算法進行簽名,而後將發來的簽名和生成的簽名比較,嚴格相等則說明用戶信息沒被篡改和僞造,驗證經過。算法
JWT 的過程當中,服務器再也不須要額外的內存存儲用戶信息,和多個服務器之間只須要共享密鑰就可讓多個服務器都有驗證能力,同時也解決了 cookie
不能跨域的問題。
JWT 之因此能被做爲一種聲明傳遞的標準是由於它有本身的結構,並非隨便的發個 token
就能夠的,JWT 用於生成 token
的結構有三個部分,使用 .
隔開。
Header
頭部中主要包含兩部分,token
類型和加密算法,如 {typ: "jwt", alg: "HS256"}
,HS256
就是指 sha256
算法,會將這個對象轉成 base64
。
Payload
負載就是存放有效信息的地方,有效信息被分爲標準中註冊的聲明、公共的聲明和私有的聲明。
下面是標準中註冊的聲明,建議但不強制使用。
jwt
簽發者;jwt
所面向的用戶;jwt
的一方;jwt
的過時時間,這個過時時間必需要大於簽發時間,這是一個秒數;jwt
都是不可用的;jwt
的簽發時間。上面的標準中註冊的聲明中經常使用的有 exp
和 nbf
。
公共的聲明能夠添加任何的信息,通常添加用戶的相關信息或其餘業務須要的必要信息,但不建議添加敏感信息,由於該部分在客戶端可解密,如 {"id", username: "panda", adress: "Beijing"}
,會將這個對象轉成 base64
。
私有聲明是提供者和消費者所共同定義的聲明,通常不建議存放敏感信息,由於 base64
是對稱解密的,意味着該部分信息能夠歸類爲明文信息。
Signature
這一部分指將 Header
和 Payload
經過密鑰 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
,若是不存在則登陸失敗。
// 文件位置:~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
自動生成的,咱們並無作改動,可是爲了方便查看咱們會將主要文件的代碼一一貼出來。
<!-- 文件位置:~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
兩個路由。
// 文件位置:~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
}
]
})
複製代碼
咱們定義了兩個路由,一個對應登陸頁,一個對應訂單頁,並引入了組件 Login
和 Order
,前端並無寫註冊模塊,可使用 postman
發送註冊請求生成一個帳戶以備後面驗證使用。
<!-- 文件位置:~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
,並跳轉路由到訂單頁,請求錯誤時彈出錯誤信息。
<!-- 文件位置:~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
,不然彈出錯誤信息。
在 Login
和 Order
兩個組件中對請求的回調內彷佛寫的太簡單了,實際上是由於 axios
的返回值會在服務器返回的返回值外面包了一層,存放一些 http
響應的相關信息,兩個接口訪問時請求地址也是同一個服務器,並且在服務器響應時的錯誤處理都是對狀態嗎 401
的處理,在涉及驗證用戶信息的請求中須要設置請求頭 Authorization
發送 token
。
這些邏輯咱們彷佛在組件請求相關的代碼中都沒有看到,是由於咱們使用 axios
的 API 設置了 baseURL
請求攔截和響應攔截,細心能夠發現其實引入的 axios
並非直接從 node_modules
引入,而是引入了咱們本身的導出的 axios
。
// 文件位置:~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 如何生成的,token
的格式是怎樣的,如何跟前端交互去驗證 token
,咱們在這些基礎上再深刻的研究一下 token
的整個生成過程和驗證過程,咱們使用的 jwt-simple
模塊的 encode
方法如何生成 token
,使用 decode
方法如何驗證 token
,下面就看看一看 jwt-simple
的實現原理。
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
const crypto = require("crypto");
/** * 其餘方法 */
// 建立對象
module.exports = {
encode,
decode
};
複製代碼
咱們知道 jwt-simple
咱們使用的有兩個方法 encode
和 decode
,因此最後導出的對象上有這兩個方法,使用加鹽算法進行簽名須要使用 crypto
,因此咱們提早引入。
// 文件位置:~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。
// 文件位置:~jwt-apply/jwt-server/jwt-simple.js
function createSign(str, secret) {
// 使用加鹽算法進行加密
return crypto.createHmac("sha256", secret).update(str).digest("base64");
}
複製代碼
這一步就是經過加鹽算法使用 sha256
和密鑰 secret
進行生成簽名,可是爲了方便咱們把使用的加密算法給寫死了,正常狀況下是應該根據 Header
中 alg
字段的值去檢索 alg
的值與加密算法名稱對應的 map
,去使用設置的算法生成簽名。
// 文件位置:~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
中將 Header
、Payload
轉換成 base64
,經過 .
鏈接在一塊兒,而後使用 secret
密鑰生成簽名,最後將 Header
和 Payload
的 base64
經過 .
和生成的簽名鏈接在一塊兒,這就造成了 「明文」 + 「明文」 + 「暗文」 三段格式的 token
。
// 文件位置:~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
,同時證實了 Header
和 Payload
並不安全,能夠被破解,因此不能存放敏感信息。