LINE 登入串接中繼站 | 免申請 Channel 的架構設計

技術分享

AI 文章延伸

AI 幫你讀這篇文章

選擇平台後可直接帶入閱讀脈絡,快速整理重點、補齊盲點,並延伸到同站相關文章。

LINE 登入串接最常卡住的地方不是程式碼,是客戶後台那一格空白的 Channel Secret。我們賣 LINE 登入外掛這幾年,收到最多的訊息不是「外掛壞了」,而是「這個 Channel ID 要貼在哪裡」。圖文教學寫過、影片錄過、檢查清單做過,每個月還是有人問到同一格。

所以我們改問另一個問題:這段設定,能不能乾脆讓客戶不用做?

答案是做一個 API 中繼站(Broker)。客戶的網站不直接跟 LINE 講話,而是跟中繼站講話,由中繼站拿一組我們自己審核通過的官方 Channel,代所有站台完成 OAuth 授權、換 token、收 Webhook,再把結果分流轉回各站。設定的部分我們一次做完,客戶端只要填一組金鑰。

這篇拆的是這套中繼站怎麼設計:資料表長什麼樣、OAuth 簽章有哪幾道、六支 API 各自負責什麼,以及選 Cloudflare Workers 之後我們實際撞到的限制。

沒有中繼站的時候,客戶要走完哪些步驟

要讓網站能用 LINE 登入,客戶得自己走完這一整串:

  1. 註冊 LINE Developers 帳號
  2. 建一個 Provider
  3. 在 Provider 底下建一個 LINE Login Channel
  4. 到 Channel 設定裡填 Callback URL(https://自己的網域/line/callback
  5. 複製 Channel ID 跟 Channel Secret,貼回網站後台
  6. 如果還要推播,再開一個 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 一行就上線。

整套中繼站的持久資料只有兩張表。第一張 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.parsestringify 回來,欄位順序或空白差一點,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_idsite_keysite_key 是高強度金鑰、只在這一刻回傳一次,之後客戶所有請求都靠它簽名。同時後台也產好這個站專屬的 relay_secret

只回傳一次這個設計參考的是 GitHub Personal Access Token 的做法。要誠實說的是,它降低的是「金鑰在後台被反覆看見」的曝光面,擋不住管理員自己把金鑰貼到公開頻道,這一塊還是靠人。

GET /oauth/start|發起登入。 管理者在 WordPress 後台外掛設定頁按下登入時導到這裡,帶著 site_urlreturn_url 跟站金鑰簽出來的 sig。中繼站先驗這個站登記過、簽章合法、return_urlsite_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_codesite_id 跟站金鑰簽名。中繼站驗簽、取出 KV 裡的 token bundle、確認沒超過 60 秒,然後把 handoff 刪掉(一次性,重複兌換直接回 410),回傳真正的 access_tokenrelay_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_secretX-OPO-Broker-Signature。中繼站對 raw body 做 HMAC-SHA256 簽章,客戶端用同一把金鑰對收到的原始 bytes 重算並比對。要注意的是必須用未經解析的原始 body,先 parse 再 stringify 會讓簽章對不上。

Cloudflare Workers 免費方案跑得動這種服務嗎?

登入與驗簽這類純 CPU 的同步工作跑得動,免費方案的請求數對中小規模的中繼站也夠。真正需要付費的是可靠轉發:免費方案沒有 Queues,背景轉發失敗沒有內建重試,只能靠客戶端去重來容錯。要嚴格的重試保證就得升級並換成 Durable Objects。


手上有第三方 API 串接的需求,不管是 LINE 登入與推播,還是其他平台的授權整合,或者想把一段「客戶老是設定錯」的流程包成一個按鈕?歡迎聯絡我們,或加入我們的 LINE 官方帳號,把簽章、加密、重試這些水面下的細節交給我們處理。

Google 偏好來源

把我們設為 Google 偏好來源

喜歡我們的內容嗎?一鍵將我們設為偏好來源,未來在 Google 焦點新聞與 AI 概覽中就能優先看到我們的文章。

作品案例

看看我們打造的產品與專案。從 WordPress 外掛到 AI 客服方案,每一個作品都是實戰經驗的累積。

瀏覽作品案例

服務項目

WordPress 開發、WooCommerce 電商、LINE 整合、AI 解決方案,依據你的需求提供最適合的技術服務。

瀏覽服務項目

Contact

聯絡我們

有任何技術需求、專案諮詢或合作想法,歡迎填寫以下表單或聯繫LINE官方帳號,我們會盡快回覆。

諮詢類型 必須