PHPでログイン機能を実装するチュートリアル #11


1. [基本設計、ユーザーモデル](http://qiita.com/ShibuyaKosuke/items/f114ffccf441edb2b745)
2. [オートローダー](http://qiita.com/ShibuyaKosuke/items/6109810464a7293293bd)
3. [例外・ログ](http://qiita.com/ShibuyaKosuke/items/88b1305a4ff0c5f3e728)
4. [PDO シングルトン　SQLインジェクション](http://qiita.com/ShibuyaKosuke/items/094df7f399912ae7df56)
	* [汎用データベース・クラス作成](http://qiita.com/ShibuyaKosuke/items/8502492ac024362b528d)

5. [ユーザーモデルの作成](http://qiita.com/ShibuyaKosuke/items/81c556d836d70a29c5f3)
6. [クラスの継承](http://qiita.com/ShibuyaKosuke/items/490ffa6a92670cf6c644)
7. [テンプレート・クラスの実装](http://qiita.com/ShibuyaKosuke/items/803dc58a23601118942e)
8. [アカウントロック機能の実装](http://qiita.com/ShibuyaKosuke/items/e890becdb8193d3def05)
9. [メール送信機能の実装](http://qiita.com/ShibuyaKosuke/items/78c8673782ebbf51584b)
10. [アカウントロック解除機能の実装](http://qiita.com/ShibuyaKosuke/items/ee4b1896071eeca37628)
11. CSRF対策

CSRF(クロスサイトリクエストフォージェリ) 対策については[wiki](https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA)を参照ください。

つまりは、`<form>` から何らかの値を送信して、サーバーに処理させる場合、この対策を怠らないようにしましょうということ。

基本的には、form の内部に、`<input type="hidden">` で値を送信し、`SESSION`に保存された値と一致するかどうかを確認する。

このCSRF対策には、二重送信を防止するという副産物もあり、正規ユーザーに対しては、「CSRFを検知したので、処理を中断しました」というような「不正」を表現するのではなく、「二重送信されたので処理を中断しました。」のようなメッセージにすると良いかもしれません。

##実装

`classes/common/Csrf.class.php`に`Csrf`クラスを定義しました。

###classes/common/Csrf.class.php

```php:classes/common/Csrf.class.php
<?php

namespace MyApp\common;

/**
 * Csrf.class.php
 */
class Csrf
{

	/**
	 * トークン
	 * @var string
	 */
	private static $token = null;

	/**
	 * 初期化
	 */
	private static function init()
	{
		self::$token = sha1(uniqid());
	}

	/**
	 * CSRF用にトークンを生成する
	 * @return string
	 */
	public static function get()
	{
		if (is_null(self::$token)) {
			self::init();
		}
		$_SESSION['csrf_token'] = self::$token;
		return self::$token;
	}

	/**
	 * CSRFをチェックする
	 * @return boolean
	 * @throws ApplicationErrorException
	 */
	public static function check()
	{
		$csrf_token = (isset($_SESSION['csrf_token'])) ? $_SESSION['csrf_token'] : null;
		$_SESSION['csrf_token'] = null;

		if (filter_input(INPUT_POST, 'csrf_token') !== $csrf_token) {
			// 二重送信されたので処理を中断しました。
			throw new InvalidErrorException(ExceptionCode::INVALID_CSRF_ERR);
		}
		return true;
	}

}
```

###classes/common/ExceptionCode.class.php

```php:classes/common/ExceptionCode.class.php
<?php

namespace MyApp\common;

/**
 * ExceptionCode.class.php
 * @since 2015/07/25
 */
class ExceptionCode
{

	/**
	 * InvalidError
	 */
	const INVALID_ERR = 1000;
	const INVALID_LOCK = 1001;
	const INVALID_LOGIN_FAIL = 1002;
	const ARGUMENT_REQUIRED = 1003;
	const INVALID_CSRF_ERR = 1004;

	/**
	 * ApplicationError
	 */
	const APPLICATION_ERR = 2000;

	/**
	 * SystemError
	 */
	const SYSTEM_ERR = 3000;
	const TEMPLATE_ERR = 3001;
	const TEMPLATE_ARG_ERR = 3002;

	/**
	 * エラーメッセージ
	 * @var array<string>
	 */
	private static $_arrMessage = array(
		self::INVALID_ERR => 'エラーが発生しました。'
		, self::INVALID_LOCK => 'アカウントがロックされています。'
		, self::INVALID_LOGIN_FAIL => 'ログインに失敗しました。'
		, self::INVALID_CSRF_ERR => '二重送信されたので処理を中断しました。'
		, self::ARGUMENT_REQUIRED => 'メールアドレスおよびパスワードは入力必須です。'
		, self::APPLICATION_ERR => 'アプリケーション・エラーが発生しました。'
		, self::SYSTEM_ERR => 'システム・エラーが発生しました。'
		, self::TEMPLATE_ERR => 'テンプレート[%s]が見つかりません。'
		, self::TEMPLATE_ARG_ERR => '引数に[%s]は利用できません。'
	);

	/**
	 * エラーメッセージを取得する
	 * @param int $intCode
	 * @return string
	 */
	static public function getMessage($intCode)
	{
		if (array_key_exists($intCode, self::$_arrMessage)) {
			return self::$_arrMessage[$intCode];
		}
	}

}
```

##利用方法

前述のように、form の中に hidden で梅子見るのですから、以下のような使い方になります。

```php
<form method="post">

	// その他のフォーム部品

	<input type="hidden" name="csrf_token" value="<?= Csrf::get(); ?>">
</form>
```

基本的に `form` を利用するところには、CSRF対策を施す必要がありますが、開発メンバーの全員が `CSRF`について理解した上で、利用しているとは限りません。「処理に関係ない値」と思ってわざわざ削除してしまわないとも限りません。多くのフレームワークで、たかだか `<form>` を生成するためのヘルパーメソッドが用意されているには、理由があり、この `CSRF対策`が `formヘルパー`で自動的に付与される仕組みになっています。

テンプレート・エンジン `Smarty`には、[カスタムプラグイン機能](http://www.smarty.net/docs/ja/plugins.tpl)が用意されており、`form` のように、開始タグと終了タグを生成するための[ブロック関数](http://www.smarty.net/docs/ja/plugins.block.functions.tpl)を拡張することができます。

###smarty/plugins/block.form.php

```php:smarty/plugins/block.form.php
<?php

/**
 * block.form.php
 */
function smarty_block_form($params, $contents)
{
	if (!$contents) {
		return;
	}

	$attr = [];
	foreach ($params as $key => $val) {
		$attr[] = sprintf('%s="%s"', $key, $val);
	}
	$attribute = (count($attr) > 0) ? ' ' . implode(' ', $attr) : '';

	$html = sprintf('<form%s>', $attribute);
	$html .= $contents;

	$html .= sprintf('<input type="hidden" name="csrf_token" value="%s">'
		, MyApp\common\Csrf::get()
	);
	$html .= '</form>';

	return $html;
}
```

テンプレート側については以下のようにして呼び出します。

`{form method="post"}` と `{/form}` の部分がそれにあたります。

###smarty/templates/unlock.tpl

```html:smarty/templates/unlock.tpl
{block name='meta'}
	<style type="text/css">
		.login-box, .register-box {
			margin: 20px auto;
		}
	</style>
{/block}
{block name='content'}
	<div class="login-box">
		<div class="login-logo">
			<a href="/">
				<b>Admin</b>
				LTE
			</a>
		</div>
		<!-- /.login-logo -->
		<div class="login-box-body">

			{if $success|default:null}
				<p class="login-box-msg">アカウントロックを解除しました。</p>
				<p class="login-box-msg">
					<a href="/">ログインページ</a> よりログインしてください。
				</p>
			{else}

				{if $is_lock}
					<p class="login-box-msg">ロックを解除するには、下のボタンを押してください。</p>

					{form method="post"}

					<div class="row">
						<div class="col-xs-6 col-xs-offset-3">
							<button type="submit" class="btn btn-primary btn-block btn-flat">
								ロックを解除する
							</button>
						</div>
					</div>

					{/form}

				{else}
					<p class="login-box-msg">アカウントはロックされていません。</p>
					<p class="login-box-msg">
						<a href="/">ログインページ</a> よりログインしてください。
					</p>
				{/if}
			{/if}

		</div>
		<!-- /.login-box-body -->
	</div>
	<!-- /.login-box -->
{/block}
{block name='script'}
{/block}
```

同様に `index.tpl` についても行っておけば良いでしょう。

###Csrf::check()の使い方

```php:classes/controller/LoginController.class.php
	/**
	 * ロックを解除する
	 * @return boolean
	 */
	public static function unlock()
	{
		if (null == filter_input_array(INPUT_POST)) {
			return;
		}

		// CSRFチェック
		Csrf::check();

		$token = filter_input(INPUT_GET, 'token');

		Db::transaction();
		$objUserModel = new UserModel();
		$objUserModel->getModelByToken($token);
		$objUserModel->setLoginFailureCount(0)
			->setLoginFailureDatetime(NULL)
			->setToken('')
			->save();
		DB::commit();

		return true;
	}
```

##GitHub レポジトリ

これまでの解説で作成したソースコードについては、こちらに。

[レポジトリ](https://github.com/ShibuyaKosuke/LoginSample)