Cookies default to SameSite=Lax - Chrome Platform Statusということで、何もなければ2020年2月4日にはリリースされる見込みのChrome 80(参考: Chrome Platform Status)を皮切りにCookieにデフォルトがSameSite=Lax
相当になるということで、駆け込みでSameSite=None
を付けて回る需要があるらしいですね。
PHP 7.3が正式リリースされる前に書いたPHPでSame-site cookie - Qiitaが未だに参照されていて厳しい気持ちがあり、さりとて邪悪なPolyfillをまた作ってコピペさせるのも抵抗があり、と悩んでいるうちにバッドノウハウを堂々と書いた記事が出てきてしまったので、仕方なくライブラリにすることにしました。
週末は具合が悪くて昨晩から衝動的に作ったので設計が練りきれてないところはありますが、ちゃんと考えて書いたので、たぶん動くと思います。
問題の背景
PHPからHTTPレスポンスヘッダのSet-Cookie
を送るには、ちょっとした問題がいくつかあります。
- PHPの
setcookie()
関数は7.3.0から'SameSite'属性に対応した - PHP 7.3.0未満のPHPで
SameSite
属性をセットするにはひどいハックが必要になる - PSR-7はそもそもCookieを操作するための高レベルの機能がない
- 当然というかなんというか、
setcookie()
はPSR-7のために何もしてくれない
Symfony\HttpFoundation ComponentなんかはCookieのための機能を持ってますが、そうでないPSR-7のPsr\Http\Message\ResponseInterface
だけに依存した良さげなCookie実装がなかったので作りました。いやdflydev/dflydev-fig-cookies: Cookies for PSR-7 HTTP Message Interface.とかhansott/psr7-cookies: 🍪 bakes cookies for PSR-7 messagesとかあるんですけど、どっちもあんまり好きじゃないなあと。
そんなわけでPSR-7を基盤にしたフレームワークでもsetcookie()
関数を直接呼ぶような治安のないバニラPHPでも使えるライブラリを自分で実装したいと思ったのでした。
21世紀になったので setcookie() を生で呼ぶようなことはあまりされないと思いますがコメントしました https://t.co/kHXOTgam8C
— tadsan (@tadsan) 2020年1月11日
Bag2\Cookieの使いかた
このパッケージはPackagistで公開したのでcomposer require bag2/cookie
でインストールできます。操作の軸になるのはCookie Ovenクラスです。
<?php // デフォルト設定を持った Bag2\Cookie\Oven クラスを作成 $cookie = Bag2\Cookie\oven(['path' => '/', 'httponly' => true]); $cookie->add('Name1', 'Value', ['expires' => \time() + 120]); $cookie->add('Name2', '', ['expires' => \time() - 1]); // delete
ここまではBag\Cookie\Oven
クラスにセットしたいクッキーを詰めただけなので、副作用は何もありません。
バニラPHP (フレームワークなし)
header()
関数やsetcookie()
関数を生で呼んでいるようなプロジェクトでは、以下のように関数を呼ぶとSet-Cookie
ヘッダを発行できます。
<?php Bag2\Cookie\emit($cookie); // この $cookie は上記のコード片で作ったOvenオブジェクト
内部ではPHPのバージョンで分岐してsetcookie()
関数を呼び分けています。
PSR-7
通常、PSR-7(HTTP Message Interface)オブジェクトには以下のようにHTTPヘッダを設定します。
<?php $response = $factory->createResponse(); $response = $response->withHeader('Set-Cookie', 'name=value');
すでにPSR-7を使っている皆様には釈迦に説法ですが、重要なのはPSR-7のオブジェクトは原則としてイミュータブルであり、メソッドを呼んでもオブジェクトの内部状態が破壊されることはないということです。そのため、withHeader()
メソッドで状態を付け足すことはできますが、その結果は受け取らないと残らないということです。
また、withHeader()
メソッドはその既に同じヘッダが設定済みの時に上書きするので、複数の設定は厄介です。コントローラで設定する際は新しく作られたばかりのResponseオブジェクトでは問題ないでしょうが、PSR-15: HTTP Server Request Handlers(ミドルウェア)でSet-Cookie
を足したりするときは不用意に行うと問題です。そのためOven::appendTo()
とOven::setTo()
という2種類のメソッドを提供しています。
Oven::appendTo()
はResponseオブジェクトに既にSet-Cookie
ヘッダが設定済みだったとき、同名のcookieをOvenにあるcookieを優先してセットしたオブジェクトを返します。 Oven::setTo()
はResponseオブジェクトに設定済みのSet-Cookie
は全て捨ててOvenにあるcookieをセットしたオブジェクトを返します。
<?php $response = $cookie->appendTo($response);
特にこだわりがなければappendTo()
で良いでしょう。
Bag2\Cookie\setcookie()
ところでPHP 7.3.0からsetcookie()
の仕様が変わったということですが、それはPHP RFC: Same Site Cookieという提案と表決に基くものです。変更前のsetcookie()
関数は以下のようなオプショナルな7引数をとる関数でした。
<?php bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false]]]]]] )
このような関数に$samesite
オプションを追加するにはどうすればよいでしょうか。上記のRFCでは以下の二種類の案が投票されました。
setcookie
1
setcookie
に引数を追加するbool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false [, string $samesite = "" ]]]]]]] )
2
setcookie
がオプションを配列でとれるようにする。$options
配列のキーはSet-Cookie
ヘッダに対応するpath
,domain
,secure
,httponly
およびsamesite
。それぞれのオプションのデフォルト値は変更しない。samesite
のデフォルト値は空文字列。bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false]]]]]] ) bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, array $options ]]] )
結果はみなさまご存じの通り、1は満場一致で否決、2の方向で採用されました。(ただしExpiresは$options
の中に入りました)
話が長くなりましたが、Bag2\Cookie\setcookie()
はRFCで否決された8引数版の実装を採用しています。
<?php namespace Bag2\Cookie { /** * Send a cookie by legacy style \setcookie() like function */ function setcookie( string $name, string $value = '', int $expires = 0, string $path = '', string $domain = '', bool $secure = false, bool $httponly = false, string $samesite = '' ): bool { return emit(oven()->add($name, $value, [ 'expires' => $expires, 'path' => $path, 'domain' => $domain, 'secure' => $secure, 'httponly' => $httponly, 'samesite' => $samesite ])); } }
もし既存のsetcookie()
を配列呼び出しに書き換えるのがだるかったらBag2\Cookie\setcookie()
にするのが簡単かもしれないですね。
設計の背景
配列オプション VS withXXX()
PHP 7.3のsetcookie()
は一個のオプション引数を受け取るスタイルですが、dflydev/fig-cookiesパッケージは以下のようなスタイルをとります。
<?php use Dflydev\FigCookies\Modifier\SameSite; use Dflydev\FigCookies\SetCookie; $setCookie = SetCookie::create('lu') ->withValue('Rg3vHJZnehYLjVg7qi3bZjzg') ->withExpires('Tue, 15-Jan-2013 21:47:38 GMT') ->withMaxAge(500) ->rememberForever() ->withPath('/') ->withDomain('.example.com') ->withSecure(true) ->withHttpOnly(true) ->withSameSite(SameSite::lax()) ;
このスタイルは縦にも横にも長めに見える一方、PhpStormのような入力補完で次々に入力できるというメリットがあります。一方でBag2\Cookieはsetcookie()
スタイルのオプションをPSR-7 Responseに適用することを念頭に置いたデザインになっています。
dflydev/fig-cookiesにはPSR-7に通じるコンセプトを感じる一方で、若干のやりすぎ感も感じます。ミュータブル・イミュータブルの以前に「一度作ったCookieに属性を足したり消したりしたいことなんてないのでは?」(必要ならSetCookieオブジェクトを作る前に$options
を組み立てる処理を書けばいいのでは?)という疑問のもと、そのスタイルのインターフェイスは提供していません。
CookieOven vs SetCookie
dflydev/fig-cookiesパッケージではSetCookie
クラスまたはSetCookies
クラスを直接扱うのに対して、Bag2\Cookieは最初にOven
を生成します。これは「一つのSet-Cookie」と「複数のSet-Cookie」というものを同列に扱えるようにしたかったからです。また、前述したコンセプトの通り、Oven内のCookieを同名の別オプジェクトで上書きすることはあっても、一度作ったSetCookieオブジェクトの内容を変更することはありません。もとより、Bag2\Cookieではエンドユーザーが意識する必要のあるオブジェクトはBag2\Cookie\Oven
とPsr\Http\Message\ResponseInterface
だけです。
関数 vs 静的メソッド
PHPの関数とメソッドは別の機能です。関数とクラスはどちらも名前空間で階層化することができます。クラスはオートローダーで定義の読み込みを遅延できますが、関数は実行前にあらかじめ明示的にロードしておかないといけません。Bag2\Cookieは関数定義ファイルのみ同期的にロードさせていますが、現在のところ提供する機能は小さく留めています。
まとめ
- Bag2\CookieはPSR-7と
setcookie()
の両方に対応したSet-Cookieライブラリです - PHP 7.3の配列オプションスタイルの
setcookie()
互換の機能を7.3未満でも使うこともできます - 逆に8引数スタイルの関数も用意したので、配列オプションに置き換えにくければ、こちらを使ってください
深夜のテンションでうっかりCookieライブラリと説明書を書いてしまってとても眠いので、tadsanの消滅した睡眠時間を慮れる皆様はGitHubまたはpixiv FANBOXでサポートしていただけると、とてもありがたいでえす ヾ(〃><)ノ゙
次回予告
PHPerKaigi 2020が2月9日から開催されますのでtadsanと握手!
3月後半にはLaravel JP Conferenceもあります。