Edited at

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

More than 1 year has passed since last update.

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


当初初級者でも理解できるように書くつもりでしたが、回を進めるうちにとても初心者が理解できるような内容ではなくなってきました。

簡単に済ませたいのであれば、「PHPログイン機能サンプル」を参考にして欲しい。



要件定義


  • ログインは「メールアドレス」と「パスワード」をフォームから送信する

  • 「パスワード」はハッシュ化してデータベースに登録する

  • ユーザー自身でパスワードを変更することができる

  • 連続してログインに失敗した時にアカウントをロックする。一定時間経過すると自動的にロックを解除する。ロックした時にはユーザーにメールで通知する。


マイルストーン

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


  1. 基本設計、ユーザーモデル

  2. オートローダー

  3. 例外・ログ


  4. PDO シングルトン SQLインジェクション



  5. ユーザーモデルの作成


  6. クラスの継承


  7. テンプレート・クラスの実装


  8. アカウントロック機能の実装


  9. メール送信機能の実装


  10. アカウントロック解除機能の実装


  11. CSRF対策



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

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

機能
ファイル名
概要

ログインページ
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
メール送信クラスを実装する


データベースを定義する

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


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 関数 によってハッシュ化して保存する。この関数によってハッシュ化された文字列は固定長となるので フィールドの型は char で定義する。

詳しくは公式サイト安全なパスワードハッシュに詳しく書かれていますので、確認しておきましょう。

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


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

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

ただし、password_hash 関数 に記載のあるように、


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


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


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 ;

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


インデックス設計

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

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

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

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

パスワードの照合は password_verify 関数 で行うため、MySQLに問い合わせるSQLは、以下のようになります。

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

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

では、この複合キーを設定します。

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

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


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

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

クラス名は UserModel でいいでしょう。

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


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;
}

次に、各プロパティにアクセスできる settergetter メソッドを実装します。機械的な作業なのでダルいですが、全て作成します。こういうだるい作業を勝手にやってくれるのが「フレームワーク」です。ORM を機能として持っているフレームワークであれば、やってくれます。

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


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;
}


メソッド名のキャメルケースがおかしいのは致し方ないので手作業で修正しましょう。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くらいまでになりそう…。ヤバイ書き切ることができるのだろうか…???