2026年9月18日 星期五

HttpOnly Cookie 與 LocalStorage:前端 Token 儲存該怎麼選?

【前端資安實戰】跨網站共用會員中心,如何安全儲存與傳遞身份憑證(Token)?

在現代 Web 架構中,企業常需要將多個獨立網站(如:官網、商城、部落格)整合至同一個「會員中心」(Single Sign-On, SSO)。在這個情境下,前端該如何安全地儲存與傳遞身份驗證憑證(Token)?又該如何在 XSS(跨站腳本攻擊)CSRF(跨站請求偽造) 之間取得最佳平衡?

本文將深入比較 Cookie (HttpOnly)LocalStorage 的優缺點,並提供一套防禦效果最佳的跨網域身份驗證架構。

儲存機制大比拼:Cookie (HttpOnly) vs LocalStorage

當我們在前端儲存敏感的身份憑證(如 JWT 或 Session Key)時,兩種最常見的儲存載體比較如下:

比較項目Cookie (HttpOnly)LocalStorage
XSS 防護極高。設定 HttpOnly 後,JavaScript 無法讀取內容,能有效防止惡意腳本直接竊取 Token。。JavaScript 可直接讀取,若網站存在 XSS 漏洞,攻擊者只需一條指令 localStorage.getItem('token') 即可竊取憑證。
CSRF 防護需額外防護。瀏覽器在發送請求時會自動帶上 Cookie,若未適當設定,易受跨站請求偽造攻擊。天然免疫。前端需要手動將 Token 放進 HTTP Request Header(如 Authorization),瀏覽器不會自動發送,因此天生免疫 CSRF。
跨網域 (跨站) 讀取設定 Domain 後可於同主網域下的子網域(Subdomain)共享;但若為完全不同的網域,受同源政策限制,需依賴 SameSite=None; Secure 進行跨站傳遞。無法跨網域共享。受限於同源政策(Same-Origin Policy),不同的 Domain 或 Subdomain 間完全無法直接存取對方的 LocalStorage。

跨三個網站共用會員中心的安全架構建議

若三個網站位於完全相同的網域或同源環境,直接使用 HttpOnly Cookie 即可完成驗證;但如果三個網站屬於不同的主網域(Cross-Domain),最佳的架構做法為 「分層儲存 + OAuth 2.0 授權流程」

1. 採用 OAuth 2.0 / OIDC (Authorization Code Flow with PKCE)

  • 驗證流程:當使用者造訪子網站 A 時,系統將使用者導頁至「會員中心」進行登入。

  • 發放授權碼:登入成功後,會員中心發放短期一次性的授權碼(Authorization Code)並重新導向回子網站 A。

  • 換取 Token:子網站 A 的後端伺服器在背景用授權碼向會員中心換取 Token,避免 Token 暴露於前端網址中。

2. Token 分層儲存策略(短命 Access Token + 長命 Refresh Token)

  • Refresh Token(長效憑證)

    • 儲存位置:僅儲存於「會員中心」網域的 HttpOnly + Secure + SameSite Cookie 中。

    • 目的:確保 JavaScript 完全無法讀取,大幅降低 XSS 被竊取的風險。

  • Access Token(短效憑證)

    • 儲存位置:子網站前端收到後,僅保存在 記憶體(Memory State / React Context / Vuex) 中。

    • 目的:每次發送 API 時手動置於 Header(Authorization: Bearer <Token>),天生防禦 CSRF;且不寫入 LocalStorage,避免遭 XSS 永久竊取。

3. 多層次防範 CSRF 攻擊

當使用 Cookie 來維持驗證狀態時,必須搭配以下措施:

  • SameSite 屬性:將 Cookie 設定為 SameSite=LaxSameSite=Strict(若需跨站支援則設為 SameSite=None; Secure)。

  • CSRF Token:對所有會改變資料狀態的非 GET 請求(POST/PUT/DELETE),在 Header 額外帶入抗偽造的 CSRF Token。

  • 檢查 Origin / Referer:後端嚴格驗證請求來源標頭,擋下非允許網域的發起請求。

結語

在跨網站會員系統的架構中,不存在完美的單一儲存方式,而是需要複合式的安全設計

