CodeIgniterでメールによるパスワード再設定機能を実装する

現時点では自分用の備忘録です。
ちょっと定時過ぎてるしさっさと帰りたいんでソースコード丸々掲載します。

現時点では、親クラスのメソッドに依存している記述が多く、
他の方が見てもなんやこれ状態だと思いますので、
そのうち、ちゃんとまとめたいと思ってます(思ってるだけ)

### 要件

* メアドとパスワードで管理画面にログインする(ログイン機能の記述は割愛します)
* パスワードを忘れた場合、専用フォームにメアドを入力することで、再設定リンクつきのメールを送信
* 再設定リンクの有効期限はメール送信から1時間以内
* 再設定ページで、新しいパスワードを入力することで、パスワードを更新、管理画面にログインする

### DB

```sql

CREATE TABLE IF NOT EXISTS `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `email` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `label` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE (email)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

CREATE TABLE IF NOT EXISTS `password_resets` (
  `email` varchar(255) NOT NULL,
  `token` varchar(255) NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  KEY (`email`, `token`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

```

### Config

```php:application/configs/auth.php

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

// @see https://qiita.com/Go-Noji/items/6a8e09e66b3f4857266e

$config['salt'] = 'h6F4htejW69moG6gndjplC12'; // なんでもいい
$config['auth_time'] = 3600; // 3600秒=1時間
```

### Controller

```php:application/controllers/Auth.php

<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
 * @see application/classes/Admin_Controller.php
 */
class Auth extends Admin_Controller
{
    private $columnNameUserName = 'email';
    private $columnNameUserPassword = 'password';

    public function __construct()
    {
        parent::__construct();
        $this->loadModel('users');
        $this->loadModel('password_resets');
        $this->config->load('auth');
    }

    /**
     * パスワードを忘れた方はこちらページ
     */
    public function password_send()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        if($this->isGetMethod()){
            return $this->displayView($this->view_data);
        }
        else if($this->isPostMethod()){
            return $this->passwordSendAction();
        }
    }

    /**
     * パスワード再設定メールを送りましたページ
     */
    public function password_sent()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        return $this->displayView($this->view_data);
    }

    /**
     * パスワード再設定ページ
     */
    public function password_reset()
    {
        $this->view_data = $this->viewVariablesBase();

        if($this->isVerified()){
            $this->setInfoMessage("既にログイン済みです");
            $this->redirectToDashBoard();
        }

        if($this->isGetMethod()){
            $currentToken = $this->input->get('token');

            if($this->isValidEmailToken($currentToken) === FALSE){
                $this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
                return $this->redirectToLoginPage();
            }

            $this->view_data['token'] = $this->token;

            return $this->displayView($this->view_data);
        }
        else if($this->isPostMethod()){
            $currentToken = $this->input->post('token');

            if($this->isValidEmailToken($currentToken) === FALSE){
                $this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
                return $this->redirectToLoginPage();
            }

            // 有効期限切れのアクセストークンをついでに削除
            $this->deleteExpiredAccessTokens();

            return $this->passwordResetAction();
        }
    }

    private function passwordSendAction()
    {
        // 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
        if(! $this->form_validation->run('password_send')){
            return $this->redirectBackWithInput();
        }

        $email = $this->input->post('email');
        $user = $this->getUserInfoByEmail($email);

        // メールアドレスが存在する場合
        if($user){
            $token = $this->createAccessToken($user->email, $user->password, time());
            $this->storeAccessToken($user->email, $token);
            $this->sendMailPasswordReset($user, $token);
        }

        return $this->redirectToPasswordSentPage();
    }


    private function getUserData($input)
    {
        $userName = $this->columnNameUserName;
        $item = $this->users->findBy($userName, $input[$userName]);

        return $item;
    }

    private function setUserInfo($item)
    {
        $this->session->is_verified = TRUE;

        $userInfo = array(
            'id' => $item->id,
            'label' => $item->label,
        );

        $this->session->set_userdata(parent::SESSION_NAME_USERINFO, $userInfo);

        return $this->session->sess_regenerate(TRUE);
    }

    /**
     * アクセストークンとメアドをパスワードリセット用テーブルに保存
     *
     * @params string $email
     * @params string $token
     * @return void
     */
    private function storeAccessToken($email, $token)
    {
        $now = date('Y-m-d H:i:s');

        try {
            $this->password_resets->email = $email;
            $this->password_resets->token = $token;
            $this->password_resets->created_at = $now;

            $this->db->trans_begin();
            $this->password_resets->insert();
            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }
    }

    private function getUserInfoByEmail($email)
    {
        return $this->users->findBy('email', $email);
    }

    private function sendMailPasswordReset($user, $token)
    {
        $serviceName = env('APP_NAME') . " CMS";

        $data = [
            'name' => $user->label,
            //'email' => $user->email,
            'reset_link' => $this->getPasswordResetLink($token),
            'service_name' => $serviceName,
            'company_name' => env('COMPANY_NAME'),
            'company_name_en' => env('COMPANY_NAME_EN'),
        ];

        $viewPath = 'admin/layouts/mails/password_reset'; 
        $message = $this->twig->render($viewPath, $data);

        $this->load->library('email'); // @see config/email.php

        $this->email->from(env('MAIL_FROM_ADDR'), env('MAIL_FROM_NAME'));
        $this->email->to($user->email);

        $this->email->subject("[" . $serviceName . "] パスワード再設定のご案内");
        $this->email->message($message);

        $ret = $this->email->send(); // boolean

        //var_dump($this->email->print_debugger()); die;

        return $ret;
    }

    /**
     * メールアドレス, パスワード, タイムスタンプ, ソルトを
     * 組み合わせてハッシュ文字列を作成する
     * [TODO] Users_modelに書くべき内容では？
     *
     * @params string $email
     * @params string $password
     * @params integer $time
     * @return string e.g. "f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
     */
    private function createAccessToken($email, $password, $time)
    {
        $salt = $this->config->item('salt');
        $hashStr = $email . '|' . $password . '|' . $time . $salt;
        $token = hash('sha256', $hashStr);

        return $token; 
    }

    private function getPasswordResetLink($token)
    {
        $protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
        $domain = $_SERVER['HTTP_HOST'];
        $path = base_url('admin/auth/password_reset/?token=' . $token);

        $url = $protocol . $domain . $path;
        return $url; // e.g. "https://www.example.com/cms/public/admin/auth/password_reset/?token=f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
    }

    private function isValidEmailToken($currentToken)
    {
        // $currentToken GETの場合、クエリパラメータ POSTの場合、hidden値

        $item = $this->password_resets->findBy('token', $currentToken);

        //トークンが不正な場合
        if(empty($item) || $currentToken !== $item->token){
            return FALSE;
        }

        $dt = new DateTime($item->created_at);

        $time = (int) $dt->format('U'); // トークン発行日時 UNIX秒
        $now = time(); // ページアクセス日時 UNIX秒
        
        $authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒

        $expired = $time + $authTime; // トークンの失効日時 UNIX秒

        // ページアクセス日時がトークン有効日時であるかをチェック
        // 条件の前半はページアクセス日時がトークンの発行日時以降であることを確認
        // 条件の後半はページアクセス日時がトークンの失効日時以内であることを確認
        if($time <= $now && $now < $expired){ // トークンが妥当な場合

            $this->token = $item->token; // TODO ここで設定するべきなのか？

            return TRUE;
        }
        else { // トークンが有効日時外である場合
            return FALSE;
        }
    }

    private function passwordResetAction()
    {
        // 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
        if(! $this->form_validation->run('password_reset')){
            return $this->redirectBackWithInput();
        }

        // より厳密な入力バリデーションのチェック(DB接続なし)
        $this->form_validation->set_rules('new_password', "新しいパスワード", 'callback_isValidPasswordPattern'); // @see isValidPasswordPattern();
        if(! $this->form_validation->run()){ // callback_isValidPasswordPattern
            return $this->redirectBackWithInput();
        }

        // アクセストークンからユーザー情報を取得
        $input = $this->input->post();

        $token = $this->token;

        $item = $this->password_resets->findBy('token', $token);
        $user = $this->getUserInfoByEmail($item->email);

        // パスワード再設定＆使用済みのアクセストークン削除処理を実行

        try {
            $this->db->trans_begin();

            // パスワード再設定
            $this->users->password = $input['new_password'];
            $this->users->update($user->id);

            // 使用済みのアクセストークンを削除
            $this->password_resets->delete_record(array('token' => $token));

            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }

        // パスワード再設定が完了した時

        // 有効期限切れのアクセストークンをついでに削除
        $this->deleteExpiredAccessTokens();

        // ダッシュボードに遷移させる
        $this->setUserInfo($user);
        $this->setSuccessMessage("パスワード再設定が完了しました。");
        return $this->redirectToDashBoard();
    }

    /**
     * パスワード再設定メールを送ったが、パスワード再設定を完了しないで放置した場合
     * アクセストークンのレコードがDBに残り続ける
     * セキュリティ上よくない気がするので有効期限外のレコードは全削除することにした
     */
    private function deleteExpiredAccessTokens()
    {
        $now = time(); // 現在日時 UNIX秒

        $authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒

        $expired = $now - $authTime; // 失効済のトークン日時 UNIX秒

        $dt = new DateTime();
        $dt->setTimestamp($expired);

        $expiredAt = $dt->format('Y-m-d H:i:s');

        try {
            $this->db->trans_begin();
            $this->db->where('created_at <', $expiredAt)->delete('password_resets');
            $this->db->trans_commit();
        }
        catch(Exception $e){
            $this->db->trans_rollback();
            echo $e->getMessage();
            return FALSE;
        }
    }

    /**
     * CodeIgniter Strong Password Validation
     * [TODO] Users.phpと同じ内容 できれば一つにまとめたい
     * @return bool
     */
    public function isValidPasswordPattern($password)
    {
        $msgLabel = "isValidPasswordPattern";
        $password = trim($password);

        $regex_lowercase = '/[a-z]/';
        $regex_uppercase = '/[A-Z]/';
        $regex_number = '/[0-9]/';
        $regex_special = '/[!@#$%^&*()\-_=+{};:,<.>§~]/';
        //$password_length_min = 8;
        //$password_length_max = 32;

        if (empty($password))
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は入力必須項目です');
            return FALSE;
        }

        if (preg_match_all($regex_lowercase, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット小文字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_uppercase, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット大文字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_number, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、数字を含む必要があります');
            return FALSE;
        }

        if (preg_match_all($regex_special, $password) < 1)
        {
            $this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、次の記号のいずれかを含む必要があります' . ' ' . '!@#$%^&*()\-_=+{};:,<.>§~');
            return FALSE;
        }

        //if (strlen($password) < $password_length_min)
        //{
        //    $this->form_validation->set_message($msgLabel, 'The {field} field must be at least ' . $password_length_min . ' characters in length.');
        //    return FALSE;
        //}

        //if (strlen($password) > $password_length_max)
        //{
        //    $this->form_validation->set_message($msgLabel, 'The {field} field cannot exceed ' . $password_length_max . ' characters in length.');
        //    return FALSE;
        //}

        return TRUE;
    }
}
```