Help us understand the problem. What is going on with this article?

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くらいまでになりそう…。ヤバイ書き切ることができるのだろうか…???

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away