承接上篇,建立了一個Cookie-Based的認證方式

這回要做的是網頁的閒置登出機制

網頁的閒置登出機制可以很簡單、很單純,也可以非常的複雜

比較簡單來說,大概就是登入後放置個幾秒就把你登出

需求

而以下是這次要來挑戰的目標

  1. 閒置10分鐘要跳閒置警告

  2. 閒置警告

    • 倒數1分鐘後觸發強制登出

    • 閒置警告可以跳出來2次

    • 當第3次閒置時,觸發強制登出

  3. 強制登出

    • 要跳到首頁

    • 要顯示提示訊息,讓user按下確定才會關

  4. 閒置登出機制要跨瀏覽器分頁

  5. 閒置登出機制要能在手機上作用

完成後的demo如下:

  • 在示意影片中:
    • 閒置10分鐘調整成閒置3秒鐘
    • 閒置警告倒數1分鐘調整成5秒鐘

Your browser does not support playing HTML5 video. You can download a copy of the video file instead.

需求分析

Baseline

首先,當在桌電上,且單一網頁的情況下,條件1~3其實還算是單純的

直觀上來說,可以做個2層的巢狀Timeout,在配合上user停止動作後去觸發第一層Timeout來進行實作

以下是概略架構:

Timer1 = setTimeout( ()=>{
    Timer2 = setTimeout( ()=>{
        // 執行強制登出
    }, 1 * 60 * 1000 // 倒數1分鐘
}, 10 * 60 * 1000 ) // 倒數10分鐘

接著搭配事件綁定來觸發RunTimer1

每次執行RunTimer1時,都先把Timer1給清掉

這樣就能做到user有動作就重新計算閒置時間的功能

function RunTimer1(){
    clearInterval(Timer1)
    Timer1.run() // js沒有這個指令,這邊只是示意
}

let unidleEvents = "mousemove click mouseup mousedown keydown keypress keyup submit change mouseenter scroll resize dblclick"
$(window).bind(unidleEvents, RunTimer1)

而以上的事件綁定我們可以用 throttle 來改善效能

如果不進行改善的話,光是滑鼠的移動,就會觸發非常多次的 RunTimer1

而加上 throttle 的話,則可以讓指定的function在一定時間內只被執行一次 [1]

$(window).bind(unidleEvents, throttle(RunTimer1, 200))

問題點

  1. 多分頁

    • 問題

      很顯然的,當多分頁的情況下,光是閒置倒數的時間就會不同

      畢竟 RunTimer1 會在不同的時間點被啟動

    • 解法

      為了同步跨分頁的Timer,可以利用 localStorage 來進行資料的儲存

      並利用 Vue 響應式的機制來觸發主要邏輯

  2. 手機

    • 問題

      若 User 使用手機進行瀏覽時,若完成登入後,看沒多久,就使用其他APP

      那網頁上的Timer,由於手機效能考量的設計,Timer是不會繼續倒數計時的

      因此如果現在是倒數50秒跳出,當 User 切換到其他APP使用了100秒後回到網頁

      那網頁上的時間還是會在50秒

    • 解法

      將 setTimeout 改成 setInterval 並且當 User 開始閒置時,設置一個初始時間點

      讓 interval 不斷地用現在的時間跟初始時間點做計算

      這樣當 User 從其他 app 切回來時,時間的計算就會對上了

  3. 極端一點來說

    若在電腦上剛完成登入就關掉分頁,一個小時候重新開啟這個分頁,那麼理論上應該要直接登出

    但如果用 setTimeout 的方式,將可能會變成僅觸發 閒置警告,或者還在偵測閒置時間,連閒置警告都不會跳出

Nested Interval Approach

綜合以上,若以 Baseline 中,巢狀 Timeout 為基礎,用 setInterval 來進行改造:

let startTimestamp = new Date()
Timer1 = setInterval( ()=>{
    if(new Date() - startTimestamp < 10 * 60 * 1000 ){ // 倒數10分鐘
        return
    }

    clearInterval(Timer1)
    startTimestamp = new Date()
    Timer2 = setInterval( ()=>{
        if(new Date() - startTimestamp < 1 * 60 * 1000 ){ // 倒數1分鐘
            return
        }

        clearInterval(Timer2)
        // 執行強制登出
    }, 200
}, 200 )

問題點

  1. 最明顯的地方在於,沒有使用 localStorage 因此時間變數還是沒辦法跨分頁共享

  2. 再來還有一個問題,當網頁進入閒置警告時,若時間變數已經從 localStorage 中得到同步

    這時如果開新的分頁進入網頁,那新的網頁會優先進入 Timer1 這時如果內部的邏輯沒處理好

    將可能導致新的分頁不會進入閒置警告的畫面

Reactive Interval Approach

其實以上的Approach遇到很多狀況都需要作出很複雜的處理

比方說登出時Timer要停掉,登入時Timer要啟動,多分頁資料的同步也是個大學問

因此為了讓各種狀況變得比較好應對,我採用響應式的方式來進行實作,基本理念如下

  1. 將 Vuex store 改成儲存在 localStorage

    這樣就可能在開發時正常操作 store,然後利用 store.commit 來同步資料到 localStorage

  2. 將閒置次數區分成 local 與 golbal(存在localStorage)

    由於閒置3次要強制登出,那就必須要有個變數紀錄目前閒置幾次

    如果每次觸發閒置警告,每個分頁都幫 localStorage 中的閒置次數+1

    那有幾個分頁就會增加幾次,那樣次數就會亂掉了

    因此,在閒置次數的判斷上,可以用 local 的閒置次數進行判斷

    當網頁第一次載入時,從localStorage將值assign到local閒置次數中

    當確定要增加次數時,先幫自己+1,接著用 assign 的方式塞到 localStorage

    這樣不管assign幾次,localStorage中的值都不會被重複增加

  3. 設定閒置狀態

    enum IdleDetermineStates{
        ByPass,       // 不須使用閒置登出狀態
        PageIdle,     // 要觀察網頁是否閒置
        UserComfirm,  // 要進行閒置警告的倒數
        Logout,       // 執行登出的動作
    }
    
  4. 設置一個 Interval

    使用一個 Interval ,在網頁載入後就啟動不停止

    並根據現在的閒置狀態來進行時間的計算,以及狀態的轉移

    const intervalRunner = setInterval(()=>{
      switch (store.state.pageIdle.idleDetermineStates) {
          case IdleDetermineStates.PageIdle:
              passSeconds = 閒置秒數
              if ( passSeconds >= idleSeconds) {
                SetIdleDetermineStates(IdleDetermineStates.UserComfirm)
              }
              break
    
          case IdleDetermineStates.UserComfirm:
              remainUserComfirmSeconds = 倒數計時秒數
              if ( remainUserComfirmSeconds <= 0 ) {
                SetIdleDetermineStates(IdleDetermineStates.Logout)
              }
              break
    
          case IdleDetermineStates.Logout:
              SetIdleDetermineStates(IdleDetermineStates.ByPass)
              break
    
          case IdleDetermineStates.ByPass:
          default:
              break
      }
    },250)
    
  5. 設置 watcher 以應對閒置狀態的改變

    watch( () => store.state.pageIdle.idleDetermineStates, 
        (newStates: IdleDetermineStates, oldStates: IdleDetermineStates) =>{
            if (oldStates == IdleDetermineStates.PageIdle && 
                newStates == IdleDetermineStates.UserComfirm) {
                AddIdleTimes()
                if ( idleRemainTimes <= 0 ) {
                  SetIdleDetermineStates(IdleDetermineStates.Logout)
                }else{
                  SetIsShowUserConfirm(true)
                }
            }
      
            if (oldStates == IdleDetermineStates.UserComfirm && 
                newStates == IdleDetermineStates.Logout) {
                Logout()
                SetIsShowLogOutNotification(true)
            }
    })
    
  6. Assign 計算時間點

    當 User 有在使用網頁時,就同步紀錄閒置時間的起始時間點 & 閒置警告的起始時間點

    function SetTimeStamp(){
        const startIdleTimestamp = new Date()
        const startUserConfirmTimestamp = new Date (startIdleTimestamp.getTime() + props.idleSeconds * 1000 + 250)
        store.commit("pageIdle/SetStartIdleTimestamp", startIdleTimestamp)
        store.commit("pageIdle/SetStartUserConfirmTimestamp", startUserConfirmTimestamp)
    }
    
    function PageIdleHandler(){
        if( store.state.pageIdle.idleDetermineStates == IdleDetermineStates.PageIdle){
            SetTimeStamp()
        }
    }
    
    $(window).bind(unidleEvents, throttle(PageIdleHandler, 200) )
    

安裝套件

為了達成以上的需求,以下是這次所安裝的套件

vuex-multi-tab-state

npm install -D vuex-multi-tab-state

透過在 plugins 中進行註冊,就能直接讓 store 裡的資料儲存在 localStorage 中

import { createStore } from 'vuex'

import createMultiTabState from 'vuex-multi-tab-state'

export default createStore({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  },
  plugins: [
    createMultiTabState(),
  ],
})

但在使用上有幾個地方要小心

  1. 型別

    由於 localStorage 中只能儲存字串,因此在拿取資料時,比方說 Date Type 的資料時,要進行轉型一下會比較保險

  2. 更新機制

    有時為了方便,會使用 store.state.variable = something 的方式來異動資料

    但這樣的寫法會無法使 vuex-multi-tab-state 同步更新 localStorage

    必須使用 mutation 的方式,套件才能將資料進行同步

jquery

npm install -D jquery
npm install -D @types/jquery // 由於有使用到 typescript ,因此要再安裝他的一些定義

lodash

npm install -D lodash.debounce
npm install -D @types/lodash.debounce
npm install -D lodash.throttle
npm install -D @types/lodash.throttle

vue-toastification

用來進行登出時,右上跳出通知的處理

npm install -D vue-toastification@next

material-design-icons

npm install -D material-design-icons

animate.css

npm install -D animate.css

前後端閒置狀態連動機制

如果前端 User 一直使用,卻剛好都沒有呼叫到後端的功能的話,後端是有可能自己閒置登出的

因此可以設置一個 KeepAlive() 確定 User 有在動作後,每隔30秒就呼叫一次後端,來避免後端自己閒置登出

  • 後端部分

    /// <summary>
    /// for refreshing session
    /// </summary>
    /// <returns></returns>
    [HttpPost]
    public ActionResult KeepAlive()
    {
        return Ok();
    }
    
  • 前端部分

    $(window).bind(unidleEvents , debounce(KeepAlive, 30 * 1000));
    

    關於 debounce 可以參考 [1:1]

測試

雖然需求的描述只有少少的幾行,跟5個列點,但實際上,第4跟第5點一加下去

WOW,要測的edge case就不少了,這邊就先簡單條列一些狀況:

  1. 開多個分頁,登入後閒置,每個分頁要一起跳出閒置警告畫面
  2. 使用過程中若不斷 F5,狀態不能錯(閒置次數要對、閒置狀態要對 ...)
  3. 在閒置警告畫面出現時開新分頁,要能夠正確顯示閒置警告畫面
  4. 登入後就關掉網頁,過了閒置時間+等待確認時間後再重新打開分頁,應該要登出
  5. 用手機時,用一半跑去用其他app後再回來,時間跟狀態的計算要對

總結來說

由於資料會異步更新,因此非常的考驗開發時頭腦的清晰度,否則狀態很容易就弄錯了

再來,開發時能避免巢狀還是儘量避免得好,對腦細胞來說比較友善一點(?

雖然處理多工的難度比較大,但實作出來後的成就感也是滿大的

以上就是一個小小的閒置登出機制,完整的Code在此 歡迎參考看看啦


  1. 關於 throttle, debounce 可以到這個網站 來玩玩看,應該會更有感覺 ↩︎ ↩︎