將長效的 Refresh Token 藏在 HttpOnly Cookie 之後以防範 XSS,並搭配 OAuth 2.0 授權流程 將短效 Access Token 放在記憶體中發送以防範 CSRF,是目前兼顧安全性與使用者體驗的最佳實踐方案!

【高併發實戰】電商結帳系統的 5 大崩壞隱患與重構指南

在電商系統中,「結帳」是最核心、也最容易遇到考驗的環節。低併發時運作正常的程式碼,往往在活動大促、數百人同時下單的瞬間崩潰——出現庫存超賣、資料不一致,甚至是整個 HTTP 連線卡死。

本文將透過一段常見的 Laravel 結帳程式碼,深入拆解高併發下的 5 大致命問題,並提供具體的重構程式碼與改善方向。

經典範例:看似正常的結帳程式碼

請先看以下這段常見的結帳邏輯:

PHP
public function checkout(Request $request)
{
    $items = $request->input('items');
    $total = 0;
    
    foreach ($items as $item) {
        $product = DB::table('products')
            ->where('id', $item['id'])
            ->first();
            
        $total += $product->price * $item['qty'];

        DB::table('products')
            ->where('id', $item['id'])
            ->decrement('stock', $item['qty']);
    }

    DB::table('orders')->insert([
        'user_id' => auth()->id(),
        'total'   => $total
    ]);

    Mail::to(auth()->user()->email)
        ->send(new OrderConfirmed());
}

五大問題深入剖析

1. 競態條件與超賣 (Race Condition & Over-selling)

  • 問題說明decrement() 執行時未事先檢查庫存,也缺乏悲觀鎖(Pessimistic Locking)。當數百個請求同時搶購同一件商品時,多個執行緒會同時讀到足夠庫存並執行扣減,導致庫存直接被扣成負數。

  • 改善方式:使用 lockForUpdate() 鎖定商品列,或改用條件更新: where('id', $id)->where('stock', '>=', $qty)->decrement('stock', $qty)

2. 缺乏資料庫交易 (Database Transaction)

  • 問題說明:扣減庫存與建立訂單沒有包在同一筆交易中。若庫存已扣除,但後續資料庫突然連線異常導致建立訂單失敗,將造成「庫存被扣除,卻沒有產生訂單」的嚴重資料不一致。

  • 改善方式:將整個結帳流程包在 DB::transaction() 區塊中,任何步驟失敗即自動 Rollback。

3. N+1 查詢與重複 SQL 開銷

  • 問題說明:在 foreach 迴圈內,針對同一件商品發送了兩次 SQL 查詢(一次 first() 撈價格,一次 decrement() 扣庫存)。如果購物車有 10 件商品,就會產生 20 次 SQL 查詢,嚴重拖慢系統效能。

  • 改善方式:在迴圈外一次性使用 whereIn('id', $ids) 撈出所有商品,在記憶體內計算金額與檢查庫存,最後進行批量處理。

4. 缺乏輸入驗證與未處理 Null 值

  • 問題說明:未對 $request->input('items') 進行驗證(如:是否為陣列、數量是否為正整數)。若傳入不存在的商品 ID,$product 會回傳 null,調用 $product->price 會直接引發 Runtime Error(Null Pointer Exception)。

  • 改善方式:採用 Form Request 或 Validator 驗證請求格式,並明確處理商品不存在的情境。

5. 同步寄送 Email 造成 Blocking I/O

  • 問題說明:採用 Mail::to()->send() 同步發送郵件。若 SMTP 伺服器回應緩慢或連線失敗,會拖累整個 HTTP 請求,讓使用者在前端持續等待甚至逾時失敗。

  • 改善方式:將耗時的副作用移至背景處理,改用 Queue 異步發送:Mail::to()->queue()

重構後的建議程式碼

結合上述 5 大改善策略,優化後的程式碼如下:

