jwt管理用戶登錄態(tài) —— 以expressjs和vuejs的前后端分離論壇項(xiàng)目作為實(shí)例
本文首發(fā)于我的個(gè)人blog哦 歡迎踩踩
session
很久很久以前,Web基本上就是文檔的瀏覽而已
既然是瀏覽,作為服務(wù)器,不需要記錄誰(shuí)在某一段時(shí)間里都瀏覽了什么文檔,每次請(qǐng)求都是一個(gè)新的HTTP協(xié)議,就是請(qǐng)求加響應(yīng)
尤其是我不用記住是誰(shuí)剛剛發(fā)了HTTP請(qǐng)求,每個(gè)請(qǐng)求對(duì)我來(lái)說(shuō)都是全新的。這段時(shí)間很嗨皮
但是隨著交互式Web應(yīng)用的興起,像在線購(gòu)物網(wǎng)站,需要登錄的網(wǎng)站等等,馬上就面臨一個(gè)問(wèn)題,那就是要管理會(huì)話,必須記住哪些人登錄系統(tǒng),哪些人往自己的購(gòu)物車(chē)中放商品,也就是說(shuō)我必須把每個(gè)人區(qū)分開(kāi),這就是一個(gè)不小的挑戰(zhàn),
因?yàn)镠TTP請(qǐng)求是無(wú)狀態(tài)的,所以想出的辦法就是給大家發(fā)一個(gè)會(huì)話標(biāo)識(shí)(session id),說(shuō)白了就是一個(gè)隨機(jī)的字串,每個(gè)人收到的都不一樣,每次大家向我發(fā)起HTTP請(qǐng)求的時(shí)候,把這個(gè)字符串給一并捎過(guò)來(lái),這樣我就能區(qū)分開(kāi)誰(shuí)是誰(shuí)了
用戶向服務(wù)器發(fā)送用戶名和密碼。
服務(wù)器驗(yàn)證通過(guò)后,在當(dāng)前對(duì)話(session)里面保存相關(guān)數(shù)據(jù),比如用戶角色登錄時(shí)間等等。
服務(wù)器向用戶返回一個(gè)session_id,寫(xiě)入用戶的Cookie。
用戶隨后的每一次請(qǐng)求,都會(huì)通過(guò) Cookie,將session_id傳回服務(wù)器。
服務(wù)器收到session_id,找到前期保存的數(shù)據(jù),由此得知用戶的身份。
這樣大家很嗨皮了,可是服務(wù)器就不嗨皮了,每個(gè)人只需要保存自己的session id,而服務(wù)器要保存所有人的session id!如果訪問(wèn)服務(wù)器多了,就得由成千上萬(wàn),甚至幾十萬(wàn)個(gè)。
這對(duì)服務(wù)器說(shuō)是一個(gè)巨大的開(kāi)銷(xiāo),嚴(yán)重的限制了服務(wù)器擴(kuò)展能力,比如說(shuō)我用兩個(gè)機(jī)器組成了一個(gè)集群,小F通過(guò)機(jī)器A登錄了系統(tǒng),那session id會(huì)保存在機(jī)器A上,假設(shè)小F的下一次請(qǐng)求被轉(zhuǎn)發(fā)到機(jī)器B怎么辦? 機(jī)器B可沒(méi)有小F的session id啊。
有時(shí)候會(huì)采用一點(diǎn)小伎倆:session sticky,就是讓小F的請(qǐng)求一直粘連在機(jī)器A上,但是這也不管用,要是機(jī)器A掛掉了,還得轉(zhuǎn)到機(jī)器B去。
那只好做session的復(fù)制了,把session id在兩個(gè)機(jī)器之間搬來(lái)搬去,快累死了。

后來(lái)有個(gè)叫Memcached的支了招: 把session id集中存儲(chǔ)到一個(gè)地方,所有的機(jī)器都來(lái)訪問(wèn)這個(gè)地方的數(shù)據(jù),這樣一來(lái),就不用復(fù)制了,但是增加了單點(diǎn)失敗的可能性,要是那個(gè)負(fù)責(zé)session 的機(jī)器掛了,所有人都得重新登錄一遍,估計(jì)得被人罵死

