昨天把 Notion 資料庫的欄位定完,今天換另一頭:LINE 那邊要準備什麼,還有 webhook 進來的第一道關卡——簽章驗證——實際上在擋什麼。
先講一個容易卡住的地方:現在沒辦法直接在 LINE Developers Console 建立 Messaging API channel 了。正確流程是先去 LINE Official Account Manager 建一個官方帳號,進帳號的「設定 → Messaging API」把 Messaging API 啟用,這時候要選一個 Provider(沒有就順手建一個)。啟用完那頁馬上就能看到 Channel ID 跟 Channel secret。接著才回到 LINE Developers Console,在剛剛那個 channel 底下的 Messaging API 分頁簽發 Channel access token,記得選 long-lived 那種,不然 token 會過期要一直重簽。
還有一步很容易漏掉:回 Official Account Manager 的「回應設定」,把 Auto-reply messages 跟 Greeting messages 都關掉。這兩個是 LINE 官方帳號預設會開的制式回覆,不關的話,使用者傳訊息進來,LINE 會自己先回一句罐頭訊息,蓋掉 bot 真正要回的內容,看起來就像 bot 壞了。
Channel secret 跟 access token 各自的角色不一樣:secret 是用來驗證「這個 webhook 真的是 LINE 送來的」,access token 是用來呼叫 Reply API、把訊息回傳給使用者。程式碼裡這兩個分別對到 LINE_CHANNEL_SECRET 跟 LINE_CHANNEL_ACCESS_TOKEN 兩個 secret,今天先講前者。
webhook 進來第一件事,就是驗證 x-line-signature 這個 header:
const body = await request.text();
const signature = request.headers.get('x-line-signature');
if (!(await verifySignature(env.LINE_CHANNEL_SECRET, body, signature))) {
return new Response('Unauthorized', { status: 401 });
}
這裡故意用 request.text() 拿原始字串,而不是先 request.json()。因為 LINE 那邊算簽章,用的是 request body 的原始 bytes,如果先解成物件再重組成字串,多一個空白、換行順序不一樣,算出來的結果就對不上,簽章驗證一定失敗。所以順序是:先拿原始字串驗簽章,驗過了才 JSON.parse。
驗證邏輯本身是標準的 HMAC-SHA256:
async function verifySignature(secret, body, signature) {
if (!secret || !signature) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));
if (expected.length !== signature.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
}
return diff === 0;
}
拿 Channel secret 當 HMAC 金鑰,對 body 算一次 SHA-256,轉成 base64,跟 LINE 傳來的 x-line-signature 比對,一樣才放行。最後那段逐字元 XOR 再用 diff |= 累加、跑完全部字元才回傳結果,是刻意不用 === 直接比字串。原因是 === 一旦某個字元不同就會提早結束比對,攻擊者理論上可以用回應時間的細微差異,一個字元一個字元去猜出正確的簽章。這裡長度不同就直接回 false(因為長度不對本來就不可能是合法簽章,也沒有時序資訊好猜),長度一樣的話才進到那個逐字元跑完全部長度的迴圈,讓比對時間跟哪個字元錯無關。
簽章驗證失敗回 401,JSON.parse 失敗回 400,這兩個都是在正式處理任何事件之前就擋掉,確保後面 ctx.waitUntil(handleEvents(...)) 收到的一定是「確定是 LINE 送的、格式也對」的資料,不會有人拿一段亂數字串打你的 Worker 網址就讓程式往下跑。
最後是設定 webhook:Developers Console 的 Messaging API 分頁,把 Cloudflare Workers 部署完拿到的網址貼進 Webhook URL 欄位,按 Verify。如果這時候跳 SSL 連線錯誤,通常不是設定錯,是剛申請的 workers.dev 子網域 DNS 還在傳播,等個幾分鐘再按一次 Verify 就過了。驗證成功後記得把 Use webhook 開關打開,不然 LINE 不會真的把事件送過來。
LINE 那邊的準備到此告一段落。明天進到部署到 Cloudflare Workers,講 wrangler.toml 裡的 vars 跟用 wrangler secret put 設定的 secret,這兩者要怎麼分。