PHP
JavaScript
セキュリティ
csrf
SSO

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

More than 1 year has passed since last update.


はじめに

PHPによる簡単なログイン認証いろいろで紹介したセッション認証において,「あるオリジン上でログインしたら別のオリジン上でもログインしたことにする方法」,つまりクロスオリジンでセッションを共有する方法(変数$_SESSIONを共有する方法)を解説します.


teratailで回答していただいた方々,ありがとうございました

teratail - シングルサインオン認証はCSRFセーフなの?(31529)



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


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


動画サービス

http://video.example.com



検索サービス

http://search.example.com


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

<?php

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


PHP 7.0 以降のみ有効な方法

<?php

session_start(['cookie_domain' => '.example.com']);


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


動画サービス

http://www.youtube.com



検索サービス

http://www.google.co.jp


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

なお,セッションファイルの保存先(php.iniのsession.save_path)は同じ場所である前提です.


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

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

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


サーバサイドでやること


login.php


  • 通常のログインが完了したら,全オリジンでログインさせるためのsso-login.phpCSRFトークンつきで遷移する.

// ログイン完了後に /sso-login.php にCSRFトークンつきで遷移

header('Location: /sso-login.php?token=' . urlencode(generate_token()));
exit;


sso-login.php



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

// 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内に埋め込む.

<script>

'use strict';

/* 省略 */

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

/* 省略 */

</script>


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


sso-login.php


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

<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引数のオリジンには*ではなく,期待するオリジンを明示しておいたほうが無難. (非推奨とされているが必須ではない)

<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対策のために,必ず送信元オリジンの検証を行う. (必須)

<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対策を行わない場合は

<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属性が使えるのが長所です.しかし,そのままではCSRF脆弱性があるため,トークンの導入が必要になります.トークンの話になると固定トークンかワンタイムトークンかに派閥が割れますが,今回は誰が誰に相乗りしてログインしたいのかが不明であるため,誰でも参照可能な有効期限つきのワンタイムトークンを使うしかありません.

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