token
于是有人就一直在思考,我為什么要保存這可惡的session呢,只讓每個(gè)客戶端去保存該多好?
可是如果不保存這些session id,怎么驗(yàn)證客戶端發(fā)給我的session id的確是我生成的呢?如果不去驗(yàn)證,我們都不知道他們是不是合法登錄的用戶,那些不懷好意的家伙們就可以偽造session id,為所欲為了。
嗯,對(duì)了,關(guān)鍵點(diǎn)就是驗(yàn)證 !
比如說(shuō),小F已經(jīng)登錄了系統(tǒng),我給他發(fā)一個(gè)令牌(token),里邊包含了小F的user id,下一次小F再次通過(guò)Http請(qǐng)求訪問(wèn)我的時(shí)候,把這個(gè)token通過(guò)Http header帶過(guò)來(lái)不就可以了。
不過(guò)這和session id沒(méi)有本質(zhì)區(qū)別啊,任何人都可以可以偽造,所以我得想點(diǎn)兒辦法,讓別人偽造不了。
那就對(duì)數(shù)據(jù)做一個(gè)簽名吧,比如說(shuō)我用SHA256算法,加上一個(gè)只有我才知道的密鑰,對(duì)數(shù)據(jù)做一個(gè)簽名,把這個(gè)簽名和數(shù)據(jù)一起作為token,由于密鑰別人不知道,就無(wú)法偽造token了。

這個(gè)token 我不保存,當(dāng)小F把這個(gè)token 給我發(fā)過(guò)來(lái)的時(shí)候,我再用同樣的HMAC-SHA256 算法和同樣的密鑰,對(duì)數(shù)據(jù)再計(jì)算一次簽名,和token 中的簽名做個(gè)比較,如果相同,我就知道小F已經(jīng)登錄過(guò)了,并且可以直接取到小F的user id,如果不相同,數(shù)據(jù)部分肯定被人篡改過(guò),我就告訴發(fā)送者:對(duì)不起,沒(méi)有認(rèn)證。

Token 中的數(shù)據(jù)是明文保存的(雖然我會(huì)用Base64做下編碼,但那不是加密),還是可以被別人看到的,所以我不能在其中保存像密碼這樣的敏感信息。
當(dāng)然,如果一個(gè)人的token被別人偷走了,那我也沒(méi)辦法,我也會(huì)認(rèn)為小偷就是合法用戶,這其實(shí)和一個(gè)人的session id被別人偷走是一樣的。
這樣一來(lái),我就不保存session id了,我只是生成token,然后驗(yàn)證token,我用我的CPU計(jì)算時(shí)間獲取了我的session存儲(chǔ)空間 !
cookie
cookie 是一個(gè)非常具體的東西,指的就是瀏覽器里面能永久存儲(chǔ)的一種數(shù)據(jù),僅僅是瀏覽器實(shí)現(xiàn)的一種數(shù)據(jù)存儲(chǔ)功能。
cookie由服務(wù)器生成,發(fā)送給瀏覽器,瀏覽器把cookie以kv形式保存到某個(gè)目錄下的文本文件內(nèi),下一次請(qǐng)求同一網(wǎng)站時(shí)會(huì)把該cookie發(fā)送給服務(wù)器。
由于cookie是存在客戶端上的,所以瀏覽器加入了一些限制確保cookie不會(huì)被惡意使用,同時(shí)不會(huì)占據(jù)太多磁盤(pán)空間,所以每個(gè)域的cookie數(shù)量是有限的。
Token的身份驗(yàn)證
用戶通過(guò)用戶名和密碼發(fā)送請(qǐng)求。
程序驗(yàn)證。
程序返回一個(gè)簽名的token 給客戶端。
客戶端儲(chǔ)存token,并且每次用于每次發(fā)送請(qǐng)求。
服務(wù)端驗(yàn)證token并返回?cái)?shù)據(jù)。

