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

Slackから自サーバーにリクエストをもらうときのリクエストの検証方法について、再考察。
先日、[記事に書いたRequest Verification](https://qiita.com/kanaxx/items/f2025cbc961de2c6bc5b)の実装を、より汎用性を持たせようと改造したらハマったメモ書きです。

（最下部に追記あり）

#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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/ddd1cd7d-1968-5e5b-7047-55068f77a925.png)

イベントAPIは、SlackApp1つに対し、URLを1つ設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/7171c368-0ae9-a70e-bc81-6da5fe387786.png)

インタラクティブメッセージも、SlackApp1つに対し1つだけURLを設定できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/4ee58b55-6fb5-270a-6350-f94a0a47987a.png)

今回の狙いは、スラッシュコマンドもイベントAPIもインタラクティブメッセージも同じ仕組みで検証することです。

##リクエストの調査
実際に送ってくるSlackからのリクエストを受け取ってみました。まずは分析です。

###1. スラッシュコマンドの特徴
スラッシュコマンドから送られるHTTPリクエストには、`api_app_id`がリクエストに含まれてないです。今は推奨されていない`token`の値は入っています。

```php:スラッシュコマンド
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`の両方が入っています。

```php: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_id`と`token`が入っています。


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

```json: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/d251c668-0291-674e-5456-fef943d0078c.png)

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

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

```bash:.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を書きます。

```php: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_id`か`token`が取れたら、SlackAppの全configを見て一致するものを探します。configが見つかれば、`client_secret`と`signing_secret`が取れるので、リクエストの検証ができます。



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

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

```php: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`が入るようになっています。これで共通した処理が作れそうです。