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


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. **アカウントロック機能の実装**
9. [メール送信機能の実装](http://qiita.com/ShibuyaKosuke/items/78c8673782ebbf51584b)
10. [アカウントロック解除機能の実装](http://qiita.com/ShibuyaKosuke/items/ee4b1896071eeca37628)
11. [CSRF対策](http://qiita.com/ShibuyaKosuke/items/8100e304e4447e44e98c)

##はじめに

　アカウントをロック（ログインフォームの利用を不可能に）するのですから、ユーザーを特定しなければなりません。 **ロックできるのは、「メールアドレス」はあっているが、「パスワード」が間違っている場合** にのみ機能するものです。

 ログイン機能については、 **「悪意のあるユーザー」と「何度もパスワードを間違った正規のユーザー」それぞれに適した実装** を心がけなければなりません。

##要件

1. 規定回数以上ログインに失敗した場合、「アカウントをロックしました」というメッセージを画面に表示し、ログインボタンおよび入力フォームを無効にする。
2. メールアドレスがあっているがパスワード不一致でログインできないことが、規定回数以上になった場合、アカウントがロックされたことを、登録されたメールアドレスあてにメールを送信する。
3. アカウントロックのメールには、設定時間が過ぎれば、通常通りログインできる旨を明記し、すぐにロックを解除したい場合は、解除方法も通知する。

##実装

###LoginController.class.php

`noticeAccountLockForMail(UserModel $objUserModel);` メソッドを追加し、`login()` メソッド内からコールします。

```php:LoginController.class.php
<?php

namespace MyApp\controller;

use \MyApp\model\UserModel;
use \MyApp\common\Db;
use \MyApp\common\InvalidErrorException;
use \MyApp\common\ExceptionCode;
use \MyApp\common\Log;
use \MyApp\common\Mail;

/**
 * LoginController
 */
class LoginController
{

	/**
	 * ログイン成功時の遷移先
	 */
	const TARGET_PAGE = '/dashboard.php';

	/**
	 * セッションに保存する名前
	 */
	const LOGINUSER = 'loginUserModel';

	/**
	 * メールアドレスとパスワードでログインする
	 * @return void
	 */
	static public function login()
	{
		// POSTされていないときは、処理を中断する
		if (!filter_input_array(INPUT_POST)) {
			return;
		}

		//フォームからの値を受け取る
		$email = filter_input(INPUT_POST, 'email');
		$password = filter_input(INPUT_POST, 'password');

		// いずれかが空文字の場合、例外
		if ('' == $email || '' == $password) {
			throw new InvalidErrorException(ExceptionCode::ARGUMENT_REQUIRED);
		}

		//トランザクションを開始する
		Db::transaction();

		//	email から ユーザーモデル を取得する
		$objUserModel = new UserModel();
		$objUserModel->getModelByEmail($email);

		//	ロックされたアカウントかどうかをチェックする
		if ($objUserModel->isAccountLock()) {

			// コミット（ロールバックでも構わない。トランザクションを残さないため。）
			Db::commit();

			//	ロックされている
			throw new InvalidErrorException(ExceptionCode::INVALID_LOCK);
		}

		//パスワードチェック
		if (!$objUserModel->checkPassword($password)) {

			// ログイン失敗記録
			$objUserModel->loginFailureIncrement();

			// コミット（失敗回数を書き込むので）
			Db::commit();

			// アカウントロック通知
			self::noticeAccountLockForMail($objUserModel);

			// アカウントロック通知(Cookie)
			self::noticeAccountLockForCookie();

			// ログインに失敗しました。
			throw new InvalidErrorException(ExceptionCode::INVALID_LOGIN_FAIL);
		}

		//	ログイン失敗をリセット
		$objUserModel->loginFailureReset();

		//コミット
		Db::commit();

		//セッション固定攻撃対策
		session_regenerate_id(true);

		//セッションに保存
		$_SESSION[self::LOGINUSER] = $objUserModel;

		//ページ遷移
		header(sprintf("location: %s", self::TARGET_PAGE));
	}

	/**
	 * アカウントロック・メール通知
	 * @param UserModel $objUserModel
	 * @return void
	 */
	private static function noticeAccountLockForMail(UserModel $objUserModel)
	{
		// 規定回数以内のとき、なにもしない。
		if (UserModel::LOCK_COUNT > $objUserModel->getLoginFailureCount()) {
			return;
		}

		// メール通知
		$strRecipient = $objUserModel->getEmail();
		$strSubject = 'アカウントをロックしました。';
		$strBody = 'とりあえず空';
		Mail::send($strRecipient, $strSubject, $strBody);

		throw new InvalidErrorException(ExceptionCode::INVALID_LOCK);
	}

}
```

###UserModel.class.php

```php:UserModel.class.php
<?php

namespace MyApp\model;

use MyApp\dao\UserDao;

/**
 * UserModel
 */
final class UserModel extends UserModelBase
{

	/**
	 * アカウントをロックするログイン失敗回数
	 */
	const LOCK_COUNT = 3;

	/**
	 * アカウントをロックする時間（分）
	 */
	const LOCK_MINUTE = 15;

	/**
	 * メールアドレスからユーザーを検索する
	 * @param string $strEmail
	 * @return \MyApp\model\UserModel
	 */
	public function getModelByEmail($strEmail)
	{
		$dao = UserDao::getDaoFromEmail($strEmail);
		return (isset($dao[0])) ? $this->setProperty(reset($dao)) : null;
	}

	/**
	 * パスワードが一致しているかどうかを判定する
	 * @param type $password
	 * @return bool
	 */
	public function checkPassword($password)
	{
		$hash = $this->getPassword();
		return password_verify($password, $hash);
	}

	/**
	 * ログイン失敗をリセットする
	 * １以上のときに０にする
	 * @return bool
	 */
	public function loginFailureReset()
	{
		$count = $this->getLoginFailureCount();
		if (0 < $count) {
			$this->setLoginFailureCount(0)
				->setLoginFailureDatetime(null);
			return $this->save();
		}
		//変更の必要がない
		return true;
	}

	/**
	 * ログイン失敗をインクリメントする
	 * 指定回数（self::LOCK_COUNT）に満たないときのみ＋１
	 * @return bool
	 */
	public function loginFailureIncrement()
	{
		$count = $this->getLoginFailureCount();
		if (self::LOCK_COUNT > $count) {
			$now = (new \DateTime())->format('Y-m-d H:i:s');
			$this->setLoginFailureCount(1 + $count)
				->setLoginFailureDatetime($now);
			return $this->save();
		}

		//ログイン失敗が設定以上のとき
		return true;
	}

	/**
	 * アカウントがロックされているかどうかを判定する
	 * @return bool ロックされていたら true
	 */
	public function isAccountLock()
	{
		$count = $this->getLoginFailureCount();
		$datetime = $this->getLoginFailureDatetime();

		$lastFailureDatetime = new \DateTime($datetime);
		$interval = new \DateInterval(
			sprintf('PT%dM', self::LOCK_MINUTE)
		);
		$lastFailureDatetime->add($interval);

		//設定時間以内で、かつ設定回数以上の失敗を記録しているとき
		if ($lastFailureDatetime > new \DateTime() && self::LOCK_COUNT <= $count) {
			return true;
		}
		return false;
	}

}
```