Yii2で暗号化クッキーとプレーンなクッキーを併用する

4連投のつもりが5連投目です。

Yii 2 は標準で Cookie を暗号化してバリデーションします。ユーザーが Cookie の内容改ざんを行えなくなるのでセキュアで結構...なのですが...

`Request` の `enableCookieValidation` が効いていると、無条件にすべての Cookie が暗号化されます。つまり、いったん暗号化を選んだら、暗号化しない Cookie を送る方法がなくなってしまいます。そうすると、リバースプロキシやロードバランサーですら、アプリケーションで作った Cookie にはいっさいアクセスできなくなってしまいます。分散/キャッシュの仕組みによっては、アプリケーション内で決定される何かの値が、内部的な経路を決めるヒントになる場合もあるでしょう。

そこで、そんな特別な状況では、「例外的にこれは暗号化しない」という指定が可能な Request/Response を作ってそれを使います。

```php
<?php
namespace app\utils\web;

use Yii;
use yii\base\InvalidConfigException;
use yii\web\Cookie;

/**
 * @inheritdoc
 */
class Request extends \yii\web\Request
{
    /**
     * Cookie names which ignores encryption and validation.
     * @var array
     */
    public $insecureCookies;

    /**
     * @inheritdoc
     */
    protected function loadCookies()
    {
        if ($this->enableCookieValidation) {
            if ($this->cookieValidationKey == '') {
                throw new InvalidConfigException(get_class($this) . '::cookieValidationKey must be configured with a secret key.');
            }
        }

        $cookies = [];
        foreach ($_COOKIE as $name => $value) {
            if ($this->enableCookieValidation && !in_array($name, $this->insecureCookies)) {
                if (is_string($value) && ($value = Yii::$app->getSecurity()->validateData($value, $this->cookieValidationKey)) !== false) {
                    $cookies[$name] = new Cookie([
                        'name' => $name,
                        'value' => @unserialize($value),
                        'expire'=> null
                    ]);
                }
            } else {
                $cookies[$name] = new Cookie([
                    'name' => $name,
                    'value' => $value,
                    'expire'=> null
                ]);
            }
        }

        return $cookies;
    }
}
```

```php
<?php
namespace app\utils\web;

use Yii;

/**
 * @inheritdoc
 */
class Response extends \yii\web\Response
{
    protected function sendCookies()
    {
        $request = Yii::$app->getRequest();
        if (!$request->enableCookieValidation) {
            parent::sendCookies();
            return;
        }

        $insecureCookieNames = $request->get('insecureCookies', []);

        // separate cookies if secure or insecure
        $secureCookies = [];
        $insecureCookies = [];
        foreach ($this->getCookies() as $cookie) {
            if (in_array($cookie->name, $insecureCookieNames)) {
                $insecureCookies[] = $cookie;
            } else {
                $secureCookies[] = $cookie;
            }
        }
        $this->getCookies()->removeAll();

        // send secure cookies
        foreach ($secureCookies as $cookie) {
            $this->getCookies()->add($cookie);
        }
        parent::sendCookies();

        // send insecure cookies
        foreach ($insecureCookies as $cookie) {
            setcookie($cookie->name, $cookie->value, $cookie->expire, $cookie->path, $cookie->domain, $cookie->secure, $cookie->httpOnly);
        }
    }
}
```

使い方

```php
<?php
return [
    // ...
    'components' => [
        'request' => [
            'class' => 'app\utils\web\Request',
            'enableCookieValidation' => true,
            'cookieValidationKey' => 'xxxx',
            'insecureCookies' => [
                'insecure_cookie_name',
                'another_insecure_cookie_name',
            ],
        ],
        'response' => [
            'class' => 'app\utils\web\Response',
        ],
    ]
];
```

これで、 `insecure_cookie_name` および `another_insecure_cookie_name` と名付けられた Cookie はセキュリティの保護を受けない代わりに、経路上で読み取ったり、JavaScript でアクセスしたりできるようになります。

(Tips のつもりが案外長いコードになってしまいました)

ただし、こんな平文 Cookie は、JavaScript 連携用のワンタイムトークンや、ロードバランサーの振り分け根拠など、単一の値で表されるものだけにしましょう。Cookie が構造的になると、壊れた文字列を送ってパースエラーを起こさせる攻撃が可能になってしまいますから。もちろん、ユーザーにとって致命的でなく、かつ揮発性の高いデータであるという条件は言うまでもありません。

また、いったん使い始めたら、暗号化していたものを暗号化しない方針に途中で変更することはできません。暗号化した Cookie を持っているブラウザが送ってきた値を、暗号化していない Cookie を想定して受け取ると、アプリケーション内では「解読できないそういう文字列」になってしまいます。十分に注意してください。