【前端資安實戰】跨網站共用會員中心,如何安全儲存與傳遞身份憑證(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=Lax或SameSite=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 大致命問題,並提供具體的重構程式碼與改善方向。
經典範例:看似正常的結帳程式碼
請先看以下這段常見的結帳邏輯:
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 大改善策略,優化後的程式碼如下:
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.com與api.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, OPTIONSAccess-Control-Allow-Headers: Content-Type, Authorization, X-Requested-WithAccess-Control-Allow-Credentials: true(若需允許發送 Cookie)
2. 常見框架配置範例
Laravel / PHP:在
config/cors.php中設定允許的allowed_origins與allowed_methods,或透過HandleCorsMiddleware 處理。Nginx(網關層處理):在 Nginx
location區塊加入 Response Header:Nginxadd_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):JavaScriptexport 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 錯誤時,請記住核心重點:
正式環境首選:由後端正確配置 CORS Header,既安全又合規。
開發與過渡階段:使用前端 Proxy 代理快速繞過限制,極適合第三方 API 無法修改後端 Header 的情境。
這是一篇針對第 4 題(資料庫高頻效能優化與全文搜尋方案)所整理的技術分享文,架構清晰、適合發佈於 Medium、iThome 鐵人賽或團隊內部技術文件:
【資料庫實戰】500 萬筆資料高頻查詢瓶頸拆解:從 SQL 最佳化到 Elasticsearch 中英文全文搜尋
當資料庫累積達到 500 萬筆文章,且每日面臨高達 10 萬次的搜尋請求時,原本運作良好的 SQL 語法往往會突然變成拖垮整台伺服器的效能殺手。
本文將針對常見的「全表掃描查詢」進行深刻的病因剖析,並提供 SQL 最佳化策略以及引進專門搜尋引擎(Elasticsearch)的架構演進方案。
一、 原 SQL 效能瓶頸剖析
我們先來觀察這段高頻執行的 SQL 語法:
SELECT *
FROM article
WHERE category='news'
AND title LIKE '%Taiwan%'
ORDER BY publish_time DESC
LIMIT 20;
在高達 500 萬筆資料與每日 10 萬次執行的情境下,這段 SQL 會引發三大嚴重的效能危機:
LIKE '%Taiwan%'導致 B-Tree 索引失效(全表掃描)在傳統關聯式資料庫中,B-Tree 索引僅能優化「字串開頭 Match」的搜尋(如
Taiwan%)。當萬用字元%放於開頭時,資料庫無法利用索引迅速定位,只能進行耗費大量 CPU 與磁碟 I/O 的全表掃描(Full Table Scan)。SELECT *撈取過多無用欄位回傳包含內文(
content)等大文字欄位時,會顯著增加記憶體開銷與網路傳輸延遲,降低 Buffer Pool 的利用率。ORDER BY publish_time DESC產生額外排序開銷如果沒有合適的複合索引協助,資料庫必須先找出所有符合條件的資料行,再於記憶體或暫存檔中進行 Filesort 排序,最後才切出前 20 筆,造成巨大的資源浪費。
二、 立即見效的改善方案
在不更動整體系統架構的前提下,可以先透過以下資料庫與快取策略進行優化:
1. 建立複合索引(Composite Index)
針對篩選與排序欄位建立複合索引:
CREATE INDEX idx_category_publish ON article(category, publish_time DESC);
此索引能讓資料庫迅速定位到 category='news' 的資料,並直接依據 publish_time 的順序讀取,大幅減少 Filesort 的成本。
2. 減少資料傳輸(避免 SELECT *)
將 SQL 改寫為只撈取列表所需的必要欄位(如 id, title, publish_time),並在前端需要顯示全文時才撈取單篇內容:
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 Analyzer或jieba分詞器,正確處理中文斷詞(例如將「台灣新聞」精準拆為「台灣」、「新聞」);英文部分則原生支援套用 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) 進行漸進式替換:
反向代理部署(API Gateway / Nginx) 在舊 CI 系統前方掛載一層 Nginx 或是 API Gateway,作為所有流量的統一入口。
模組拆分與漸進式轉移 不求一次重寫全部,而是優先挑選「變動頻繁」、「邏輯獨立」或「效能瓶頸最高」的模組(例如:會員系統、API 服務、結帳)用 Laravel 重構。Nginx 依據 URL 路徑將特定流量導向新 Laravel 系統,其餘流量繼續留給舊 CI 處理。
共用 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 萬行的大型專案安全地煥發新生!
沒有留言:
張貼留言