5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Slack Appに挑戦(6) - Request Verificationの2回目

Last updated at Posted at 2019-12-14

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での検証ができます。

  1. スラッシュコマンド(Slash Command)
  2. イベント(Event API)
  3. インタラクティブアクション(interactive message)

スラッシュコマンドは、SlackApp内に複数定義できます。コマンドごとにURLを設定します。
image.png

イベントAPIは、SlackApp1つに対し、URLを1つ設定します。
image.png

インタラクティブメッセージも、SlackApp1つに対し1つだけURLを設定できます。
image.png

今回の狙いは、スラッシュコマンドもイベント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_idtokenの両方が入っています。

EventAPI
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_idtokenが入っています。

IntaractiveAction
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の内容だけを整えてみます。一部省略しています。

IntractiveAction#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の形式に落とし込みます。

Appの設定
image.png

↓ LaravelのカスタムConfigの形式に変更

config/slack.php
<?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つずつセットで増えていく形です。ちょっと鬱陶しいですが仕方なし。

.env
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を書きます。

App\Http\Middleware\SlackRequestVerification.php
<?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_idtokenが取れたら、SlackAppの全configを見て一致するものを探します。configが見つかれば、client_secretsigning_secretが取れるので、リクエストの検証ができます。

##HTTPのkernel.phpに追加
前後、省略して必要最低限だけ記載しています。routeMiddlewareに登録するだけです。

App\Http\Kernel.php

    //(省略)

    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があっても大丈夫です

routes/api.php

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の呼び出しのトップレベルのパラメータで渡して欲しいです:bow:

#追記
https://api.slack.com/changelog?year=2020
2020年8月のアップデートで、slash commandのレスポンスにもapi_app_idが入るようになっています。これで共通した処理が作れそうです。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?