網友問到:網頁使用 ASP.NET Session 保存資料,因輸入內容較多加上使用者需接電話或離開辦事,操作過程常超過 20 分鐘,送出表單時 Session 資料早已逾時被清除導致錯誤。遇到這種情境,除了延長逾時期限(預設值只有 20 分鐘自有其考量,過長會導致使用者關閉網頁後資料仍殘留在伺服器佔用空間,亦提高資訊外洩的風險),一個簡單解法是在背後定時送出請求讀取 Session 避免逾時,我習慣稱它為 Heartbeat,這篇文章將會示範這個實用小技巧。

ASP.NET Session 預設的逾時時間是 20 分鐘,拎杯性急如王藍田,做實驗等 20 分鐘不如一刀給我個痛快,為方便測試,在 web.config 指 sessionState timeout 縮短成一分鐘。

  <system.web>
    <compilation debug="true" targetFramework="4.7.2"/>
    <httpRuntime targetFramework="4.7.2"/>
    <sessionState timeout="1"></sessionState>
  </system.web>

在此以 ASP.NET MVC 示範,相同原理可套用在 WebForm。IdleDetectController.cs 程式如下。顯示網頁時將動態產生一個 GUID Token 對映到 Session 資料,並另寫一個 Action 供前端取回 Session 資料檢查是否遺失。

using System;
using System.Web.Mvc;

namespace MvcWeb.Controllers
{
    public class IdleDetectController : Controller
    {
        [HttpGet]
        public ActionResult Index()
        {
            var token = Guid.NewGuid().ToString();
            Session[token] = DateTime.Now.ToString("HHmmss");
            ViewBag.Token = token;
            return View();
        }

        public ActionResult Check(string token)
        {
            return Content(Session[token] as string ?? "lost");
        }
    }
}

前端程式碼 Index.cshtml 如下。畫面隨意放上 input、checkbox 及 textarea 模擬輸入介面,點擊檢查鈕會以 AJAX 呼叫 /IdleDetect/Check Action 取回 Session 值,若 Session 逾時將得到 lost 文字:

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>網頁閒置測試</title>
</head>
<body>
    <div>
        <input />
        <input type="checkbox" />
        <br />
        <textarea></textarea>
        <br />
        <button id="btnCheck">檢查Session</button>
        <input type="hidden" id="hdnToken" value="@ViewBag.Token" />
    </div>
    <div>
        <ul id="ulMessages">
            
        </ul>
    </div>
    <script src="~/lib/jquery/jquery.min.js"></script>
    <script>
        function showMessage(msg) {
            $("#ulMessages")
                .append("<li>" + new Date().toTimeString() + " " + msg + "</li>");
        }
        var token = $("#hdnToken").val();
        $("#btnCheck").click(function () {
            $.post("@Url.Content("~/IdleDetect/Check")?token=" + token).done(function (res) {
                showMessage("check = " + res);
            });
        });
    </script>
</body>
</html>

由於已設定 Session 一分鐘就 Timeout,故意超過一分鐘再按鈕,如預期得到 Session 消失訊息:

有個簡單小技巧可解決這個問題,既然一分鐘 Session 會 Timeout,我們就 30 秒讀取一次,這種設計我習慣稱之為 Heartbeat。在 Server 端加上 Heartbeat Action,被呼叫時讀取 Session,同時我們再讓它精緻一點:若發現 Session 因 AppPool 重啟或其他原因遺失,則傳回 Session lost 以便提示使用者:

public ActionResult Heartbeat(string token)
{
    //讀取Session,避免逾時
    var chk = Session[token] as string;
    if (string.IsNullOrEmpty(chk))
        return Content("Session lost");
    return Content("OK");
}

前端部分用 setInterval() 每 30 秒觸發一次 /IdleCheck/Heartbeat,維持 Session 不會逾時。若伺服器端傳回 Session 已遺失,則 alert 告知使用者並停止定期發送。

var hnd = setInterval(function () {
    $.post("@Url.Content("~/IdleDetect/Heartbeat")?token=" + token).done(function (res) {
        if (res !== "OK") {
            clearInterval(hnd);
            alert("資料連線中斷,請重新整理網頁 - " + res);
        }
    });
}, 30 * 1000);

修改後實測,就算超過兩分鐘再檢查 Session 資料也不會遺失,而透過 F12 開發工具,可以看到這是背後每 30 秒一次 Heartbeat 的功勞:

測試重啟網站,警示也如預期出現並且停止 Heartbeat 傳送:

至此,我們已達成「Session 永遠不會因逾時消失」的要求。但是等等! 永遠不逾時? 這不太對,使用者若忘了關網頁,網頁不斷發送 Heartbeat ,伺服器一直保留 Session,一方面浪費資源,另一方面介面可能遭人盜用危及資安,母湯呀母湯。因此,更理想的設計是當使用者未操作網頁一段時間,系統還是該中斷 Session 甚至退出操作介面。

以下是我常用的解法 - 用 setInterval 跑閒置計數器每秒加 1,偵測 onmousedown 及 onkeydown 事件遇使用者點滑鼠或敲鍵盤就歸零重新計時;當計數器累積到一定門檻顯示警示,超過上限即中斷連線禁止操作。完整範例程式如下:

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>網頁閒置測試</title>
    <style>
        #spnIdleWarning {
            color: coral;
        }

        .blink {
            animation: blink-animation 1s steps(5, start) infinite;
            -webkit-animation: blink-animation 1s steps(5, start) infinite;
        }

        @@keyframes blink-animation {
            to {
                visibility: hidden;
            }
        }

        @@-webkit-keyframes blink-animation {
            to {
                visibility: hidden;
            }
        }
    </style>
