入力されたメールアドレスを本当にユーザーが持っているか確認するために、ユーザー登録フローの途中で認証URLを記載したメールを送信することはよくあることかと思います。
そんな機能をCodeIgniterでパパっと作ってみましょう。
要件定義
ユーザーには
- メールアドレス
- パスワード
- ユーザーネーム
を入力してもらいます。
一旦入力してもらったメールアドレスへ認証URLを記載したメールを送信し、そのURLへアクセスした瞬間に本登録が完了するシステムを作ります。
また、認証URLには時間制限(今回は1時間)を設定できるようにし、時間が過ぎてしまった場合はそのURLを無効にします。
ちなみに、今回のシステムは
- CodeIgniterの3.1.8
- MySQLの5.5.60
- PHPの5.6.15
で検証しました。
ユーザー情報用のテーブルを作成
まずはユーザー情報を司るテーブルを作成します。
今回はMySQLで作成しますが、CodeIgnierがサポートしているデータベースなら大体似たようなことができるかと思います。
今回のケースでは事前にmailauth
という名前でデータベースとユーザーを作成しておいてから、以下のSQLを実行してテーブルを作成しておきましょう。
CREATE TABLE `mailauth`.`user`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT 'AI' ,
`mail` VARCHAR(255) NOT NULL COMMENT 'メールアドレス' ,
`password` VARCHAR(64) NOT NULL COMMENT 'パスワード(sha256)' ,
`name` VARCHAR(255) NOT NULL COMMENT 'ユーザー名' ,
`status` INT(1) NOT NULL COMMENT '0:仮登録, 1は通常' ,
PRIMARY KEY (`id`)
) ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = 'ユーザー';
今回はこのテーブルだけで大丈夫です。
configディレクトリ内のファイルを編集・追加して必要な設定を行う
CodeIgniterでは様々な設定をapplication/config
内のファイルを操作することで変更・追加することができます。
まずconfig.php
やdatabase.php
でURLの設定やデータベースの設定を行います。
最低限動かす場合は、
-
config.php
のbase_url
-
database.php
のhostname
-
database.php
のusername
-
database.php
の````password``` -
database.php
のdatabase
辺りを設定しましょう。
基本の設定は公式のインストール方法やデータベース設定を確認してみてください。
ついでにオプションの設定ですが、CSRF脆弱性対策をするならconfig.php
のcsrf_protection
をTRUE
にしておくべきでしょう。
さて、今回は追加の設定として認証URLを作成するためのソルトと有効時間の設定が必要です。
個別にcofigディレクトリへファイルを追加してもいいのですが、ロードが面倒なので大規模なプロジェクトでもない限りconfig.php
へ追記していく形でも問題ないでしょう。
ソルトは予測されづらい適当な文字列、有効時間はタイムスタンプをで時間を比較するため1時間を秒に直した3600を指定します。
//~略
$config['salt'] = 'h6F4htejW69moG6gndjplC12';
$config['auth_time'] = 3600;
さらにCodeIgniterには、デフォルトでは存在しませんがconfig
ディレクトリに追加するだけで各ライブラリが勝手に読み込んで使用してくれる特別なファイルが存在します。画像操作ライブラリに対するimage_lib.php
やメール送信ライブラリに対するemail.php
等です。
今回はバリデーションを使用する(メール送信もしますが、デフォルト設定で大丈夫なので設定は行いません)ので、form_validation.php
を作成してみましょう。Controllerで$this->form_validation->set_rules()
を用いて設定してもいいのですが、Controllerの肥大化を予防できるのと同時にバリデーション設定を使いまわすのが容易になります。ただし、独自バリデーションをControllerのメソッドとして定義するのは難しくなるので無名関数で定義する方法を使いましょう。
余談ですが、これを逆手に取って、設定ファイルの中でControllerのメソッドを検証ルールとして設定しておけば、いざControllerで検証をするときにそのルールを動的に変えることが可能です。
複数のControllerでバリデーション設定の大部分は使いまわしたい、しかしControllerのみが知りうる情報で一部の検証内容を変えたい、というケースで有用です。
<?php
//このように編集しておくと複数の状況で各設定を使いまわせます
$mail = array(
'field' => 'mail',
'label' => 'メールアドレス',
'rules' => 'required|valid_email|is_unique[user.mail]',
'errors' => array(
'required' => '{field} は必須です',
'valid_email' => '{field} がメールアドレスとして正しくありません',
'is_unique' => '{field} は既に存在しています'
)
);
$password = array(
'field' => 'password',
'label' => 'パスワード',
'rules' => array(
'required',
array(
'isPassword',
function ($password)
{
if($password === '')
{
return TRUE;
}
if(preg_match('/\A(?=.*?[a-z])(?=.*?\d)[!-~]{8,100}+\z/i', $password))
{
return TRUE;
}
return FALSE;
}
)
),
'errors' => array(
'required' => '{field} は必須です',
'isPassword' => '{field} は8文字以上100文字以下の英数字記号で入力してください'
)
);
$passconf = array(
'field' => 'passconf',
'label' => 'パスワード(再入力)',
'rules' => 'matches[password]',
'errors' => array(
'matches' => '{field} が一致しません'
)
);
$name = array(
'field' => 'name',
'label' => 'ユーザーネーム',
'rules' => 'required|max_length[128]',
'errors' => array(
'required' => '{field} は必須です',
'max_length' => '{field} は128文字以下で入力してください'
)
);
//この名前で設定しておけばRegisterクラスのindexメソッドで勝手に呼び出されるようになります
$config['register/index'] = array($mail, $password, $passconf, $name);
Modelの作成
次にユーザー情報を扱うModelを作成します。
CodeIniterではModelとControllerの区分が他のフレームワークよりあえて曖昧に設計されており、ある処理の責務をどちらが負うかは開発者に選択の余地があります。
データベースを操作し、情報を加工するのはModelで間違いないでしょう。
ただしメールに使用するトークンに関して、バリデーションはCodeIgniterの性質上Controllerで扱うほうが自然なので、Modelではハッシュ文字列の生成のみを担当させることにします。
userテーブルのCRUD(今回DELETEは無し)機能に加えてユーザーのメールアドレス、パスワード、時間、ソルトを用いてトークンを作成するメソッドを用意します。
攻撃者にとってメールアドレスと時間は予想・総当たりしやすく、パスワードもユーザーが入力した情報ゆえに辞書攻撃が成功するケースもあるので、ソルトも加えてトークンを作成します。
<?php
/**
* Class User_model
* @property CI_Loader $load
* @property CI_DB $db
* @property CI_Config $config
*/
class User_model extends CI_Model
{
public function __construct()
{
parent::__construct();
//データベースのロード
$this->load->database();
}
/**
* idから仮登録ユーザー情報を取得する
* 情報がなかった場合は空配列が返る
* @param int $id
* @return array
*/
private function _get_temp_user($id)
{
//クエリビルダでid指定されたユーザーを取得
$this->db->where('id', $id);
$this->db->where('status', 0);
$query = $this->db->get('user', 1);
$result = $query->row_array();
return $result ? $result : array();
}
/**
* idからuserテーブルを参照してユーザー認証用のtokenを作成する
* $timeはタイムスタンプを設定
* データが存在せず、場合は空文字を返す
* @param int $id
* @param int $time
* @return string
*/
public function get_token($id, $time)
{
$user = $this->_get_temp_user($id);
//ユーザー情報が存在しなかった場合は空文字(エラーの意)を返す
if ( ! isset($user['mail']) || ! isset($user['password']))
{
return '';
}
//メールアドレス、、パスワード、タイムスタンプ、ソルトを組み合わせてハッシュを作成
return hash('sha256', $user['mail'].'|'.$user['password'].'|'.$time.$this->config->item('salt'));
}
/**
* userテーブルに仮登録ステータスで情報をinsert する
* $dataに必要情報が無かった場合 or insertに失敗した場合は0を返す
* @param array $data
* @return int
*/
public function insert($data)
{
//データ内容の検証
//今回はNULLを使わないのでOKだが、
//もしNULLを明示的に入れる場合はissetで未定義判定ができないので注意
$columns = array('mail', 'password', 'name');
foreach ((array)$columns as $column)
{
if ( ! isset($data[$column]))
{
return 0;
}
}
//クエリビルダでINSERT
$result = $this->db->insert('user', array(
'mail' => $data['mail'],
'password' => hash('sha256', $data['password']),
'name' => $data['name'],
'status' => 0
));
return $result ? $this->db->insert_id() : 0;
}
/**
* ユーザーステータスを本登録にする
* エラーはFALSEを返す
* @param int $id
* @return bool
*/
public function authentication($id)
{
$user = $this->_get_temp_user($id);
//ユーザーが存在しない場合はFALSEを返す
if ( ! isset($user['id']))
{
return FALSE;
}
//statusカラムをUPDATE
return $this->db->update('user', array(
'status' => 1
),array(
'id' => $id
));
}
}
Controllerの作成
Controllerでは主にバリデーションとメールの送信、表示情報の用意を行います。
フォームからの入力はform_validation.php
に全て投げれますが、トークン検証はControllerの中に書きます。
独自バリデーション設定を作成してform_validationライブラリーを使うこともできますが、
- 既存のバリエーション設定で使えるものがあまりない
- エラーメッセージを出力する必要がない
- 他で使いまわす可能性が低い
という理由からこのケースではベタ書きのifで判定することにしました。
さらにメールの本文設定方法ですが、自分は主に
- 文章のうち大体が同じテンプレート的な部分
- 残りの可変部分は変数で展開できる
- 署名など、ヘッダーやフッター的な部分を分割すれば使いまわすのが楽
- ヘルパーやPHPの関数も気兼ねなく使える
という点からViewに担当させることが多いです。
公式のマニュアルで使っている気配がないので推奨される方法ではないかもしれませんが、メールテンプレートとして用意したViewを$this->load->view()
の第三引数をTRUE
にして文字列として取得しています。
<?php
/**
* Class Register
* @property CI_Loader $load
* @property CI_Input $input
* @property CI_Form_validation $form_validation
* @property CI_Email $email
* @property CI_Config $config
* @property User_model $user_model
*/
class Register extends CI_Controller
{
/**
* Register constructor.
*/
public function __construct()
{
parent::__construct();
//バリデーションライブラリーのロード
$this->load->library('form_validation');
//メールライブラリーのロード
$this->load->library('email');
//ユーザーモデルのロード
$this->load->model('user_model');
//フォームヘルパー・URLヘルパーのロード
$this->load->helper(array('form', 'url'));
}
/**
* メール設定を行い本登録用のメールを送信する
* @param $id
* @return bool
*/
private function _sendMail($id)
{
//現在のタイムスタンプを取得
$time = time();
//モデルからトークンを取得
$token = $this->user_model->get_token($id, $time);
if ($token === '')
{
return FALSE;
}
$this->email->from('example.com', 'hoge form');
$this->email->to((string)$this->input->post('mail'));
$this->email->subject('本登録案内メール');
$this->email->message($this->load->view('mail/register_user', compact('id', 'time', 'token'), TRUE));
return $this->email->send();
}
/**
* ユーザーデータを登録し、メール送信を行う
* どちらも成功した場合は空文字を返し、エラーが発生した場合はエラー文を返す
* @return string
*/
private function _register_temp()
{
//userテーブルへデータをINSERT
$id = $this->user_model->insert($this->input->post());
//情報 or データベースにエラーが発生したら処理中断
if ($id === 0)
{
return 'データベースエラーが発生しました';
}
//メール送信
return $this->_sendMail($id) ? '' : 'メールの送信に失敗しました';
}
/**
* フォームの表示・入力データの検証を行う
* 検証をクリアした場合はDB操作とメール送信担当メソッドを呼び出す
*/
public function index()
{
//エラー文
$error = '';
//もし$_POST['submit']が存在したらデータ入力後のアクセスと判定
//config/form_validation.phpで設定したregisterバリデーション検証
if ($this->input->post('submit') && $this->form_validation->run())
{
$error = $this->_register_temp();
if ($error === '')
{
//エラーが無かった=処理が成功した場合はリダイレクトする
redirect(site_url('register/complete'));
}
}
//view
$this->load->view('front/register_index', compact('error'));
}
/**
* 仮登録完了画面表示
*/
public function complete()
{
$this->load->view('front/register_complete');
}
/**
* URLからトークンを検証
* OKならステータスを本登録に変更して完了画面を表示
* @param int $id
* @param int $time
* @param string $token
*/
public function auth($id, $time, $token)
{
//現在時刻のタイムスタンプを取得
$now_time = time();
//timeがメール送信から一時間以内ではなかったらリダイレクト
//条件の前半はメール送信時間が現在より未来を弾く
//後半はメール送信時間が現在-1時間より過去を弾く
if ($now_time < $time || $time < $now_time - (int)$this->config->item('auth_time'))
{
redirect(site_url());
}
//トークンが不正だったらリダイレクト
if ($token !== $this->user_model->get_token($id, $time))
{
redirect(site_url());
}
//ユーザーステータスをアップデート
$failure = ! $this->user_model->authentication($id);
//成功ページを表示
$this->load->view('front/register_auth', compact('failure'));
}
}
Viewの作成
先ほど説明したメール用のViewはごくごく簡単に書くとこんな感じになります。
<?php
/**
* @var int $id
* @var int $time
* @var string $token
*/
defined('BASEPATH') OR exit('No direct script access allowed');
?>この度は、ご登録頂きありがとうございます。
まだ登録は完了しておりません。
以下のURLへアクセスして登録を完了させてください。
<?php
echo site_url('register/auth/'.(string)$id.'/'.(string)$time.'/'.$token);
細かい点ですが、?>
直後の改行はメール本文にも影響する場合があるので「この度は~」は```?>````の直後に書いたほうが良いでしょう。
公式のViewファイルを最初に見たときは一瞬、「なぜ改行していないんだ、見辛いのうwww」と思ったものですが、素のテキスト部分はPHP部分と違い、書いたまま素直に出力されるので注意です。
逆にファイルの末端がphp部分だった場合は?>
を省略し、代わりに空行を一つ入れることが推奨されています。
他のHTMLを出力するViewファイルは以下の通りです。
今回は入力確認画面を用意しなかったので、必要な場合はViewとControllerのメソッドを追加してください。
<?php
/**
* @var string $error
*/
defined('BASEPATH') OR exit('No direct script access allowed');
?><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<meta name="description" content="">
<title>メール認証ユーザー登録フローテスト</title>
</head>
<body>
<h1>ユーザー登録</h1>
<?php echo validation_errors(); ?>
<?php echo $error; ?>
<?php echo form_open(); ?>
<div>
<label>
メールアドレス<?php echo form_input('mail', set_value('mail')); ?>
</label>
</div>
<div>
<label>
パスワード<?php echo form_password('password', set_value('password')); ?>
</label>
</div>
<div>
<label>
パスワード(再入力)<?php echo form_password('passconf', set_value('passconf')); ?>
</label>
</div>
<div>
<label>
ユーザーネーム<?php echo form_input('name', set_value('name')); ?>
</label>
</div>
<div>
<?php echo form_submit('submit', '認証メールを送信する'); ?>
</div>
<?php echo form_close(); ?>
</body>
</html>
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
?><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<meta name="description" content="">
<title>メール認証ユーザー登録フローテスト</title>
</head>
<body>
<h1>ユーザー登録</h1>
<p>ユーザー認証メールを送信しました。</p>
<p>メールに記載されているURLへアクセスして登録を完了させてください。</p>
</body>
</html>
<?php
/**
* @var bool $failure
*/
defined('BASEPATH') OR exit('No direct script access allowed');
?><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<meta name="description" content="">
<title>メール認証ユーザー登録フローテスト</title>
</head>
<body>
<h1>ユーザー登録</h1>
<?php if ($failure): ?>
<p>データベースエラーが発生しました。</p>
<p>開発者の次回アップデートにご期待ください。</p>
<?php else: ?>
<p>ユーザー登録が完了しました。</p>
<p>開発者がログイン機能を作るまでお待ちください。</p>
<?php endif; ?>
</body>
</html>
まとめ
メール認証は面倒だからこそ一度作ったものは使いまわしたいところですね。
フレームワークのオイシイ部分をちゃんと使ってあげれば素で書くより楽になるのは間違いないのですが、やっぱりコードはそれなりに長くなる気がします。
半ば自分で考えて作った部分があるので問題がある箇所があるかもしれませんが、その場合はコメントにて指摘していただけると非常に助かりますのでよろしくお願いします!