PHP
public function checkout(CheckoutRequest $request)
{
    $items = collect($request->validated('items'));
    $productIds = $items->pluck('id');

    try {
        $order = DB::transaction(function () use ($items, $productIds) {
            // 1. 一次性撈取所有商品並加上悲觀鎖(Lock For Update)
            $products = Product::whereIn('id', $productIds)
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            $total = 0;
            $orderItems = [];

            foreach ($items as $item) {
                $product = $products->get($item['id']);

                // 2. 驗證商品存在與庫存充裕
                if (!$product || $product->stock < $item['qty']) {
                    throw new \Exception("商品 [{$item['id']}] 庫存不足或不存在");
                }

                // 3. 扣減庫存與累計金額
                $product->decrement('stock', $item['qty']);
                $total += $product->price * $item['qty'];

                $orderItems[] = [
                    'product_id' => $product->id,
                    'qty'        => $item['qty'],
                    'price'      => $product->price,
                ];
            }

            // 4. 建立訂單與明細
            $order = Order::create([
                'user_id' => auth()->id(),
                'total'   => $total,
            ]);

            $order->items()->createMany($orderItems);

            return $order;
        });

        // 5. 移出 Transaction 外的非同步寄信任務
        Mail::to(auth()->user())->queue(new OrderConfirmed($order));

        return response()->json(['order_id' => $order->id], 201);

    } catch (\Exception $e) {
        return response()->json(['message' => $e->getMessage()], 400);
    }
}

結語

編寫高併發結帳系統的核心精神在於:把「必須保證一致性」的操作放入資料庫交易與鎖定機制中;把「可以延後執行」的非核心任務移出主流程。 做好防範超賣、交易隔離、批量查詢與異步處理,就能讓你的結帳系統在流量暴增時依然穩定可靠!


這是一篇針對第 3 題(CORS 跨網域存取問題與解法)所整理的技術分享文,架構清晰、適合發佈於 Medium、iThome 鐵人賽或團隊內部技術文件:

【前端資安實戰】為什麼 AJAX 跨網域請求會失敗?徹底搞懂 CORS 原理與 2 大解決方案

在前後端分離或微服務架構中,開發者最常遇到的前端錯誤莫過於控制台出現的那行紅色大字:Blocked by CORS policy。為什麼前端用 AJAX 發送 API 請求時會觸發跨網域錯誤?背後的原理是什麼?

本文將深入剖析 CORS 錯誤的根本原因,並提供後端 CORS 標頭設定前端 Proxy 代理兩種實務上最可行的解決方案。

一、 為何會出現 CORS 錯誤?

CORS(Cross-Origin Resource Sharing,跨來源資源共享)錯誤並非 API 伺服器壞掉,而是瀏覽器基於安全性所執行的防禦機制

1. 同源政策(Same-Origin Policy, SOP)

瀏覽器預設實施「同源政策」,限制一個來源的腳本只能讀取相同來源的資源。所謂的同源(Same-Origin),代表請求的兩端必須同時滿足以下三者完全相同:

  • 通訊協定(Protocol)http://https://

  • 網域名稱(Domain)example.comapi.example.com

  • 通訊埠(Port):80:8080

只要上述任何一項不同,該請求就被視為跨網域(Cross-Origin)

2. 缺少 Access-Control-Allow-Origin 標頭

當前端在瀏覽器中發送跨域 AJAX 請求時,瀏覽器會允許請求發出,但在收到 Response 後,會檢查伺服器是否回傳了允許跨域的 HTTP Header。若伺服器未提供(或設定不符合當前網域),瀏覽器就會基於安全考量擋下回應並拋出 CORS policy 錯誤

二、 兩種可行解決方案

解法一:後端設定 CORS 標頭(最標準、根治的做法)

因為同源政策的檢查發生在伺服器與瀏覽器之間的規範,最正統的解決方式是由 API 後端伺服器在 Response Header 中聲明允許哪些來源跨網域存取

1. HTTP 響應標頭配置

