JWT用戶登錄態(tài)管理(expressjs vue)

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í)了

  1. 用戶向服務(wù)器發(fā)送用戶名和密碼。

  2. 服務(wù)器驗(yàn)證通過(guò)后,在當(dāng)前對(duì)話(session)里面保存相關(guān)數(shù)據(jù),比如用戶角色登錄時(shí)間等等。

  3. 服務(wù)器向用戶返回一個(gè)session_id,寫(xiě)入用戶的Cookie。

  4. 用戶隨后的每一次請(qǐng)求,都會(huì)通過(guò) Cookie,將session_id傳回服務(wù)器。

  5. 服務(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)搬去,快累死了。

image

后來(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ì)得被人罵死

image

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了。

image

這個(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)證。

image

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)證

  1. 用戶通過(guò)用戶名和密碼發(fā)送請(qǐng)求。

  2. 程序驗(yàn)證。

  3. 程序返回一個(gè)簽名的token 給客戶端。

  4. 客戶端儲(chǔ)存token,并且每次用于每次發(fā)送請(qǐng)求。

  5. 服務(wù)端驗(yàn)證token并返回?cái)?shù)據(jù)。

image

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è)部分依次如下。

image

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ā)呢!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容