PHP+JavaScriptでクロスオリジンなシングルサインオン認証

# はじめに

[PHPによる簡単なログイン認証いろいろ](http://qiita.com/mpyw/items/bb8305ba196f5105be15)で紹介したセッション認証において，「あるオリジン上でログインしたら別のオリジン上でもログインしたことにする方法」，つまり**クロスオリジンでセッションを共有する**方法（変数`$_SESSION`を共有する方法）を解説します．

> teratailで回答していただいた方々，ありがとうございました
> [teratail - シングルサインオン認証はCSRFセーフなの？(31529)](https://teratail.com/questions/31529)

# 共通のドメイン上にサブドメインで分岐している場合

## セッション用Cookieのdomain属性を変更する

```text:動画サービス
http://video.example.com
```

```text:検索サービス
http://search.example.com
```

この場合には，Cookieのdomain属性を書き換えるだけで対応できます.

```php
<?php
ini_set('session.cookie_domain', '.example.com'); // 先頭のドットを忘れないように注意
session_start();
```

```php:PHP&#32;7.0&#32;以降のみ有効な方法
<?php
session_start(['cookie_domain' => '.example.com']);
```


# 全く別のドメイン上に存在している場合

```text:動画サービス
http://www.youtube.com
```

```text:検索サービス
http://www.google.co.jp
```

このような場合には，あるオリジンでログインしたとき，対象とする全てのオリジンにおけるセッションIDを**ログイン後のセッションIDで置き換える**というアプローチをとることになります．

なお，セッションファイルの保存先（php.iniの`session.save_path`）は同じ場所である前提です．

## HTML5 の Web Messaging API でセッションIDの書き換えを依頼する

こちらはバックエンドはシンプルになる代わりに，フロントエンドのJavaScriptはゴリゴリ使います．JavaScriptでCookieを操作する性質上，**`httponly`属性は取り扱えない**というのが大きな欠点です．

サンプル実装はGitHubに置いたので，ポイントだけ解説します．

- [GitHub - mpyw-yattemita/php-sso-examples](https://github.com/mpyw-yattemita/php-sso-examples)


### サーバサイドでやること

#### login.php

- 通常のログインが完了したら，全オリジンでログインさせるための`sso-login.php`に**CSRFトークンつきで**遷移する．

```php
// ログイン完了後に /sso-login.php にCSRFトークンつきで遷移
header('Location: /sso-login.php?token=' . urlencode(generate_token()));
exit;
```

#### sso-login.php

- **CSRFトークンの検証を行った上で**HTML+JavaScriptの表示を許可する．

```php
// CSRFトークンを検証
if (!validate_token(filter_input(INPUT_GET, 'token'))) {
    // 「400 Bad Request」
    header('Content-Type: text/plain; charset=UTF-8', true, 400);
    exit('トークンが無効です');
}
```


- ログイン済みのセッションIDをJavaScript内に埋め込む．

```php
<script>
   'use strict';

   /* 省略 */

   const id = <?=json_encode(session_id())?>;

   /* 省略 */

</script>
```

### クライアントサイドでやること

#### sso-login.php

- 他の対象サービス (条件分岐するのが面倒であれば自分自身も含んで問題ない) の未ログイン状態でアクセスできるページ，つまりログインフォームのあるページをiframe要素のsrc属性に指定する…だけで本来は良いはずだが，IE10以下とSafariはデフォルトでサードパーティCookieの書き込みを禁止しているので，迂回策として**フォーム送信をiframe要素に向けて行う**という方法を採る．<br />**※ 但し今回の例ではバリバリECMAScript6互換で書いているので，適宜書き換えを行わないとChromeやFirefox以外では動作しません**

```html
<form target="ifr0" method="get" action="http://localhost:8080/login.php"></form>
<form target="ifr1" method="get" action="http://127.0.0.1:8081/login.php"></form>
<iframe name="ifr0" style="visibility: hidden;" data-origin="http://localhost:8080"></iframe>
<iframe name="ifr1" style="visibility: hidden;" data-origin="http://127.0.0.1:8081"></iframe>

<script>
   'use strict';

    addEventListener('DOMContentLoaded', () => {
        // DOMを読み込み終わるまで待ってから実行

        /* 省略 */

        // 全てのformからiframeに対して送信
        Array
        .from(document.querySelectorAll('form'))
        .forEach(form => form.submit());

    });
</script>
```

- セッションIDを書き換える処理を`postMessage`を使ってiframe要素内のページに依頼する．iframe要素内で想定しないページ遷移があった場合に備え，送信先制限のために，`postMessage`の第2引数のオリジンには`*`ではなく，期待するオリジンを明示しておいたほうが無難． (非推奨とされているが必須ではない)

```html
<script>
   'use strict';

    addEventListener('DOMContentLoaded', () => {
        // DOMを読み込み終わるまで待ってから実行

        Promise.all(
            // 全てのiframeに対して適用
            Array
            .from(document.querySelectorAll('iframe'))
            .map(iframe => {
                // ログイン済みのセッションIDをPHPから受け取る
                const id = <?=json_encode(session_id())?>;
                // メッセージを定義
                const message = {
                    operation: 'overwrite-session-id',
                    value: id,
                };
                return new Promise(r => iframe.addEventListener('load', r))
                // iframeを読み込み終わるまで待ってから実行
                .then(() => iframe.contentWindow.postMessage(message, iframe.dataset.origin));
            })
        )
        // 全てのiframeに対してメッセージを送信し終えてから実行
        .then(() => location.replace('/'))
        .catch(e => console.error(e));

        /* 省略 */

    });
</script>
```

#### login.php

- `postMessage`で依頼された処理を実行する．**この際CSRF対策のために，必ず送信元オリジンの検証を行う． (必須)**

```html
<script>
   'use strict';

    addEventListener('message', e => {
        // CSRF対策のため，許可したオリジン以外からのメッセージは無視する
        switch (e.origin) {
            case 'http://localhost:8080':
            case 'http://127.0.0.1:8081':
                break;
            default:
                return;
        }
        // セッションID上書きイベントを取り扱う
        if (e.data.operation === 'overwrite-session-id') {
            document.cookie =
            document.cookie
            .split('; ')
            .map(pair =>
                pair.indexOf('PHPSESSID=') === 0
                ? 'PHPSESSID=' + e.data.value
                : pair
            ).join('; ');
        }
    });
</script>
```

## セッションIDの書き換えを受け入れるPHPスクリプトを用意する

PHP側で**「GETパラメータに指定されたセッションIDをもとに`setcookie`関数を実行して，セッションIDを書き換えるための`Set-Cookie`ヘッダを送る」**スクリプトを用意しておく方式です．IE10以下とSafari対策を行わない場合は

```php
<iframe style="visibility: hidden;" src="http://localhost:8080/overwrite-sid.php?sid=<?=h(session_id())?>"></iframe>
<iframe style="visibility: hidden;" src="http://127.0.0.1:8081/overwrite-sid.php?sid=<?=h(session_id())?>"></iframe>
```

のようにJavaScript無しで完結し，**`httponly`属性が使える**のが長所です．しかし，そのままでは**<font color="red">CSRF脆弱性</font>**があるため，トークンの導入が必要になります．トークンの話になると固定トークンかワンタイムトークンかに派閥が割れますが，今回は**<ins>誰が誰に相乗りしてログインしたいのかが不明</ins>**であるため，誰でも参照可能な有効期限つきのワンタイムトークンを使うしかありません．

今回は具体例の提示は割愛しますが，一定時間で自動的にストアした値を蒸発させることができるAPCu，あるいはMemcachedやRedisを使うと比較的実装が容易になるでしょう．