在 Inertia.js 中防止用戶登出後返回敏感頁面

程式技術

最近開始在用 Inertia.js 搭配 Laravel 與 Svelte 寫網頁。不用不知道,一用嚇一跳,有了 Inertia.js 當做前後端的粘合劑,我除了不用寫 API 以外,還可以在前後端都使用自己喜歡具工具,開發起來真的非常舒服。

不過開發沒多久我就遇到了一個問題,那就是用戶在登出後,可以透過返回上一頁回到需要登入的頁面,這個 …

妥妥的資安漏洞啊!

存在已久的問題

這個問題在 Inertia.js V1 實際上就已經被討論很久了,很多人也希望 Inertia.js 能想辦法解決這個問題,然而這個看似簡單的問題,想要解決卻不是這麼容易。

主要原因是 Inertia.js 會將用戶訪問的頁面紀錄儲存在瀏覽器的歷史狀態(History State)中,基本上瀏覽器都允許用戶透過歷史狀態回去訪問之前的頁面,而瀏覽器並沒有提供任何 API 可以清除歷史狀態,這才讓阻擋用戶在登出後返回上一頁變得有些困難。

Inertia.js 的解決方案 — History Encryption

為了解決這個問題,Inertia.js 的作者想了不少解決方案,例如將頁面訪問紀錄儲存在 localStoragesessionStorage 或是 indexedDB 中,雖然這些方案都提供清除紀錄的功能,但這些解決方案都有存儲上限的限制。

最後作者想到了一個很精妙的解決辦法 — History Encryption。使用 Crypto API 產生一個金鑰來加密頁面紀錄,並把這個金鑰儲存在 sessionStorage 中,當用戶登出後,後端可以透過回應(Response)指示前端輪換一個新的金鑰。如此一來,當用戶想要返回上一頁時,就會因為無法解密歷史狀態中的頁面訪問紀錄而停留在當前頁面。

真的是相當厲害的解決辦法。😮

需要注意的是,Crypto API 需要網頁開啟 SSL 才能使用。

在 Laravel 中開啟 History Encryption

想要開啟 History Encryption 功能有很多種方式,最簡單的方式就是設定環境變數。

INERTIA_ENCRYPT_HISTORY=true

或是在 bootstrap/app.php 中,將 EncryptHistoryMiddleware::class 設定為全局中介軟體(Global Middleware)。

use Inertia\EncryptHistoryMiddleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(EncryptHistoryMiddleware::class);
})

如此一來,你就可以在登出後呼叫 Inertia::clearHistory() 方法來輪換前端的金鑰了。用戶也無法在登出後透過返回上一頁訪問登入後的頁面。

public function destroy(Request $request): RedirectResponse
{
    // ...

    Inertia::clearHistory();

    return redirect(route('login'));
}

就是這樣,使用起來非常簡單!

深入看看背後的運作原理

如果深入後端套件查看 Inertia::clearHistory() 背後的邏輯,其實就是在會話中將 inertia.clear_history 的值設定為 true

public function clearHistory(): void
{
    session(['inertia.clear_history' => true]);
}

在回傳 JSON 回應的時候,會根據會話中 inertia.clear_history 的設定,來設定 JSON 回應中 clearHistory 的值。

$this->clearHistory = session()->pull('inertia.clear_history', false);
{
  "clearHistory": true
}

在 Inertia.js 的前端套件中,可以看到套件會根據 clearHistory 的值來決定是否要輪換金鑰。

// packages/core/src/page.ts

if (page.clearHistory) {
    history.clear();
}
// packages/core/src/history.ts

public clear() {
    // 刪除儲存在 SessionStorage 的加密金鑰與初始向量
    SessionStorage.remove(historySessionStorageKeys.key)
    SessionStorage.remove(historySessionStorageKeys.iv)
}

加解密頁面紀錄

history.ts 檔案中,我們可以看到 Inertia.js 會根據 encryptHistory 的值是否為 true 來決定要不要加密頁面紀錄。

// packages/core/src/history.ts

protected getPageData(page: Page): Promise<Page | ArrayBuffer> {
    return new Promise((resolve) => {
        return page.encryptHistory ? encryptHistory(page).then(resolve) : resolve(page)
    })
}

Inertia.js 使用 Crypto API 對頁面紀錄進行加密。

// packages/core/src/encryption.ts

export const encryptHistory = async (data: any): Promise<ArrayBuffer> => {
    // ...

    const encrypted = await encryptData(iv, key, data)

    return encrypted
}

// ...

const encryptData = async (iv: Uint8Array, key: CryptoKey, data: any) => {
    // ...

    return window.crypto.subtle.encrypt(
        {
            name: 'AES-GCM',
            iv,
        },
        key,
        encoded.subarray(0, result.written),
    )
}

來看看解密的部分,也是放在 encryption.ts 檔案中。

// packages/core/src/encryption.ts

export const decryptHistory = async (data: any): Promise<any> => {
    // ...

    return await decryptData(iv, storedKey, data)
}

const decryptData = async (iv: Uint8Array, key: CryptoKey, data: any) => {
    // ...

    const decrypted = await window.crypto.subtle.decrypt(
        {
            name: 'AES-GCM',
            iv,
        },
        key,
        data,
    )

    return JSON.parse(new TextDecoder().decode(decrypted))
}

參考資料

Allen
written by
Allen

持續努力中的後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

2 則留言
訪客

想請教目前是從 Livewire 跳槽去 inertiajs 了嗎?能不能分享是碰到什麼問題才做此決定捏~

新增留言
訪客 2025 年 11 月 13 日