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


入門用の書籍などで紹介はされているのですが、質も悪く、サイトの情報も古いものが多くて初心者にとっては情報の良し悪しもつかない。だからここに書く。初心者向けだからといってグローバル空間は汚したくないので、クラスやメソッドで実装する。

> 当初初級者でも理解できるように書くつもりでしたが、回を進めるうちにとても初心者が理解できるような内容ではなくなってきました。
簡単に済ませたいのであれば、「[PHPログイン機能サンプル](https://qiita.com/ShibuyaKosuke/items/83c21b6cc2dff2a93f72)」を参考にして欲しい。

##要件定義##

* ログインは「メールアドレス」と「パスワード」をフォームから送信する
* 「パスワード」はハッシュ化してデータベースに登録する
* ユーザー自身でパスワードを変更することができる
* 連続してログインに失敗した時にアカウントをロックする。一定時間経過すると自動的にロックを解除する。ロックした時にはユーザーにメールで通知する。

##マイルストーン##

ざっくりリストアップするとこんな感じ

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対策](http://qiita.com/ShibuyaKosuke/items/8100e304e4447e44e98c)

##作成するページをリストアップする##

現時点で必要と思われるファイルをあげてみます。作成を進めるうちに分割したほうがいいとか、このファイルが必要だった、など変更があるかもしれないということは念頭に置いておいてください。

|機能|ファイル名|概要|
|------|-------|-------|
|ログインページ|webroot/index.php|ログインフォームを表示する|
|トップページ	|webroot/top.php|ログイン後に表示するページ|
|ユーザー登録ページ|webroot/user-create.php|新規ユーザーを追加するページ|
|パスワード変更ページ|webroot/passwd.php|ログイン・ユーザー自身のパスワードを変更するページ|
|ログインコントローラクラス|classes/controller/LoginController.class.php|ログインに関するメソッドを実装する|
|ユーザーモデルクラス|classes/model/UserModel.class.php|ユーザーモデルを実装する|
|ユーザーDAOクラス|classes/dao/UserDao.class.php|データベースとのアクセスを実装する|
|基盤|autoload.php|オートローダーを実装する|
|ログクラス|classes/common/log.class.php|ログクラスを実装する|
|メール送信クラス|classes/common/mail.class.php|メール送信クラスを実装する|

##データベースを定義する##

要件より考慮し、以下のフィールドを定義します。

```sql:Userテーブル
CREATE TABLE `tbl_user` (
  `userId` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'プライマリーキー',
  `password` CHAR(60) NOT NULL DEFAULT '' COMMENT 'パスワード',
  `displayName` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '氏名',
  `email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'メールアドレス',
  `token` CHAR(60) NOT NULL DEFAULT '' COMMENT 'トークン',
  `loginFailureCount` TINYINT(1) NOT NULL DEFAULT '0' COMMENT 'ログイン失敗回数',
  `loginFailureDatetime` DATETIME DEFAULT NULL COMMENT 'ログイン失敗日時',
  `deleteFlag` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '削除フラグ',  
  PRIMARY KEY (`userId`) COMMENT 'プライマリーキー',
  UNIQUE KEY `email` (`email`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ;
```

###解説###
パスワードは [password_hash 関数](http://php.net/manual/ja/function.password-hash.php) によってハッシュ化して保存する。この関数によってハッシュ化された文字列は固定長となるので フィールドの型は [char](http://mysql.stu.edu.tw/doc/refman/5.1-olh/ja/char.html) で定義する。

詳しくは公式サイト[安全なパスワードハッシュ](http://php.net/manual/ja/faq.passwords.php)に詳しく書かれていますので、確認しておきましょう。


ここで、password_hash の動きを確認しておきます。

```php:sample.php
<?php
$res = password_hash('password', PASSWORD_DEFAULT);
var_dump("res:" . $res);
var_dump("len:" . strlen($res));
```

実行時のスクリーンショットはこちら

![スクリーンショット 2015-07-23 14.33.36.png](https://qiita-image-store.s3.amazonaws.com/0/46841/6455c27e-3ece-d871-6a1a-32af7aab7e76.png "スクリーンショット 2015-07-23 14.33.36.png")

こちらを確認した結果、実行するたびにハッシュ化された値は常に同じ結果を返すわけではなく、実行ごとに異なることがわかります。文字列の長さは、常に 60 文字と一定の長さと成っていますので、char(60) で定義すれば良い、ということになります。

ただし、[password_hash 関数](http://php.net/manual/ja/function.password-hash.php) に記載のあるように、

> PASSWORD_DEFAULT - bcrypt アルゴリズムを使います (PHP 5.5.0 の時点でのデフォルトです)。 新しくてより強力なアルゴリズムが PHP に追加されれば、 この定数もそれにあわせて変わっていきます。 そのため、これを指定したときの結果の長さは、変わる可能性があります。 したがって、結果をデータベースに格納するときにはカラム幅を 60 文字以上にできるようなカラムを使うことをお勧めします (255 文字くらいが適切でしょう)。 

とあるので、PHPがバージョンアップされれば、文字列の長さは少なくとも60文字以上となることには違いないことですので、やはり、varchar(255) で定義します。

```sql:Userテーブル
ALTER TABLE `tbl_user` (
  `userId` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'プライマリーキー',
  `password` varchar(255) NOT NULL DEFAULT '' COMMENT 'パスワード',
  `displayName` varchar(64) NOT NULL DEFAULT '' COMMENT '氏名',
  `email` varchar(128) NOT NULL DEFAULT '' COMMENT 'メールアドレス',
  `token` char(60) NOT NULL DEFAULT '' COMMENT 'トークン',
  `loginFailureCount` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'ログイン失敗回数',
  `loginFailureDatetime` datetime DEFAULT NULL COMMENT 'ログイン失敗日時',
  `deleteFlag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '削除フラグ',  
  PRIMARY KEY (`userId`) COMMENT 'プライマリーキー',
  UNIQUE KEY `email` (`email`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ;
```

ログインの失敗を記録するための、 `loginFailureCount` と `loginFailureDatetime` について解説すると、`loginFailureCount` は単にログインに失敗した回数を記録します。ログインに成功したら値をリセットし、「0」に値を変更します。`loginFailureDatetime`は最も最後にログインに失敗した時刻を記録するためのものです。

###インデックス設計###
「メールアドレス」はユーザー固有のものであるはずだし、重複することは運用上発生しないはずですから、unique キーを設定しています。

「メールアドレス」と「パスワード」でログインするのだから、email, password, deleteFlag の複合キーで良いのではないかというと、この場合はそうではありません。パスワードはハッシュ化され、password_hashの返す値は先ほどの検証コードの結果より、一定ではありません。

```sql
SELECT * FROM `tbl_user` WHERE `email` = ? AND `password` = ? AND `deleteFlag` = ?
```

このようなSQLでユーザーを特定することはできないのです。

パスワードの照合は [password_verify 関数](http://php.net/manual/ja/function.password-verify.php) で行うため、MySQLに問い合わせるSQLは、以下のようになります。

```sql
SELECT * FROM `tbl_user` WHERE `email` = ? AND `deleteFlag` = ?
```

よって、email, deleteFlag の複合キーで設定することになります。
では、この複合キーを設定します。

※ email には unique キーが設定されているので、MySQLはこの複合キーを選択しないかもしれませんが、ここでは考慮しないことにします。

```sql
ALTER TABLE `tbl_user` ADD KEY (`email`, `deleteFlag`);
```

##ユーザーモデルクラスの実装##

では、ユーザーモデルについて考えていきます。データベースを作成したので、これに密接に関連したモデルを作成すると後戻りが少ないものと思われます。

クラス名は `UserModel` でいいでしょう。
プロパティは、データベースに設定したフィールドと同じ名前で設定します。

```php:UserModel.class.php
<?php
/**
 * ユーザーモデル
 */
final class UserModel
{
	private $_userId = null;
	private $_password = null;
	private $_displayName = null;
	private $_email = null;
	private $_token = null;
	private $_loginFailureCount = null;
	private $_loginFailureDatetime = null;
	private $_deleteFlag = null;
}
```

次に、各プロパティにアクセスできる `setter` と `getter` メソッドを実装します。機械的な作業なのでダルいですが、全て作成します。こういうだるい作業を勝手にやってくれるのが「フレームワーク」です。[ORM](https://ja.wikipedia.org/wiki/%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E9%96%A2%E4%BF%82%E3%83%9E%E3%83%83%E3%83%94%E3%83%B3%E3%82%B0) を機能として持っているフレームワークであれば、やってくれます。

手作業でやるのはやっぱりだるいので、高機能なテキストエディタでも使って、正規表現で置換しちゃいましょうか…

```untitled.txt
	private $_userId = null;
	private $_password = null;
	private $_displayName = null;
	private $_email = null;
	private $_token = null;
	private $_loginFailureCount = null;
	private $_loginFailureDatetime = null;
	private $_deleteFlag = null;
```

テキストファイルにプロパティ部分をコピペ。

検索文字列は 

```検索文字列
\tprivate \$_(\w+) = null;
```

```置換文字列
public function set$1(\$$1)\n{\n\t$this->_$1 = \$$1;\n\treturn $this;\n}\n

```

置換した結果は以下のようになりました。これを UserModel.class.php にコピペして追記します。

```untitled.txt
public function setuserId($userId)
{
	$this->_userId = $userId;
	return $this;
}

public function setpassword($password)
{
	$this->_password = $password;
	return $this;
}

(中略)

public function setdeleteFlag($deleteFlag)
{
	$this->_deleteFlag = $deleteFlag;
	return $this;
}
```

メソッド名の[キャメルケース](https://ja.wikipedia.org/wiki/%E3%82%AD%E3%83%A3%E3%83%A1%E3%83%AB%E3%82%B1%E3%83%BC%E3%82%B9)がおかしいのは致し方ないので手作業で修正しましょう。set〜の直後の1文字を大文字にします。

```untitled.txt
public function setUserId($userId)
{
	$this->_userId = $userId;
	return $this;
}

public function setPassword($password)
{
	$this->_password = $password;
	return $this;
}

（中略）

public function setDeleteFlag($deleteFlag)
{
	$this->_deleteFlag = $deleteFlag;
	return $this;
}
```

この状態から getter を作成すればそれほどの手間はありませんね。

```untitled.txt
public function getUserId()
{
	return $this->_userId;
}

public function getPassword()
{
	return $this->_password;
}

（中略）

public function setDeleteFlag()
{
	return $this->_deleteFlag;
}
```

長くなったので、続きはまた後日…
この粒度で記述してたら、#6〜10くらいまでになりそう…。ヤバイ書き切ることができるのだろうか…???