JWT
JSON Web Token(縮寫(xiě) JWT)是目前最流行的跨域認(rèn)證解決方案。
JWT的原理
JWT 的原理是,服務(wù)器認(rèn)證以后,生成一個(gè) JSON 對(duì)象,發(fā)回給用戶。以后,用戶與服務(wù)端通信的時(shí)候,都要發(fā)回這個(gè) JSON 對(duì)象。服務(wù)器完全只靠這個(gè)對(duì)象認(rèn)定用戶身份。為了防止用戶篡改數(shù)據(jù),服務(wù)器在生成這個(gè)對(duì)象的時(shí)候,會(huì)加上簽名。
JWT 的數(shù)據(jù)結(jié)構(gòu)
它是一個(gè)很長(zhǎng)的字符串,中間用點(diǎn)(.)分隔成三個(gè)部分。
JWT 的三個(gè)部分依次如下。

Header 部分是一個(gè) JSON 對(duì)象,描述 JWT 的元數(shù)據(jù),
Payload 部分也是一個(gè) JSON 對(duì)象,用來(lái)存放實(shí)際需要傳遞的數(shù)據(jù)。JWT 規(guī)定了7個(gè)官方字段供選用。
Signature 部分是對(duì)前兩部分的簽名,防止數(shù)據(jù)篡改。
項(xiàng)目實(shí)例
我們現(xiàn)在看下項(xiàng)目中的應(yīng)用實(shí)例
生成token
先來(lái)看看后端是如何生成一個(gè)token的
router.post("/login", async function(req, res, next) {
const { username, password } = req.body;
const userinfo_pw = await query(my_user_info_with_password, [username]);
const md5_pw = md5(password);
if (userinfo_pw.length === 0) {
res.json({
code: 4001,
msg: "no such user"
});
} else if (md5_pw === userinfo_pw[0].password) {
const token = jwt.sign(
{
iss: "joyinn",
aud: username,
uid: userinfo_pw[0].uid
},
myconfig.jwtSecret,
{
// 授權(quán)時(shí)效1day
expiresIn: 60 * 60 * 24
}
);
// update last login time
await query(update_logintime, [userinfo_pw[0].uid]);
// get userinfo
const userinfo = await query(my_user_info, [username]);
res.json({
code: 0,
msg: "login ok",
token,
user: userinfo[0]
});
} else {
res.json({
code: 4002,
msg: "wrong password"
});
}
});
當(dāng)響應(yīng)login handler,比對(duì)密碼正確后,調(diào)用jsonwebtoken依賴(lài)庫(kù)生成token
const jwt = require("jsonwebtoken");
const token = jwt.sign(
{
iss: "joyinn",
aud: username,
uid: userinfo_pw[0].uid
},
myconfig.jwtSecret,
{
// 授權(quán)時(shí)效1day
expiresIn: 60 * 60 * 24
}
);
這里jwt.sign(payload, secretOrPrivateKey, [options, callback]),具體文檔可以參考jsonwebtoken
判斷token正確性
依舊是后端,我們?cè)趺磁袛嘤脩舻恼?qǐng)求攜帶的登錄態(tài)信息是正確的
根據(jù)上面陳述的token原理,我們只需要利用secret對(duì)JWT的前兩段加密得到簽名,比對(duì)JWT給出的簽名即可
具體實(shí)現(xiàn)我們可以采用express-jwt依賴(lài)庫(kù),會(huì)對(duì)headers中的Authorization字段的token進(jìn)行檢驗(yàn)
// filename: app.js
const expressJWT = require("express-jwt");
// ... other code
// 設(shè)置允許跨域訪問(wèn)該服務(wù).
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS,POST,PUT");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization"
);
next();
});
// jwt auth
app.use(
expressJWT({
secret: myconfig.jwtSecret
}).unless({
path: [
"/user/login",
"/user/register",
"/user/mailtozju",
"/user/checkvalidcode"
] //除了這個(gè)地址,其他的URL都需要驗(yàn)證
})
);
我們這里將jwt驗(yàn)證中間件放在url響應(yīng)函數(shù)之前,同時(shí)通過(guò)unless參數(shù)設(shè)置不需要jwt檢驗(yàn)的api,實(shí)現(xiàn)了一個(gè)攔截層中間件
訪問(wèn)jwt內(nèi)部payload
中間件會(huì)將驗(yàn)證解析好的數(shù)據(jù)放在request里面,比如我們?cè)趆andler里面可以通過(guò)req.user訪問(wèn)到token的所有信息
// post one say
router.post("/", async (req, res) => {
const { photo, say_text } = req.body;
const uid = req.user.uid;
console.log(req.user);
const photoNum = JSON.parse(photo).length;
console.log("photoNum", photoNum);
let type;
if (photoNum === 0) type = 0;
else if (say_text === "") type = 1;
else type = 2;
const insertResult = await query(post_say, [type, say_text, photo, uid, 1]);
res.json({
code: 0,
msg: "insert success",
insertId: insertResult.insertId
});
});
前端http通信的設(shè)置
這里我的實(shí)例項(xiàng)目是vuejs,http通信采用了axios,作為一個(gè)plugin被調(diào)用
我們?yōu)榱藢?shí)現(xiàn)jwt加載,需要在發(fā)送數(shù)據(jù)包和接收數(shù)據(jù)包的時(shí)候均對(duì)axios模塊添加一些邏輯
// 在發(fā)送之前檢查localstorage里面有無(wú)token字段,如果有就寫(xiě)入Authorization字段
_axios.interceptors.request.use(
function(config) {
const my_token = window.localStorage.getItem("token");
if (my_token) {
config.headers["Authorization"] = `Bearer ${my_token}`;
}
return config;
},
function(error) {
return Promise.reject(error);
}
);
// 在收到以后檢查response中有無(wú)token字段,如果有就將token寫(xiě)入到localstorage中
// 如果響應(yīng)發(fā)生了錯(cuò)誤(一般是由于在未登錄狀態(tài)下訪問(wèn)api、或者token有誤導(dǎo)致被express-jwt攔截)
// 那么就移除token并且跳轉(zhuǎn)到登錄頁(yè)面
_axios.interceptors.response.use(
function(response) {
if (response.data.token) {
window.localStorage.setItem("token", response.data.token);
}
return response;
},
function(error) {
const errRes = error.response;
if (errRes.status === 401) {
window.localStorage.removeItem("token");
router.push("/login");
}
return Promise.reject(error);
}
);
我對(duì)vue默認(rèn)的axios plugin做了二次封裝,添加了傳入自定義config以及token支持,詳細(xì)代碼可見(jiàn)gist
bonus: 路由守衛(wèi)
我們有了用戶登錄態(tài),對(duì)vue路由守衛(wèi)實(shí)踐起來(lái)自然就容易了
Vue.use(Router);
let router = new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "home",
component: Home,
meta: {
requireAuth: true
}
},
{
path: "/login",
name: "login",
component: Login,
meta: {
requireAuth: false
}
},
// ... more routes
]
});
router.beforeEach((to, from, next) => {
const token = localStorage.getItem("token") || null;
if (to.matched.some(record => record.meta.requireAuth)) {
if (!Auth.loggedIn(token)) {
next("/login");
} else {
if (!store.state.user.isLogin) {
axios.get("/user/getuserinfo").then(res => {
store.dispatch("setUser", res.data.user);
next();
});
} else next();
}
} else if (to.path === "/login" && Auth.loggedIn(token)) {
next("/");
} else {
next();
}
});
export default router;
我們對(duì)路由的meta對(duì)象加入requireAuth,設(shè)置布爾值表示是否需要路由守衛(wèi)
接下來(lái)在router.beforeEach函數(shù)中對(duì)token進(jìn)行檢驗(yàn),剩余對(duì)state更新以及路由跳轉(zhuǎn)等邏輯我想不需要再贅述
btw
如果對(duì)這一工程實(shí)例感興趣的話可以查看joyinn
這是一個(gè)論壇的demo,采用expressjs+mysql+vue全家桶,說(shuō)不定你能有所啟發(fā)呢!