</head>
<body>
    <div id="dvIdleInfo">
        操作閒置倒數 <span id="spnCountdown"></span> 秒
        <span id="spnIdleWarning" class="blink"></span>
    </div>
    <div>
        <input />
        <input type="checkbox" />
        <br />
        <textarea></textarea>
        <br />
        <button id="btnCheck">檢查Session</button>
        <input type="hidden" id="hdnToken" value="@ViewBag.Token" />
    </div>
    <div>
        <ul id="ulMessages">
        </ul>
    </div>
    <script src="~/lib/jquery/jquery.min.js"></script>
    <script>
        function showMessage(msg) {
            $("#ulMessages")
                .append("<li>" + new Date().toTimeString() + " " + msg + "</li>");
        }
        var token = $("#hdnToken").val();
        $("#btnCheck").click(function () {
            $.post("@Url.Content("~/IdleDetect/Check")?token=" + token).done(function (res) {
                showMessage("check = " + res);
            });
        });
        var hndHeartbeat = setInterval(function () {
            $.post("@Url.Content("~/IdleDetect/Heartbeat")?token=" + token).done(function (res) {
                if (res !== "OK") {
                    clearInterval(hndHeartbeat);
                    alert("資料連線中斷,請重新整理網頁 - " + res);
                }
            });
        }, 30 * 1000);


        var idleCounter = 0;
        var idleWarn = 90;
        var idleLimit = 120;
        var showIdleWarning = function (msg) {
            $("#spnIdleWarning").text(msg);
        }
        var showCountdown = function () {
            $("#spnCountdown").text(idleLimit - idleCounter);
        }
        showCountdown();
        var hndIdleDetect = setInterval(function () {
            idleCounter++;
            showCountdown();
            if (idleCounter > idleLimit) {
                clearInterval(hndHeartbeat);
                clearInterval(hndIdleDetect);
                alert("閒置逾時,請重新登入");
                //TODO: 導向登入畫面
            }
            else if (idleCounter > idleWarn) {
                showIdleWarning("提醒:操作閒置過久,系統即將登出");
            }
        }, 1000);
        $("body").on("mousedown keydown", function () {
            if (idleCounter > idleWarn) showIdleWarning("");
            idleCounter = 0;
        });
    </script>
</body>
</html>

下面動畫是實際運作的效果:

顯示警示後若繼續閒置至上限,網頁將停止倒數及 Heartbeat、彈出訊息並強制使用者登出。

以上就是我常用的防止 Session 逾時及使用者操作閒置偵測技巧。

Tutorial of heartbeat trick to prevent ASP.NET session timeout and user operation idle detection exmaple.


Comments

# by Grace

請問大師,為什麼不使用webconfig設定session timeout時間拉長達到本文目的呢?謝謝。

# by Jeffrey

to Grace, web.config 預設值只設 20 分鐘是有原因的。拉長 Timeout 有負面影響,一是使用者關閉網頁後垃圾資料會殘留在伺服器端過久耗用空間,二則敏感性資料留著愈久資安風險愈高,視你的應用情境而定。若你的用戶不多,Session 資料很小且無關安全,延長 Timeout 自然是最省事的解法沒錯。但不是每個情境都能用這招,例如銀行WebATM或資安要求較嚴的應用,就會出現允許使用者持續操作很久,但一進入閒置則要盡快把使用者踢出去的兩極化要求,此時便需要較複雜的設計。

# by Chong

This is awesome!

# by ChrisTorng

我在使用 SignalR 時遇到一個會導致斷線的問題。若出現 alert/confirm 訊息不按掉的話,背景的 JavaScript keep alive 等統統不會執行,結果 SignalR 連線會中斷。因此我們有要求不可使用 alert/confirm,必須以其他 UI library 所提供的 html message box 相關功能代替。因此若真要「Session 永遠不會因逾時消失」,還得注意禁用 alert/confirm 才行。

# by ChrisTorng

我在這裡偶爾也會遇到 Captcha 逾時的問題,這裡提供一些可能的改善建議: * 載入頁面時不產生 Captcha,而是點入 comment 欄位,或者開始輸入 comment,動態展開 Name/Captcha 時才產生載入 * 如果 comment 裡有文字,才以 heartbeat 方式持續保持 Captcha 有效不逾時 * 如果因為未輸入/未點滑鼠而真的逾時了,停用 Post comment 按鈕,於 Captcha 欄位顯示訊息 * 提供更新 Captcha 功能,比如在 Captcha 處顯示重新整理鈕,或者鍵盤焦點進入 Captcha 輸入欄位時自動更新 * 更新 Captcha 值後啟用 Post comment 按鈕,並再重新以 heartbeat 保持有效

# by ChrisTorng

另兩位數 Captcha 對於我這個腦筋退化比較快的人有時還真有點難...本來想建議改為兩個一位數加減,但又想到 10~18 的機率越來越少,而 0~9 隨便猜,可能有近 1/10 的機率可以猜對...攻擊成功的機率太高了...最後認為兩位數加減一位數,對攻擊者仍然要猜 0~99,攻擊成功機率未增加,但對我少算一位,確實容易一些...提供參考...

# by ChrisTorng

除了 alert/confirm 還得加上 prompt,這是我目前所知必須禁用的指令...

Post a comment


98 - 79 =