LINEのMessaging API WebhookをAPI Gatewayで受ける

こんにちは、AWS担当のwakです。

最近LINE botがMessaging APIとしてリニューアルしたとのことで、同僚が面白がって触っていたので説明してもらいました。聞くところによると、LINEのbotを友達登録したりメッセージを送ったりアンケートに答えたりすると、その内容がリアルタイムで自分のサーバーに送られてくるWebhookがあるそうです。

f:id:nurenezumi:20161102104059p:plain

そしてLINEのMessaging API WebhookはHTTPSのエンドポイントを要求します。このためだけにサーバーを立てたり証明書を取ったりするのも面倒ですし、AWSAPI Gateway+Lambda(Node.js)で受けることにしました。今回はその話を書きます。

f:id:nurenezumi:20161102113329j:plain

概要

話はシンプルです。LINEからのメッセージをAPI Gatewayで受け、署名(後述)をチェックし、それ以外のものをどこか(SNS, DynamoDB, アプリケーションサーバーなど)に投げます。こうすることにより、仮に一気にアクセスが集中した場合でも応答ができなくなることは防げますし、API Gateway側で流量制御もできます。また、アプリケーションが直接インターネットに露出する必要がなくなるので気を遣う点が少なくなります。

準備

Lambda

Node.jsで書きます。コードはこの記事の末尾にあります。先に作成しておいてください。

API Gateway

いつも通りResources→Create Resource→Create MethodとたどってPOSTのエンドポイントを用意します。ここでLambdaを指定するのですが、"Use Lambda Proxy integration"のチェックをONにしてください。

f:id:nurenezumi:20161027171436p:plain

Use Lambda Proxy integrationがONなので、その他の細かい設定は不要です。API Gatewayがアクセスを受け付けると、HTTPヘッダ、ボディ等がまとめてLambdaに渡されます。

問題なければデプロイしてエンドポイントを確定させておきます。

LINE Messaging API Webhook

LINE Business Centerにログインし、「アカウントリスト」タブからMessaging APIを探して設定画面を開きます。ここにある「Channel Secret」を取得し、Lambdaのコード中にコピペして更新しておきます*1

f:id:nurenezumi:20161027172503p:plain

同じ画面の下の方にWebhookのエンドポイントを登録する場所があります。

f:id:nurenezumi:20161027173056p:plain

ここにAPI Gatewayのエンドポイントを書いて「VERIFY」ボタンを押してみましょう。ここまでの設定が全て正しく行っていれば、API Gateway経由でLambdaが実行され、ログが出力されるはずです。

正しくログが出力されればこれで終わりです。あとはbotを友達登録したりチャットをしたりしてください。

メッセージの検証

さて、ちょっと別のことを考えます。

LINE(メッセージを送信する側)の立場から見た場合、WebhookのエンドポイントはHTTPS限定ですから、登録したエンドポイントにメッセージを送信すれば確実に私に届くことは保証されています。なりすましに騙されて違う相手にメッセージが届いてしまうこと、送信経路でメッセージが盗聴・改竄を受ける可能性を気にする必要はありません*2。ところが私(メッセージを受ける側)から見た場合は話が変わります。たとえば誰かがLINEのサーバーを装って本物と同じフォーマットで偽のメッセージを送ってくるかもしれません。こういった攻撃は正しく見破って破棄する必要があります。

HMACってなに

このように、メッセージの改竄・捏造を検出する手段の一つがHMAC: Hash-based Message Authentication Codeです。HMACでは、事前に共有した秘密鍵とメッセージを混ぜ合わせてハッシュを取り、MAC値と呼ばれる値を算出します。送信側はこのMAC値をメッセージに添えて送ります。受信側はメッセージの内容と秘密鍵*3から同じ手順でMAC値を算出し、添えられたMAC値と比較して、一致すればこのメッセージは本物であると判断します。なぜならば、メッセージに対応した正しいMAC値が分かるのは秘密鍵を知っている2人だけだからです。秘密鍵が分からないのにメッセージをでっち上げようとしてもうまくいきません。

なお、HMACの利点は秘密鍵そのものをメッセージに含める必要がないことで、つまりSSLを通さず平文でメッセージをやり取りすることも可能です(覗き見は避けられませんが、改竄は不可能だということになります)。

算出方法

ドキュメント | LINE Developers に従ってメッセージのMAC値(をBASE64エンコードした文字列)を算出します*4

var crypto = require('crypto');
var hmacsha256 = crypto.createHmac("sha256", "deadbeefdeadbeefdeadbeefdeadbeef"); // 第2引数はChannel Secret

var body = "..."; // 受信したbody部
var bodyStream = new stream.Readable();
bodyStream.push(body);
bodyStream.push(null);

bodyStream.on("data", chunk => {
    hmacsha256.update(chunk);
});
bodyStream.on("end", () => {
    var digest = hmacsha256.digest("base64");
    console.log(`digest: ${digest}`);
});

このdigestの値と、X-Line-Signatureヘッダの値とを比較すれば検証は終わりです。

var signature = (event.headers || {})["X-Line-Signature"];

Lambdaのコード

aws-sdk, crypto, streamはいずれも標準の環境に含まれているため、特に複雑なことをする必要はありません。このコードではログを出力するだけですが、もちろんなんでも好きなことができます。

'use strict';

var AWS = require("aws-sdk");
var lambda = new AWS.Lambda({region: "ap-northeast-1"});
var crypto = require('crypto');
var stream = require("stream");

exports.handler = function(event, context) {
    console.log(event);

    var signature = (event.headers || {})["X-Line-Signature"];
    var body = event.body || "";
    var bodyStream = new stream.Readable();
    bodyStream.push(body);
    bodyStream.push(null);
    
    var hmacsha256 = crypto.createHmac("sha256", "YOUR-CHANNEL-SECRET"); // TODO: 管理画面からChannel Secretを取得して書き換える
    bodyStream.on('data', chunk => {
        hmacsha256.update(chunk);
    });
    
    bodyStream.on('end', () => {
        var digest = hmacsha256.digest("base64");
        if (digest === signature)
        {
            console.log("署名検証結果OK");
            // TODO: 何かする
        }
        else 
        {
            console.log("署名検証結果NG");
            // TODO: 何かする
        }
        var response = {
            statusCode: 200,
            headers: {
                "x-custom-header" : "my custom header value"
            },
            body: '{"result":"owari"}'
        };
        context.succeed(response);
    });
};

*1:もちろん本当はDynamoDBとかに入れておきます

*2:そのためのSSLなので

*3:上述の通り、秘密鍵は事前にこっそり共有します。メッセージには含まれていません

*4:もちろん本来はバイトストリームから直接MAC値を算出すべきですが、API Gatewayでは文字列しか扱えません。LINEがメッセージを送信する際の文字エンコーディングも、Streamのデフォルトの文字エンコーディングUTF-8であることを前提に処理を書いています