Slackから自サーバーにリクエストをもらうときのリクエストの検証方法について、再考察。
先日、記事に書いたRequest Verificationの実装を、より汎用性を持たせようと改造したらハマったメモ書きです。
(最下部に追記あり)
#RequestVerificationのおさらい
Slackに向けて開放しているURLが第三者にバレなければいいわけなので、ランダムな文字列をURLの一部にすることで危険性を下げられる可能性はあります。が、これは完璧ではありません。
自分のサーバーに送られたきたメッセージ(APIのコール、外部からのリクエスト)が、Slackから送られたものか、内容が途中で改ざんされていないかを確認するための仕組みがRequestVerificationです。
マニュアルはこちら:https://api.slack.com/docs/verifying-requests-from-slack
##Slackから届くHTTPリクエストの種類
いろいろ調べてみたところ、以下の3種類がありました。3種類ともにRequest Verification
での検証ができます。
- スラッシュコマンド(Slash Command)
- イベント(Event API)
- インタラクティブアクション(interactive message)
スラッシュコマンドは、SlackApp内に複数定義できます。コマンドごとにURLを設定します。
イベントAPIは、SlackApp1つに対し、URLを1つ設定します。
インタラクティブメッセージも、SlackApp1つに対し1つだけURLを設定できます。
今回の狙いは、スラッシュコマンドもイベントAPIもインタラクティブメッセージも同じ仕組みで検証することです。
##リクエストの調査
実際に送ってくるSlackからのリクエストを受け取ってみました。まずは分析です。
###1. スラッシュコマンドの特徴
スラッシュコマンドから送られるHTTPリクエストには、api_app_id
がリクエストに含まれてないです。今は推奨されていないtoken
の値は入っています。
array (
'token' => '1YxL3GCEx7R9CFgal0LvIDpK',
'team_id' => 'TDWRKFV0S',
'team_domain' => 'kanaxx',
'channel_id' => 'CDWNT347M',
'channel_name' => 'channel2',
'user_id' => 'UDX2143CM',
'user_name' => 'kanaxx-user',
'command' => '/debug',
'text' => 'a',
'response_url' => 'https://hooks.slack.com/commands/TDWRKFV0S/xx/xx',
'trigger_id' => '869342852774.472869539026.2f48bbb8387b2933755533d709b529e6',
)
###2. EventAPIの特徴
EventAPIから送られるHTTPリクエストには、api_app_id
とtoken
の両方が入っています。
array (
'token' => '1YxL3GCEx7R9CFgal0LvIDpK',
'team_id' => 'TDWRKFV0S',
'api_app_id' => 'ANNK9HN4E',
'event' =>
array (
'type' => 'reaction_added',
'user' => 'UDX2143CM',
'item' =>
array (
'type' => 'message',
'channel' => 'CDWNT347M',
'ts' => '1575294625.003200',
),
'reaction' => 'heart',
'item_user' => 'UNFPH4Q2W',
'event_ts' => '1575986127.000300',
),
'type' => 'event_callback',
'event_id' => 'EvR4AE2DU2',
'event_time' => 1575986127,
'authed_users' =>
array (
0 => 'UDX2143CM',
1 => 'UNUGXMXQQ',
),
)
###3. Intaractive Messageの特徴
こいつはちょっと厄介です。まず、リクエストパラメータがpayload
だけです。なんてこった。payload
の中にJSONな文字列がごりごりと丸ごと入っています。
ただ、payloadをちゃんと読み取ると、api_app_id
とtoken
が入っています。
array (
'payload' => '{"type":"block_actions","team":{"id":"TDWRKFV0S","domain":"kanaxx"},"user":{"id":"UDX2143CM","username":"kanaxx-user","name":"kanaxx-user","team_id":"TDWRKFV0S"},"api_app_id":"ANNK9HN4E","token":"1YxL3GCEx7R9CFgal0LvIDpK","container":{"type":"view","view_id":"VRE1W93U0"},"trigger_id":"854357955618.472869539026.d34d43f7ec2469eb54f68a75816c6a2e","view":{"id":"VRE1W93U0","team_id":"TDWRKFV0S","type":"home","blocks":[{"type":"section","block_id":"xzl8q","text":{"type":"mrkdwn","text":"@KANAXX \\u3055\\u3093\\u306b\\u9001\\u3063\\u3066\\u307e\\u3059\\u3002\\n\\u305f\\u3060\\u3044\\u307e2019-12-10 22:57:27","verbatim":false}},{"type":"divider","block_id":"6+N5"},{"type":"actions","block_id":"UDY","elements":[{"type":"button","action_id":"YNmt=","text":{"type":"plain_text","text":"Create New Task","emoji":true},"style":"primary","value":"create_task"},{"type":"button","action_id":"1gvg","text":{"type":"plain_text","text":"Create New Project","emoji":true},"value":"create_project"},{"type":"button","action_id":"T7d","text":{"type":"plain_text","text":"Help","emoji":true},"value":"help"}]}],"private_metadata":"","callback_id":"","state":{"values":{}},"hash":"1575986248.89d5c70f","title":{"type":"plain_text","text":"View Title","emoji":true},"clear_on_close":false,"notify_on_close":false,"close":null,"submit":null,"previous_view_id":null,"root_view_id":"VRE1W93U0","app_id":"ANNK9HN4E","external_id":"","app_installed_team_id":"TDWRKFV0S","bot_id":"BNP5CN72B"},"actions":[{"action_id":"YNmt=","block_id":"UDY","text":{"type":"plain_text","text":"Create New Task","emoji":true},"value":"create_task","style":"primary","type":"button","action_ts":"1575986290.374551"}]}',
)
↓ 長いのでpayloadの内容だけを整えてみます。一部省略しています。
{
"type":"block_actions",
"team":{
"id":"TDWRKFV0S",
"domain":"kanaxx"
},
"user":{
"id":"UDX2143CM",
"username":"kanaxx-user",
"name":"kanaxx-user",
"team_id":"TDWRKFV0S"
},
"api_app_id":"ANNK9HN4E",
"token":"1YxL3GCEx7R9CFgal0LvIDpK",
"container":{
"type":"view",
"view_id":"VRE1W93U0"
},
"trigger_id":"854357955618.472869539026.d34d43f7ec2469eb54f68a75816c6a2e",
"view":{
"id":"VRE1W93U0",
"team_id":"TDWRKFV0S",
"type":"home",
"blocks":[
(省略)
],
"private_metadata":"",
"callback_id":"",
"state":{
"values":{
}
},
"hash":"1575986248.89d5c70f",
"title":{
"type":"plain_text",
"text":"View Title",
"emoji":true
},
"clear_on_close":false,
"notify_on_close":false,
"close":null,
"submit":null,
"previous_view_id":null,
"root_view_id":"VRE1W93U0",
"app_id":"ANNK9HN4E",
"external_id":"",
"app_installed_team_id":"TDWRKFV0S",
"bot_id":"BNP5CN72B"
},
"actions":[
{
"action_id":"YNmt=",
"block_id":"UDY",
"text":{
"type":"plain_text",
"text":"Create New Task",
"emoji":true
},
"value":"create_task",
"style":"primary",
"type":"button",
"action_ts":"1575986290.374551"
}
]
}
3つをまとめると、以下のようになります。
- リクエストのトップレベル要素から
api_app_id
が取れるパターン - リクエストの
payload
の値の中からapi_app_id
が取れるパターン - リクエストのトップレベル要素から、
token
しかとれないパターン
送ってくる形式がこんなに違うなんて。受け手を困らせる仕掛けですね、なんてこった。
#プログラム作る
この差異を吸収するような、Verificationの仕組みをLaravelのMiddlewareで実装してみます。
SlackAppの固有の設定値は、Laravelのconfigと.envの仕組みで引き込むようにします。
##Laravel config
Slack Appの設定値をLaravelのconfigの形式に落とし込みます。
↓ LaravelのカスタムConfigの形式に変更
<?php
return [
'team'=>'TDWRKFV0S',
'apps'=>[
'slalack' =>[
'app_name'=>'すららっく',
'app_id'=>'ANNK9HN4E',
'client_id'=>'472869539026.770655600150',
'client_secret'=>env('SLACK_CLIENT_SECRET'),
'signing_secret'=>env('SLACK_SIGNING_SECRET'),
'verification_token'=>'1YxL3GCEx7R9CFgal0LvIDpK',
// OAuth values
//'redirect_url'=>'https://slalack-bot.herokuapp.com/apps/slalack/auth',
//'bot_token'=>env('SLACK_TOKEN_BOT'),
//'admin_token'=>env('SLACK_TOKEN_ADMIN'),
//'scope'=>['identity.basic', ],
],
'2gou' =>[
'app_name'=>'2号',
'app_id'=>'APUKS4YD6',
'client_id'=>'472869539026.810672168448',
'client_secret'=>env('SLACK_CLIENT_SECRET_2GOU'),
'signing_secret'=>env('SLACK_SIGNING_SECRET_2GOU'),
'verification_token'=>'GQad5uU6m19WwDrcIl4n3BOc',
// OAuth values
//'redirect_url'=>'https://slalack-bot.herokuapp.com/apps/2gou/auth',
//'bot_token'=>env('SLACK_TOKEN_BOT_2GOU'),
//'admin_token'=>env('SLACK_TOKEN_ADMIN_2GOU'),
//'scope'=>['channels:history', 'users.profile:read', 'users.profile:write', 'channels:read', 'groups:read', ],
],
]
];
一つのLaravelアプリで複数のSlackAppのリクエストを受け取れるようにするために、slack.php
に複数個のSlackAppを定義できるようになってます。作ったファイルはLaravel Configのルールに従い、config
ディレクトリに置きます。
知られてもいい値はconfigファイルに直書き、知られてはいけない値は.envから取るようになっています。app_id
, client_id
, verification token
は、Gitにコミットしても問題ないはずですが、気持ち悪い場合は全部.envに出すのもいいと思います。
##.envファイル
.envファイルは、config/slack.phpの変数と一致させます。SlackAppが増えると4つずつセットで増えていく形です。ちょっと鬱陶しいですが仕方なし。
SLACK_SIGNING_SECRET=
SLACK_CLIENT_SECRET=
SLACK_TOKEN_ADMIN=xoxp-472869539026-xxx
SLACK_TOKEN_BOT=xoxb-472869539026-yyy
SLACK_SIGNING_SECRET_2GOU=
SLACK_CLIENT_SECRET_2GOU=
SLACK_TOKEN_ADMIN_2GOU=xoxp-472869539026-aaa
SLACK_TOKEN_BOT_2GOU=xoxb-472869539026-bbb
##ミドルウェア本体
Laravelのmiddlewareを書きます。
<?php
namespace App\Http\Middleware;
use Closure;
use Log;
class SlackRequestVerification
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$appId = $request->input('api_app_id');
$token = $request->input('token');
if(blank($appId) && blank($token)){
$payload = json_decode($request->input('payload'), true);
$appId = $payload['api_app_id']??'';
$token = $payload['token']??'';
}
$timestamp = $request->header('x-slack-request-timestamp');
$signature = $request->header('x-slack-signature');
Log::info('verification:', ['appId'=>$appId, ' token'=>$token, 'x-signature'=>$signature, 'x-ts'=>$timestamp]);
$config = null;
$configs = config('slack.apps');
foreach($configs as $name=>$c){
if( $c['app_id']==$appId || $c['verification_token']==$token){
$config = $c;
break;
}
}
if( $config == null ){
return response()->json([
'ok'=>false,
'error' => 'not found app config',
], '500');
}
$requestBody = $request->getContent();
$secret = $config['signing_secret'];
$sigBasestring = 'v0:' . $timestamp . ':' . $requestBody;
$hash = 'v0=' . hash_hmac('sha256', $sigBasestring, $secret);
$debug = ['Timestamp'=>$timestamp,
'Body'=>$requestBody,
'Sig'=>$sigBasestring,
'Secret'=>substr_replace($secret,'*',5),
'Hash'=>$hash,
'Signature'=>$signature,];
Log::debug($debug);
//不一致なら500で終わり
if( $hash !== $signature ){
Log::info('signature un match');
return response()->json([
'ok'=>false,
'error' => 'signature error',
], '500');
}
Log::info('middle varification:true', [$config['app_name']]);
//念のため、verificationをした証拠を入れておく
$verified = [
'slack_signing_verification'=>true,
'slack_use_secret'=>substr_replace($secret,'*',5),
'slack_verify_hash'=>$hash,
];
$request->merge($verified);
//次の処理へ
return $next($request);
}
}
リクエストから$api_app_id
が定まれば、config/slack.phpからconfigの配列が取れるわけなのですけど、すんなり取れるのはEventAPIの場合だけです。スラッシュコマンドの場合はtoken
しか受け取れないので、tokenの値も取っておき、どっちがが取れていればよいことにしています。
どちらも取れないときは、interactive message
の形式とみなしてpayloadを取り出して、その中からapi_app_id
を取り出しています。
api_app_id
かtoken
が取れたら、SlackAppの全configを見て一致するものを探します。configが見つかれば、client_secret
とsigning_secret
が取れるので、リクエストの検証ができます。
##HTTPのkernel.phpに追加
前後、省略して必要最低限だけ記載しています。routeMiddleware
に登録するだけです。
//(省略)
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
//ココ追加
'slack_verify' => \App\Http\Middleware\SlackRequestVerification::class,
];
##Route
ルートファイルはこんな感じです。slackのverifyを適用するリクエストパターンで登録します。これなら、Slackと関係ないAPIがあっても大丈夫です
Route::middleware('slack_verify')->group(function(){
Route::any('/debug', 'SlalackBotController@debug');
Route::any('/slalack/event', 'SlackEventController@event');
Route::any('/slalack/action', 'SlackActionController@action');
Route::any('/2gou/event', 'SlackEvent2GouController@event');
});
#実行
いつも通りHerokuにデプロイして実行してみます。
1つ目のappのSlashコマンド
[2019-12-14 15:03:55] production.INFO: middle varification:true ["http://slalack-bot.herokuapp.com/api/debug","すららっく"]
1つ目のappのEvent API
[2019-12-14 15:04:55] production.INFO: middle varification:true ["http://slalack-bot.herokuapp.com/api/slalack/event","すららっく"]
1つ目のappのInteractive Message
[2019-12-14 15:06:10] production.INFO: middle varification:true ["http://slalack-bot.herokuapp.com/api/slalack/action","すららっく"]
2つ目のappのEvent APIコマンド
[14-Dec-2019 15:07:45 Asia/Tokyo] [2019-12-14 15:07:45] production.INFO: middle varification:true ["http://slalack-bot.herokuapp.com/api/2gou/event","2号"]
#まとめ
何故、受け取る形式に差があるのか分からないのですが、とりあえず共通化できたので良しとしましょう。
できれば、api_app_id
を全APIの呼び出しのトップレベルのパラメータで渡して欲しいです
#追記
https://api.slack.com/changelog?year=2020
2020年8月のアップデートで、slash commandのレスポンスにもapi_app_id
が入るようになっています。これで共通した処理が作れそうです。