Laravelの署名つきURLってどうやってできてるの？

最近、APIサーバで署名付きのURLを生成したいと思う時が多々あるが、何も考えずに一から作るのはしんどかったので、PHPフレームワークのLaravelでLaravel5.6から実装された署名付きURLの実装を見ていきたいと思う

※ 今回の調査に使ったLaravelのバージョンは5.8.17ですが、今後のバージョンアップによって内容が変化する可能性があります。

## はじめに
Webアプリケーションを作成していると、一定時間内のみ有効なURLを作成したい場合が多々ある
しかし、署名付きURLの生成方法はあまりQiitaなどでも実装について触れられていないので、実装するとなるとどうやって実装したらいいのか行きつまることも・・・

今回はLaravelの署名付きURLについて調べたので今後の自分の為にも記事にする

## 調査環境
### 物理マシーン
- MacBook (Retina, 12-inch, 2017) 
    - CPU: 1.4 GHz Intel Core i7
    - メモリ: 16 G
    - MacOS Mojave

### 実行環境
- Docker for Mac: version 18.09.2
    - php-image: php:7.3.5-cli
        - ext: pdo json pdo_mysql
        - Composer: version 1.8.5
    - db: mariadb:10.3.15

上記の通り構成は非常にシンプルにしている

## 署名付きURLの生成メソッド

Laravelで署名付きURLを生成するときには以下の２つのスタティックメソッドのどちらかを利用する
これらのメソッドは以下のnamespaceに記述されている
`Illuminate\Routing\UrlGenerator;`

- 署名付きで有効期限がないURLの時

URL::signedRoute

```PHPDoc.php
@method static string signedRoute(string $name, array $parameters = [], \DateTimeInterface|\DateInterval|int $expiration = null, bool $absolute = true)
```
- 署名付きで有効期限のあるURLの時

URL::temporarySignedRoute

```PHPDoc.php
@method static string temporarySignedRoute(string $name, \DateTimeInterface|\DateInterval|int $expiration, array $parameters = [], bool $absolute = true)
```

両メソッドの引数を見ると受け取ってる値は変わらないことに気がつくはずだ
しかし、別々のメソッドになっている
これは、expirationを指定しない場合はデフォルト値nullを指定するようになっており、引数を必要としない為である
よって、基本的にsignedRouteの中身を確認することで署名付きURLの正体を知ることができる

実際に記述部分を見ると以下のソースコードになっている

```Illuminate\Routing\UrlGenerator.php
public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true)
    {
        $parameters = $this->formatParameters($parameters);

        if ($expiration) {
            $parameters = $parameters + ['expires' => $this->availableAt($expiration)];
        }

        ksort($parameters);

        $key = call_user_func($this->keyResolver);
        return $this->route($name, $parameters + [
            'signature' => hash_hmac('sha256', $this->route($name, $parameters, $absolute), $key),
        ], $absolute);
    }
```

1行づつ見ていくことにしよう
### 1行目
```php
$parameters = $this->formatParameters($parameters);
```
例： `$parameters = ['user' => 1]`
Format the array of URL parameters. とPHPDocに書いてあるので、おそらくURLエンコードしていると思われる
とくに記号や全角文字を使っていなければ通さなくても良いと思う

### 2行目
```php
if ($expiration) {
    $parameters = $parameters + ['expires' => $this->availableAt($expiration)];
}
```
例: `$parameters = ['user' => 1, 'expires' => 1560882044]`

この部分は有効期限終了する時間のタイムスタンプをexpiresという添字でparametersに追加している

### 3行目
ソートしてるだけなので、飛ばす

### 4行目
```php
$key = call_user_func($this->keyResolver);
```
例： `$key = 'base64:hogehogefugafuga'`
環境変数に設定したAPP_KEYが代入されている

### 5行目
```php
$this->route($name, $parameters + [
            'signature' => hash_hmac('sha256', $this->route($name, $parameters, $absolute), $key),
        ], $absolute);
```
例： `$parameters = ['user' => 1, 'expires' => 1560882044, 'signature' => 'acc5a1b36005eff636c8e6097224ededb6d1b6f9c1cd58953ff905bd09bb86a5'`

この部分が今回の最大の肝になるsignatureの生成部分である
結果的にsignatureはhash_hmac関数で生成されている
signatureのパラメータを除いたURLをsha256でハッシュ化した値になっている

※ this->route()の部分は以下のようになっている
基本的には`$name`にルーティングテーブルのnameと`$parameters`にパラメータの配列を渡すとクエリパラメータ付きのURLを取得できる
$absoluteをfalseにするとクエリパラメータ部分のみ取得できる

```
route($name, $parameters = [], $absolute = true)
```

## 署名付きURLの検証用メソッド
もちろん、署名付きURLを生成したということは検証もする必要がある

Laravelで署名付きURLを検証するには以下のメソッドを利用する
こちらのメソッドは以下のnamespaceに同じく記述されている
`Illuminate\Routing\UrlGenerator;`

- 検証用のメソッド

URL::hasValidSignature

```PHPDoc.php
@method bool hasValidSignature(Request $request, $absolute = true)
```

こちらのメソッドは非常に簡単な作りになっていて、$requestを引数で渡すとbool値を返してくれる

実際に記述部分を見ると以下のソースコードになっている

````Illuminate\Routing\UrlGenerator.php
public function hasValidSignature(Request $request, $absolute = true)
    {
        $url = $absolute ? $request->url() : '/'.$request->path();

        $original = rtrim($url.'?'.Arr::query(
            Arr::except($request->query(), 'signature')
        ), '?');

        $expires = $request->query('expires');

        $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));

        return  hash_equals($signature, (string) $request->query('signature', '')) &&
               ! ($expires && Carbon::now()->getTimestamp() > $expires);
    }
```

こちらも1行づつ見ていくことにしよう

### 1行目
```php
$url = $absolute ? $request->url() : '/'.$request->path();
```
例： `$url = 'http://localhost:8000/valid'`
検証用のURLのエンドポイント

### 2行目
```php
$original = rtrim($url.'?'.Arr::query(
    Arr::except($request->query(), 'signature')
), '?');
```
例： `http://localhost:8000/valid?expires=1560882044&user=1`
これは、クエリパラメータからsignatureを除いたURLになっていることがわかる
hash_hmacでハッシュ化した元の値っぽい匂いがする

### 3行目
```php
$expires = $request->query('expires');
```
例： `$expires = '1560882044'`
有効期限をクエリパラメータから取得

### 4行目
```php
$signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));
```
これは、signedRouteの5行目で作成した$signatureと同じ処理をしていることがわかる

### 5行目
```php
hash_equals($signature, (string) $request->query('signature', '')) &&
               ! ($expires && Carbon::now()->getTimestamp() > $expires);
```
ここでようやく署名の検証ができる
4行目で生成した＄signatureは送られてきた$signatureと同じハッシュアルゴリズムとキーを使っているので、等しいはずである
hash_equalsを利用して比較を行う
最後に有効期限がきれていないか確認を行う


## 終わりに
今回はLaravel5.6から追加された署名付きURLについてLaravelのソースコードを追っていたが、結果的にクエリパラメータをhash化して比較しているだけだったので、簡単に自作することができそうだ
このように一般に使われているフレームワークでもしっかりとソースコードを読むと意外と無難な実装がされているのが新鮮だった

## 参考
- [LaravelでURLを取得する全６項目・実例！](https://blog.capilano-fw.com/?p=2537#URL-7)
- [Laravel URL Generation](https://laravel.com/docs/5.8/urls)
- [laravel GitHub Repository](https://github.com/laravel/laravel)