後端需在 Response 中包含以下 Header:

  • Access-Control-Allow-Origin: [https://your-frontend-domain.com](https://your-frontend-domain.com)(開發環境可暫設為 *,但生產環境建議指定特定網域)

  • Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

  • Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

  • Access-Control-Allow-Credentials: true(若需允許發送 Cookie)

2. 常見框架配置範例

  • Laravel / PHP:在 config/cors.php 中設定允許的 allowed_originsallowed_methods,或透過 HandleCors Middleware 處理。

  • Nginx(網關層處理):在 Nginx location 區塊加入 Response Header:

    Nginx
    add_header 'Access-Control-Allow-Origin' 'https://your-frontend-domain.com' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'DNT,Authorization,Content-Type' always;
    

解法二:前端透過 Proxy 代理請求(繞過瀏覽器限制)

同源政策僅存在於「瀏覽器」端,伺服器對伺服器(Server-to-Server)之間的 HTTP 通訊完全不受同源政策限制。因此,前端可以透過搭建中間代理伺服器(Proxy)來繞過此問題。

[前端瀏覽器] ---> (同源請求) ---> [Proxy 代理伺服器] ---> (跨域請求) ---> [目標 API 伺服器]

1. 本地開發環境(Dev Server Proxy)

在本地開發時,可利用前端建構工具(如 Vite、Webpack Dev Server)自帶的代理功能:

  • Vite 設定範例 (vite.config.js)

    JavaScript
    export default {
      server: {
        proxy: {
          '/api': {
            target: 'https://api.backend-domain.com',
            changeOrigin: true,
            rewrite: (path) => path.replace(/^\/api/, '')
          }
        }
      }
    }
    

    如此一來,前端呼叫 /api/users 時,會被本地開發伺服器轉發給目標 API,瀏覽器認定為同源請求,便不會觸發 CORS 錯誤。

2. 生產環境(Nginx 正向代理)

線上環境可架設 Nginx 伺服器,讓前端靜態資源與 API 請求掛在同一個網域下,由 Nginx 根據路徑將 /api/* 的流量轉發至後端真實伺服器。

結語

面對 CORS 錯誤時,請記住核心重點:

  1. 正式環境首選:由後端正確配置 CORS Header,既安全又合規。

  2. 開發與過渡階段:使用前端 Proxy 代理快速繞過限制,極適合第三方 API 無法修改後端 Header 的情境。

這是一篇針對第 4 題(資料庫高頻效能優化與全文搜尋方案)所整理的技術分享文,架構清晰、適合發佈於 Medium、iThome 鐵人賽或團隊內部技術文件:

【資料庫實戰】500 萬筆資料高頻查詢瓶頸拆解:從 SQL 最佳化到 Elasticsearch 中英文全文搜尋

當資料庫累積達到 500 萬筆文章,且每日面臨高達 10 萬次的搜尋請求時,原本運作良好的 SQL 語法往往會突然變成拖垮整台伺服器的效能殺手。

本文將針對常見的「全表掃描查詢」進行深刻的病因剖析,並提供 SQL 最佳化策略以及引進專門搜尋引擎(Elasticsearch)的架構演進方案。

一、 原 SQL 效能瓶頸剖析

我們先來觀察這段高頻執行的 SQL 語法:

SQL
SELECT * 
FROM article 
WHERE category='news' 
  AND title LIKE '%Taiwan%' 
ORDER BY publish_time DESC 
LIMIT 20;

在高達 500 萬筆資料與每日 10 萬次執行的情境下,這段 SQL 會引發三大嚴重的效能危機:

  1. LIKE '%Taiwan%' 導致 B-Tree 索引失效(全表掃描)

    在傳統關聯式資料庫中,B-Tree 索引僅能優化「字串開頭 Match」的搜尋(如 Taiwan%)。當萬用字元 % 放於開頭時,資料庫無法利用索引迅速定位,只能進行耗費大量 CPU 與磁碟 I/O 的全表掃描(Full Table Scan)

  2. SELECT * 撈取過多無用欄位

    回傳包含內文(content)等大文字欄位時,會顯著增加記憶體開銷與網路傳輸延遲,降低 Buffer Pool 的利用率。

  3. ORDER BY publish_time DESC 產生額外排序開銷

    如果沒有合適的複合索引協助,資料庫必須先找出所有符合條件的資料行,再於記憶體或暫存檔中進行 Filesort 排序,最後才切出前 20 筆,造成巨大的資源浪費。

二、 立即見效的改善方案

在不更動整體系統架構的前提下,可以先透過以下資料庫與快取策略進行優化:

1. 建立複合索引(Composite Index)

針對篩選與排序欄位建立複合索引:

SQL
CREATE INDEX idx_category_publish ON article(category, publish_time DESC);

此索引能讓資料庫迅速定位到 category='news' 的資料,並直接依據 publish_time 的順序讀取,大幅減少 Filesort 的成本。

2. 減少資料傳輸(避免 SELECT *

將 SQL 改寫為只撈取列表所需的必要欄位(如 id, title, publish_time),並在前端需要顯示全文時才撈取單篇內容:

SQL
SELECT id, title, publish_time 
FROM article 
WHERE category='news' AND title LIKE '%Taiwan%' 
ORDER BY publish_time DESC 
LIMIT 20;

3. 導入快取層(Caching with Redis)

每日 10 萬次的查詢中,往往有大量的熱門搜尋或前幾頁重複請求。可以使用 Redis 將熱門關鍵字與第一頁的搜尋結果進行快取(設定適當的 TTL),讓 80% 以上的高頻流量直接在記憶體層返回,避免打入底層資料庫。

三、 中英文全文搜尋(Full-Text Search)的最佳選型

若業務需求需要精準且高效地支援「中英文全文搜尋」,傳統關聯式資料庫的 LIKE 語法已無法滿足需求,建議採用以下方案:

方案一:引進 Elasticsearch / OpenSearch(首選 / 最佳實踐)

  • 運作機制:基於倒排索引(Inverted Index)架構,將文字拆解成 Token(詞項)進行快速比對,搜尋複雜度從 $O(N)$ 降至接近 $O(1)$

  • 核心優勢

    • 強大的中英文分詞支援:可整合 IK Analyzerjieba 分詞器,正確處理中文斷詞(例如將「台灣新聞」精準拆為「台灣」、「新聞」);英文部分則原生支援套用 Stemming(詞幹提取)與同義詞搜尋。

    • 極致的毫秒級搜尋與權重排序:支援相關度評分(Relevance Score)、關鍵字高亮(Highlighting)與靈活的權重調整。

    • 高擴充性:支援分片(Sharding)與副本(Replica),能輕鬆擴展至上億筆資料。

方案二:使用資料庫內建全文索引(MySQL / PostgreSQL FULLTEXT)

  • 運作機制:建立 FULLTEXT 索引,並搭配 MATCH() AGAINST() 語法搜尋。

  • 優點:架構簡單,不需要維護獨立的搜尋引擎叢集與資料同步作業。

  • 缺點

    • 中文分詞較弱(需手動配置 ngram 分詞器,易產生過多無效索引片段)。

    • 在巨量資料(千萬級別以上)與高併發寫入時,效能與擴充性遠不及 Elasticsearch。

結語

面對巨量資料的高頻查詢,最佳的架構進化路徑為:「短時間靠複合索引與 Redis 快取止血,中長期導入 Elasticsearch 徹底解決搜尋瓶頸」。這樣既能保證關聯式資料庫的交易穩定性,又能獲得搜尋引擎的高效性能!


這是一篇針對第 5 題(Legacy System 舊系統重構與漸進式演進策略)所整理的技術分享文,架構清晰、適合發佈於 Medium、iThome 鐵人賽或團隊內部技術文件:

【架構演進實戰】如何把 50 萬行無測試的舊系統(PHP 5.6 CI),在半年內安全重構為 Laravel?

在技術團隊的日常中,「重構 Legacy 系統」往往是最具挑戰的任務之一。想像一下這個情境:

公司現狀:PHP 5.6 + CodeIgniter 框架、高達 50 萬行程式碼、每天都在更新業務內容、且完全沒有單元測試目標:半年內將系統全面改為 Laravel。

面對如此巨大的技術債,若採取傳統「打掉重練」的方式,幾乎註定會走向延期與上線災難。本文將為你拆解這類大型重構案的執行策略、主要風險以及如何透過機制將上線風險降至最低。

一、 核心執行策略:絞殺者模式(Strangler Fig Pattern)

在無測試且高達 50 萬行程式碼的系統中,嚴禁採用「大爆炸式重構」(Big Bang Rewrite)——即開一個新專案默默寫半年,最後一天一次切換。

最佳的實踐是採用 絞殺者模式(Strangler Fig Pattern) 進行漸進式替換:

  1. 反向代理部署(API Gateway / Nginx) 在舊 CI 系統前方掛載一層 Nginx 或是 API Gateway,作為所有流量的統一入口。

  2. 模組拆分與漸進式轉移 不求一次重寫全部,而是優先挑選「變動頻繁」、「邏輯獨立」或「效能瓶頸最高」的模組(例如:會員系統、API 服務、結帳)用 Laravel 重構。Nginx 依據 URL 路徑將特定流量導向新 Laravel 系統,其餘流量繼續留給舊 CI 處理。

  3. 共用 Session 與資料庫 在過渡期建置 shared-session 機制(例如共用 Redis Session),確保使用者在跨新舊系統時保持登入狀態,且兩邊資料庫能即時同步。

二、 評估四大主要風險

在推進重構的過程中,團隊必須提防以下四個致命風險:

  • 隱性業務邏輯遺失(Implicit Logic Risks) 50 萬行程式碼歷經多年迭代且缺乏測試保護,許多特殊的 Edge Cases(邊緣情境)只隱藏在 Code 的角落。手動重寫時極易遺漏這些「沒有文件記錄的業務規則」。

  • PHP 版本跨度過大的語法與相容性衝擊 從 PHP 5.6 跨越到現代 Laravel 支援的 PHP 8.x,存在極大的語言特性差異(如強型別檢查、廢棄函式、套件不支援等),容易引發非預期的 Runtime 錯誤。

  • 雙重維護(Dual Maintenance)導致人力透支 「舊系統每天仍需更新內容」意味著工程師必須同時修復舊 CI 系統的 Bug 並開發新系統的功能。開發資源被分散容易導致項目進度嚴重落後。

  • ORM 效能黑洞與 DB 結構拉扯 舊系統可能充斥著大量的原生手寫 SQL 語法;若直上 Laravel Eloquent ORM 卻未做適當的慢查詢優化,極易引發 N+1 查詢問題,造成資料庫效能滑坡。

三、 如何將上線風險降至最低?

為確保系統在轉移過程中不影響線上業務與營收,必須建立健全的發布與退路機制:

1. 灰度發布 / 藍綠部署(Canary Release / Blue-Green Deployment)

透過 Nginx 或 Load Balancer 進行百分比流量控制(如:1% -> 5% -> 20% -> 100%)。先讓極少數真實使用者測試新模組,觀察系統指標無誤後再擴大發布範圍。

2. 流量雙寫與結果比對(Dark Launching / Shadow Traffic)

在新功能正式上線前,可在後端複製一份真實流量同時發送給新舊兩個系統(以非同步方式執行)。透過自動化工具比對兩邊輸出的 Response 是否完全一致,確認零誤診後才正式切換。

3. 可觀測性與一秒切回(Observability & Instant Fallback)

  • 集中式日誌與監控:部署 Sentry 與 APM(如 New Relic / Datadog),即時追蹤新系統的 Error Rate 與 Latency。

  • 自動化降級機制:在 Gateway 層設置開關(Feature Flag),一旦新 Laravel 模組拋出重大異常,可在 1 秒之內將流量瞬間切回舊 CI 系統,實現「無感倒退(Rollback)」。

結語

面對龐大的舊系統重構,資深架構師的精神在於:「不追求一蹴而就的完美,而是建立漸進演進與隨時可恢復(Recoverable)的架構。」 透過絞殺者模式拆解目標,並用監控與灰度發布築起安全網,才能讓 50 萬行的大型專案安全地煥發新生!

沒有留言:

張貼留言

【後端架構選型】Laravel vs EasySwoole vs Python:從執行模型、併發瓶頸到 AI 時代的後端演進哲學

在現代後端開發中,技術選型常常陷入一種迷思:「哪種框架 Benchmark 跑分最高,我們就用哪種。」 然而,當我們將目光從單純的 PHP 生態(如 Laravel 與 EasySwoole )放大到橫跨多領域的 Python 生態時,會發現真正影響系統命運的,從來就不是「誰...