LINE 登入串接中繼站 | 免申請 Channel 的架構設計
AI 文章延伸
選擇平台後可直接帶入閱讀脈絡,快速整理重點、補齊盲點,並延伸到同站相關文章。
LINE 登入串接最常卡住的地方不是程式碼,是客戶後台那一格空白的 Channel Secret。我們賣 LINE 登入外掛這幾年,收到最多的訊息不是「外掛壞了」,而是「這個 Channel ID 要貼在哪裡」。圖文教學寫過、影片錄過、檢查清單做過,每個月還是有人問到同一格。
所以我們改問另一個問題:這段設定,能不能乾脆讓客戶不用做?
答案是做一個 API 中繼站(Broker)。客戶的網站不直接跟 LINE 講話,而是跟中繼站講話,由中繼站拿一組我們自己審核通過的官方 Channel,代所有站台完成 OAuth 授權、換 token、收 Webhook,再把結果分流轉回各站。設定的部分我們一次做完,客戶端只要填一組金鑰。
這篇拆的是這套中繼站怎麼設計:資料表長什麼樣、OAuth 簽章有哪幾道、六支 API 各自負責什麼,以及選 Cloudflare Workers 之後我們實際撞到的限制。
沒有中繼站的時候,客戶要走完哪些步驟
要讓網站能用 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 跟 LINE Login 是兩個不同的 Channel,常常被當成同一個。
我們寫過的設定教學已經細到逐格截圖,客戶還是會在某一格停下來,這時候該改的不是文件,是流程本身。
中繼站怎麼運作:一組官方 Channel 服務所有站台
中繼站站在客戶網站跟 LINE 中間。整條資料流是這樣:
客戶網站(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 這種最敏感的東西從頭到尾只存在中繼站,客戶網站永遠拿不到,也不需要拿到。客戶端只保管一組屬於自己站台的金鑰,管好那一份就夠了。
我們之前寫過的 API 驗證機制三層防護設計處理的是兩個網站點對點的信任,這次是一對多:一個中繼站要同時服務 N 個彼此不該互相看到資料的站台,信任模型完全不一樣。
Cloudflare Workers、D1、KV 各自負責什麼
要理解中繼站,得先知道我們用了 Cloudflare 的哪三樣東西。
Workers 是跑程式的地方。上傳一段 TypeScript,它就跑在 Cloudflare 全球節點上,有請求進來才執行、按次數計費,沒有一台要自己開機、自己更新、自己擔心被打掛的伺服器。中繼站平常沒事、有人登入才動一下,這個計價模式剛好。
D1 是 Cloudflare 的 SQLite 資料庫,能建資料表、下 SQL,直接綁在 Workers 上,不用另外接一台資料庫主機。我們拿它存需要長期保留的資料:哪些網站登記過、哪個 LINE 管理者屬於哪個站、每個站的金鑰。
KV 是鍵值儲存,可以想成一個會自動過期的字典。丟一組 key 跟 value 進去、設好幾秒後消失,時間到它自己清掉。它讀取快,但寫入之後不保證每個節點立刻同步,這個特性決定了它只能放「短命、掉了也能重來」的資料。
三個湊在一起的結果是整套中繼站沒有一台傳統伺服器,wrangler deploy 一行就上線。
D1 只存兩張表:sites 與 account_links
整套中繼站的持久資料只有兩張表。第一張 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)
);
這裡有個跟一般 API Key 管理不同的決定,值得說清楚:常見做法是「只存 Hash 不存明文」,因為後端只需要驗證來的 Key 對不對,比 Hash 就夠了,但中繼站的 relay_secret 不能這樣處理,它要拿原文去對轉發出去的 Webhook 重新簽章,Hash 是單向的,簽不回來,所以這兩把金鑰進 D1 之前走的是 AES-GCM 加密,解密的鑰匙 RELAY_ENC_KEY 只放在 Workers 環境變數裡,不進資料庫。
判準很簡單:只需要驗證就存 Hash,需要再拿出來用就加密存,兩者不能混用。
至於 Channel Secret 本身,連加密存都不存,只放環境變數。就算 D1 整個被撈走,攻擊者也換不到任何一個 token。我們在拆解 WordPress 被駭現場時看過太多次資料庫外洩就全盤皆輸的案例,金鑰的擺放位置值得多想一層。
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 的 top-level GET 請求,做不好就會被拿來做 CSRF、帳號接管,甚至把攻擊者的 LINE 帳號綁到別人的網站帳號上。這次我們從設計階段就把四件事鎖死。
第一,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 直接作廢。
這裡用的是標準 HMAC-SHA256,不是「密鑰跟欄位直接串接後算一次 SHA-256」的簡化版。標準 HMAC 會對密鑰做 padding 並跑兩次 Hash,能擋掉 length extension attack,在中繼站這種一組金鑰服務所有站台的場景值得用嚴格版本。
第二,nonce 一次性
每次發起登入產一組 nonce 寫進 KV,callback 消費一次就刪掉。同一組 nonce 想用第二次就查不到,重放攻擊擋在這裡。
單純靠時間戳做重放防護會留一個窗口:只要在容許的時間範圍內,同一個請求重送還是會過。加上一次性 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 一樣只在中繼站這一側,客戶端連推播的金鑰都不用保管:
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。
只回傳一次這個設計參考的是 GitHub Personal Access Token 的做法。要誠實說的是,它降低的是「金鑰在後台被反覆看見」的曝光面,擋不住管理員自己把金鑰貼到公開頻道,這一塊還是靠人。
GET /oauth/start|發起登入。 管理者在 WordPress 後台外掛設定頁按下登入時導到這裡,帶著 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 建立對照,把站台狀態轉成 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 事件。 驗 X-Line-Signature、把 raw body 入列後立刻回 200,接著在背景依 userId 查出對應的站、用該站的 relay_secret 重簽,批次轉發回各站台。這支是整個轉發鏈的入口,也是唯一直接面對 LINE 高頻流量的地方。
為什麼是 Cloudflare,以及它的限制
選 Cloudflare Workers 不是因為它潮,是因為這個服務的形狀剛好合。中繼站平常沒流量,有人登入或客戶開直播才瞬間爆量,Workers 按請求計價、自動全球擴展,不用為了偶爾的尖峰養一台整天開著的機器。同步階段只做驗簽跟入列,純 CPU 運算,Workers 一秒扛幾千個沒問題。真正的瓶頸從來不在中繼站,而在客戶那端的 WordPress bootstrap,所以我們才在轉發層做批次合併。
我們在部署 Astro 官網與預覽環境時就用過同一套 Cloudflare 工具鏈,這次只是把它從靜態託管推到了帶狀態的後端。
限制也要老實說:
- D1 適合單純查詢。 複雜的 JOIN 在量大時會摸到最終一致性跟執行時間的邊。
- KV 寫入後不保證每個節點立刻讀到。 它只能放快取跟一次性碼,不能拿來當「寫完馬上要讀到正確值」的地方。
- state 的 10 分鐘有效期依賴 NTP 時間同步。 中繼站與 LINE 之間的時間漂移過大時,合法請求也會被判過期。
- 免費方案沒有 Queues。 目前是用
ctx.waitUntil()在背景做轉發,好處是省錢,代價是失敗沒有內建的重試保證。這一塊靠客戶端以事件 ID 去重來補:就算某次轉發掉了、LINE 重送導致重複,客戶站也只會認第一次。要更嚴格的每站隔離跟精準重試,下一步會換成 Durable Objects,這是還沒補上的坑。 - 中繼站沒有權限分級。 一組
site_key等於這個站的全部能力,沒有 RBAC。 - 集中的代價是單點。 所有客戶共用一組 LINE 額度,也共用中繼站這一個故障點。這是「把設定複雜度收攏到一處」必然要付的帳。
這套設計可以套到哪些場景
這篇拆的是 LINE,但中繼站這個模式能套在很多地方:任何「客戶自己申請 API、自己貼金鑰會卡關」的第三方服務,都可以用一個中繼層把授權、token 保管、Webhook 轉發收攏起來,讓客戶端只保留最小的責任。金流、物流、CRM、通訊軟體,形狀都類似。
判斷值不值得做中繼站的門檻其實很具體:如果同一段設定問題每個月都會重複出現,而且問題出在第三方平台的申請流程而不是你的程式,那這段流程就該被包成一個按鈕。我們在做 LINE 官方帳號的會員綁定與拍照上傳時得到的也是同一個結論,把複雜度往開發端收,使用端才會順。
這套東西不算完美,但作為一個讓客戶零 Channel 設定就能用 LINE 登入的方案,它已經在跑,而且把原本每個月都要回答的那串設定問題,變成客戶按一個按鈕的事。
延伸思考
這套架構建立在幾個前提上,值得進一步思考:
- 客戶接受好友掛在我們的官方帳號底下,這是最強的前提,客戶只要要求品牌帳號必須是自己的,中繼站直接不適用,沒有折衷版本。
- 第三方平台的服務條款允許一組憑證服務多個獨立商家,這一層的合規風險大於任何技術風險,動工前要先確認。
- 客戶數落在甜蜜區內,3 家時中繼站是過度工程,直接教設定更快,300 家時額度共用會先於技術瓶頸爆掉,適用範圍是一段區間而不是一個方向。
其他領域也有類似的形狀:
- 銀行的聯行代收與清算中心,清算中心持有等級最高的憑證、對每家成員銀行各發一組識別碼。金融業對這個模式的成熟答案是「清算中心必須有最高等級的可用性保證」,這正好指出中繼站該優先補的是可靠轉發那個坑,而不是再加一道簽章。
- 電力變電所降壓,高壓集中在變電所、配到每戶的是低壓,用戶端拿不到高壓不是功能缺陷而是設計目標,代價同樣是變電所故障全區停電。
- 旅行社代辦簽證,價值不在跑得比較快,在於把一次性的學習成本攤提到上千件,代價是你的件掛在代辦方的配額與信譽底下。
關鍵概念:一對多信任邊界、API 中繼站設計流程、Gateway 架構、重送攻擊防禦
常見問題 FAQ
用中繼站的話,客戶的 LINE 好友會加到誰的官方帳號?
會加到中繼站這一組官方帳號。這是這個架構最需要事先講清楚的取捨:客戶省掉了全部 Channel 設定,代價是官方帳號的門面與好友名單不在自己手上。如果客戶要求品牌帳號必須是自己的,就得走傳統的自行申請流程,中繼站不適用這種需求。
一組 Channel 服務所有站台,額度會不會不夠用?
會是真實的限制。LINE 的推播額度算在 Channel 上,所有客戶共用同一組,尖峰時互相排擠。實務上要在中繼站層做每站的用量統計與上限控制,並在接近額度時提前擋下低優先度的推播,而不是等 LINE 回錯誤才處理。
客戶網站要怎麼確認收到的 Webhook 真的來自中繼站?
用中繼站在登記時配發的 relay_secret 驗 X-OPO-Broker-Signature。中繼站對 raw body 做 HMAC-SHA256 簽章,客戶端用同一把金鑰對收到的原始 bytes 重算並比對。要注意的是必須用未經解析的原始 body,先 parse 再 stringify 會讓簽章對不上。
Cloudflare Workers 免費方案跑得動這種服務嗎?
登入與驗簽這類純 CPU 的同步工作跑得動,免費方案的請求數對中小規模的中繼站也夠。真正需要付費的是可靠轉發:免費方案沒有 Queues,背景轉發失敗沒有內建重試,只能靠客戶端去重來容錯。要嚴格的重試保證就得升級並換成 Durable Objects。
手上有第三方 API 串接的需求,不管是 LINE 登入與推播,還是其他平台的授權整合,或者想把一段「客戶老是設定錯」的流程包成一個按鈕?歡迎聯絡我們,或加入我們的 LINE 官方帳號,把簽章、加密、重試這些水面下的細節交給我們處理。
Google 偏好來源
喜歡我們的內容嗎?一鍵將我們設為偏好來源,未來在 Google 焦點新聞與 AI 概覽中就能優先看到我們的文章。