賣 LINE 登入外掛這幾年,我最常收到的訊息不是「外掛壞了」,而是「這個 Channel ID 要貼在哪裡」。客戶買了外掛卻卡在第一步:去 LINE Developers 建 Provider、建 Channel、複製 Channel Secret、設定 Callback URL。我寫過圖文教學、錄過影片、做過檢查清單,還是每個月都有人問到同一個地方。後來我開始想一件事:這段設定,能不能乾脆讓客戶不用做?
這篇記錄我怎麼用 Cloudflare Workers 做一個 LINE 登入串接的中繼站,讓客戶的網站不用自己申請 Channel 就能完成 LINE 登入、推播與 Webhook 轉發,會談到實際的資料表結構、OAuth 簽章流程、幾支 API 的職責。如果你正在評估要不要找人做這類第三方 API 串接,這篇能讓你對相關知識有基礎認識。
Cloudflare 的 Workers、D1、KV
要理解中繼站,得先知道我用了 Cloudflare 的哪些東西:Workers 是跑程式的地方,D1 是存資料的資料庫,KV 是存短期資料的快取。三個加起來,就是一個不用自己養主機的後端。
Workers 是 Cloudflare 的無伺服器運算。你上傳一段 JavaScript 或 TypeScript,它就跑在 Cloudflare 全球的節點上,有請求進來才執行、收費按次數算,沒有一台你要自己開機、自己更新、自己擔心會不會被打掛的伺服器。對中繼站這種「平常沒事、有人登入才動一下」的服務來說,這個計價模式剛好。
D1 是 Cloudflare 的 SQLite 資料庫。它跟一般的 MySQL 概念一樣,能建資料表、下 SQL,但直接綁在 Workers 上,不用另外接一台資料庫主機。我拿它存需要長期保留的東西:哪些網站登記過、哪個 LINE 管理者屬於哪個站、每個站的金鑰。
KV 是 Cloudflare 的鍵值儲存,可以想成一個會自動過期的字典,你丟一組 key 跟 value 進去、設好幾秒後消失,時間到它自己清掉。它的特性是讀取快,但寫入之後不保證每個節點立刻同步(最終一致性),所以我只拿它存那些「短命、掉了也能重來」的資料:一次性的驗證碼、token 的暫存、熱路徑的快取。
這三個湊在一起的好處是:整套中繼站沒有一台傳統伺服器,部署一行 wrangler deploy 就上線,全球節點自動就近回應。
沒有中繼站前的設定流程
客戶要讓網站能用 LINE 登入,得自己走完這一整串:
- 註冊 LINE Developers 帳號
- 建一個 Provider
- 在 Provider 底下建一個 LINE Login Channel
- 到 Channel 設定裡填 Callback URL(
https://自己的網域/line/callback) - 複製 Channel ID 跟 Channel Secret,貼回網站後台
- 如果還要推播,再開一個 Messaging API Channel,重複一次類似的設定,還要處理 Channel access token 跟 Webhook URL
對開發者這不難,但對客戶每一步都是卡點,Callback URL 少一個斜線就登入失敗,Channel Secret 複製到多一個空白就換不到 token,Messaging API 跟 Login 是兩個不同的 Channel 常常搞混。我把〈LINE Login API 設定教學〉跟〈LINE Messaging API 設定教學〉寫得很細,但文件寫得再細,客戶還是會在某一格卡住。
於是我換個方向想:我自己早就有一個審核通過、設定好的 LINE Channel,能不能讓所有客戶的網站都透過我這個 Channel 來登入跟推播,設定的部分我一次做完,客戶端只要填一組金鑰就好?這就是中繼站的起點。
中繼站怎麼運作:一個 Channel 服務所有站台
中繼站(Broker)站在客戶網站跟 LINE 中間。客戶的網站不再直接跟 LINE 講話,而是跟中繼站講話,中繼站拿我的 Channel 去跟 LINE 完成 OAuth、換 token、收 Webhook,再把結果轉回各個站台。
整條資料流長這樣:
客戶網站(LINE 登入外掛)
│ ① 帶站金鑰簽名,發起登入
▼
中繼站 /oauth/start
│ ② 用「我的」Channel 導向 LINE 授權
▼
LINE 授權頁(使用者按同意)
│ ③ LINE 帶授權碼導回中繼站
▼
中繼站 /oauth/callback
│ ④ 用 Channel Secret 換 token、取 profile
│ ⑤ 產生一次性 handoff code(網址不含 token)
▼
客戶網站 /redeem(後端對後端)
│ ⑥ 用站金鑰換回 access token 與 relay_secret
▼
之後:LINE 事件進中繼站 Webhook,驗簽後重簽、轉回各站台
關鍵在於:Channel Secret 這種最敏感的東西,從頭到尾只存在中繼站,客戶網站永遠拿不到、也不需要拿到。客戶端只保管一組「這個站專屬」的金鑰,管好自己那一份就夠了。
D1 裡存了什麼:兩張表
整套中繼站的持久資料只有兩張表。第一張 sites 記錄每個登記過的客戶站台:
CREATE TABLE sites (
site_id TEXT PRIMARY KEY, -- 中繼站發的站台識別碼,格式 site_<hex>
site_url TEXT NOT NULL, -- 客戶站基址,如 https://shopA.com
webhook_url TEXT NOT NULL, -- 事件要轉發回去的目標網址
relay_secret_enc TEXT NOT NULL, -- 轉發簽章用的金鑰,AES-GCM 加密後存
site_key_enc TEXT NOT NULL, -- 站台呼叫中繼站的驗簽金鑰,一樣加密存
status TEXT DEFAULT 'pending', -- pending / active / revoked
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
第二張 account_links 記錄「LINE 管理者屬於哪個站」,因為所有站台共用我這個官方帳號,事件進來時我得知道這則訊息該轉給誰:
CREATE TABLE account_links (
line_user_id TEXT PRIMARY KEY, -- LINE 的 userId
site_id TEXT NOT NULL, -- 對應到哪個站
display_name TEXT,
status TEXT DEFAULT 'active',
linked_at INTEGER NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (site_id)
);
Channel Secret 這種金鑰不能明文躺在資料庫裡,資料庫一旦外洩就全毀。所以 relay_secret 跟 site_key 進 D1 之前,都先用 AES-GCM 加密過,解密的鑰匙(RELAY_ENC_KEY)只放在 Workers 的環境變數裡不進資料庫。至於 Channel Secret 本身,連加密存都不存,只放環境變數,這樣就算 D1 整個被撈走,攻擊者也換不到任何一個 token。
KV 裡存了什麼:三種短命資料
KV 存的都是活不過半小時的東西,剛好對應它「快、但最終一致」的特性:
| 鍵名 | 存活時間 | 內容 | 用途 |
|---|---|---|---|
nonce:{值} | 600 秒 | {site_id, return_url} | 防止 OAuth 重放,用過即丟 |
handoff:{碼} | 60 秒 | {access_token, line_user_id, relay_secret} | token 的一次性暫存 |
resolve:{line_user_id} | 1800 秒 | 加密後的站台記錄 | 轉發熱路徑的快取,少查 D1 |
resolve 這條快取是後來才加的。一開始每收到一則 LINE 事件,我都去 D1 查一次「這個 userId 屬於哪個站」,萬一是高併發那種一秒幾十次請求的情境,D1 查詢就會開始排隊。把結果快取進 KV、設 30 分鐘後,同一個使用者的後續事件直接命中快取,D1 壓力掉了一大截。
OAuth 流程與簽章:安全性都藏在細節裡
中繼站最需要小心的就是 OAuth。因為登入的 callback 是一個沒有 nonce 的 GET 請求,做不好就會被拿來做 CSRF、帳號接管,甚至把攻擊者的 LINE 帳號綁到別人的網站帳號上。我在〈LINE 登入沒給 Email 怎麼辦,最後變成 CVE 漏洞〉裡踩過一次類似的雷,這次從設計就把幾件事鎖死。
第一,state 要能自我驗證。 導向 LINE 授權前,我把發起這次登入的資訊簽進 state:
payload = base64url(JSON.stringify({ site_id, nonce, ts }))
state = payload + "." + hex(HMAC-SHA256(payload, BROKER_STATE_KEY))
簽章是簽在 base64url 字串上,不是原始 JSON,所以不會因為 JSON 欄位順序不同就對不起來。callback 回來時,我用同一把 BROKER_STATE_KEY 重算 HMAC 比對,改一個字元就驗不過。ts 還限制 10 分鐘內有效,過期的 state 直接作廢。
第二,nonce 一次性。 每次發起登入產一組 nonce 寫進 KV,callback 消費一次就刪掉,同一組 nonce 想用第二次就查不到,重放攻擊擋在這裡。
第三,token 絕不進網址。 callback 換到 token 之後,我不是把 token 塞在網址 query string 導回客戶網站(那會留在瀏覽器歷史、伺服器 log、Referer 裡),而是產一組 60 秒就過期的 handoff_code,只把這組碼放進導回網址。客戶網站的後端再拿這組碼、加上自己的站金鑰簽名,走一次 server-to-server 的 /oauth/redeem 把真正的 token 換回去。token 全程走後端通道,前端網址裡看不到。
第四,所有簽章比對用常數時間。 驗簽這種地方如果用一般的字串比對,會因為「第幾個字元開始不同」造成回應時間微小差異,理論上可以被拿來一個字元一個字元猜金鑰。所以比對走的是常數時間演算法:
export function timingSafeEqual(a: string, b: string): boolean {
const ab = encoder.encode(a);
const bb = encoder.encode(b);
if (ab.length !== bb.length) return false;
let diff = 0;
for (let i = 0; i < ab.length; i++) {
diff |= ab[i] ^ bb[i];
}
return diff === 0;
}
這一層要防的是時序攻擊,也就是像猜密碼鎖,如果鎖會在你「轉對第一個數字」時發出一點聲音,你就能靠聽聲音一位一位破解,而不用暴力試遍所有組合,而用常數時間就逼得攻擊者一定要比完所有字串,大幅增加攻擊成本。
推播與 Webhook 轉發:一個帳號進,多個站台出
登入只是入口,客戶真正要的是後續能推播、能收 LINE 傳進來的訊息。
推播走的是我這個 Messaging API Channel。客戶網站要發訊息時,不是自己拿 Channel access token 去打 LINE,而是呼叫中繼站、帶上站金鑰簽名由中繼站代發。真正的 token 一樣只在中繼站這一側,客戶端連推播的金鑰都不用保管。發送打的是 LINE 的標準端點:
POST https://api.line.me/v2/bot/message/push
Authorization: Bearer {CHANNEL_ACCESS_TOKEN}
{ "to": "{line_user_id}", "messages": [ ... ] }
Webhook 轉發麻煩一點。所有站台共用我這一個官方帳號,代表 LINE 只會把事件送到我這一個 Webhook URL。中繼站收到之後,得自己判斷每一則事件屬於哪個站,再轉回去。收件那一刻我只做兩件事:驗簽、入列,然後立刻回 LINE 200,剩下的路由跟轉發全部丟到背景做,免得卡住 LINE 的重送機制。
驗簽是驗 LINE 送來的 X-Line-Signature,它是用 Channel Secret 對整個 raw body 做 HMAC-SHA256 再 base64。這裡有個坑:驗簽一定要用「還沒被 JSON 解析過的原始 bytes」去算,只要先 JSON.parse 再 stringify 回來,欄位順序或空白差一點,HMAC 就對不上。
轉回客戶網站時,我重新用那個站專屬的 relay_secret 簽一次,客戶端收到後用自己的 relay_secret 驗,確認這則轉發真的來自中繼站:
POST {webhook_url}
X-OPO-Broker-Signature: sha256=HMAC-SHA256(raw_body, relay_secret)
X-OPO-Broker-Timestamp: {unix_timestamp}
為了不要讓客戶的 WordPress 被高頻事件連環觸發,轉發有做批次合併:250 毫秒或累積 20 則先到先送,把一秒幾十則的爆量壓成兩三次 POST。每則事件都帶原始的事件 ID,客戶端以此去重,就算中繼站因為重試送了兩次,同一則訊息也不會被處理兩遍。
專案裡幾支 API 各自在做什麼
整個中繼站對外就六支端點,各有清楚的職責。照使用順序走一遍:
POST /sites/register|站台登記。 客戶第一次設定外掛時呼叫,送上 site_url 跟要接收轉發的 webhook_url。中繼站回一組 site_id 跟 site_key,site_key 是高強度金鑰、只在這一刻回傳一次,之後客戶所有請求都靠它簽名。同時後台也產好這個站專屬的 relay_secret。
GET /oauth/start|發起登入。 管理者在 WP 後台外掛設定頁按「LINE 管理者登入」時導到這裡,帶著 site_url、return_url 跟站金鑰簽出來的 sig。中繼站先驗這個站登記過、簽章合法、return_url 跟 site_url 同源(擋 open redirect),再產 nonce、簽 state,最後把使用者導向 LINE 的授權頁:
https://access.line.me/oauth2/v2.1/authorize
?response_type=code
&client_id={CHANNEL_ID}
&redirect_uri={BROKER_BASE_URL}/oauth/callback
&state={簽好的 state}
&scope=profile%20openid%20email
GET /oauth/callback|授權導回。 LINE 帶授權碼回來,這支做最多事:驗 state 的 HMAC、檢查 ts 沒過期、消費掉 nonce,然後用 Channel Secret 去 LINE 換 token 並取 profile:
POST https://api.line.me/oauth2/v2.1/token (授權碼換 access token)
GET https://api.line.me/v2/profile (取 userId、displayName)
拿到使用者資料後,寫入 account_links 建立「這個 LINE 使用者屬於這個站」的對照,把站台狀態轉成 active,再產一組 60 秒的 handoff_code 存進 KV,最後導回 return_url?opo_handoff={code}。整條網址裡沒有任何 token。
POST /oauth/redeem|兌換 token。 由客戶網站的後端呼叫,送上 handoff_code、site_id 跟站金鑰簽名。中繼站驗簽、取出 KV 裡的 token bundle、確認沒超過 60 秒,然後把 handoff 刪掉(一次性,重複兌換直接回 410),回傳真正的 access_token 跟 relay_secret。走到這裡,客戶網站才第一次、也是唯一一次拿到屬於它的 token。
GET /webhook|Webhook 驗證握手。 LINE 後台設定 Webhook URL 時會來驗一次,確認這個網址是活的、回應正常。
POST /webhook|接收 LINE 事件。 LINE 把訊息事件送到這裡。中繼站驗 X-Line-Signature、把 raw body 入列後立刻回 200,接著在背景依 userId 查出對應的站、用該站的 relay_secret 重簽,批次轉發回各站台的 webhook_url。這支是整個轉發鏈的入口,也是唯一直接面對 LINE 高頻流量的地方。
為什麼是 Cloudflare,以及它的限制
選 Cloudflare Workers 不是因為它潮,是因為這個服務的形狀剛好合。中繼站平常沒流量、有人登入或直播才瞬間爆量,Workers 按請求計價、自動全球擴展,不用為了偶爾的尖峰養一台整天開著的機器。同步階段只做驗簽跟入列,純 CPU 運算,Workers 一秒扛幾千個沒問題,真正的瓶頸從來不在中繼站,而在客戶那端的 WordPress bootstrap,所以我才在轉發層做批次合併。
限制也要老實說。D1 適合單純查詢,複雜的 JOIN 在量大時會摸到最終一致性跟執行時間的邊;KV 寫入後不保證每個節點立刻讀到,所以它只能放快取跟一次性碼,不能拿來當你「寫完馬上要讀到正確值」的地方。還有免費方案沒有 Queues(Cloudflare 的訊息佇列),我目前是用 ctx.waitUntil() 在背景做轉發,好處是省錢,代價是失敗沒有內建的重試保證。這一塊我靠客戶端以事件 ID 去重來補:就算某次轉發掉了、Meta 或 LINE 重送導致重複,客戶站也只會認第一次。要更嚴格的每站隔離跟精準重試,下一步會換成 Durable Objects,這是還沒補上的坑。
這套東西不算完美,但作為一個「讓客戶零 Channel 設定就能用 LINE 登入」的方案,它已經在跑,而且把原本每個月都要回答的那串設定問題,變成客戶按一個按鈕的事。
有第三方 API 串接的需求,可以找我聊
這篇拆的是 LINE,但中繼站這個模式能套在很多地方:任何「客戶自己申請 API、自己貼金鑰會卡關」的第三方服務,都可以用一個中繼層把繁瑣的授權、token 保管、Webhook 轉發收攏起來,讓客戶端只保留最小的責任。金流、物流、CRM、通訊軟體,形狀都類似。
如果你手上正好有這類第三方 API 串接的需求,不管是 LINE 登入與推播、還是其他平台的授權整合,或者你只是想找人幫你把一段「客戶老是設定錯」的流程包成一個按鈕,都歡迎跟我聊聊。把水面下這些簽章、加密、重試的細節交給我,你可以專心在你的生意上。